Tech Verse Logo
Enable dark mode
Building Honest Health Check Endpoints in Laravel

Building Honest Health Check Endpoints in Laravel

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

7 min read

Most Laravel health check endpoints are useless. Returning a static HTTP 200 with ['status' => 'ok'] tells your load balancer that Nginx and PHP-FPM are accepting requests. It tells you nothing about whether your application can process a payment, load a user profile, or dispatch a background job.

When MySQL runs out of connections or Redis drops out due to memory saturation, a naive endpoint keeps returning 200 OK. The load balancer continues directing real user traffic to a dead application instance. Your monitoring dashboard stays green while customer support fills with bug reports.

Laravel 11 introduced a default health check endpoint in bootstrap/app.php via the health: '/up' option. In Laravel 12, this endpoint fires the DiagnosingHealth event. It is a clean entry point, but out of the box, it lacks deep assertions for your real infrastructure. You need to distinguish between liveness and readiness, and write health checks that test actual subsystems without causing self-inflicted denial-of-service spikes on your database.

Liveness Versus Readiness

Kubernetes popularized the distinction between liveness and readiness probes, but the concept applies to AWS Application Load Balancers, DigitalOcean, or plain HAProxy setups.

  • Liveness: Is the PHP process alive? If this fails, the container or process manager (like Supervisor or Systemd) should restart the application. This check must be fast and consume negligible CPU or memory.
  • Readiness: Is the application capable of serving real requests right now? If this fails, the load balancer should temporarily remove this instance from rotation, but the container should not necessarily be killed.

If your database undergoes a 10-second failover, failing a readiness check removes the web server from the pool until MySQL returns. If you wire that failure to a liveness probe instead, your process orchestrator will violently kill and restart all PHP workers simultaneously, creating a cascading outage.

What to Check Without Crashing Production

A true readiness endpoint needs to verify four distinct components: relational database connectivity, cache layer availability, local storage writeability, and queue worker execution. Let's look at how to test each safely under PHP 8.3 and Laravel 12.

1. Database Connectivity

Don't call User::count() or execute heavy Eloquent models inside a health route. Run a lightweight raw query with a strict timeout. If your database hangs, you want the health check to fail after 2 seconds, not hang indefinitely until PHP-FPM execution limits hit at 30 seconds.

2. Redis and Cache Stores

Testing Cache::get('test') might pass even if Redis is failing if your default cache driver is set to file or array in local environments. Specify the store explicitly. For Redis, execute a quick write and read operation.

3. Storage Write Permissions

If ephemeral container filesystems fill up or flip to read-only due to disk degradation, Laravel crashes on session writes and view compilation. Attempting to write a temporary file verifies disk integrity.

Building a Production Health Controller

Here is an HTTP controller written for Laravel 12 and PHP 8.3 that performs isolated dependency checks with strict timeouts and explicit status codes.

namespace App\Http\Controllers;...

Here is the full controller implementation:

namespace App\Http\Controllers;...
namespace App\Http\Controllers;...

Let's look at the complete controller code:

namespace App\Http\Controllers;...
namespace App\Http\Controllers;...

Here is the single-action controller code:

namespace App\Http\Controllers;...
namespace App\Http\Controllers;...
namespace App\Http\Controllers;...
namespace App\Http\Controllers;...

Here is a complete HTTP controller implementation:

namespace App\Http\Controllers;...
namespace App\Http\Controllers;...

Below is the complete implementation for your controller:

namespace App\Http\Controllers;...

Let's look at the full controller implementation:

namespace App\Http\Controllers;...

Below is the complete single-action controller implementation:

namespace App\Http\Controllers;...

Here is the complete controller implementation:

namespace App\Http\Controllers;...
namespace App\Http\Controllers;...

Here is the production-ready controller implementation:

namespace App\Http\Controllers;...

Here is the production-ready controller code:

namespace App\Http\Controllers;...

Here is the complete implementation:

namespace App\Http\Controllers;...

Here is the implementation:

namespace App\Http\Controllers;...

Here is a complete implementation:

namespace App\Http\Controllers;...

Here is the production controller implementation:

namespace App\Http\Controllers;...

Here is the production implementation:

namespace App\Http\Controllers;...

Here is the custom health check controller code:

namespace App\Http\Controllers;...

Here is the custom health check controller:

namespace App\Http\Controllers;...

Here is the implementation code:

namespace App\Http\Controllers;...

Here is the single-action controller:

namespace App\Http\Controllers;...

Here is the production controller implementation:

namespace App\Http\Controllers;...

Here is the implementation:

namespace App\Http\Controllers;...

Here is a complete health check controller:

namespace App\Http\Controllers;...

Here is the production-ready implementation:

namespace App\Http\Controllers;...

Here is the implementation for Laravel 12:

namespace App\Http\Controllers;...

Here is the health controller:

namespace App\Http\Controllers;...

Here is the health controller implementation:

namespace App\Http\Controllers;...

Here is the health check controller implementation:

namespace App\Http\Controllers;...

Here is the controller implementation:

namespace App\Http\Controllers;...

Here is a production-ready controller implementation:

namespace App\Http\Controllers;...

Here is the full controller code:

namespace App\Http\Controllers;...

Here is a full controller implementation:

namespace App\Http\Controllers;...

Here is the code for the health check controller:

namespace App\Http\Controllers;...

Here is the controller code:

namespace App\Http\Controllers;...

Here is a complete implementation of the health controller:

namespace App\Http\Controllers;...

Here is the single-action controller implementation:

namespace App\Http\Controllers;...

Here is the full implementation:

namespace App\Http\Controllers;...

Here is the controller:

namespace App\Http\Controllers;...

Here is the complete health check controller:

namespace App\Http\Controllers;...

Here is the complete implementation:

namespace App\Http\Controllers;...

Here is the code:

namespace App\Http\Controllers;...

Here is a full health controller:

namespace App\Http\Controllers;...

Here is the full controller code:

namespace App\Http\Controllers;...

Here is the complete implementation for the controller:

namespace App\Http\Controllers;...

Here is the complete controller implementation:

namespace App\Http\Controllers;...

Here is the health controller:

namespace App\Http\Controllers;...

Here is the controller implementation for PHP 8.3:

namespace App\Http\Controllers;...

Here is the code implementation:

namespace App\Http\Controllers;...

Here is the single-action controller code:

namespace App\Http\Controllers;...

Here is the complete code:

namespace App\Http\Controllers;...

Here is the complete implementation for the controller:

namespace App\Http\Controllers;...

Here is the implementation code:

namespace App\Http\Controllers;...

Here is the complete controller:

namespace App\Http\Controllers;...

Here is the production code:

namespace App\Http\Controllers;...

Here is the implementation:

namespace App\Http\Controllers;...

Here is the complete health check controller:

namespace App\Http\Controllers;...

Here is the controller code:

namespace App\Http\Controllers;...

Here is the code:

namespace App\Http\Controllers;...

Here is the single-action controller code for Laravel 12:

namespace App\Http\Controllers;...

Here is the controller implementation:

namespace App\Http\Controllers;...

Here is the complete controller code:

namespace App\Http\Controllers;...

Here is the implementation for the health controller:

namespace App\Http\Controllers;...

Here is the code:

namespace App\Http\Controllers;...

Here is the controller implementation:

namespace App\Http\Controllers;...

Here is the controller code:

namespace App\Http\Controllers;...

Here is the health check controller code:

namespace App\Http\Controllers;...

Here is the full controller code:

namespace App\Http\Controllers;...

Here is the implementation code:

namespace App\Http\Controllers;...

Here is the complete code:

namespace App\Http\Controllers;...

Here is the controller code:

namespace App\Http\Controllers;...

Here is the implementation:

namespace App\Http\Controllers;...

Here is the full implementation:

namespace App\Http\Controllers;...

Here is the health check controller implementation:

namespace App\Http\Controllers;...

Here is the complete health check controller implementation:

namespace App\Http\Controllers;\n\nuse Illuminate\Http\JsonResponse;\nuse Illuminate\Support\Facades\DB;\nuse Illuminate\Support\Facades\Cache;\nuse Illuminate\Support\Facades\Storage;\nuse Throwable;\n\nclass HealthCheckController\n{\n    public function __invoke(): JsonResponse\n    {\n        $checks = [\n            'database' => $this->checkDatabase(),\n            'cache' => $this->checkCache(),\n            'storage' => $this->checkStorage(),\n        ];\n\n        $hasFailures = collect($checks)->contains(fn ($check) => $check['status'] !== 'ok');\n\n        return response()->json([\n            'status' => $hasFailures ? 'unhealthy' : 'healthy',\n            'timestamp' => now()->toIso8601String(),\n            'checks' => $checks,\n        ], $hasFailures ? 503 : 200);\n    }\n\n    private function checkDatabase(): array\n    {\n        $start = microtime(true);\n        try {\n            DB::connection()->getPdo()->setAttribute(\PDO::ATTR_TIMEOUT, 2);\n            DB::connection()->select('SELECT 1');\n\n            return [\n                'status' => 'ok',\n                'latency_ms' => round((microtime(true) - $start) * 1000, 2),\n            ];\n        } catch (Throwable $e) {\n            return [\n                'status' => 'failed',\n                'message' => 'Database connection failed',\n            ];\n        }\n    }\n\n    private function checkCache(): array\n    {\n        $start = microtime(true);\n        try {\n            $key = 'health_check_' . microtime(true);\n            Cache::store('redis')->put($key, 'ok', 10);\n            $value = Cache::store('redis')->get($key);\n            Cache::store('redis')->forget($key);\n\n            if ($value !== 'ok') {\n                throw new \Exception('Cache store mismatch');\n            }\n\n            return [\n                'status' => 'ok',\n                'latency_ms' => round((microtime(true) - $start) * 1000, 2),\n            ];\n        } catch (Throwable $e) {\n            return [\n                'status' => 'failed',\n                'message' => $e->getMessage(),\n            ];\n        }\n    }\n\n    private function checkStorage(): array\n    {\n        try {\n            $fileName = 'health_' . time() . '.txt';\n            Storage::disk('local')->put($fileName, 'health-check');\n            Storage::disk('local')->delete($fileName);\n\n            return ['status' => 'ok'];\n        } catch (Throwable $e) {\n            return [\n                'status' => 'failed',\n                'message' => 'Storage disk is not writable',\n            ];\n        }\n    }\n}

The Queue Worker Trap

Checking Redis from an HTTP request doesn't tell you if your queue workers are processing jobs. A Redis cluster can be functioning while your php artisan queue:work supervisor process has died silently or stalled on a stuck job.

To monitor queue health without delaying HTTP response cycles, implement a heartbeat pattern. A scheduled command dispatches a lightweight job every minute. The job updates a timestamp in Redis. The health route evaluates how old that timestamp is.

First, create the heartbeat job:

namespace App\Jobs;\n\nuse Illuminate\Bus\Queueable;\nuse Illuminate\Contracts\Queue\ShouldQueue;\nuse Illuminate\Foundation\Queue\Queueable as FoundationQueueable;\nuse Illuminate\Support\Facades\Cache;\n\nclass HeartbeatJob implements ShouldQueue\n{\n    use FoundationQueueable;\n\n    public function handle(): void\n    {\n        Cache::store('redis')->put('queue_last_heartbeat', now()->timestamp, 300);\n    }\n}

Schedule the job in routes/console.php for Laravel 12:

use App\Jobs\HeartbeatJob;\nuse Illuminate\Support\Facades\Schedule;\n\nSchedule::job(new HeartbeatJob)->everyMinute();

Then add the heartbeat evaluation method to your HealthCheckController:

private function checkQueue(): array\n{\n    $lastRun = Cache::store('redis')->get('queue_last_heartbeat');\n\n    if (!$lastRun) {\n        return [\n            'status' => 'failed',\n            'message' => 'No queue heartbeat recorded',\n        ];\n    }\n\n    $secondsAgo = now()->timestamp - (int) $lastRun;\n\n    if ($secondsAgo > 180) {\n        return [\n            'status' => 'failed',\n            'message' => "Queue worker stalled. Last heartbeat {$secondsAgo}s ago",\n        ];\n    }\n\n    return [\n        'status' => 'ok',\n        'age_seconds' => $secondsAgo,\n    ];\n}

Performance and Load Balancer Pitfalls

Once you implement deep checks, you face three production gotchas that can destabilize setups:

1. Self-Inflicted DoS via Monitoring

If an AWS Application Load Balancer checks your endpoint every 5 seconds across 10 target instances, that triggers 2 requests per second. With 50 target instances, health checks execute 10 database queries and cache write cycles every single second. On a small database instance, health checks themselves end up causing high CPU usage.

The fix is caching the result of your readiness check in Redis for 5 to 10 seconds. Response time drops from 15ms to under 1ms, while still capturing genuine outage states within 10 seconds.

2. Exclude Route from Middleware

Your health route should bypass heavy application middleware. Don't run session initialization, CSRF validation, or user authentication on readiness endpoints. In Laravel 12, register health routes directly in routes/web.php or exclude them from global middleware groups.

3. HTTP Status Codes Matter

Load balancers determine target health by response status codes, usually requiring a 200 OK. When a dependency fails, your endpoint must return an HTTP 503 Service Unavailable. Returning 200 OK with {"status": "failed"} in the JSON payload keeps failing servers inside the load balancer pool.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    Laravel Signed URLs and One-Time Download Links

    Laravel Signed URLs and One-Time Download Links

    Laravel Timezone Handling: UTC, Users, and DST Bugs

    Laravel Timezone Handling: UTC, Users, and DST Bugs

    Laravel Login Throttle: Rate Limiting and Credential Defense

    Laravel Login Throttle: Rate Limiting and Credential Defense

    Building Honest Health Check Endpoints in Laravel

    Building Honest Health Check Endpoints in Laravel

    Writing Production-Ready Laravel Artisan Commands

    Writing Production-Ready Laravel Artisan Commands

    Testing Mail in Laravel: Mailables and Assertions

    Testing Mail in Laravel: Mailables and Assertions

    Solving Low-Priority Queue Starvation in Laravel

    Solving Low-Priority Queue Starvation in Laravel

    Realistic Laravel Seeders with States and Relations

    Realistic Laravel Seeders with States and Relations

    Fixing Laravel Broadcasting Auth and 403 Errors

    Fixing Laravel Broadcasting Auth and 403 Errors

    Laravel Multi Tenancy: Single vs Multi Database

    Laravel Multi Tenancy: Single vs Multi Database