prolix/maintenance-bundle
Prolix Maintenance Bundle is a Symfony bundle for putting your app into maintenance mode. It serves configurable 503 responses, supports IP whitelisting and custom maintenance templates, and helps manage planned downtime cleanly without exposing errors to visitors.
This is a Symfony bundle, not a Laravel package. If you're a Laravel developer, the closest day-to-day equivalent is php artisan down / php artisan up (with --secret, --render, --redirect). The mental model transfers 1:1, but everything below is Symfony wiring.
Install the fork via Composer (note: repo is named LexikMaintenanceBundle, Packagist name is prolix/maintenance-bundle — confirm the constraint in composer.json matches your Symfony version):
composer require prolix/maintenance-bundle
Register the bundle in config/bundles.php (Flex usually does this automatically):
return [
// ...
Lexik\Bundle\MaintenanceBundle\LexikMaintenanceBundle::class => ['all' => true],
];
Configure it in config/packages/lexik_maintenance.yaml — start with the file driver, which is the simplest:
lexik_maintenance:
authorized:
ips: ['127.0.0.1', '10.0.0.0/8']
hosts: []
paths: ['^/healthz$', '^/_ops']
driver:
ttl: 3600
file:
storage_dir: '%kernel.project_dir%/var/maintenance'
key_name: maintenance
template: '@LexikMaintenance/Maintenance/maintenance.html.twig'
First use case: put the site into maintenance while you deploy, keep yourself in via the IP allowlist, and let it auto-expire after an hour in case you forget to unlock:
php bin/console lexik:maintenance:lock --ttl=3600
# deploy, migrate, warm cache...
php bin/console lexik:maintenance:unlock
Where to look first in the source: the bundle's MaintenanceListener (kernel.request listener that returns the 503 response), the Driver interface, and the three commands. That's 90% of the surface area.
Deploy-script sandwich. The canonical pattern: lock → deploy → unlock, with a trap so a failed deploy doesn't leave the site locked forever.
#!/usr/bin/env bash
set -euo pipefail
php bin/console lexik:maintenance:lock --env=prod --ttl=1800
trap 'php bin/console lexik:maintenance:unlock --env=prod' EXIT
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console cache:clear --env=prod
php bin/console cache:warmup --env=prod
Use lexik:maintenance:check for gating and monitoring. It exits non-zero/zero depending on lock state, so it's script-friendly — useful in a deploy wrapper to skip redundant locks, or as a smoke-test step that fails the build if maintenance is still on after a deploy.
Multi-server / containerized apps need a shared driver. The file driver writes to a local path, so with two app servers only one serves 503. Use the database, Redis, or Memcached driver so every node sees the same state:
# prod
lexik_maintenance:
driver:
redis:
host: '%env(REDIS_HOST)%'
port: 6379
The database driver needs its table created (check the bundle's Resources for the schema/SQL) — wire it into a migration.
Bypass rules are your most-used knob. Health checks, load balancers, webhooks (Stripe, GitHub), and your own admin routes must keep working during maintenance. Use authorized.paths (regexes on the pathinfo) and authorized.ips (CIDR supported). This is the Symfony analogue of Laravel's --secret cookie, but broader.
Custom 503 page. Override the template config to point at your Twig template. Keep the response status at 503 with a Retry-After header — that's what tells Google/browsers this is temporary, so you don't lose index position the way a 200 "we'll be back" page would.
Programmatic control. For admin-panel "maintenance mode" toggles, inject the driver service (lexik_maintenance.driver) or the factory and call lock() / unlock() / isLocked() from a controller or command instead of shelling out. This is the cleanest way to expose it to non-Symfony-savvy ops people.
CI/test wiring. Disable the bundle in config/packages/test/lexik_maintenance.yaml (or guard the listener) — a stale lock file in a shared build workspace will make every functional test return 503 and produce mystifying failures.
Package-name confusion. The repository says LexikMaintenanceBundle, the install name is prolix/maintenance-bundle. That's a fork of the original lexik/maintenance-bundle. Pin the version in composer.json and check whether the fork or the upstream is the one you actually want — with 6 stars and 0 dependents, neither has a large safety net.
The file driver lives inside var/cache by default. cache:clear / cache:warmup can wipe your lock state mid-deploy, or worse, write the lock into the new cache dir while the old one still serves. Point storage_dir outside var/cache (e.g. var/maintenance) or use Redis/DB in prod.
--env matters. Running lock without --env=prod writes state for the dev environment; your prod site is unaffected and you'll spend an hour debugging a "broken" command. Always pass it explicitly in scripts, and make sure the prod config actually defines the driver.
TTL is a footgun in both directions. A short TTL silently unlocks mid-migration; a missing TTL leaves the site down indefinitely. Set ttl to slightly longer than your worst-case deploy and also add a monitoring alert that fires when lexik:maintenance:check reports locked for more than N minutes.
HTTP caches and CDNs ignore it. The listener only returns 503 for requests that reach PHP. If Varnish/Cloudflare/Fastly is serving cached pages, users won't see maintenance mode until you purge. Add a CDN purge step to the deploy, or a CDN-level rule keyed on a /.maintenance marker.
Authorized paths are regexes against the pathinfo, not globs and not full URLs. ^/admin won't match if your app is in a subdirectory or the route is locale-prefixed (/en/admin). Test with bin/console debug:router and a real curl before relying on it during an incident.
Unlock before you clear cache. If something goes wrong with warmup, you want the site live while you debug rather than locked with a broken console app.
Debugging: php bin/console debug:container lexik_maintenance shows which driver is wired in the current env; php bin/console lexik:maintenance:check -v shows the current state. If the listener isn't firing, check event priority — another kernel.request listener (e.g. a security firewall or a redirect listener) registered earlier can preempt the 503.
Extension point: for custom backends (DynamoDB, Consul, etcd, S3) implement the bundle's driver interface and register it via the driver factory. Given how small this project is, that's often less risky than waiting for upstream support for a storage backend.
Security listener ordering: the maintenance listener should generally run before the firewall, otherwise unauthenticated users get redirected to /login during maintenance instead of a 503 — confusing for users and bad for SEO.
Zero dependents is a signal. Treat this as "small, single-maintainer, low-bus-factor." Read the listener and driver code (it's short) before relying on it in a production incident path; if you need it to be bulletproof, wrapping the driver interface yourself is a cheap insurance policy.
How can I help you explore Laravel packages today?