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, confirmed server binaries) so a failed deploy never takes the site down and rollback is a symlink flip.
Steps at a glance:
- Install the Deployer CLI — Get
depon thePATHso every later task can run. - 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.envwarning below). - Create and configure
deploy.php— On a first run, copy the template; on a rerun, never blindlycpover an existingdeploy.php— it holds your real hostnames, SSH ports, users, and binary paths. - Confirm the server binary paths — Shared hosting often exposes the wrong
/usr/bin/php. Confirm the domain’s deploy PHP and pin Deployer’s PHP binary to it; Phase 5 writes the Composer platform pin immediately before the first live deploy. - 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.
- Validate, then verify SSH access — Prove the file parses and the servers are reachable before any real deploy.
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 <target>"] --> 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/deployerdep --version | grep -E 'Deployer +7\.'# Expected: "Deployer 7.x.x" after install or from the existing dep binary- ✅
depis installed and reports Deployer 7.x. If it reports Deployer 6 or 8+, use the matching docs before continuing.
- ✅
-
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;after('deploy:symlink', 'artisan:storage:link')so each fresh release re-createspublic/storage→ sharedstorage/app/public; and any cache-warm / queue-restart hooks that should run after the current symlink flips -
✅
shared_dirs,shared_files,keep_releases, the storage-link hook, and the migrate/cache hooks are all defined.
-
-
Map the template branch names to this project’s branch model. Templates often use
stagingandproductionbranch names, while setup-new projects commonly usedevelopas the default work branch andmainas the protected release/tag branch. ReadZaj-PROJECT.md, then set each Deployer host’s branch/source explicitly instead of inferring it from the target name.host('staging')->set('branch', 'develop'); // selected non-production source branch from Zaj-PROJECT.mdhost('production')->set('branch', 'main'); // release/tag branch, not the default PR target- ✅ Deployer target names (
staging,qa,production) are mapped to the actual Git branches recorded inZaj-PROJECT.md.
- ✅ Deployer target names (
-
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. Confirm the server binary paths
Section titled “4. Confirm the server binary paths”Shared hosting often exposes the wrong /usr/bin/php. Confirm the correct PHP per host so Deployer uses the versioned CLI binary your selected environment row records. This is an [AUTHOR] step: Phase 4 records the deploy PHP facts and proves the dry-run graph; Phase 5 writes the Composer platform pin immediately before the first live deploy.
-
Discover the available PHP binaries on the server.
Terminal window DEPLOY_TARGET="<selected-nonproduction-deploy-target>" # from Zaj-PROJECT.mddep run 'ls /usr/bin/php* /opt/alt/php*/usr/bin/php 2>/dev/null | sort' --selector "alias=$DEPLOY_TARGET"# Expected: absolute paths to the PHP binaries present on the host- ✅ You have candidate versioned PHP binaries. Do not rely on login-shell Composer; Deployer’s vendors task will verify the configured Composer install path under the selected PHP.
-
Confirm both server PHP surfaces. On shared hosting, web PHP and CLI PHP are separate. The hosting panel controls the domain’s web PHP; Deployer runs the versioned CLI binary.
Terminal window BIN_PHP="/opt/alt/php83/usr/bin/php" # example: versioned binary, not login-shell phpdep run "$BIN_PHP -v | head -1" --selector "alias=$DEPLOY_TARGET"dep run "php -v | head -1" --selector "alias=$DEPLOY_TARGET" # diagnostic only — login-shell PHP may differ# Expected: BIN_PHP is the confirmed deploy PHP. Bare php may differ and is not the deploy proof.- ✅
Zaj-PROJECT.mdrecords the domain web PHP and the versioned CLIBIN_PHPpath for the selected non-production and production rows.
- ✅
-
Pin
bin/phpto the domain’s deploy PHP path.set('bin/php', '/opt/alt/php83/usr/bin/php'); // match the confirmed deploy PHP binary- ✅
bin/phppoints at the confirmed versioned deploy PHP binary.
- ✅
-
Record the Phase 5 platform-pin carry-forward. Do not write Composer’s platform setting in Phase 4. Store the confirmed deploy PHP version, the web-PHP selector value, and the versioned CLI binary in
Zaj-PROJECT.md; Phase 5 page 1 consumes those facts and commits the Composer pin before the first deploy.Terminal window DEPLOY_PHP_VERSION="$(dep run "$BIN_PHP -r 'echo PHP_VERSION;'" --selector "alias=$DEPLOY_TARGET" 2>/dev/null | tail -1)"test -n "$DEPLOY_PHP_VERSION" || { echo "Could not read deploy PHP version — fix BIN_PHP before continuing"; exit 1; }printf 'Phase 5 carry-forward: pin Composer platform to deploy PHP %s from %s\n' "$DEPLOY_PHP_VERSION" "$BIN_PHP"# Expected: a non-empty deploy PHP version and a carry-forward note for Phase 5- ✅
Zaj-PROJECT.mdrecords the deploy PHP version, web PHP, versioned CLI binary, and a Phase 5 carry-forward to write the Composer platform pin.
- ✅
-
Commit the Deployer runtime facts with the deploy pipeline changes.
Terminal window git add deploy.php Zaj-PROJECT.mdgit commit -m "Configure Deployer PHP paths"# Expected: deploy.php plus non-secret runtime facts committed together- ✅ The Deployer PHP path and
Zaj-PROJECT.mdruntime facts are version-controlled together. Composer platform metadata is intentionally still untouched until Phase 5.
- ✅ The Deployer PHP path and
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 Deployer hosts without old remote-command syntax.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, then SSH-test the aliases from Zaj-PROJECT.md.grep -n "^host(" deploy.phpDEPLOY_SSH_ALIAS="<selected-non-production-ssh-alias-from-Zaj-PROJECT>"PROD_SSH_ALIAS="<production-ssh-alias-from-Zaj-PROJECT>"ssh -o BatchMode=yes -o ConnectTimeout=8 "$DEPLOY_SSH_ALIAS" 'pwd && php -v | head -1'ssh -o BatchMode=yes -o ConnectTimeout=8 "$PROD_SSH_ALIAS" 'pwd && php -v | head -1'# Expected: host() rows match the deploy targets; both direct SSH checks exit 0- ✅
php -lis clean,dep listshows 20+ tasks, everyhost()grep-matches, and direct SSH reaches the selected aliases fromZaj-PROJECT.md.
- ✅
-
Verify the Deployer graph with a dry deploy.
Terminal window DEPLOY_TARGET="<selected-nonproduction-deploy-target>" # from Zaj-PROJECT.mddep deploy "$DEPLOY_TARGET" --plan | tee /tmp/dep-plan.txtgrep -E 'deploy:vendors|deploy:symlink' /tmp/dep-plan.txt# Expected: dry-run plan parses the selected target and reaches deploy:vendors + deploy:symlink- ✅
dep deploy "$DEPLOY_TARGET" --planreaches the vendor install and symlink steps without mutating the server.
- ✅
-
Confirm the servers can reach GitHub and have the right tools.
Terminal window dep run "ssh -T git@github.com 2>&1 | tee /tmp/github-ssh-test.txt" --selector "alias=$DEPLOY_TARGET"dep run "grep -E 'successfully authenticated|Hi .*successfully authenticated' /tmp/github-ssh-test.txt && echo 'GitHub deploy key OK'" --selector "alias=$DEPLOY_TARGET"dep run "$BIN_PHP -v | head -1" --selector "alias=$DEPLOY_TARGET" # match Zaj-PROJECT.md# Expected: GitHub banner confirms auth, and the server reports the confirmed deploy PHP version- ✅ GitHub authenticates from the server and the versioned PHP binary matches
Zaj-PROJECT.md. Phase 5 owns the Composer platform pin.
- ✅ GitHub authenticates from the server and the versioned PHP binary matches
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, anartisan:storage:linkhook (sopublic/storageexists per release), and migrate/cache/queue hooks. - 🤖 PHP confirmed —
bin/phpset to the confirmed domain deploy PHP,Zaj-PROJECT.mdrecords web + CLI PHP, and the Phase 5 Composer platform-pin carry-forward is explicit. - 🤖 First-deploy migrations off — the migrate hook is commented out.
- 🔀 SSH + dry run — direct SSH reaches the selected aliases, server GitHub auth works, and
dep deploy "$DEPLOY_TARGET" --planreaches the symlink step for the selected non-production target.