Laravel Maintenance Mode That Actually Works: down --render, Secret Bypass and the Octane Trap

Laravel maintenance mode done properly: pre-render the 503, set Retry-After, issue a bypass secret, share state across web nodes and survive Octane workers.

Steven Richardson
Steven Richardson
· 9 min read

The maintenance page returned a 500. I had run php artisan down, started a migration, and the migration wedged — so the site was down, and the page telling everyone it was down could not render, because rendering it needed the application I had just taken apart.

That is the default behaviour of php artisan down, and it is the reason four of its seven flags exist. Everything below is verified against the framework's DownCommand and the pre-render stub it writes into storage/framework/maintenance.php.

Decide whether this deploy needs maintenance mode at all#

Start by ruling it out. Most deploys need zero downtime, and Laravel maintenance mode is a blunt instrument you should reach for maybe twice a year. If your migration can be split into an additive step and a destructive step, use expand/contract migrations and never take the site down. If the problem is the deploy mechanics rather than the schema, an automated zero-downtime pipeline with GitHub Actions and Forge or a blue-green Nginx upstream switch solves it without a 503.

Maintenance mode is for the migration you genuinely cannot make backwards-compatible: a column split that must be atomic, a data rewrite that cannot tolerate concurrent writes, a vendor cutover. That is the whole list.

Build a 503 view that does not depend on the framework#

Write the view assuming nothing works. No Vite manifest, no @vite, no layout inheritance, no config() calls, no external fonts. The pre-rendered output is a snapshot of HTML — if it references a hashed CSS bundle that is mid-deploy, the page loads naked.

{{-- resources/views/errors/503.blade.php --}}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Back shortly</title>
    <style>
        /* Inlined deliberately: the asset pipeline may be mid-deploy. */
        body { font-family: system-ui, sans-serif; display: grid; place-items: center;
               min-height: 100vh; margin: 0; background: #0f172a; color: #e2e8f0; }
        main { max-width: 32rem; padding: 2rem; text-align: center; }
    </style>
</head>
<body>
    <main>
        <h1>We are back shortly</h1>
        <p>A scheduled database migration is running. This usually takes under ten minutes.</p>
    </main>
</body>
</html>

Laravel picks up resources/views/errors/503.blade.php automatically as the default maintenance template.

Pre-render the view with down --render#

Run down with --render so the HTML is rendered once, at the moment you run the command, and stored in the down file:

php artisan down --render="errors::503"

Two files get written. storage/framework/down holds a JSON payload containing the rendered template, the status code, the retry value and the excluded paths. storage/framework/maintenance.php holds the handler stub — and public/index.php requires it before Composer's autoloader loads:

// public/index.php, first thing in the file
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
    require $maintenance;
}

The handler reads the JSON, echoes the stored template, and exits. Nothing else boots. Without --render there is no template key, the handler returns early, and the full framework boots to produce the 503 — which is exactly the code path you cannot trust during a deploy.

The rendered HTML is a snapshot. Editing the Blade file while the app is down changes nothing until you run up and down --render again.

Set the retry window and the status code deliberately#

Set --retry on anything that will last more than a minute. It writes a Retry-After header onto the maintenance response, which is what tells Googlebot the outage is temporary and to come back rather than treat the URL as gone. A bare 503 with no Retry-After is the shape that gets pages dropped from the index during a long window.

php artisan down --render="errors::503" --retry=900 --refresh=60

--refresh=60 adds a Refresh header so a browser sitting on the page reloads itself. --status exists too and defaults to 503; the only reason to touch it is a planned permanent shutdown, where 503 is wrong. Never serve a 200 with an apology page — that is the one option search engines cannot distinguish from your real content.

Confirm the headers rather than trusting them:

curl -sI https://example.com/pricing
# HTTP/2 503
# retry-after: 900
# refresh: 60

Issue a bypass secret for yourself and QA#

Add --secret so you can verify the migration landed before letting traffic back in:

php artisan down --render="errors::503" --retry=900 \
  --secret="1630542a-246b-4b66-afa1-dd72a4c43515"

Visit https://example.com/1630542a-246b-4b66-afa1-dd72a4c43515 and Laravel sets a laravel_maintenance cookie, then redirects you to /. The cookie is a base64 JSON payload carrying an expiry and an HMAC of that expiry keyed by the secret — so it is per-browser. Every person who needs access visits the URL themselves; you cannot hand one out.

Use --with-secret if you want a generated token printed to the console instead. Keep secrets to alphanumerics and dashes: the handler compares $_SERVER['REQUEST_URI'] against '/'.$secret as a literal string, so a ? or & in the token means the match never fires.

Two behaviours worth knowing. Running down again while already down is a no-op unless you pass a secret — so you cannot change --retry or --render without running up first. And because the cookie's HMAC is keyed by the current secret, a second down with a different secret silently invalidates every cookie already issued.

Share maintenance state across web nodes with the cache driver#

The default driver writes a file, so php artisan down on one box leaves every other box serving live traffic. On two or more nodes, point the driver at a store all of them can reach:

# .env, on every web node
APP_MAINTENANCE_DRIVER=cache
APP_MAINTENANCE_STORE=redis

Now one down flips the whole fleet. But read the trade-off carefully, because it is not in the docs: the cache driver and --render do not work together. The pre-render handler runs before the autoloader, so it cannot resolve a cache store — all it can do is file_exists() on storage/framework/down. Under the cache driver that file is never written, the handler returns early, and every node boots the framework to render the 503.

So you pick one:

  • One web node — file driver plus --render. Fast, framework-independent 503.
  • Several web nodes — cache driver, no pre-render, and accept that the 503 needs a bootable application. Mitigate by taking nodes out of the load balancer rather than relying on the maintenance page.

Getting this wrong looks like a load balancer sending half your traffic to a live application in the middle of a destructive migration, which is worse than either outcome above.

Exclude health checks and webhooks before you go down#

Your orchestrator will kill the pods you deliberately took offline if /up starts returning 503. Declare the exclusions in bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->preventRequestsDuringMaintenance(except: [
        '/up',                  // Laravel's built-in health endpoint
        'webhooks/stripe',      // payment provider retries are expensive to lose
        'webhooks/*',           // wildcards are matched with preg_match
    ]);
})

This matters for Kubernetes liveness and readiness probes in particular — a 503 on the readiness probe is the correct signal, but a 503 on the liveness probe restarts the container mid-migration.

The trap: down snapshots this list into the down file when you run the command. Adding a path to except while the application is already down does nothing until you run up and down again.

Make it work under Octane#

Under Octane, public/index.php is not the entrypoint. octane:install writes public/frankenphp-worker.php (or Swoole and RoadRunner equivalents) and that script boots the application once and keeps it resident — so the require of storage/framework/maintenance.php never executes. The pre-rendered response is dead weight on an Octane FrankenPHP deployment.

You still get a 503, because PreventRequestsDuringMaintenance is in the middleware stack and runs per request. But it is a 503 rendered by a booted application, which is the failure mode --render existed to prevent.

Reload workers around the state change and verify on your own stack rather than trusting anyone's blog post, this one included:

php artisan down --retry=900 --secret="$DEPLOY_SECRET"
php artisan octane:reload   # graceful; finishes in-flight requests first
curl -sI https://example.com/pricing | head -1   # expect HTTP/2 503

Wire it into the deploy script and test it with Pest#

Put the whole sequence in the deploy script so nobody improvises it at 2am. Note that queued jobs stop being processed while the application is down, and scheduled tasks are skipped unless they call evenInMaintenanceMode() — so anything you need draining during the window has to opt in explicitly. If you want workers to finish cleanly first, restart them before you go down; Supervisor-managed queue workers respond to queue:restart between jobs.

#!/usr/bin/env bash
set -euo pipefail

SECRET=$(openssl rand -hex 16)

php artisan down --render="errors::503" --retry=900 --secret="$SECRET"
php artisan octane:reload || true          # no-op when Octane is not running
echo "Bypass: https://example.com/$SECRET"

php artisan migrate --force
php artisan optimize

php artisan up
php artisan octane:reload || true
php artisan queue:restart

Then assert the behaviour, because the exclusion list is the part that rots:

use function Pest\Laravel\get;

it('returns 503 for normal routes while down', function () {
    $this->app->maintenanceMode()->activate(['except' => ['up'], 'status' => 503]);

    get('/pricing')->assertStatus(503);
});

it('keeps the health endpoint up while down', function () {
    $this->app->maintenanceMode()->activate(['except' => ['up'], 'status' => 503]);

    get('/up')->assertStatus(200);
})->after(fn () => $this->app->maintenanceMode()->deactivate());

Two more things that bite after up: a CDN or reverse proxy will happily cache the 503, so purge it as part of the deploy; and a stale config:cache or OPcache entry can keep an application behaving as though it is still down. php artisan optimize after up clears both.

Wrapping Up#

Set --render, --retry and --secret every time, know which of the file and cache drivers you are on before you scale to a second node, and reload Octane workers around both down and up. Then work on not needing any of it — expand/contract migrations remove the reason for most maintenance windows, and a blue-green deployment removes the rest.

FAQ#

How do I put a Laravel app into maintenance mode?

Run php artisan down on the server. For a real deployment you want the flags too: php artisan down --render="errors::503" --retry=900 --secret="your-token" pre-renders the 503 page, sets a Retry-After header, and gives you a bypass URL. Bring it back with php artisan up.

What does php artisan down --render do?

It renders the given Blade view to static HTML at the moment you run the command and stores that HTML inside storage/framework/down. A small handler in storage/framework/maintenance.php is required by public/index.php before Composer's autoloader runs, so the page is served without booting the framework. Without --render, Laravel has to boot the whole application to produce the 503, which fails if the application itself is broken.

How do I bypass Laravel maintenance mode with a secret URL?

Pass --secret="some-token" to the down command, then visit https://your-app.com/some-token in a browser. Laravel issues a laravel_maintenance cookie and redirects you to the site root, after which you browse normally. The cookie is per-browser, so everyone who needs access must visit the URL themselves, and the token should contain only alphanumerics and dashes.

Does Laravel maintenance mode work across multiple servers?

Not by default. The file driver writes storage/framework/down on whichever machine ran the command, so the rest of the fleet stays live. Set APP_MAINTENANCE_DRIVER=cache and APP_MAINTENANCE_STORE to a store every node can reach, and one down covers all of them. The cost is that --render stops working, because the pre-render handler runs before the framework and can only read a local file.

How do I stop Google de-indexing my site during maintenance?

Return a 503 status with a Retry-After header, which is what php artisan down --retry=900 produces. The 503 tells crawlers the condition is temporary and the Retry-After tells them when to come back. The dangerous alternatives are a 200 response carrying an apology page, which looks like real content, and a 404, which looks like the page is gone for good.

Does php artisan down work with Laravel Octane?

Maintenance mode still returns a 503 under Octane, because the PreventRequestsDuringMaintenance middleware runs on every request. The pre-rendered page does not, because Octane's worker script replaces public/index.php as the entrypoint and that is where the pre-render handler is required. Run php artisan octane:reload after both down and up, and verify the response with curl -I on your own infrastructure.

Steven Richardson
Steven Richardson

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