1 · Pre-flight & provision host
Objective — get four things right before the first byte ships: the Phase 4 handoff is complete, Composer is pinned to the confirmed deploy PHP, the selected non-production branch/target is current, and the server scaffolding (deploy tree, PHP limits, .env) exists — because a pre-flight failure is cheap and a deploy:vendors failure three minutes into a release is not.
Steps at a glance:
- Consume the Phase 4 handoff and pin Composer — Phase 4 confirms the deploy PHP; this page writes the Composer platform pin and refreshes the lock metadata before the first live deploy.
- Run the canonical pre-flight — Do not deploy blind. Preview the full pipeline, then confirm the PHP version the server actually runs — not just the binary Deployer points at.
- Confirm the deploy-safety flags — The recipe disables and enables a few tasks deliberately. Confirm the flags are set the way the pipeline expects before you trust the plan.
- Spot-check skip-policy gating — Optional tasks (AWS backup, Atlas, ZajModule migrations, Livewire publish, Sentry/Slack/Discord) must each carry a graceful-skip guard so a feature you haven’t configured can’t hard-fail the deploy.
- Prepare the non-production branch — Bring the source branch from
Zaj-PROJECT.mdcurrent withdevelopand push it, then confirm the host config and SSH still resolve. - Clear the server composer cache — Shared hosts carry stale composer cache from prior projects — even an
identical
composer.lockcan pull old cached package versions with extra migration files. - Take a pre-deploy snapshot — Capture the current local DB before the first non-production deploy so you have a rollback reference.
- Scaffold the server — Create the deploy directory tree, set the PHP limits the installer needs, then
place the selected environment’s
.env— three commands that prepare the host for its first release. - Scan the
.envfor placeholders — A deploy fails if.envstill has unfilled placeholders or empty required values. Catch them before you ship.
Background
Section titled “Background”Everything in this phase rides on four things being right before the first byte ships: the Phase 4 handoff is complete, the server PHP actually satisfies composer.json, the selected non-production branch/target is current, and the server scaffolding (deploy tree, PHP limits, .env) exists. A pre-flight failure is cheap; a deploy:vendors failure three minutes into a release is not.
0. Load the selected environment row
Section titled “0. Load the selected environment row”Read Zaj-PROJECT.md first and choose the non-production environment row you are deploying to. If the project has more than one non-production target, do not guess — use the row the operator selected for this pass.
ENV_KEY="staging-primary" # example; use the real Zaj-PROJECT.md row keyDEPLOY_TARGET="<selected-nonproduction-deploy-target>" # Deployer host key from Zaj-PROJECT.mdSOURCE_BRANCH="develop" # example; use the source branch from Zaj-PROJECT.mdSSH_ALIAS="<non-prod-alias>" # SSH alias from Zaj-PROJECT.mdHOSTING_ACCOUNT="<hostinger-account-ref>"DEPLOY_DOMAIN="nonprod.example.com"DEPLOY_PATH="~/domains/$DEPLOY_DOMAIN/deploy"# Expected: values match one Zaj-PROJECT.md non-production row, including the hosting account- ✅ The environment key, deploy target, source branch, hosting account, SSH alias, domain, and deploy path all match
Zaj-PROJECT.md.
1. Consume the Phase 4 handoff and pin Composer
Section titled “1. Consume the Phase 4 handoff and pin Composer”Phase 4 confirmed the deploy PHP and authored the pipeline. This step writes the Composer platform pin for the selected non-production target and proves the lock still installs before any server mutation happens.
-
Verify the Phase 4 handoff evidence exists.
Terminal window test -f deploy.php && php -l deploy.phpdep deploy "$DEPLOY_TARGET" --plan 2>&1 | grep -E 'deploy:vendors|deploy:symlink'grep -n "set('bin/php'" deploy.php# Expected: deploy.php parses, the plan reaches vendors + symlink, and bin/php is configured- ✅ Phase 4’s AUTHOR proof is present for the selected target.
-
Pin Composer to the confirmed deploy PHP and refresh lock metadata.
Terminal window BIN_PHP=$(grep "set('bin/php'" deploy.php | grep -oE "/[^']+")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 pinning"; exit 1; }composer config platform.php "$DEPLOY_PHP_VERSION"composer update --lockcomposer prohibits php "$DEPLOY_PHP_VERSION" --locked --treecomposer check-platform-reqs --lock --no-devcomposer install --dry-run --no-devgit diff -- composer.json composer.lock# Expected: platform.php matches the confirmed deploy PHP; lock metadata refreshed;# dry-run succeeds; no package version changes unless intentionally resolving a blocker- ✅
composer.jsonandcomposer.lockagree with the confirmed deploy PHP, andcomposer install --dry-run --no-devsucceeds.
- ✅
-
Commit the platform pin before the first deploy.
Terminal window git add composer.json composer.lock Zaj-PROJECT.mdgit commit -m "Pin Composer platform for non-production deploy"# Expected: one commit with the Composer platform/lock metadata and project-state update- ✅ Composer platform metadata and
Zaj-PROJECT.mdare version-controlled before the first live deploy.
- ✅ Composer platform metadata and
2. Run the canonical pre-flight
Section titled “2. Run the canonical pre-flight”Do not deploy blind. Preview the full pipeline, then confirm the PHP version the server actually runs — not just the binary Deployer points at.
-
Preview the plan.
Terminal window # Deployer 7 uses --plan; the old --dry-run was removed and errors outdep deploy "$DEPLOY_TARGET" --plan 2>&1 | tail -60# Expected: a ~40–50 task pipeline including the gates that matterYou should see a ~40–50 task pipeline. The exact list depends on enabled features (Atlas, AWS backup, Sentry, Slack/Discord, module migrations, Livewire publish), but the canonical shape includes the gates that matter:
deploy:check_existing_pending ← BLOCKS if staging has pending migrationsdeploy:verify_clear_paths ← BLOCKS if a sensitive file survives clear_pathsdeploy:vendors ← composer install — fails here on PHP mismatchdeploy:verify_migrationsdeploy:smoke_test ← BLOCKS before the symlink switchdeploy:symlink ← atomic cutoverdeploy:health_check ← BLOCKS after symlink; auto-rollback on failure- ✅ The plan previews a sane ~40–50 task pipeline with the gates above present.
-
Triple-check PHP —
composer.json↔ Deployerbin/php↔ Composer runtime, with the web SAPI captured by hPanel now and page 4 later.Terminal window # 1. What does composer.json require?grep '"php"' composer.json | head -1# 2. What does deploy.php point bin/php at, and what does that binary report?BIN_PHP=$(grep "set('bin/php'" deploy.php | grep -oE "/[^']+")dep run "$BIN_PHP -v | head -1" --selector "alias=$DEPLOY_TARGET"# 3. Does bin/composer run through the same configured PHP?grep -n "set('bin/composer'.*bin/php" deploy.phpdep run "$BIN_PHP -r 'echo PHP_VERSION.PHP_EOL;'" --selector "alias=$DEPLOY_TARGET"# Expected: composer.json's PHP requirement is satisfied by the configured deploy PHP,# and bin/composer is wired through bin/php (not bare login-shell php)- ✅
bin/phpsatisfiescomposer.json, andbin/composeris wired throughbin/php.
- ✅
Then cross-check the control panel: hPanel → Websites → [domain] → Advanced → PHP Configuration. On shared hosting the web-serving PHP is set there per-domain — not by bin/php in deploy.php.
Use the table to read the result of the triple-check:
| Pattern | Meaning | Action |
|---|---|---|
bin/php and bin/composer satisfy composer.json | Safe for deploy:vendors | Proceed |
bin/composer does not include bin/php | Composer may run under login-shell PHP | Fix deploy.php, re-run the plan |
| hPanel/web SAPI is lower than the app needs | Browser requests or installer checks may fail | Upgrade PHP in hPanel, wait ~60s, re-check |
bin/php path doesn’t exist on the server | bin/php is stale | Re-run the SSH PHP discovery from Phase 4 |
The three layers and which one actually runs composer install:
flowchart TD CJ["composer.json<br/>requires PHP ^8.x"] BP["deploy.php bin/php<br/>(CLI binary)"] BC["deploy.php bin/composer<br/>(must use bin/php)"] WS["web SAPI php<br/>(hPanel + installer screen)"] CI["composer install<br/>(deploy:vendors)"] BP --> BC BC --> CI CJ -->|must be satisfied by| CI WS -.->|browser request truth| CI3. Confirm the deploy-safety flags
Section titled “3. Confirm the deploy-safety flags”The recipe disables and enables a few tasks deliberately. Confirm the flags are set the way the pipeline expects before you trust the plan.
-
Confirm the three deploy-safety flags.
Terminal window grep -n "artisan:migrate.*disable" deploy.php # expect: task('artisan:migrate')->disable();grep -n "clear_paths_strict" deploy.php # expect: set('clear_paths_strict', true);grep -n "atlas_enabled" deploy.php # expect: set('atlas_enabled', true);# Expected: each grep prints its matching line- ✅
artisan:migrateis disabled,clear_paths_strictistrue, andatlas_enabledis set.
- ✅
4. Spot-check skip-policy gating
Section titled “4. Spot-check skip-policy gating”Optional tasks (AWS backup, Atlas, ZajModule migrations, Livewire publish, Sentry/Slack/Discord) must each carry a graceful-skip guard so a feature you haven’t configured can’t hard-fail the deploy. Confirm each guards on missing config.
-
Scan each optional task for a skip guard.
Terminal window for t in deploy:aws_backup deploy:atlas_preflight deploy:zajmodule_migrations \livewire:publish_assets sentry:release slack:notify:success; doecho "=== $t ==="awk -v t="$t" '$0 ~ "task(\047" t "\047" { grab=1 }grab && /^task\(/ && $0 !~ "task(\047" t "\047" { exit }grab { print }' deploy.php \| grep -E "if \(!get\(|if \(empty|NOT_FOUND|return;" | head -3 | sed 's/^/ /'done# Expected: each task prints at least one if (!get('..._enabled')) / if (empty(...)) / NOT_FOUND … return guard- ✅ Every optional task prints at least one graceful-skip guard; none is unguarded.
A task with zero guards may abort the pipeline when its feature isn’t configured — read its full body before you deploy.
5. Prepare the selected non-production branch
Section titled “5. Prepare the selected non-production branch”Bring the selected source branch from Zaj-PROJECT.md current with develop and push it, then confirm the host config and SSH still resolve.
-
Confirm the selected branch matches the Deployer host block, then push it.
Terminal window git status --short # expect: clean before branch workgit fetch origin develop "$SOURCE_BRANCH" --no-tagsDEPLOY_BRANCH=$(awk -v host="host('$DEPLOY_TARGET')" '$0 ~ host { in_host=1 }in_host && /set\('\''branch'\''/ {match($0, /set\('\''branch'\'', *'\''([^'\'']+)/, m); print m[1]; exit}in_host && /^host\('/ && $0 !~ host { exit }' deploy.php)test -n "$DEPLOY_BRANCH" || { echo "STOP — no branch set for $DEPLOY_TARGET in deploy.php"; exit 1; }test "$DEPLOY_BRANCH" = "$SOURCE_BRANCH" || {echo "STOP — Zaj-PROJECT says $SOURCE_BRANCH, deploy.php says $DEPLOY_BRANCH";exit 1;}git checkout "$SOURCE_BRANCH" || git checkout -b "$SOURCE_BRANCH" "origin/$SOURCE_BRANCH"if [ "$SOURCE_BRANCH" != "develop" ]; thengit merge developfigit push -u origin "$SOURCE_BRANCH"# Expected: selected non-production branch matches deploy.php and is pushed to origin- ✅ The selected non-production branch matches the Deployer host’s
branchsetting and is pushed to origin.
- ✅ The selected non-production branch matches the Deployer host’s
-
Confirm the host config and SSH resolve.
Terminal window grep -A5 "host('$DEPLOY_TARGET')" deploy.php # correct hostname, remote_user, deploy_pathssh "$SSH_ALIAS" "echo 'Non-production SSH OK'"# Expected: deploy.php host block looks right; SSH prints "Non-production SSH OK"- ✅ The selected Deployer host block is correct and SSH responds via the alias.
6. Clear the server composer cache (first deploy only)
Section titled “6. Clear the server composer cache (first deploy only)”Shared hosts carry stale composer cache from prior projects — even an identical composer.lock can pull old cached package versions with extra migration files.
-
Clear the server’s composer cache.
Terminal window dep clear_composer_cache "$DEPLOY_TARGET"# manual equivalent:ssh "$SSH_ALIAS" 'CACHE_DIR=~/.cache/composer/files; [ -d "$CACHE_DIR" ] && mv "$CACHE_DIR" "${CACHE_DIR}_backup_$(date +%Y%m%d%H%M%S)"; mkdir -p "$CACHE_DIR"; find "$CACHE_DIR" -mindepth 1 | wc -l'# Expected: 0- ✅ The server composer cache is empty (
0).
- ✅ The server composer cache is empty (
Run this on a first deploy, after major composer.lock changes, or when you hit mysterious migration conflicts.
7. Take a pre-deploy snapshot
Section titled “7. Take a pre-deploy snapshot”Capture the current local DB before the first non-production deploy so you have a rollback reference.
-
Snapshot the current local DB.
Terminal window SNAP="Admin-Local/1-Project/6-Schema/2-Snapshots/$(date +%Y-%m-%d)-pre-${ENV_KEY}"mkdir -p "$SNAP"cp Admin-Local/1-Project/6-Schema/1-Current/local.sql "$SNAP/local.sql"echo "Pre-first-${ENV_KEY}-deploy snapshot" > "$SNAP/notes.md"# Expected: a dated snapshot dir holding local.sql + notes.md- ✅ A dated pre-
$ENV_KEYsnapshot exists withlocal.sqlandnotes.md.
- ✅ A dated pre-
8. Scaffold the server
Section titled “8. Scaffold the server”Create the deploy directory tree, set the PHP limits the installer needs, then place the selected environment’s .env — three commands that prepare the host for its first release.
-
Create the server directory tree.
Terminal window ssh "$SSH_ALIAS" "mkdir -p $DEPLOY_PATH/shared/storage"# Expected: no output (directories created)- ✅
deploy/shared/storageexists on the server.
- ✅
-
Set the PHP limits the installer needs.
Terminal window # Feed the heredoc to the LOCAL ssh's stdin (EOF at column 0 here), and let# ssh forward it to the remote `cat`. Keep the heredoc outside the remote# quoted command so the terminator is controlled by the local shell.ssh "$SSH_ALIAS" "cat > $DEPLOY_PATH/shared/.user.ini" <<'EOF'max_execution_time = 300memory_limit = 512Mmax_input_time = 300upload_max_filesize = 64Mpost_max_size = 64MEOF# Expected: shared/.user.ini written with the installer limits- ✅
shared/.user.inisetsmax_execution_time = 300and the upload limits.
- ✅
-
Place the selected environment
.env— always via the SSH alias, never rawuser@IP:port. Prefer rendering from.env.tpland 1Password so no filled env file lives on disk.Terminal window TMP_ENV="$(mktemp)"op inject -f -i .env.tpl -o "$TMP_ENV"ssh "$SSH_ALIAS" "mkdir -p '$DEPLOY_PATH/shared' && umask 027 && cat > '$DEPLOY_PATH/shared/.env'" < "$TMP_ENV"ssh "$SSH_ALIAS" "chmod 640 '$DEPLOY_PATH/shared/.env' && test -s '$DEPLOY_PATH/shared/.env' && wc -c '$DEPLOY_PATH/shared/.env'"shred -u "$TMP_ENV" 2>/dev/null || rm -f "$TMP_ENV"test ! -f "$TMP_ENV"# Expected: shared/.env has a non-zero byte count, mode 640, and the local temp file is gone- ✅
shared/.envis present,chmod 640, and no rendered temp file remains locally.
- ✅
9. Scan the .env for placeholders
Section titled “9. Scan the .env for placeholders”A deploy fails if .env still has unfilled placeholders or empty required values. Catch them before you ship.
-
Scan for placeholders and empty required secrets.
Terminal window ssh "$SSH_ALIAS" "grep -inE '_HERE|your_|YOUR_|\[.*\]|CHANGE_ME|REPLACE|PLACEHOLDER|TODO|FIXME|password_here' \'$DEPLOY_PATH/shared/.env'" 2>/dev/nullssh "$SSH_ALIAS" "grep -E '^(DB_PASSWORD|MAIL_PASSWORD|REDIS_PASSWORD)=(\"\")?$' \'$DEPLOY_PATH/shared/.env'" 2>/dev/null# Expected: no output (no placeholders, no empty required secrets)- ✅ Neither scan returns a line — no placeholders, no empty required secrets.
If a service isn’t ready yet (e.g. Redis/Upstash), set it to a local driver temporarily and switch later:
CACHE_DRIVER="file" # Laravel 11+: CACHE_STORE="file"SESSION_DRIVER="file"QUEUE_CONNECTION="sync"Checklist
Section titled “Checklist”Do not mark this step done until every box below is checked.
- 🤖 Phase 4 handoff consumed —
deploy.phpparses,dep --planreaches vendors + symlink, and confirmedbin/phpis present. - 🤖 Composer platform pinned — Composer platform matches the confirmed deploy PHP, lock metadata refreshed, dry-run install passes, and the commit is made before first deploy.
- 🤖 Plan previewed —
dep deploy "$DEPLOY_TARGET" --planpipeline shape looks right. - 🔀 PHP triple-check passes — Deployer
bin/php/bin/composersatisfycomposer.json; hPanel web SAPI checked now and installer Requirements screen recorded on page 4. - 🤖 Deploy-safety flags confirmed —
artisan:migratedisabled,clear_paths_stricttrue. - 🤖 Optional tasks guarded — each carries a graceful-skip guard (no unguarded hard-fail).
- 🤖 Source branch ready — selected non-production source branch from
Zaj-PROJECT.mdmerged fromdevelopand pushed; SSH + host config verified. - 🤖 Cache cleared + snapshot taken — composer cache cleared (first deploy); pre-deploy snapshot saved.
- 🤖 Server scaffolded —
deploy/shared/storagetree exists;shared/.user.inisets installer limits. - 🤖
shared/.envplaced —chmod 640, no placeholders,APP_KEYempty,APP_DEBUG=false.