Laravel health check endpoint and the built-in /up route
Laravel 11 and 12 register a health route at /up out of the box, but it only proves the framework booted, so add a route that runs one database query and returns 503 when it fails.
Open bootstrap/app.php in any Laravel 11 or 12 app and the health route is already there, passed as an argument: Application::configure()->withRouting(web: ..., commands: ..., health: '/up'). Laravel registers a GET route on that path, fires a DiagnosingHealth event and returns 200 unless a listener throws, in which case you get a 500. Nothing subscribes to that event in a fresh app, so out of the box /up answers 200 whenever PHP-FPM is alive and the framework boots.
So the useful question is not whether you have a health endpoint. It is whether yours can fail. A booted Laravel app that can no longer reach MySQL still returns 200 from /up while every request to a real controller throws a QueryException, and the uptime monitor watching the site records a clean day through the incident. The check has to touch the thing the app needs: one cheap query, and a status code outside the success range when that query does not come back.
A route that fails when the database does
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Route;
Route::get('/health', function () {
try {
DB::select('select 1');
} catch (\Throwable $e) {
report($e);
// 503, so a booted app with a dead database reads as down.
return response()->json(['ok' => false, 'error' => 'database'], 503);
}
return response()->json(['ok' => true]);
});If you would rather keep the built-in path, hook the event instead. In AppServiceProvider::boot, Event::listen(DiagnosingHealth::class, fn () => DB::select('select 1')). A throw from that listener turns /up into a 500, and a monitor reads a 500 the same way it reads a 503.
What belongs in the check
- The default connection, once, with something as cheap as select 1. DB::select uses the same pool and the same credentials your controllers use, so it fails for the same reasons they do.
- Redis or the queue, only when the app cannot serve a page without them. A slower cache miss is not an outage.
- Nothing that reaches a payment provider or an object store. That turns their bad night into your pager, and their night is not yours to fix.
- Under a second, end to end. The Logdash pinger times out at 10 seconds and records the check as down, so a slow health route becomes a false alarm.
- A body of two keys. No app version, no queue depth, no config values. Assume anyone can curl the URL, because they can.
Point a monitor at it
- Check both directions Load /health in a browser, then break the database credentials in a staging .env and load it again. You want your JSON with a 503, so confirm APP_DEBUG is false anywhere a monitor can reach.
- Create the monitor Add a monitor in Logdash, paste the URL, and the first check runs straight away. Every check stores the status code and the response time, at 5-minute intervals on the free plan, 1 minute on Builder and 15 seconds on Pro.
- Prove the alert Stop the database container and wait one interval. The status code leaves the 200-399 window, the monitor flips to down on the transition, and Telegram gets a message naming the endpoint and the code.
artisan down does not take /up down
Laravel excludes the built-in health path from maintenance mode on purpose: withRouting registers it through PreventRequestsDuringMaintenance::except(). Run php artisan down and every page returns 503 while /up carries on returning 200. That is right for a load balancer, which should keep routing to a pod that is deliberately in maintenance, and wrong for an uptime monitor, which will report the site as fine while nobody can buy anything. A custom route in routes/web.php goes through that middleware and returns 503 like everything else, so the monitor follows your deploys. Decide which of the two you want before you pick the URL.