Guides

A Laravel Health Check Endpoint, Step by Step

When Laravel's built-in /up route is enough, and how to build a /health route that checks the database, cache, queue backlog and scheduler, answers 503 when it should, and stays out of your middleware's way.

What to put behind a /health endpoint covers the principles: what a deep check should touch, what it should leave alone, and why the status code matters more than the body. This post is the Laravel version, built up one piece at a time, with a test at the end.

It assumes Laravel 11 or later, where routing and middleware are configured in bootstrap/app.php.

Start with /up

A new Laravel application already has a health route. In bootstrap/app.php:

->withRouting(
    web: __DIR__.'/../routes/web.php',
    commands: __DIR__.'/../routes/console.php',
    health: '/up',
)

/up boots the framework, fires a DiagnosingHealth event, and returns 200 unless a listener throws, in which case it returns 500. It answers with a small HTML page, or with {"status": "up"} when the request asks for JSON. You can hang checks off that event:

use Illuminate\Foundation\Events\DiagnosingHealth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;

Event::listen(function (DiagnosingHealth $event) {
    DB::select('select 1');
});

For a small application that is often enough. Point a monitor at /up, and a dead database turns the check red.

Three things make people outgrow it. The first listener that throws ends the run, so you learn that something failed but not how many things did. A failure is a 500, which reads like a bug, where a 503 says the service is temporarily unable to serve. And /up is excluded from maintenance mode, so it keeps answering 200 during php artisan down. That is handy if maintenance should never page anyone, and confusing if you expected the monitor to notice.

A dedicated /health route

When you want control over the checks and the status code, write a small invokable controller:

namespace App\Http\Controllers;

use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;

final class HealthCheckController
{
    public function __invoke(): JsonResponse
    {
        $checks = [
            'database' => fn () => DB::select('select 1'),
            'cache' => fn () => Cache::get('health:probe'),
        ];

        $failed = [];

        foreach ($checks as $name => $check) {
            try {
                $check();
            } catch (Throwable $exception) {
                $failed[] = $name;

                Log::warning('Health check failed', [
                    'check' => $name,
                    'error' => $exception->getMessage(),
                ]);
            }
        }

        return response()
            ->json(['status' => $failed === [] ? 'ok' : 'fail'], $failed === [] ? 200 : 503)
            ->header('Cache-Control', 'no-store');
    }
}

The response says only ok or fail. Which check failed, and why, goes to the log. Anyone can call this URL, so it should not tell them your database host or your Redis version.

Register it outside the web group

Routes in routes/web.php run through the web middleware group: sessions, cookies, CSRF. A monitor calling every 30 seconds does not need a session, and every call would write one. Register the route in the then callback instead, where no group applies:

use App\Http\Controllers\HealthCheckController;
use Illuminate\Support\Facades\Route;

->withRouting(
    web: __DIR__.'/../routes/web.php',
    commands: __DIR__.'/../routes/console.php',
    health: '/up',
    then: function () {
        Route::get('/health', HealthCheckController::class);
    },
)

Global middleware still runs, so the route returns 503 during maintenance like the rest of the site. That is usually what you want from a monitor's point of view: the site is not serving customers, and the check says so.

Keep it out of any rate limiter, and if you run a firewall or bot protection in front of the app, allow the monitor through. A blocked health check looks exactly like an outage.

Check the queue backlog

The database and cache answering does not mean jobs are moving. If your app sends mail or processes orders on a queue, a stuck worker is an outage that no page request will show. Check the backlog:

use Illuminate\Support\Facades\Queue;
use RuntimeException;

'queue' => function () {
    if (Queue::size('default') > 500) {
        throw new RuntimeException('Queue backlog above 500 jobs.');
    }
},

Pick the threshold from what normal looks like for your app. A backlog that grows past it means workers are down or far behind.

Check the scheduler

Scheduled tasks fail silently. Nothing errors when cron stops calling schedule:run; things simply stop happening. Have the scheduler leave a heartbeat, and check its age. In routes/console.php:

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schedule;

Schedule::call(fn () => Cache::put('health:scheduler', now()->timestamp))
    ->everyMinute()
    ->name('health-scheduler-heartbeat');

And in the controller:

'scheduler' => function () {
    $lastRun = (int) Cache::get('health:scheduler', 0);

    if (now()->timestamp - $lastRun > 180) {
        throw new RuntimeException('Scheduler has not run for 3 minutes.');
    }
},

Three minutes gives a one-minute task two missed runs of slack before the check fails.

Keep it fast

A monitor waits for the response, and so does a load balancer. A health route that hangs for 30 seconds on an unreachable database is worse than one that fails quickly. Set short connection timeouts for the database and Redis in their config, and keep the whole response well under a second on a healthy day. If a check is genuinely slow, run it on the scheduler and have the route read the stored result.

Test it

A health route that is never tested is the one that breaks during a framework upgrade. With Pest:

it('reports healthy when its dependencies answer', function () {
    Cache::put('health:scheduler', now()->timestamp);

    $this->getJson('/health')
        ->assertOk()
        ->assertExactJson(['status' => 'ok'])
        ->assertHeader('Cache-Control', 'no-store, private');
});

it('reports 503 when the scheduler has stopped', function () {
    Cache::put('health:scheduler', now()->subMinutes(10)->timestamp);

    $this->getJson('/health')->assertStatus(503);
});

Laravel adds private to a no-store cache header on its own, hence the assertion.

Point a monitor at it

The route only helps if something outside your servers calls it. In Barkme, verify your domain once with a TXT record, then add a monitor for https://your-app.com/health at a 30-second or 1-minute interval. A 2xx counts as up and a 503 as down. A failed request is retried, and two consecutive failures open an incident, so one slow response does not page anyone. When the route recovers, you get a recovery notice with the total downtime.

Keep one monitor on a real page as well, on a slower interval. The health route proves the application can work. It cannot prove the checkout template renders. API monitoring covers watching JSON endpoints the same way, and the SSL checker shows how long the certificate in front of it has left.