Building a Public Status Page with Laravel and the Scheduler
Hosted status page tools are convenient but add another monthly bill and another account to manage for something Laravel already has most of the pieces for. This tutorial builds a minimal public status page for PhpArchitect's API and website: a scheduled command pings each monitored endpoint, stores the result, and a public route renders the current and historical status.
Step 1: The schema
Schema::create('monitors', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('url');
$table->timestamps();
});
Schema::create('monitor_checks', function (Blueprint $table) {
$table->id();
$table->foreignId('monitor_id')->constrained()->cascadeOnDelete();
$table->boolean('is_up');
$table->unsignedInteger('response_time_ms')->nullable();
$table->unsignedSmallInteger('status_code')->nullable();
$table->timestamp('checked_at');
});
Storing every check (rather than just the latest status) is what lets the page show a 90-day uptime history, not just a current up/down badge.
Step 2: The check command
namespace App\Console\Commands;
use App\Models\Monitor;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
class CheckMonitors extends Command
{
protected $signature = 'monitors:check';
public function handle(): void
{
Monitor::all()->each(function (Monitor $monitor) {
$start = microtime(true);
try {
$response = Http::timeout(10)->get($monitor->url);
$isUp = $response->successful();
$statusCode = $response->status();
} catch (\Throwable) {
$isUp = false;
$statusCode = null;
}
$monitor->checks()->create([
'is_up' => $isUp,
'response_time_ms' => (int) ((microtime(true) - $start) * 1000),
'status_code' => $statusCode,
'checked_at' => now(),
]);
});
}
}
Catching \Throwable matters here — a DNS failure or connection timeout throws a ConnectionException rather than returning a response, and an unhandled exception in a scheduled command silently kills that run without recording a check.
Step 3: Schedule it
// routes/console.php
use App\Console\Commands\CheckMonitors;
use Illuminate\Support\Facades\Schedule;
Schedule::command(CheckMonitors::class)
->everyFiveMinutes()
->withoutOverlapping()
->runInBackground();
withoutOverlapping() prevents a slow check run (a hung endpoint with a long timeout) from stacking with the next scheduled run. runInBackground() matters once you have more than a couple of monitors, since it stops the check command from blocking every other scheduled task tied to the same minute.
Step 4: Computing uptime percentage
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Monitor extends Model
{
public function checks(): HasMany
{
return $this->hasMany(MonitorCheck::class);
}
public function uptimePercentage(int $days = 90): float
{
$checks = $this->checks()
->where('checked_at', '>=', now()->subDays($days))
->get(['is_up']);
if ($checks->isEmpty()) {
return 100.0;
}
return round($checks->where('is_up', true)->count() / $checks->count() * 100, 2);
}
public function isCurrentlyUp(): bool
{
return $this->checks()->latest('checked_at')->first()?->is_up ?? true;
}
}
Step 5: The public route and view
// routes/web.php
use App\Models\Monitor;
use Illuminate\Support\Facades\Route;
Route::get('/status', function () {
return view('status', [
'monitors' => Monitor::with(['checks' => fn ($q) => $q->latest('checked_at')->limit(90)])->get(),
]);
})->name('status');
{{-- resources/views/status.blade.php --}}
<div class="mx-auto max-w-2xl py-12">
<h1 class="text-2xl font-bold">PHP Architect System Status</h1>
@foreach ($monitors as $monitor)
<div class="mt-6 rounded-lg border p-4">
<div class="flex items-center justify-between">
<span class="font-medium">{{ $monitor->name }}</span>
<span class="rounded px-2 py-1 text-sm {{ $monitor->isCurrentlyUp() ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800' }}">
{{ $monitor->isCurrentlyUp() ? 'Operational' : 'Down' }}
</span>
</div>
<p class="mt-1 text-sm text-gray-500">
{{ $monitor->uptimePercentage() }}% uptime over the last 90 days
</p>
</div>
@endforeach
</div>
Where to take it from here
This covers the core loop: check, store, display. A production version should add incident records (a manually-created note explaining a known outage), email or Slack alerts when a monitor flips from up to down via a model observer on MonitorCheck, and response-time graphing using the response_time_ms column already being collected. Because everything lives in your own database, all of that is a migration and a controller away, rather than a support ticket to a vendor.