kilik/maintenance-bundle
A maintenance-mode bundle for PHP applications that makes it easy to enable maintenance mode, show a custom maintenance page, and let selected users or IP addresses bypass downtime during deployments. Lightweight and simple to configure for existing projects.
First, the honest framing: kilik/maintenance-bundle is a Symfony 2 bundle, not a Laravel package. There is no service provider, no config publish tag, no facade — dropping composer require kilik/maintenance-bundle into a Laravel app will install Symfony HttpKernel/EventDispatcher/DependencyInjection contracts and do nothing. The repository name (LexikFr/LexikMaintenanceBundle) also disagrees with the Composer name (kilik/maintenance-bundle), which is a fork-of-a-fork signal. Treat this as reference material and a feature spec, not a dependency.
Minimal steps if you just want the behaviour in Laravel:
php artisan down / php artisan up, backed by App\Http\Middleware\PreventRequestsDuringMaintenance and the storage/framework/down payload.EventListener/MaintenanceListener.php — a kernel.request listener that asks a driver "are we locked?" and short-circuits with a 503 response.Command/MaintenanceLockCommand.php / MaintenanceUnlockCommand.php — the two console commands the README brags about.Driver/DriverInterface.php — isLocked() / lock() / unlock(), with file, Doctrine, and "service" implementations.php artisan down, so your first real task is usually extending it — an IP allowlist and a friendly 503 page.Where to look first in your own app:
bootstrap/app.php (Laravel 11+) or app/Http/Kernel.php for where PreventRequestsDuringMaintenance sits in the global stack.storage/framework/down — the JSON payload containing time, retry, status, secret, render, redirect. Everything the bundle does with a lock file, Laravel does with this file.resources/views/errors/503.blade.php — your maintenance page, no config needed.If you genuinely need the bundle (i.e. you maintain a legacy Symfony 2.8/3.x app), install with composer require kilik/maintenance-bundle, register new Lexik\Bundle\MaintenanceBundle\LexikMaintenanceBundle() in AppKernel.php, then bin/console lexik:maintenance:lock and bin/console lexik:maintenance:unlock. Verify the lock by hitting the site and confirming a 503 with Retry-After.
The bundle's core idea is worth copying: maintenance mode is data, not code. Never deploy a code change to enter maintenance — you'll deploy a second time to leave it, and the second deploy can fail.
// app/Console/Commands/MaintenanceDown.php — a Laravel take on lexik:maintenance:lock
class MaintenanceDown extends Command
{
protected $signature = 'app:down {--ttl=0} {--allow=*} {--message=}';
public function handle(): int
{
$payload = [
'time' => now()->getTimestamp(),
'retry' => (int) $this->option('ttl') ?: null,
'status' => 503,
'message' => $this->option('message'),
'allow' => $this->option('allow'), // CIDR list, unlike the bundle
];
File::put(storage_path('framework/down'), json_encode($payload));
return self::SUCCESS;
}
}
Keep up as the exact inverse (delete the file) and make both idempotent — lock on an already-locked site should succeed, not error, so your deploy scripts can call it unconditionally.
The bundle puts authorized.ips in config.yml under lexik_maintenance.authorized. That's the right instinct: the allowlist is stable policy, the lock is transient state. In Laravel:
// config/maintenance.php
return [
'allow' => array_filter(explode(',', env('MAINTENANCE_ALLOW', ''))),
'bypass_secret' => env('MAINTENANCE_SECRET'),
'retry_after' => 3600,
];
Then a middleware in front of PreventRequestsDuringMaintenance:
class AllowlistedMaintenance extends PreventRequestsDuringMaintenance
{
protected function inExceptArray($request): bool
{
foreach (config('maintenance.allow') as $cidr) {
if (IpUtils::matches($request->ip(), $cidr)) {
return true;
}
}
return parent::inExceptArray($request);
}
}
The bundle only does exact string matches on REMOTE_ADDR. If you're behind a load balancer you must trust proxies (TrustProxies) or every request looks like the LB's IP and the allowlist becomes "everyone or no one."
The bundle returns 503 with Status: Service Temporarily Unavailable and Retry-After. Do the same — Retry-After is what stops Google from treating the page as a soft-404 and what stops naive uptime checkers from paging you at 3 a.m. every minute.
503 (not 502/500) — it is semantically "temporarily unavailable."Retry-After in seconds or HTTP-date.errors/503.blade.php. Once the app is down, asset routes are down too, and you get an unstyled white page — the single most common complaint about this bundle and Laravel's default alike.The bundle's whole surface is two commands. Mirror that discipline — a maintenance toggle should be invokable from CI, not from a web admin panel:
php artisan app:down --ttl=600 --allow=203.0.113.9 --message="Upgrading database"
php artisan migrate --force
php artisan app:up
Wrap it so a failed migration still unlocks (otherwise the previous deploy's traffic sees 503 forever):
php artisan app:down || exit 1
trap 'php artisan app:up' EXIT
php artisan migrate --force
The bundle supports driver: file, driver: database, and driver: service. This exists because a lock file is per-server. If you have three app servers behind a load balancer, lexik:maintenance:lock locks exactly one of them and users see a coin flip.
In Laravel the same trap applies: storage/framework/down is local. On multi-node, either:
artisan down in each container's entrypoint), orCache::get('maintenance')) or a settings table row. This is where the bundle's database driver is genuinely instructive.kernel.request listeners and Laravel's global middleware both run on everything. Your ALB health check, your Kubernetes liveness probe, and your Stripe webhook endpoint will all get 503 and start pulling instances out of rotation or re-queuing events. Add them to the except list:
$middleware->preventRequestsDuringMaintenance(except: [
'health', 'up', 'webhooks/*',
]);
Zero stars, zero dependents, last release 2022-01-28 — treat it as abandoned. No security response, no Symfony 6/7 support, no PHP 8.2+ guarantees. It exists as a historical artifact of Symfony 2's service-container era. Do not add it to a new project, in any framework.
Package/repo name mismatch. kilik/maintenance-bundle vs LexikFr/LexikMaintenanceBundle means the fork's composer.json may still declare the original name, or the branch aliasing is stale. If you must install it, check the actual name and require constraints in composer.json first — composer why-not will tell you fast.
ttl: 0 means "never expire." The bundle's TTL is a self-destruct for the lock, and the default is no expiry. This is a footgun: a deploy that dies between lock and unlock leaves the site down indefinitely. Always pass a finite TTL in CI (--ttl=1800), even if you intend to unlock manually.
Lock file in the cache directory is a classic mistake. Symfony's cache:clear wipes %kernel.cache_dir%. If the file driver's filename points inside the cache dir, deploying (which typically clears cache) silently un-locks the site mid-deploy. Put it in var/ or app/var/, never var/cache/.
Permissions. The console user writes the lock file; the web-server user reads it. A 0600 file written by your deploy user produces a "maintenance mode is off" result for PHP-FPM (unreadable → isLocked() returns false), or an exception. Check umask and the file's group.
CLI and web use the same APP_ENV. php artisan down in local doesn't stop a production container, and vice versa. In Symfony this manifests as lexik:maintenance:lock --env=prod. Be explicit in scripts.
The listener doesn't cover queue workers, cron, or artisan commands. HTTP middleware only guards HTTP. If your maintenance window is a schema migration, queued jobs will still run and crash against the new schema. Pause the queue explicitly (php artisan queue:pause on Horizon, or a Cache flag your jobs check) — the bundle gives you nothing here.
Sub-requests and ESI fragments re-enter kernel.request. If your maintenance listener has a low priority or doesn't guard against sub-requests, you can get a 503 body nested inside a 200 page. Laravel's equivalent: be careful with Http:: internal calls and with middleware that runs on both console and HTTP.
Extension points (Symfony): the service driver lets you implement DriverInterface against your own store — that's the intended hook for "maintenance flag lives in Redis/DB." The template config key overrides the 503 Twig template; the response key overrides status code and headers. In Laravel, the equivalent extension points are errors/503.blade.php, the --render option, --redirect, and a custom middleware extending PreventRequestsDuringMaintenance.
Debugging checklist when "the site says it's down but the file is gone":
curl -I https://yourapp/ — read the Status: and Retry-After headers to confirm it's your maintenance page and not an upstream proxy's.storage_path('framework/down') vs the container's working dir).opcache.validate_timestamps and revalidate_freq matter.TrustProxies — the allowlist failing is almost always $request->ip() returning the LB.The one idea worth stealing permanently: a maintenance toggle must be a data flip that takes effect on the next request, requires no deploy, and is reversible by one command. Everything else in this bundle — the file/database/service drivers, the IP allowlist, the TTL, the Retry-After header — is a detail you can reconstruct in ~80 lines of Laravel middleware and two Artisan commands, with modern PHP support and no abandoned dependency.
How can I help you explore Laravel packages today?