The Status Page — Building Self-Awareness Into Your Application

The Status Page — Building Self-Awareness Into Your Application

Reading Time: 4 minutes

Series: Every Laravel Project Should Have These Building Blocks 
Part: 21 of 35 Level: Intermediate Prerequisites: DTOsService ClassesCaching Strategy


What You’ll Learn

  • Why every production application needs a status page
  • The StatusCheckInterface — a plug-in contract for health checks
  • The ServiceCheckResult DTO — carrying check results
  • Writing checks for database, cache, queue, disk, and external APIs
  • The StatusService that runs all checks and caches results
  • Securing the status endpoint

Why Every Application Needs a Status Page

“Is the application working?” sounds like a simple question. But it’s actually several:

  • Is the database reachable?
  • Is the cache working?
  • Are jobs being processed?
  • Is the disk I/O healthy?
  • Can we reach the external APIs we depend on?

Without a status page, the answer to all of these is “I don’t know until something breaks.” With one, you know proactively — before users call.

The pattern from neurax-host is clean and extensible: each check is its own class. Adding a new check takes 5 minutes.


The Contracts

// app/Services/Status/Contracts/StatusCheckInterface.php
interface StatusCheckInterface
{
    public function run(): ServiceCheckResult;
}

// app/Services/Status/Contracts/StatusServiceInterface.php
interface StatusServiceInterface
{
    /**
     * @return array{
     *     results: ServiceCheckResult[],
     *     overall: ServiceStatus,
     *     checkedAt: \DateTimeImmutable,
     * }
     */
    public function run(): array;
}

The ServiceCheckResult DTO

// app/DTOs/ServiceCheckResult.php
final readonly class ServiceCheckResult
{
    public function __construct(
        public string $name,
        public string $icon,
        public ServiceStatus $status,
        public string $message,
        public ?float $responseTimeMs = null,
    ) {}
}

And the status enum:

// app/Enums/ServiceStatus.php
enum ServiceStatus: string
{
    case Operational       = 'operational';
    case Degraded          = 'degraded';
    case PartialOutage     = 'partial_outage';
    case Outage            = 'outage';
    case Maintenance       = 'maintenance';

    public function isHealthy(): bool
    {
        return $this === self::Operational;
    }

    public function isWarning(): bool
    {
        return in_array($this, [self::Degraded, self::PartialOutage, self::Maintenance]);
    }

    public function isCritical(): bool
    {
        return $this === self::Outage;
    }
}

Individual Checks

Database Check

// app/Services/Status/Checks/DatabaseCheck.php
final class DatabaseCheck implements StatusCheckInterface
{
    public function run(): ServiceCheckResult
    {
        $start = microtime(true);

        try {
            DB::connection()->getPdo();
            $responseTimeMs = round((microtime(true) - $start) * 1000, 1);

            // Warn if the connection is slow
            $status = $responseTimeMs > 200
                ? ServiceStatus::Degraded
                : ServiceStatus::Operational;

            $message = $status === ServiceStatus::Degraded
                ? "Slow connection ({$responseTimeMs}ms)"
                : 'Connected';

            return new ServiceCheckResult(
                name: 'Database',
                icon: 'fa-database',
                status: $status,
                message: $message,
                responseTimeMs: $responseTimeMs,
            );
        } catch (Throwable) {
            return new ServiceCheckResult(
                name: 'Database',
                icon: 'fa-database',
                status: ServiceStatus::Outage,
                message: 'Connection failed',
            );
        }
    }
}

Cache Check

// app/Services/Status/Checks/CacheCheck.php
final class CacheCheck implements StatusCheckInterface
{
    public function run(): ServiceCheckResult
    {
        // Use a unique key so concurrent checks don't interfere
        $key   = 'status.probe.' . uniqid('', true);
        $token = random_int(100_000, 999_999);

        $start = microtime(true);

        try {
            Cache::put($key, $token, 10);
            $retrieved = Cache::get($key);
            Cache::forget($key);

            $responseTimeMs = round((microtime(true) - $start) * 1000, 1);

            if ($retrieved !== $token) {
                return new ServiceCheckResult(
                    name: 'Cache',
                    icon: 'fa-bolt',
                    status: ServiceStatus::Outage,
                    message: 'Cache read/write mismatch',
                );
            }

            return new ServiceCheckResult(
                name: 'Cache',
                icon: 'fa-bolt',
                status: ServiceStatus::Operational,
                message: 'Read/write OK',
                responseTimeMs: $responseTimeMs,
            );
        } catch (Throwable $e) {
            return new ServiceCheckResult(
                name: 'Cache',
                icon: 'fa-bolt',
                status: ServiceStatus::Outage,
                message: "Cache error: {$e->getMessage()}",
            );
        }
    }
}

Queue Check

// app/Services/Status/Checks/QueueCheck.php
final class QueueCheck implements StatusCheckInterface
{
    public function run(): ServiceCheckResult
    {
        $heartbeat = HeartBeat::latest()->first();

        if ($heartbeat === null) {
            return new ServiceCheckResult(
                name: 'Queue',
                icon: 'fa-list-check',
                status: ServiceStatus::Outage,
                message: 'No heartbeat recorded',
            );
        }

        $ageSeconds = $heartbeat->last_beat_at->diffInSeconds(now());

        if ($ageSeconds > 300) { // 5 minutes
            return new ServiceCheckResult(
                name: 'Queue',
                icon: 'fa-list-check',
                status: ServiceStatus::Outage,
                message: "No heartbeat for {$ageSeconds}s",
            );
        }

        if ($ageSeconds > 120) { // 2 minutes
            return new ServiceCheckResult(
                name: 'Queue',
                icon: 'fa-list-check',
                status: ServiceStatus::Degraded,
                message: "Last beat {$ageSeconds}s ago",
                responseTimeMs: (float) $ageSeconds * 1000,
            );
        }

        return new ServiceCheckResult(
            name: 'Queue',
            icon: 'fa-list-check',
            status: ServiceStatus::Operational,
            message: "Last beat {$ageSeconds}s ago",
        );
    }
}

Disk Check

final class DiskCheck implements StatusCheckInterface
{
    public function run(): ServiceCheckResult
    {
        $path  = storage_path();
        $total = disk_total_space($path);
        $free  = disk_free_space($path);
        $used  = $total - $free;
        $usedPercent = ($used / $total) * 100;

        if ($usedPercent > 95) {
            return new ServiceCheckResult(
                name: 'Disk',
                icon: 'fa-hard-drive',
                status: ServiceStatus::Outage,
                message: sprintf('%.1f%% used — critical', $usedPercent),
            );
        }

        if ($usedPercent > 80) {
            return new ServiceCheckResult(
                name: 'Disk',
                icon: 'fa-hard-drive',
                status: ServiceStatus::Degraded,
                message: sprintf('%.1f%% used — warning', $usedPercent),
            );
        }

        return new ServiceCheckResult(
            name: 'Disk',
            icon: 'fa-hard-drive',
            status: ServiceStatus::Operational,
            message: sprintf('%.1f%% used', $usedPercent),
        );
    }
}

The StatusService

// app/Services/Status/StatusService.php
final readonly class StatusService implements StatusServiceInterface
{
    public function __construct(
        private DatabaseCheck $databaseCheck,
        private CacheCheck    $cacheCheck,
        private QueueCheck    $queueCheck,
        private DiskCheck     $diskCheck,
    ) {}

    public function run(): array
    {
        return Cache::flexible(CacheKeys::StatusChecks->value, [30, 60], function (): array {
            $results = [
                $this->databaseCheck->run(),
                $this->cacheCheck->run(),
                $this->queueCheck->run(),
                $this->diskCheck->run(),
            ];

            return [
                'results'   => $results,
                'overall'   => $this->computeOverall($results),
                'checkedAt' => new DateTimeImmutable(),
            ];
        });
    }

    /**
     * @param ServiceCheckResult[] $results
     */
    private function computeOverall(array $results): ServiceStatus
    {
        $statuses = array_column($results, 'status');

        if (in_array(ServiceStatus::Outage, $statuses, true)) {
            return ServiceStatus::Outage;
        }

        if (in_array(ServiceStatus::PartialOutage, $statuses, true)) {
            return ServiceStatus::PartialOutage;
        }

        if (in_array(ServiceStatus::Degraded, $statuses, true)) {
            return ServiceStatus::Degraded;
        }

        return ServiceStatus::Operational;
    }
}

Register in AppServiceProvider:

$this->app->singleton(StatusServiceInterface::class, StatusService::class);

The Controller

// app/Http/Controllers/StatusController.php
class StatusController extends Controller
{
    public function __construct(
        private readonly StatusServiceInterface $status
    ) {}

    public function __invoke(): JsonResponse
    {
        $data = $this->status->run();

        return response()->json([
            'success' => true,
            'data'    => [
                'overall'    => $data['overall']->value,
                'checks'     => array_map(fn (ServiceCheckResult $r) => [
                    'name'            => $r->name,
                    'icon'            => $r->icon,
                    'status'          => $r->status->value,
                    'message'         => $r->message,
                    'response_time_ms' => $r->responseTimeMs,
                ], $data['results']),
                'checked_at' => $data['checkedAt']->format('c'),
            ],
        ]);
    }
}

Register the route — consider protecting it or leaving it public:

// Public endpoint (visible to monitoring services like UptimeRobot)
Route::get('/status', StatusController::class)->name('status');

// Or protected with a token for sensitive infrastructure details
Route::get('/status', StatusController::class)
    ->middleware('auth:sanctum')
    ->name('status');

Key Takeaways

  • Each health check is its own class implementing StatusCheckInterface. Adding a new check means creating one file.
  • ServiceCheckResult is a DTO — the run() return type is unambiguous for both the IDE and Larastan.
  • StatusService caches results with Cache::flexible() — fresh enough for monitoring, fast enough for users.
  • computeOverall() applies severity ordering: Outage > PartialOutage > Degraded > Operational.
  • The QueueCheck relies on the HeartBeat job from article 13 — they’re complementary patterns.
  • Use Cache::flexible() with short TTLs (30/60s) for status pages — you want near-real-time without running all checks on every page load.

Tips and Gotchas

⚠️ Warning: Health check endpoints must be fast. If a /status check queries every third-party service synchronously, a slow external API can make your entire app appear down to your load balancer. Use timeouts on every check, and cache results that don’t need to be real-time.

💡 Tip: Expose the status page at /up (Laravel’s default health route) for basic load balancer checks, and at a separate /status for human-readable detailed checks. Load balancers should never see your internal service check details.

🔥 Expert Note: The StatusCheckInterface pattern scales naturally: to add a new check, implement the interface and register it. No switch statements, no conditionals, no changes to StatusService. This is the Open/Closed Principle applied to a real operational problem.

Further Reading


← Event & Listeners | Next: Exports and Reports →

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.