Laravel Health Checks for Kubernetes Readiness and Liveness Probes

Build the Laravel health check Kubernetes actually needs for readiness and liveness probes. Spatie checks, /up vs /readyz, and probe YAML to ship today.

Steven Richardson
Steven Richardson
· 8 min read

You've containerised a Laravel app, deployed it to Kubernetes, and everything looks fine until the database connection silently dies on one pod. Traffic keeps flowing to that pod because nothing told the Service to stop sending it. Without proper readiness and liveness probes, Kubernetes can't tell the difference between "running" and "actually able to serve a request."

Laravel ships a /up route in v11+ that returns 200 if the framework boots. That's a fine smoke test, but it doesn't check the database, Redis, the queue, or anything else your app actually depends on. For a real production deployment — the kind walked through in dockerising your Laravel app for Kubernetes — you need endpoints that fail when your dependencies fail.

Readiness vs Liveness vs Startup Probes#

The three Kubernetes probes look similar in YAML but they do completely different things, and conflating them is the most common mistake I see in Laravel deployments.

Liveness probes ask "is this container deadlocked?" If the probe fails enough times in a row, the kubelet kills the container and lets the restart policy bring it back. The check should be cheap and only fail on conditions a restart will actually fix — a wedged PHP-FPM master, a deadlocked process, OOM thrashing.

Readiness probes ask "should this pod receive traffic right now?" If the probe fails, the pod is removed from the Service's endpoint list but is not killed. This is where you check things like database and Redis connectivity. A blip should pull the pod out of rotation; it shouldn't trigger a restart loop.

Startup probes gate the other two while a slow-booting container is coming up. If you're warming caches or running migrations on boot, set a startup probe with a generous failureThreshold so liveness doesn't fire before the app is ready to be checked.

A useful rule of thumb: if the failure mode requires a restart, it's a liveness check. If it's transient and the pod can recover by itself, it's a readiness check.

Setting Up Laravel Health Checks#

Install spatie/laravel-health — it ships with check classes for everything you'll typically wire into a probe.

composer require spatie/laravel-health
php artisan vendor:publish --tag="health-config"
php artisan vendor:publish --tag="health-migrations"
php artisan migrate

Register your checks in AppServiceProvider::boot():

use Spatie\Health\Facades\Health;
use Spatie\Health\Checks\Checks\DatabaseCheck;
use Spatie\Health\Checks\Checks\RedisCheck;
use Spatie\Health\Checks\Checks\QueueCheck;
use Spatie\Health\Checks\Checks\UsedDiskSpaceCheck;
use Spatie\Health\Checks\Checks\CacheCheck;

public function boot(): void
{
    Health::checks([
        DatabaseCheck::new(),
        RedisCheck::new(),
        CacheCheck::new(),
        QueueCheck::new()->onQueue(['default', 'emails']),
        UsedDiskSpaceCheck::new()
            ->warnWhenUsedSpaceIsAbovePercentage(70)
            ->failWhenUsedSpaceIsAbovePercentage(90),
    ]);
}

Schedule the runner so checks execute on a cadence and notifications throttle properly:

// routes/console.php
use Illuminate\Support\Facades\Schedule;
use Spatie\Health\Commands\RunHealthChecksCommand;

Schedule::command(RunHealthChecksCommand::class)->everyMinute();

Now expose the endpoints. Spatie ships two controllers — pick the right one for the job:

// routes/web.php
use Spatie\Health\Http\Controllers\SimpleHealthCheckController;
use Spatie\Health\Http\Controllers\HealthCheckJsonResultsController;

// For Kubernetes probes — returns 200 or 503
Route::get('/readyz', SimpleHealthCheckController::class);

// For humans / debugging — JSON detail of every check
Route::middleware('auth')->get('/health', HealthCheckJsonResultsController::class);

SimpleHealthCheckController is the one Kubernetes actually wants. It returns 200 when every check passed on the last run and 503 when anything failed, with no payload — kubelet only cares about the status code.

Custom Checks for Things Spatie Doesn't Cover#

The bundled checks cover database, Redis, cache, queue, schedule, optimized config, migrations, and disk space. For application-specific signals you write a class extending Check:

namespace App\Health\Checks;

use Spatie\Health\Checks\Check;
use Spatie\Health\Checks\Result;
use Stripe\StripeClient;
use Throwable;

class StripeApiCheck extends Check
{
    public function run(): Result
    {
        $result = Result::make();

        try {
            (new StripeClient(config('services.stripe.secret')))
                ->balance->retrieve();
        } catch (Throwable $e) {
            return $result->failed("Stripe unreachable: {$e->getMessage()}");
        }

        return $result->ok();
    }
}

Register it alongside the others. Be careful what you put in here — every probe hits this code path, so anything in your readiness check should be fast and shouldn't hammer a third-party API at the same rate as your probe interval.

For queue-heavy apps, the QueueCheck only verifies workers are processing jobs from the queues you list. It does not check queue length or worker memory. That's a separate concern handled at a different layer — see scaling Laravel queues in production for what to monitor and how.

Two Endpoints, Two Probes#

Don't point liveness and readiness at the same URL. The split that works in production:

  • /up — Laravel's built-in route. Liveness probe target. Cheap, no dependencies, only fails if the framework itself is broken.
  • /readyz — Spatie's SimpleHealthCheckController. Readiness probe target. Fails if any dependency a request needs is unhealthy.
// bootstrap/app.php (Laravel 11+) — /up is configured here by default
->withRouting(
    web: __DIR__.'/../routes/web.php',
    health: '/up',
)

That gives you a Deployment YAML like:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: laravel-app
spec:
  replicas: 3
  template:
    spec:
      terminationGracePeriodSeconds: 60
      containers:
        - name: app
          image: registry.example.com/laravel-app:1.0.0
          ports:
            - containerPort: 8080
          startupProbe:
            httpGet:
              path: /up
              port: 8080
            failureThreshold: 30
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /up
              port: 8080
            initialDelaySeconds: 0
            periodSeconds: 20
            timeoutSeconds: 3
            failureThreshold: 3
          readinessProbe:
            httpGet:
              path: /readyz
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10
            timeoutSeconds: 5
            failureThreshold: 2
            successThreshold: 1
          lifecycle:
            preStop:
              exec:
                command: ["/bin/sh", "-c", "sleep 15"]

A few details worth understanding rather than copying blindly. The startup probe gives the app 150 seconds (30 × 5) to boot — generous, but it absorbs cold starts after a deploy without a single liveness flap. The liveness probe runs less often than readiness because restarting a container is expensive and you want to be sure before pulling that trigger. The failureThreshold: 2 on readiness means two consecutive failures pull the pod from the Service — fast enough to react, slow enough that one slow query doesn't blackhole traffic.

The image build itself is upstream of all of this. If your container is bloated or your entrypoint isn't right, no probe configuration saves you — that's covered in optimising Laravel Docker images with multi-stage builds.

Graceful Shutdown for In-Flight Requests#

When Kubernetes terminates a pod it sends SIGTERM, waits up to terminationGracePeriodSeconds, then sends SIGKILL. PHP-FPM handles SIGTERM by waiting for in-flight requests to finish before exiting — but only if your stack actually forwards the signal.

The preStop sleep above is the trick that makes rolling deploys actually graceful. When the pod is marked for termination, two things happen concurrently: the Service controller starts removing the pod from endpoints, and the container gets SIGTERM. Without the preStop, in-flight requests can land on a pod that's already shutting down. The 15-second sleep gives the endpoint removal time to propagate through kube-proxy before PHP-FPM stops accepting new connections.

For queue worker pods, the equivalent is making sure php artisan queue:work gets SIGTERM and finishes the current job before exiting. Workers handle this correctly out of the box, but you still need terminationGracePeriodSeconds to be longer than your slowest job — otherwise the kubelet will SIGKILL mid-job and the work is lost. This pairs naturally with the patterns in zero-downtime Laravel deployments with GitHub Actions and Forge.

Gotchas and Edge Cases#

The RunHealthChecksCommand doesn't run inside the request lifecycle. SimpleHealthCheckController reads the last result from the result store. If your scheduler isn't running (no php artisan schedule:work, no cron, no separate scheduler pod), every probe response is stale forever and your readiness state lies. Confirm the scheduler is up before you trust the probe.

HealthCheckJsonResultsController always returns 200 by default. It's designed for dashboards, not probes. If you point a probe at it without setting health.json_results_failure_status to 503, the probe will report healthy even when every check is failing.

Don't authenticate the probe endpoint. kubelet doesn't carry a session or token. If you wrap SimpleHealthCheckController in auth middleware, every probe gets a 302 or 401 and the pod is permanently unready. Authenticate the JSON dashboard, not the boolean endpoint.

Probe timeoutSeconds defaults to 1. If your DB ping takes longer than that under load, every readiness check times out and the pod gets pulled. Bump to 3-5 for any check that touches the network.

Multiple workers, one probe target. A pod with both web and queue workers in sidecars needs separate probe targets per container — readiness for the web container shouldn't depend on the worker container being ready, and vice versa.

The check store hits the database. EloquentHealthResultStore writes results on every run. If your readiness probe period is 10 seconds and your DB is the failure mode, you're hammering an unhealthy DB with probe writes. Switch to CacheHealthResultStore with a Redis or file driver if the DB itself is the dependency you're checking.

Wrapping Up#

Probe configuration is one of those areas where the defaults look fine until production actually tests them. Wire /up to liveness and /readyz (backed by SimpleHealthCheckController) to readiness, set failureThreshold so transient blips don't trigger restarts, and add a preStop sleep so rolling deploys actually drain.

For the broader observability story — what to do once a probe goes red and you need to know why — compare the options in Laravel Telescope vs Debugbar vs Pulse for monitoring. The probes tell Kubernetes what to do; Pulse and Telescope tell you what just broke.

FAQ#

How do I add health checks to a Laravel app?

Install spatie/laravel-health, register checks in AppServiceProvider::boot() via Health::checks([...]), and expose them with the SimpleHealthCheckController for a 200/503 endpoint or the HealthCheckJsonResultsController for a JSON dashboard. Schedule RunHealthChecksCommand every minute so the result store stays fresh.

What is a Kubernetes readiness probe?

A readiness probe is an HTTP, TCP, or exec check Kubernetes runs against your container to decide whether the pod should receive traffic from a Service. If the probe fails, the pod is removed from the Service's endpoint list but the container is not restarted. This is the right place to check dependencies like the database, Redis, and the queue — anything that's transiently unavailable but doesn't need a container restart.

How do I check if Laravel can connect to the database?

The simplest reliable check is DB::connection()->getPdo() — it forces an actual connection rather than relying on cached state. The DatabaseCheck class from spatie/laravel-health does this for you, plus runs a configurable lightweight query and surfaces the connection name in the result. Hook it into your readiness probe so a dead DB pulls the pod out of rotation automatically.

What's the difference between liveness and readiness probes?

A liveness probe asks whether the container should be restarted — a failure triggers a restart by the kubelet. A readiness probe asks whether the container should receive traffic right now — a failure pulls the pod out of the Service endpoints but does not restart it. Liveness should only fail on conditions a restart will fix; readiness should fail on transient dependency issues.

Can I use Laravel's /up route as a Kubernetes health check?

Yes for liveness probes, no for readiness probes. The /up route only verifies the framework can boot and respond — it doesn't touch the database, Redis, or any other dependency. Use it for liveness, where you only want to restart on a wedged framework. Use a dedicated readiness endpoint backed by SimpleHealthCheckController for traffic-gating.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.