1 · Deployer (zero-downtime)
Objective — install Deployer and author deploy.php for atomic, zero-downtime releases (symlinked current → timestamped releases, shared storage + .env, retention, hooks, pinned server binaries) so a failed deploy never takes the site down and rollback is a symlink flip.
Background
Section titled “Background”Deployer gives you atomic, zero-downtime releases: each deploy builds a fresh timestamped directory, links shared state into it, runs your hooks, and only on success flips a current symlink. A failed deploy never takes the site down, and rollback is a symlink flip back to the previous release.
flowchart TD D["dep deploy staging"] --> R["releases/2026-06-09-1/"] R --> SH["link shared: storage/, .env"] SH --> H["hooks: migrate · cache warm · queue restart"] H --> SY["flip current → new release"] SY --> OK["live ✅ (old release kept for rollback)"] style SY fill:#0b3,stroke:#062,color:#fff1. Install the Deployer CLI
Section titled “1. Install the Deployer CLI”Get dep on the PATH so every later task can run.
-
Check for, then install, Deployer.
Terminal window dep --version # already installed?composer global require deployer/deployer# Expected: a version line, or a fresh global install of deployer/deployer- ✅
depis installed (globally via Composer).
- ✅
-
Add the Composer global bin to
PATHifdepis still not found.Terminal window COMPOSER_BIN="$(composer global config bin-dir --absolute)"grep -q "$COMPOSER_BIN" ~/.zshrc \|| echo "export PATH=\"\$PATH:$COMPOSER_BIN\"" >> ~/.zshrcsource ~/.zshrcdep --version# Expected: dep --version returns "Deployer 7.x.x"- ✅
dep --versionreturnsDeployer 7.x.x.
- ✅
2. Learn the configuration API before touching deploy.php
Section titled “2. Learn the configuration API before touching deploy.php”Deployer’s DSL has four functions that look interchangeable but behave very differently. Picking the wrong one silently overrides recipe defaults — the exact bug class behind a real 90-minute production debug session (see the .env warning below).
| Function | Semantics | Use when |
|---|---|---|
set('key', $v) | Replaces the value, overriding recipe defaults | Defining a value from scratch, or intentionally overriding a default |
add('key', $v) | Merges into an existing array, preserving defaults | Extending a recipe-default list with your own entries |
get('key') | Reads the current value | Referencing a value inside a task closure |
has('key') | Checks whether a value is defined | Conditional logic on a setting |
Laravel recipe array defaults you can silently destroy with set():
| Setting | Recipe default | If you set() it wrong |
|---|---|---|
shared_files | ['.env'] | Drops .env from the symlink list — every release boots on .env.example values. |
shared_dirs | ['storage'] | Logs, uploads, and framework cache get wiped on every deploy. |
writable_dirs | ['bootstrap/cache', 'storage', 'storage/app', 'storage/app/public', 'storage/framework', 'storage/framework/cache', 'storage/framework/sessions', 'storage/framework/views', 'storage/logs'] | A shorter replacement list means Laravel cannot write missing paths at runtime → 500s after deploy. |
clear_paths | [] | Safe to set() or add(); use add() for symmetry with the GIT_ONLY_PATHS check on the CI page. |
keep_releases | 10 | Safe to set() — scalar, not array. |
bin/php | Auto-detected | Safe to set() — you usually want to override detection on shared hosting. |
Quick audit: grep -nE "^\\s*set\\('(shared_files|shared_dirs|writable_dirs|clear_paths)'" deploy.php — any hit on an array key means you must confirm the replacement includes every recipe-default entry, or switch to add().
3. Create and configure deploy.php
Section titled “3. Create and configure deploy.php”On a first run, copy the template; on a rerun, never blindly cp over an existing deploy.php — it holds your real hostnames, SSH ports, users, and binary paths. Reconcile instead.
-
Detect first-run vs rerun before copying anything.
Terminal window [ -f deploy.php ] && echo "RERUN — reconcile, do not overwrite" || echo "FIRST RUN — cp template"# Expected: "FIRST RUN" on a fresh project, "RERUN" if deploy.php already exists- ✅ You know whether to copy the template or reconcile an existing file.
-
Define the core settings. At minimum:
-
shared_dirs→storage(persist uploads/logs/sessions across releases) -
shared_files→.env(the production env lives server-side, shared) -
keep_releases→ e.g.5(retention for fast rollback) -
hooks →
after('deploy:vendors', 'artisan:migrate')for migrations, andafter('deploy:symlink', …)for cache warm / queue restart hooks that should run after the current symlink flips -
✅
shared_dirs,shared_files,keep_releases, and the hooks are all defined.
-
-
Write
shared_dirs/shared_filescorrectly — two silent footguns.set('shared_dirs', ['storage', // all of storage — DO NOT list subdirs like storage/app]);// ✅ add() MERGES with the recipe default ['.env'] — .env stays sharedadd('shared_files', [// '.env' is already in the recipe default — do NOT repeat it// add only files OUTSIDE any shared_dirs directory]);- ✅
storageis the only shared dir (no nested subdirs);.envis shared viaadd(), notset().
- ✅
-
Register extra migration paths — module-based CodeCanyon apps ship migrations outside
database/migrations/, and the recipe won’t run them unless you list each path.Terminal window find packages -type d -name "Migrations" 2>/dev/null# Expected: one path per module that ships its own migrationsset('extra_migration_paths', ['packages/<vendor>/*/src/Database/Migrations','packages/<modules-namespace>/*/src/Database/Migrations','database/migrations-<suffix>',]);set('has_migration_installer_check', true);- ✅ Every
Migrationsdirectory found byfind packagesis covered by a pattern inextra_migration_paths.
- ✅ Every
Don’t nest shared dirs — 'storage' already includes storage/app, storage/logs, etc. Listing a subdir alongside it breaks symlink creation.
4. Pin the server binary paths
Section titled “4. Pin the server binary paths”Shared hosting often exposes the wrong /usr/bin/php. Pin the correct PHP (and Composer) per host so Deployer and your app run on the same version your composer.json requires.
-
Discover the available PHP binaries on the server.
Terminal window dep ssh staging 'ls /usr/bin/php* /opt/alt/php*/usr/bin/php 2>/dev/null | sort; command -v composer'# Expected: absolute paths to the PHP binaries present on the host, plus Composer- ✅ You have the real path to the PHP version
composer.jsonrequires.
- ✅ You have the real path to the PHP version
-
Pin
bin/phpto that path.set('bin/php', '/usr/bin/php8.x'); // match composer.json's required version- ✅
bin/phppoints at the PHP versioncomposer.jsonrequires.
- ✅
5. Disable migrations for the first deploy
Section titled “5. Disable migrations for the first deploy”Running migrations before the vendor installer wizard causes schema conflicts. Comment the hook out for deploy #1, then re-enable after the installer runs in Phase 5.
-
Comment out the migrate hook.
// after('deploy:vendors', 'artisan:migrate');- ✅ The migrate hook is commented out for the first deploy.
-
Confirm it’s disabled.
Terminal window grep -E "^\s*after\('deploy:vendors', 'artisan:migrate'\);" deploy.php \&& echo "ENABLED" || echo "DISABLED — correct for first deploy"# Expected: "DISABLED — correct for first deploy"- ✅ The grep reports
DISABLED — correct for first deploy.
- ✅ The grep reports
6. Validate, then verify SSH access
Section titled “6. Validate, then verify SSH access”Prove the file parses and the servers are reachable before any real deploy.
-
Syntax-check and parse
deploy.php, then enumerate hosts the Deployer-7 way.Terminal window php -l deploy.php # PHP syntax validdep list 2>&1 | tail -30 # Deployer parses the file — expect 20+ tasks grouped by namespace# Expected: "No syntax errors detected", then a task catalog with no PHP/Deployer errorsTerminal window # Deployer 7 removed the old host-listing commands. These now fail:# dep config:hosts, dep hosts, dep list hosts# Correct host enumeration: parse deploy.php directly, then SSH-test each host.grep -n "^host(" deploy.phpfor h in $(grep -oP "(?<=^host\\(')[^']+" deploy.php); doprintf " %s: " "$h"; dep ssh "$h" "echo OK" 2>&1 | tail -1done# Expected: each defined host grep-matches and answers "OK" over SSH- ✅
php -lis clean,dep listshows 20+ tasks, everyhost()grep-matches, and each answersOK.
- ✅
-
Verify SSH to both hosts and run a dry deploy.
Terminal window dep ssh staging "echo 'Staging SSH OK'"dep ssh production "echo 'Production SSH OK'"dep deploy staging --plan # dry run — no changes made# Expected: both "SSH OK" lines, then a dry-run plan reaching the symlink step- ✅ Both hosts answer and
dep deploy staging --planreaches the symlink step.
- ✅ Both hosts answer and
-
Confirm the servers can reach GitHub and have the right tools.
Terminal window dep ssh staging "ssh -T git@github.com 2>&1 | tee /tmp/github-ssh-test.txt"dep ssh staging "grep -q 'successfully authenticated' /tmp/github-ssh-test.txt && echo 'GitHub deploy key OK'"dep ssh staging "php -v | head -1" # match composer.json# Expected: GitHub banner confirms auth, and the server reports the PHP version required by composer.json- ✅ GitHub authenticates from the server and PHP is 8.1+.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Cause | Fix |
|---|---|---|
dep: command not found | Composer bin not on PATH | Add to ~/.zshrc and source it |
| Cannot create symlink | Nested shared dirs | Keep only root folders (storage, not storage/app) |
| SSH timeout | Wrong port/IP | Match ~/.ssh/config |
PENDING MIGRATIONS DETECTED | Unapplied migrations on server | Run manually or deploy with --allow-pending |
Checklist
Section titled “Checklist”Do not mark this step done until every box below is checked.
- 🤖 Deployer installed —
dep --versionreturns Deployer 7.x;php -l deploy.phpclean;dep listshows 20+ tasks. - 🤖 Shared state correct —
deploy.phpsharesstorage/+.envviaadd()(neverset([])), with retention and migrate/cache/queue hooks. - 🤖 PHP pinned —
bin/phpset to the versioncomposer.jsonrequires. - 🤖 First-deploy migrations off — the migrate hook is commented out.
- 🔀 SSH + dry run — both hosts reachable;
dep deploy staging --planreaches the symlink step.