Run dep deploy with the stock recipe/laravel.php and three things happen that I do not want in production. Composer runs on the server, so the code that serves traffic was never the code CI tested. artisan:migrate runs on every deploy, whether or not the release contains a migration someone approved. And if the new release starts returning 500 after current switches to it, Deployer reports success and keeps serving it until a human types dep rollback.
In my Laravel starter I kept Deployer for what it does well (release directories, shared files, the deploy lock, the atomic switch, cleanup) and replaced everything else. This post walks through that deploy.php. The PHP blocks from "Host and settings" on, read in order, form a complete recipe. It targets Deployer 8 (v8.0.5 as of 2026-10-27), a single VM with Nginx and PHP-FPM, and GitHub Actions as the only place that holds the SSH key.
What the stock recipe actually runs
Read the recipe before overriding it. In Deployer 8, recipe/laravel.php defines deploy as:
task('deploy', [
'deploy:prepare',
'deploy:vendors',
'artisan:storage:link',
'artisan:optimize',
'artisan:migrate',
'deploy:publish',
'artisan:reload',
]);
deploy:prepare is itself a group: deploy:info, deploy:setup, deploy:lock, deploy:release, deploy:update_code, deploy:env, deploy:shared, deploy:writable. deploy:publish is deploy:symlink, deploy:unlock, deploy:cleanup, deploy:success.
That structure is the useful part. Every step is a named task, and redefining a task with the same name replaces its body. So I keep the group, swap deploy:update_code and deploy:vendors, and write my own top-level deploy list.
On the server, Deployer gives you this layout:
/var/www/starter/
├── .dep/ # lock, release log, my own state files
├── current -> releases/42
├── releases/
│ ├── 38/ … 42/
└── shared/
├── .env
└── storage/
Host and settings come from the environment
Nothing about the target lives in the repository. The workflow passes host, user and path as environment variables, and the recipe refuses to start without them.
<?php
declare(strict_types=1);
namespace Deployer;
require 'recipe/laravel.php';
/** @return non-empty-string */
function deploymentEnvironment(string $name): string
{
$value = getenv($name);
if ($value === false || $value === '') {
throw new \RuntimeException(sprintf('Missing required environment variable %s.', $name));
}
return $value;
}
host(deploymentEnvironment('DEPLOY_ENVIRONMENT'))
->setHostname(deploymentEnvironment('DEPLOY_HOST'))
->setRemoteUser(deploymentEnvironment('DEPLOY_USER'))
->set('deploy_path', deploymentEnvironment('DEPLOY_PATH'));
set('application', 'starter');
set('keep_releases', 5);
set('shared_files', ['.env']);
set('shared_dirs', ['storage']);
set('writable_dirs', ['bootstrap/cache', 'storage']);
set('writable_mode', 'acl');
set('writable_use_sudo', false);
set('artifact_path', deploymentEnvironment('DEPLOY_ARTIFACT'));
set('artifact_sha256', deploymentEnvironment('DEPLOY_ARTIFACT_SHA256'));
.env lives in shared/ and is symlinked into every release by deploy:shared. keep_releases is how many directories deploy:cleanup leaves behind, which is also how far back you can roll.
Build in CI, upload a tarball
The build job in GitHub Actions runs the test suite, builds Vite assets and the SSR bundle, then runs composer install --no-dev --classmap-authoritative and packs the result with git archive plus vendor/, public/build and bootstrap/ssr. It writes the tarball's SHA-256 and a small manifest (commit, PHP and Node versions, lockfile hashes) next to it.
On the server there is no Git, no Composer and no Node. deploy:update_code becomes an upload:
task('deploy:update_code', function (): void {
$artifactPath = get('artifact_path');
if (! is_file($artifactPath)) {
throw new \RuntimeException(sprintf('Release artifact not found: %s.', $artifactPath));
}
upload($artifactPath, '{{release_path}}/release.tar.gz');
run('test "$(sha256sum {{release_path}}/release.tar.gz | cut -d " " -f 1)" = "{{artifact_sha256}}"');
run('tar -xzf {{release_path}}/release.tar.gz -C {{release_path}} --no-same-owner');
run('rm {{release_path}}/release.tar.gz');
run('printf "%s\n" "{{release_name}}" > {{release_path}}/RELEASE');
});
task('deploy:vendors', function (): void {
run('test -f {{release_path}}/vendor/autoload.php');
});
The checksum is compared on the server, after the upload, so a truncated transfer fails the deploy before anything is extracted. deploy:vendors stops installing and only asserts that the artifact brought its own vendor/. The RELEASE file is read by config/ops.php, so every log line carries the release number.
Migrations are opt-in
The stock artisan:migrate runs every time. I want a person to decide, per deploy, that this release may touch the schema:
task('deploy:migrate', function (): void {
if (getenv('DEPLOY_ALLOW_MIGRATIONS') !== 'true') {
writeln('<comment>Migrations skipped: DEPLOY_ALLOW_MIGRATIONS=true is required.</comment>');
return;
}
run('cd {{release_path}} && php artisan migrate --force --no-interaction');
});
The flag is a boolean input on the workflow, default false. Migrations run from the new release before the switch, while the old release still serves traffic. That only works if the migration is additive, which is the expand/contract rule from zero-downtime database migrations. Nothing in this recipe rolls the schema back. A bad migration gets a forward fix.
Check the candidate before it gets traffic
At this point the new release has its caches built (artisan:config:cache, route:cache, view:cache, event:cache) and the old release is still current. This is the cheapest moment to fail:
task('deploy:smoke', function (): void {
run('test -f {{release_path}}/public/build/manifest.json');
run('test -f {{release_path}}/bootstrap/ssr/app.js');
run('cd {{release_path}} && php artisan about --only=environment --no-interaction');
run('cd {{release_path}} && php artisan ops:readiness --no-interaction');
});
php artisan about boots the framework with the release's cached config, so a broken service provider or a config file that calls a missing class fails here. ops:readiness is the starter's own command: it runs the same checks as the /health/ready endpoint (database, Redis, writable storage) from the CLI with the new code. If any line exits non-zero, Deployer stops, runs deploy:failed, and users never saw the release.
Remember which release was healthy
The built-in dep rollback picks the previous directory in the release log. During an automatic rollback I want something stricter: the release that was serving traffic a minute ago.
task('deploy:remember_healthy', function (): void {
if (test('[ -L {{current_path}} ]')) {
$healthy = run('readlink -f {{current_path}}');
set('healthy_release', $healthy);
run('mkdir -p {{deploy_path}}/.dep && printf "%s\n" '.escapeshellarg($healthy).' > {{deploy_path}}/.dep/healthy_release');
} else {
set('healthy_release', '');
}
});
It is written to .dep/healthy_release too, so after an interrupted deploy an operator can see the target without reading CI logs. On the first deploy there is no current, so there is nothing to roll back to.
Switch atomically, and make Nginx follow it
deploy:symlink in Deployer 8 runs mv -T {{deploy_path}}/release {{current_path}} when mv supports --no-target-directory. A rename is atomic, so no request sees a missing current.
PHP-FPM is the catch. If Nginx passes SCRIPT_FILENAME with current in it, OPcache keeps serving the old files after the switch. Deployer's documentation recommends resolving the symlink in Nginx instead of reloading FPM, and that is what the starter's vhost does:
root /var/www/starter/current/public;
location = /index.php {
include fastcgi_params;
fastcgi_pass unix:/run/php/starter.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root/index.php;
fastcgi_param DOCUMENT_ROOT $realpath_root;
}
Each request is bound to releases/42/public at the moment Nginx resolves it, and requests already running finish on releases/41.
Restart what keeps code in memory
Horizon, the scheduler and the Inertia SSR server load the code once and keep it. Each runs as a systemd unit with WorkingDirectory=/var/www/starter/current and Restart=always, so stopping them is enough. systemd starts them again from the new current:
task('deploy:restart_workers', function (): void {
run('cd {{current_path}} && php artisan horizon:terminate --no-interaction');
run('cd {{current_path}} && php artisan schedule:interrupt --no-interaction');
run('cd {{current_path}} && (php artisan inertia:stop-ssr --no-interaction || true)');
});
horizon:terminate lets workers finish the job in hand. The SSR stop is allowed to fail because on a fresh host there may be no SSR process yet. This is also why queued jobs must stay compatible across one release: a job pushed by release 41 can be picked up by a worker on release 42. Laravel queues in depth covers that part.
One automatic rollback, never two
After the switch, the recipe asks the real site whether it is ready. The request goes to the local Nginx but with the public host name, pinned with curl --resolve, so TLS and TrustHosts behave as they do for users:
set('smoke_command', function (): string {
$host = getenv('DEPLOY_SMOKE_HOST');
if ($host === false || preg_match('/\A[a-z0-9.-]+\z/i', $host) !== 1) {
throw new \RuntimeException('DEPLOY_SMOKE_HOST must be a host name.');
}
return sprintf(
'curl --fail --silent --show-error --max-time 5 --resolve %1$s:443:127.0.0.1 https://%1$s/health/ready > /dev/null',
$host,
);
});
function readinessPasses(): bool
{
for ($attempt = 1; $attempt <= 3; $attempt++) {
if (test(get('smoke_command'))) {
return true;
}
writeln(sprintf('<comment>Readiness attempt %d/3 failed.</comment>', $attempt));
if ($attempt < 3) {
run('sleep 5');
}
}
return false;
}
task('deploy:smoke:live', function (): void {
if (readinessPasses()) {
return;
}
$failedRelease = run('readlink -f {{current_path}}');
$healthy = (string) get('healthy_release');
if ($healthy === '' || $healthy === $failedRelease || ! test('[ -d '.escapeshellarg($healthy).' ]')) {
throw new \RuntimeException('Readiness failed after the switch and there is no healthy release to return to.');
}
run('cd {{deploy_path}} && ln -sfn '.escapeshellarg($healthy).' current.rollback && mv -T current.rollback current');
run('printf "%s\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ) readiness failed" > '.escapeshellarg($failedRelease.'/BAD_RELEASE'));
invoke('deploy:restart_workers');
if (! readinessPasses()) {
run('printf "%s\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ) rollback target unhealthy" > {{deploy_path}}/.dep/deploy_blocked');
throw new \RuntimeException('Rolled back, but the previous release fails readiness too. Deploys are blocked.');
}
throw new \RuntimeException('Rolled back to '.basename($healthy).'.');
});
task('deploy:check_blocked', function (): void {
if (test('[ -f {{deploy_path}}/.dep/deploy_blocked ]')) {
throw new \RuntimeException('Deploys are blocked after a failed rollback (.dep/deploy_blocked).');
}
});
Three details matter here. The rollback builds a temporary symlink and renames it over current, the same atomic move as the forward switch (the built-in rollback task uses ln -nfs directly). It writes BAD_RELEASE into the failed directory, which is the marker the built-in dep rollback already checks before choosing a candidate. And it happens once. If the old release is also unhealthy, the problem is probably the database, Redis or the disk, and flipping between two releases would only hide that. The recipe writes .dep/deploy_blocked and every later deploy stops at the first task until a person deletes that file. In every branch the deploy fails, so the workflow run is red and nobody reads a rollback as a success.
The starter's full file also sends logger -p user.crit lines that its alerting picks up. I left them out here to keep the flow readable.
The task list
The stock list runs artisan:optimize and artisan:migrate unconditionally, so I define deploy explicitly:
task('deploy', [
'deploy:check_blocked',
'deploy:prepare',
'deploy:vendors',
'artisan:storage:link',
'artisan:config:cache',
'artisan:route:cache',
'artisan:view:cache',
'artisan:event:cache',
'deploy:migrate',
'deploy:smoke',
'deploy:remember_healthy',
'deploy:symlink',
'deploy:restart_workers',
'deploy:smoke:live',
'deploy:unlock',
'deploy:cleanup',
'deploy:success',
]);
after('deploy:failed', 'deploy:unlock');
Everything above deploy:symlink can fail without users noticing. Everything below it is covered by the rollback. after('deploy:failed', 'deploy:unlock') releases the lock on failure, so the next run does not need dep deploy:unlock first.
Running it from GitHub Actions
The deploy job downloads the artifact built by the previous job, checks the SHA-256 and that the manifest's commit equals GITHUB_SHA, installs Deployer from composer.json (deployer/deployer: ^8.0 in require-dev), writes the SSH key and a pinned known_hosts, then runs:
export DEPLOY_ARTIFACT_SHA256="$(cut -d ' ' -f 1 .release/starter.tar.gz.sha256)"
vendor/bin/dep deploy "$DEPLOY_ENVIRONMENT" --file=deploy.php --revision="$GITHUB_SHA" --ansi
The workflow is workflow_dispatch only, with concurrency: deploy-${{ inputs.environment }} and cancel-in-progress: false, so two deploys never overlap and a running migration is never cancelled. Staging and production are GitHub environments. The SSH key is an environment secret, so it exists only inside this job, and required reviewers on production are where a person approves the release (check that your GitHub plan offers them for private repositories). The GitHub Actions side of a symlink deploy without Deployer is in zero-downtime deployment with GitHub Actions.
When not to use this
- You run containers. On Kubernetes or Docker Swarm the image is the artifact and the orchestrator does the switch and the rollback. Deployer would duplicate both.
- You have several web servers behind a load balancer. Deployer can target many hosts, but the switch is no longer atomic across them. Per-host health checks belong to the load balancer.
- A managed platform already does it. Laravel Cloud, Forge with Envoyer and similar services give you release directories and health checks without a recipe to maintain.
- Your migrations are not additive. Then no deploy tool gives you zero downtime. Plan a maintenance window and say so.
For one VM running Nginx and PHP-FPM, the full recipe is about 230 lines of PHP, and every step it takes is a named task you can read with dep tree deploy.
If you want this set up on your servers, or a second pair of eyes on a deploy pipeline your team already runs, see what I offer.
Sources
- Deployer 8 source,
recipe/laravel.php,recipe/common.php,recipe/deploy/*.php, tag v8.0.5 (accessed 2026-10-27) - Deployer: Avoid PHP-FPM reloading (accessed 2026-10-27)
- Deployer: Laravel recipe (accessed 2026-10-27)
- Laravel 13: Horizon, deploying (accessed 2026-10-27)
- Laravel 13: Task scheduling, interrupting sub-minute tasks (accessed 2026-10-27)
- GitHub Actions: Using environments for deployment (accessed 2026-10-27)