Laravel Magazine
Building a Public Status Page with Laravel and the Scheduler

Building a Public Status Page with Laravel and the Scheduler

Eric Van Johnson ·

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.

Stay Updated

Subscribe to our newsletter

Get latest news, tutorials, community articles and podcast episodes delivered to your inbox.

Weekly articles
We send a new issue of the newsletter every week on Friday.
No spam
We'll never share your email address and you can opt out at any time.