Laravel Health Check: Past the Built-In /up Route
The built-in Laravel health check proves the framework booted and nothing more. Here is how to deepen it, and cover the queue and scheduler it cannot see.
Since Laravel 11, every new application ships a Laravel health check at /up and most teams point their uptime monitor at it on day one. That is a reasonable start and a poor finish. The route proves the framework booted, which is a much smaller claim than the one people read into it.
What does the built-in Laravel health check prove?
That the web server answered and the application booted without throwing. Laravel's deployment documentation is precise about it: the route returns 200 if the application booted without exceptions, and 500 otherwise. Nothing else is inspected.
So /up is a liveness check. It does not open a database connection, ping Redis, look at the queue, or notice that your scheduler stopped running three days ago. An application whose workers have all exited will return 200 from that route indefinitely, while nothing gets processed.
The route arrived with the Laravel 11 skeleton, and Laravel News covered it in February 2024 when the release landed. Applications upgraded from Laravel 10 do not get it automatically, so check bootstrap/app.php before assuming the path exists. A monitor pointed at a route that was never registered reports a 404 rather than a problem with your application.
You can move the path if /up collides with something, since the URI is a withRouting argument in bootstrap/app.php.
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/status',
)
Making the route mean something
Laravel gives you the hook in the same place. Every request to the health route dispatches a DiagnosingHealth event, and a listener can inspect whatever it likes. Throwing from that listener turns the 200 into a 500, which is the documented way to deepen the check.
namespace App\Listeners;
use Illuminate\Foundation\Events\DiagnosingHealth;
use Illuminate\Support\Facades\DB;
use RuntimeException;
class CheckCriticalDependencies
{
public function handle(DiagnosingHealth $event): void
{
DB::connection()->select('select 1');
if (cache()->getStore() instanceof \Illuminate\Cache\RedisStore) {
cache()->put('health:probe', 1, 10);
}
if (DB::table('jobs')->where('created_at', '<', now()->subMinutes(10))->exists()) {
throw new RuntimeException('queue backlog older than 10 minutes');
}
}
}
Be careful what you put in there. Every dependency you touch becomes a reason for the route to fail, and a 500 from this path usually means an orchestrator pulls the instance out of the pool. Check only what the instance truly cannot serve without, and keep third-party APIs out of it entirely.
Put a short timeout on anything that talks over the network. A Laravel health check that hangs for thirty seconds is worse than one that fails, because the caller waits rather than acting.
When to reach for a package
Once you want more than pass or fail, the event listener gets awkward. Spatie's laravel-health is the common answer, and it registers named checks that each report their own result rather than collapsing into one exception.
composer require spatie/laravel-health
php artisan vendor:publish --tag="health-config"
php artisan health:check
Its useful trick is storing results rather than recomputing them on every request. Run health:check from the scheduler every few minutes, serve the stored results as JSON, and a hundred probes per minute cost you nothing. That is the same caching argument that applies to any deep check, covered under health check endpoint design.
We prefer the plain listener for two or three dependencies and the package once disk space, certificates and queue lag are all in scope. Adding a package to return {"status":"ok"} is overhead without a payoff.
One detail decides whether any of this works in production. The status code is the contract, because load balancers and orchestrators read it and never parse your JSON. A body reporting a failure behind a 200 response is read as healthy by everything in the path, so make sure the package or the listener actually changes the code.
The blind spots no route can see
Two failures matter more than the rest in a Laravel app, and neither is visible over HTTP. Queue workers exit quietly, and a scheduler that stopped running raises nothing at all. In both cases /up keeps returning 200.
These need an inbound signal instead of an outbound check. The job reports that it ran, and an alert fires when the report does not arrive.
// routes/console.php
Schedule::command('reports:nightly')
->dailyAt('02:00')
->thenPing('https://uptimeroom.com/api/heartbeat/<token>');
thenPing is built into the scheduler, so there is nothing to install. Give the queue the same treatment with a job dispatched every five minutes that pings a second heartbeat token on completion. If workers die, the pings stop, and the missed window is the alert. The trade-off is worth stating plainly: a missed heartbeat tells you the job did not report, not why it stopped. More on that pattern sits in cron job monitoring.
Watching the route from outside
Every check so far runs inside your own network. They all stay green while DNS, TLS or the load balancer in front of Nginx is broken, because nothing in that path is being exercised.
curl -X POST https://uptimeroom.com/api/v1/monitors \
-H "Authorization: Bearer urm_live_..." \
-H "Content-Type: application/json" \
-d '{
"name": "laravel up",
"type": "http",
"target": "https://example.com/up",
"intervalSec": 60,
"regions": ["london", "sao-paulo"],
"expectedStatus": [200],
"timeoutMs": 5000,
"alertAfterCycles": 2
}'
We tested this against an application with the queue deliberately stopped. The external check stayed green for the whole run, exactly as expected, and only the heartbeat caught it. That is the argument for running both rather than choosing one.
Two regions and a 60 second interval are a sensible default, drawn from the six probe regions available as of September 2026. If your firewall needs an allowlist, the probe IPs page carries the egress addresses for each region code.
Common mistakes
Treating /up as a readiness check. It reports that the framework booted. Add a DiagnosingHealth listener before you let it gate traffic, or accept it as liveness only.
Putting the database in a liveness check. If a brief database problem makes the route return 500, your orchestrator restarts healthy containers and turns a blip into an outage.
Checking an external API inside the listener. Your application is not unhealthy because a payment gateway is slow. That belongs on a dashboard, not in a route that can empty your pool.
Trusting an HTTP route to cover the queue. Horizon's dashboard tells you the state of workers that are running. It cannot page you about workers that are not, which is what a heartbeat is for.
Leaving a deep route unauthenticated and uncached. A public path that runs four queries on every hit is a free load generator. Store the results and serve the stored copy.
Start with /up, decide honestly whether you need it to mean more, and add a listener only for the dependencies that genuinely block serving. Then cover the scheduler and the queue with heartbeats, because that is where a Laravel health check over HTTP runs out of visibility. Two probe regions and a one-minute interval come with the free plan as of September 2026. That is enough to watch the route and the heartbeats side by side.
FAQ
It returns a 200 response if the application booted without throwing an exception, and a 500 if it did not. It opens no database connection and inspects no queue. Laravel also dispatches a DiagnosingHealth event on every request to that route, which is the documented place to add checks of your own.
The path is an argument to withRouting in bootstrap/app.php. Change the health value from the default of /up to whatever you prefer, such as /status, and clear the route cache afterwards. Write the new path down next to your monitor configuration, since every caller you forget about is hard-coded to the old one.
Only if you intend it as a readiness check rather than a liveness check. A listener that queries the database makes the route return 500 during any database blip, and an orchestrator reading that will restart otherwise healthy containers. Keep liveness free of dependencies and put dependency checks on a separate route.
Not for two or three dependencies, where a DiagnosingHealth listener is fewer moving parts. The package earns its place once you want named checks with individual results, things like disk space and certificate expiry, and stored results served from a scheduled run rather than recomputed on every request.
Not over HTTP, because a route returns 200 whether or not anything is processing. Use an inbound heartbeat instead. The scheduler supports thenPing on any scheduled command, and a job dispatched every few minutes can ping a second token on completion. A missed ping inside the expected window is the alert.
Related reading
- Health Check Endpoint Design: What It Should Return - A health check endpoint answers one question for one caller. Here is how to pick the caller, choose 200 or 503, and keep the wrong things out of the body.
- Cron Job Monitoring: From Crontab to Monitors - Cron job monitoring starts with your crontab. Here is how to turn five schedule fields into an expected period, plus the DST trap that breaks them.
- Heartbeat Monitoring: Catch Jobs That Never Ran - Heartbeat monitoring waits for your cron job to check in, then alerts when the signal never arrives. Here is how to size the grace period.