
Table of Contents
By Khimananda Oli | Last reviewed: August 2026
The moment a PHP deploy means SSH-ing in and running git pull on the live directory, you are shipping downtime. For a few seconds the code is half-updated, the autoloader is stale, and a request that lands mid-pull gets a 500. Zero-downtime deployment with Deployer fixes this by building each release in its own directory and switching traffic over with a single atomic symlink flip. This guide covers the releases/shared/current model, a real deploy.php, safe migrations, cache warming, and one-command rollback. If you drive deploys from a pipeline, pair it with my GitLab CI/CD pipeline for Laravel walkthrough.
.env and storage into it, runs migrations and warms caches, then atomically repoints the current symlink to the new release. Live traffic only ever touches current, so no request hits a half-built app.releases/, a persistent shared/, and an atomic current symlink that always points at one complete release.What does zero-downtime deployment with Deployer actually mean?
Deployer never edits your live directory in place. Every deploy creates a new numbered folder under releases/, builds it completely — clone, composer install, migrations, cache warming — and only then repoints current to it. Because your web server document root points at current/public, the switch is a single filesystem operation that either fully succeeds or does not happen at all. There is no window where the app is partly old and partly new.
Three directories do all the work:
releases/— one timestamped folder per deploy, each a full, ready-to-serve checkout.shared/— the real.env,storage/, and any user uploads, symlinked into every release so state persists across deploys.current— the atomic symlink that decides which release is live right now.
How do you install and configure Deployer for a PHP app?
Deployer is a single PHP tool you add per project. Require it with Composer, then generate a recipe:
composer require --dev deployer/deployer
vendor/bin/dep init For a Laravel app, discard the generated stub and use the built-in Laravel recipe, which already knows how to run migrations and warm caches. A minimal, production-ready deploy.php looks like this:
<?php
namespace Deployer;
require 'recipe/laravel.php';
set('application', 'example.com');
set('repository', '[email protected]:you/example.git');
set('keep_releases', 5);
set('bin/php', '/usr/bin/php8.4');
add('shared_files', ['.env']);
add('shared_dirs', ['storage']);
add('writable_dirs', ['bootstrap/cache', 'storage']);
host('production')
->set('hostname', '203.0.113.10')
->set('remote_user', 'deploy')
->set('deploy_path', '/srv/example.com')
->set('branch', 'main');
after('deploy:failed', 'deploy:unlock'); Two settings carry more weight than they look. set('bin/php', ...) pins the exact PHP binary so cache warming runs on the same runtime that serves requests — a mismatch here is a classic source of "works in build, breaks live" bugs. And keep_releases defines your rollback depth: keep at least three so a known-good release is always one flip away. If you are still standing up the box itself, my guide on deploying Laravel on an Ubuntu VPS with Nginx covers the PHP-FPM and web-server side that Deployer sits on top of.
What order do Deployer tasks run in during a deploy?
The Laravel recipe runs a fixed task pipeline. Understanding the order is what lets you reason about safety: everything that can fail happens before the symlink swap, so a failure aborts the release while the old one keeps serving.
deploy:symlink, the only step that changes what visitors see.In order, a Laravel deploy does the following:
deploy:lock— takes a lock so two deploys cannot race on the same host.deploy:releaseanddeploy:update_code— create the new timestamped folder and fetch the target commit into it.deploy:sharedanddeploy:vendors— symlink the shared.envandstorage/, then runcomposer install --no-dev --optimize-autoloader.artisan:migrate— runsphp artisan migrate --forceon the new release, before it goes live.- Cache warming —
artisan:config:cache,artisan:route:cache, andartisan:view:cacheso the first request is fast. deploy:symlink— the atomic flip ofcurrentto the new release.deploy:cleanupanddeploy:unlock— prune releases beyondkeep_releasesand release the lock.
Trigger the whole pipeline with one command:
vendor/bin/dep deploy production --verbose Why should migrations run before the symlink swap?
Because the migration is the riskiest step, and running it before the swap means a failure never reaches users. In the Laravel recipe, artisan:migrate runs against the new release while current still points at the old one. If the migration throws, the deploy stops, deploy:failed fires, the lock is released, and the previous release keeps serving traffic untouched. Only a clean migration lets the pipeline proceed to the symlink flip.
There is a real constraint to respect: for the seconds between "migration applied" and "symlink flipped", the old code runs against the new schema. Keep migrations backward compatible — add columns and tables rather than renaming or dropping them in the same deploy. The safe pattern is expand-then-contract:
- Deploy 1 (expand): add the new nullable column and write to both old and new.
- Deploy 2: backfill data and switch reads to the new column.
- Deploy 3 (contract): drop the old column once nothing references it.
This is the same discipline any real production database needs, Deployer or not — the atomic swap just makes the window small and predictable.
How do you roll back a bad deploy instantly?
Since every release under keep_releases is a complete, already-built checkout, rollback recompiles nothing — it repoints current at the previous release. That is why rollback is effectively instant and just as atomic as the deploy.
current back to the previous release.The everyday commands are short:
vendor/bin/dep rollback production # flip current back to the previous release
vendor/bin/dep releases production # list releases and see which is live
vendor/bin/dep deploy:unlock production # clear a stale lock after an aborted deploy One caveat mirrors the migration rule: code rolls back instantly, but the database does not. If a release ran a destructive migration, rolling back the symlink puts old code in front of a changed schema. This is the practical reason to keep migrations additive and reversible — so a symlink rollback is genuinely enough to recover.
How do you keep concurrent deploys and shared state safe?
Two mechanisms protect a live system when more than one deploy or engineer is involved:
- Locks.
deploy:lockwrites a lock file at the start of every deploy anddeploy:unlockremoves it at the end. If a second deploy starts while one is running, it fails fast instead of interleaving. Theafter('deploy:failed', 'deploy:unlock')hook guarantees a crashed deploy does not leave a permanent lock. - Shared files and directories. Anything in
shared_filesorshared_dirslives once undershared/and is symlinked into each release. Your.env, logs, and user uploads therefore survive every deploy and every rollback — they are never part of the cloned code.
For teams, the cleanest setup is to never run dep deploy from laptops at all. Drive it from CI so deploys are logged, ordered, and reproducible; the same deploy.php works unchanged whether you invoke it by hand or from a pipeline runner.
Conclusion
Zero-downtime deployment with Deployer turns a PHP release from a held-breath moment into a routine event: build the release in isolation, run migrations before the swap, warm caches, flip one atomic symlink, and keep the last few releases ready for an instant rollback. Add a deploy.php to one project this week, run dep deploy a few times until it is boring, then wire it into CI. If you want a deployment pipeline designed and hardened for your stack, explore my DevOps and cloud services, browse deployment case studies, or get in touch to talk through your setup.