On Kubernetes
Web and worker Deployments from one image, probes on the built-in health route, env from ConfigMap and Secret, migrations as a gated Job.
php artisan migrate --force --isolatedKubernetes runs one image as several differently shaped processes. A Laravel application is usually three objects: a Deployment of web pods, a Deployment of worker pods, and a Job that runs the migrations.
The health route
Laravel registers one for you. bootstrap/app.php passes health: '/up' to withRouting(), and that route answers 200 when the framework booted without throwing and 500 when it did not. It checks nothing else — not the database, not Redis. It also dispatches Illuminate\Foundation\Events\DiagnosingHealth, and an exception thrown from a listener for that event turns the route red. That listener is where a dependency check belongs.
Probes, config and secrets
Point readinessProbe at the health route: a pod that fails it leaves the Service and stops receiving traffic. Keep livenessProbe on something that only proves the process still answers, because a failed liveness probe restarts the container. Plain values come from a ConfigMap and credentials from a Secret, both reaching the pod through envFrom. They arrive as real system environment variables, which outrank .env and are the only thing env() can still see once config is cached.
Migrations as a gated Job
Run php artisan migrate --force --isolated in a Job and wait for it to succeed before the Deployments roll. --isolated takes a lock through the cache store, so two pods cannot migrate at the same time. That guarantee needs a shared store such as Redis or the database.
Example
<?php
// app/Listeners/VerifyDependencies.php
// Generated with:
// php artisan make:listener VerifyDependencies --event=DiagnosingHealth
declare(strict_types=1);
namespace App\Listeners;
use Illuminate\Foundation\Events\DiagnosingHealth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class VerifyDependencies
{
/**
* Anything thrown here makes the health route return 500, which is
* what takes the pod out of the Service when readinessProbe points
* at that route. Returning normally leaves the route at 200.
*
* Keep it cheap: the probe runs on every pod every few seconds.
*/
public function handle(DiagnosingHealth $event): void
{
// Throws if no connection can be established.
DB::connection()->getPdo();
// Throws if the cache store is unreachable. A missing key is not
// an error, so the return value is ignored.
Cache::store()->get('health:probe');
}
}
/*
* No registration needed: Laravel discovers listeners in app/Listeners by
* the type of their handle() parameter. That discovery is frozen by
* `php artisan event:cache`, so a new listener does nothing in production
* until the cache is rebuilt.
*/When to use it
- Giving an uptime monitor and a load balancer a single endpoint that means 'can serve traffic'.
- Stopping a rollout from reaching pods before the migration Job has finished.
- Scaling workers independently of web pods for a queue that is backing up.
- Delivering DB_PASSWORD to pods without a .env file anywhere in the image.
More examples
The web Deployment
This is the shape that makes the difference between a degraded site and a restart loop. /up carries the DiagnosingHealth listener and gates traffic; /ping is a route defined in routes/web.php that returns a string and proves only that PHP is answering. Add a preStop sleep if your ingress takes a moment to notice a pod leaving.
apiVersion: apps/v1
kind: Deployment
metadata:
name: shop-web
spec:
replicas: 3
selector:
matchLabels: { app: shop, role: web }
template:
metadata:
labels: { app: shop, role: web }
spec:
containers:
- name: web
image: registry.example.com/shop:1.4.2
envFrom:
- configMapRef: { name: shop-config } # APP_ENV, APP_URL, ...
- secretRef: { name: shop-secrets } # APP_KEY, DB_PASSWORD
ports: [{ containerPort: 8080 }]
readinessProbe:
httpGet: { path: /up, port: 8080 }
periodSeconds: 5
livenessProbe:
# A route with no dependency checks, so a database outage
# drains pods instead of restarting them.
httpGet: { path: /ping, port: 8080 }
periodSeconds: 10
failureThreshold: 6The worker Deployment
Identical image, different args, separate replica count. There is no HTTP probe because nothing listens: the worker's health is whether the process is alive and whether the queue is draining, and both are watched from outside. --max-time=3600 makes each pod exit after an hour and Kubernetes start a fresh one, which caps the damage from any leak.
apiVersion: apps/v1
kind: Deployment
metadata:
name: shop-worker
spec:
replicas: 4
selector:
matchLabels: { app: shop, role: worker }
template:
metadata:
labels: { app: shop, role: worker }
spec:
# Longer than your longest job: SIGTERM lets the worker finish the
# job in hand, and this is how long Kubernetes waits before SIGKILL.
terminationGracePeriodSeconds: 120
containers:
- name: worker
image: registry.example.com/shop:1.4.2
args:
['php', 'artisan', 'queue:work', '--queue=high,default',
'--tries=3', '--max-time=3600']
envFrom:
- configMapRef: { name: shop-config }
- secretRef: { name: shop-secrets }Migrations as a gated Job
Two things are being bought here. The image tag in the Job name means the migration that ran is traceable to the code that needed it. The wait means the Deployments never roll onto a schema that was not applied, which is the usual cause of a column-not-found error that clears up on its own a minute later.
apiVersion: batch/v1
kind: Job
metadata:
name: shop-migrate-1.4.2 # version in the name: Jobs are immutable
spec:
backoffLimit: 0 # a failed migration is not retried blindly
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: registry.example.com/shop:1.4.2
args: ['php', 'artisan', 'migrate', '--force', '--isolated']
envFrom:
- configMapRef: { name: shop-config }
- secretRef: { name: shop-secrets }
---
# The gate, in the pipeline:
# kubectl apply -f job-migrate.yaml
# kubectl wait --for=condition=complete job/shop-migrate-1.4.2 --timeout=300s
# kubectl set image deploy/shop-web web=.../shop:1.4.2
# kubectl rollout status deploy/shop-web --timeout=300s
Discussion