Skip to main content
Laravel, shipping fast.
Chapter 14 ยท DevOps & Infrastructure

Deploying without dropping requests

Julian Beaujardin

Here's the uncomfortable truth about a naive deploy script: git pull && composer install && php artisan migrate --force sounds simple, and it is, but for a few seconds in the middle of it your application directory contains half of the old code and half of the new code. A request that lands during that window can hit a controller from the new release calling a class that hasn't finished downloading yet. That's not a hypothetical, that's what happens on a box serving real traffic while composer install is still writing files to disk.

Zero-downtime deployment fixes this with a release, not an overwrite. The new commit gets checked out into its own directory. Dependencies install there. Config gets cached there. Only once that entire release is built and verified does a symlink flip, atomically, from the old release directory to the new one. The web server was always looking at a symlink, so from its point of view the application changed instantly, and it never served a half-built directory. This is exactly why this fleet models its infrastructure provisioner as a first-class dependency instead of a shell script someone runs by hand:

// app/Enums/Dependency.php
enum Dependency: string
{
    case GitHub = 'github';   // repos + commits (via api-git)
    case Forge = 'forge';     // server provisioning + deploy (fronts DigitalOcean)
    case Stripe = 'stripe';   // billing + subscriptions
    ...
}

Forge sits in the same enum as GitHub and Stripe. To the rest of the API, a deploy target is exactly the same shape of thing as a payment processor: an external system you depend on, whose failures need to be visible instead of buried inside an HTTP client somewhere.

Two things still need to happen correctly inside that atomic swap.

Health checks have to gate traffic, not follow it. Every current app boots through the same slim bootstrap/app.php skeleton, and that skeleton already declares a health route as part of routing itself, not as a hand-written controller bolted on afterward:

// bootstrap/app.php
return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        api: __DIR__.'/../routes/api.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ...

That route is what your deploy tooling should ping before it decides the new release is worth switching traffic to, and it's a separate concern from the richer, Responsable-wrapped /health endpoint you'd build for a monitoring dashboard, from Chapter 1's HealthController. One answers "did the process boot," the other answers "are the things this process depends on actually reachable." A deploy needs the first question answered before it flips the symlink. Skip that check and you've automated shipping broken code just as efficiently as you automated shipping working code.

Queue workers don't reload on their own. A queue:work process is a long-running PHP process that loaded your job classes into memory once, when it started. Swapping the symlink doesn't touch that memory. A worker that's been running since before your deploy will happily keep processing jobs using whatever code it had loaded an hour ago, which means a job dispatched under the new release can be picked up by a worker still running the old one. php artisan queue:restart doesn't kill anything directly, it sets a restart signal that each worker checks between jobs and exits cleanly on, so your process manager can boot a fresh worker that loads the new code. Leave this step out of your deploy and you get version skew: new code writing data, old code reading it, and a bug report that makes no sense until someone remembers the workers never restarted.