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.
API monitoring
Barkme sends HTTP(S) requests to your API endpoints from outside your servers and alerts you after the failure is confirmed.
Barkme watches your sites around the clock and barks the moment one goes down. Free plan, no card required.
Keep reading
More from the blog
The Domain That Lapsed
A lapsed domain is not an outage you can restart your way out of. The site, the API and the email all go at once, and the clock on getting it back is set by the registry. What happens after a domain expires, stage by stage, and how to make sure it never gets that far.
Why Registrar Renewal Emails End Up in Spam
Registrars do send renewal reminders. They go to an address nobody reads, from a sender that looks like marketing, and through filters trained to bury automated mail. Why the one email that matters gets lost, and how to stop depending on it.
The SSL Certificate Nobody Renewed
An expired certificate doesn't crash anything. The server keeps running, the owner's browser still shows the site, and every new visitor gets a full-page security warning. How an expiry plays out, why auto-renewal doesn't save you, and the two layers that catch it.