The Status Page — Building Self-Awareness Into Your Application
Series: Every Laravel Project Should Have These Building Blocks
Part: 21 of 35 Level: Intermediate Prerequisites: DTOs, Service Classes, Caching Strategy
What You’ll Learn
- Why every production application needs a status page
- The
StatusCheckInterface— a plug-in contract for health checks - The
ServiceCheckResultDTO — carrying check results - Writing checks for database, cache, queue, disk, and external APIs
- The
StatusServicethat 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. ServiceCheckResultis a DTO — therun()return type is unambiguous for both the IDE and Larastan.StatusServicecaches results withCache::flexible()— fresh enough for monitoring, fast enough for users.computeOverall()applies severity ordering: Outage > PartialOutage > Degraded > Operational.- The
QueueCheckrelies on theHeartBeatjob 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
/statuscheck 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/statusfor human-readable detailed checks. Load balancers should never see your internal service check details.
🔥 Expert Note: The
StatusCheckInterfacepattern scales naturally: to add a new check, implement the interface and register it. No switch statements, no conditionals, no changes toStatusService. This is the Open/Closed Principle applied to a real operational problem.
Further Reading
- Laravel Docs: Health Check Route (
/up) - Spatie: Laravel Health — a full-featured health monitoring package
- PHP Docs: Interfaces