Deployer — deploy.php
deploy.php drives Deployer — a PHP tool that ships releases to a server with zero downtime: each deploy builds a fresh release folder, then flips a symlink so the switchover is atomic. This config wraps the stock Laravel recipe with safety: it verifies the remote commit, runs a smoke test before the symlink flips, checks APP_DEBUG is off in production, and confirms the site returns HTTP 200 after going live.
You only edit the project block at the top (sections A–L). Everything below it is universal task logic you rarely touch. Ship with dep deploy staging or dep deploy production.
What to fill in
Section titled “What to fill in”Every placeholder is wrapped in [BRACKETS]. Search the file for [ and replace each one:
| Placeholder | What goes here | Example |
|---|---|---|
[PROJECT_NAME] | Your project’s name | acme-crm |
[REPO_SSH_URL] | Git SSH clone URL | git@github.com:acme/crm.git |
[PHP_BINARY_PATH] | PHP binary on the server | /opt/alt/php83/usr/bin/php |
[SSH_USERNAME] | Hosting account username | u123456789 |
[STAGING_HOST] / [PRODUCTION_HOST] | Server IP or hostname | 185.xx.xx.xx |
[STAGING_USER] / [PRODUCTION_USER] | SSH username per stage | u123456789 |
[STAGING_PORT] / [PRODUCTION_PORT] | SSH port (22, or 65002 on Hostinger) | 65002 |
[STAGING_SSH_KEY] / [PRODUCTION_SSH_KEY] | Path to the SSH private key | ~/.ssh/id_acme |
[STAGING_DOMAIN] / [PRODUCTION_DOMAIN] | The domain folder on the server | staging.acme.com |
[STAGING_URL] / [PRODUCTION_URL] | Full URL for the HTTP health check | https://staging.acme.com/ |
[DATE] | Today’s date in the header comment | 2026-06-09 |
After filling those, review three knobs that depend on your app:
composer_cache_strategy— leave at'smart'(only skips the cache whencomposer.lockchanged); use'fresh'if you keep hitting stale-package bugs.extra_migration_paths+has_migration_installer_check— many CodeCanyon apps (WorkDo, nWidart modules) load migrations from custom folders; uncomment the pattern that matches yours so the deployer can detect orphaned migrations.atlas_enabled— set tofalseunless you have the Atlas CLI installed and.env.atlasconfigured for schema diffing.
The project block (sections A–L)
Section titled “The project block (sections A–L)”This is the only part you edit. Copy the whole file, then replace placeholders in this top block.
<?phpnamespace Deployer;
require 'recipe/laravel.php';use Symfony\Component\Console\Input\InputOption;
// ════════════════════════════════════════════════════════════════════════════// 🛡️ CLI OPTIONS — SAFETY FLAGS// ════════════════════════════════════════════════════════════════════════════//// --allow-pending// Allow deploy even when the server has existing pending migrations.// Use ONLY after reviewing the pending migrations manually via SSH.// Example: dep deploy staging --allow-pending//// --skip-atlas// Skip the Atlas schema preflight check.// Use when Atlas is not installed yet, or schema differences are expected.// Example: dep deploy staging --skip-atlas//// ════════════════════════════════════════════════════════════════════════════option('allow-pending', null, InputOption::VALUE_NONE, 'Allow deploy with pending migrations (after review)');option('skip-atlas', null, InputOption::VALUE_NONE, 'Skip Atlas schema preflight check');
// ════════════════════════════════════════════════════════════════════════════// 🚀 [PROJECT_NAME] - PRODUCTION-READY DEPLOYMENT CONFIGURATION// ════════════════════════════════════════════════════════════════════════════//// FEATURES ENABLED:// ✅ Repository Cache Fix - Prevents wrong branch deployments// ✅ Enhanced Error Handling - Graceful failures, continues deployment// ✅ Preflight Checks - Verify server readiness before deployment// ✅ Security Checks - Auto-fix APP_DEBUG in production// ✅ Health Checks - Verify deployment succeeded (artisan + HTTP)// ✅ Smoke Test - Verify release boots before symlink switch// ✅ Migration Prep - Clear stale caches before migrations// ⚠️ Database Backup - Disabled for staging, enable for production//// Created: [DATE]// Last Updated: [DATE]//// ════════════════════════════════════════════════════════════════════════════// A- 📋 PROJECT CONFIGURATION// ════════════════════════════════════════════════════════════════════════════
set('application', '[PROJECT_NAME]'); // CUSTOMIZE: Your project nameset('repository', '[REPO_SSH_URL]'); // CUSTOMIZE: e.g., git@github.com:org/repo.git
// ════════════════════════════════════════════════════════════════════════════// B- ENVIRONMENT CONFIGURATION// ════════════════════════════════════════════════════════════════════════════
set('default_timeout', 900); // 15 minutes for shared hostingset('writable_mode', 'chmod');set('writable_chmod_mode', '0775'); // Hostinger requirementset('keep_releases', 5);
// Hostinger SSH banner suppressionset('git_tty', false);set('ssh_multiplexing', false);
// ════════════════════════════════════════════════════════════════════════════// C- 📋 BINARY PATHS// ════════════════════════════════════════════════════════════════════════════
// PHP binary path// CUSTOMIZE: Update for your hosting environment// Hostinger shared: /opt/alt/php83/usr/bin/php (or php84)// VPS/Standard: /usr/bin/phpset('bin/php', '[PHP_BINARY_PATH]');
// Composer binary pathset('bin/composer', function () { return 'COMPOSER_MEMORY_LIMIT=-1 {{bin/php}} /usr/local/bin/composer';});
// 📦 COMPOSER CACHE STRATEGY// 'cache' = Use Composer cache (fastest, but may use wrong versions)// 'smart' = --no-cache only when composer.lock changed (RECOMMENDED)// 'fresh' = Always --no-cache (slowest, guaranteed fresh downloads)set('composer_cache_strategy', 'smart'); // Options: 'cache', 'smart', 'fresh'
set('composer_options', function () { $baseOptions = '--verbose --prefer-dist --no-progress --no-interaction --no-dev --optimize-autoloader'; $strategy = get('composer_cache_strategy');
if ($strategy === 'cache') { return $baseOptions; } if ($strategy === 'fresh') { writeln(' <fg=cyan>Composer strategy: fresh (--no-cache)</>'); return "$baseOptions --no-cache"; }
// Strategy: smart (no-cache only when composer.lock changed) $deployPath = get('deploy_path'); $releasePath = get('release_path'); $currentPath = "$deployPath/current";
if (!test("[ -L $currentPath ]")) { writeln(' <fg=cyan>First deploy — using --no-cache</>'); return "$baseOptions --no-cache"; }
$currentHash = run("md5sum $currentPath/composer.lock 2>/dev/null | cut -d' ' -f1 || echo 'none'"); $newHash = run("md5sum $releasePath/composer.lock | cut -d' ' -f1");
if ($currentHash !== $newHash) { writeln(' <fg=yellow>composer.lock changed — using --no-cache</>'); return "$baseOptions --no-cache"; }
writeln(' <fg=green>composer.lock unchanged — using cache</>'); return $baseOptions;});
// ════════════════════════════════════════════════════════════════════════════// E- 🚫 CLEAR PATHS (Files Deleted After Deployment)// ════════════════════════════════════════════════════════════════════════════// These files are removed from the server after each release — dev tooling, AI// configs, tests, and docs that should never reach a production box.set('clear_paths_strict', true); // RECOMMENDED: true for production safety
add('clear_paths', [ // AI agent configs (should NEVER be on server) '.agent', '.claude', '.cursor', '.ai', 'assets',
// Dev/build files (not needed on server) 'Admin-Local', '.github', 'tests', '.editorconfig', 'phpunit.xml', '.phpunit.result.cache', '.temp',
// Documentation (not needed on server) 'README.md', 'CHANGELOG.md', '_CHANGELOG.md', '_CHANGELOG-PUBLIC.md', 'CLAUDE.md', 'CLAUDE.local.md', 'GEMINI.md', 'AGENTS.md', 'ONBOARDING.md', 'DECISIONS.md', '.cursorignore', '.env.tpl',
// Deployment script itself 'deploy.php', // CUSTOMIZE: Add any project-specific clear paths below]);
// ════════════════════════════════════════════════════════════════════════════// F- 📁 SHARED FILES & DIRECTORIES// ════════════════════════════════════════════════════════════════════════════// Directories that persist across releases (user data lives here, not in code).set('shared_dirs', [ 'storage', // User data, logs, cache (persists across releases) 'uploads', // User-uploaded files: avatars, logos, documents (persists) // CUSTOMIZE: Add any project-specific shared directories]);
// 🚨 Use add() NOT set() — the recipe default is ['.env']; add() MERGES, set() REPLACES.add('shared_files', [ // The recipe already includes '.env'. add() merges your list into it. // Add ONLY files OUTSIDE any shared_dirs directory, e.g.: // 'app-config/feature-flags.json',]);
// ════════════════════════════════════════════════════════════════════════════// G- WRITABLE DIRECTORIES (Web Server Write Permissions)// ════════════════════════════════════════════════════════════════════════════set('writable_dirs', [ 'bootstrap/cache', 'storage', 'storage/app', 'storage/app/public', 'storage/framework', 'storage/framework/cache', 'storage/framework/cache/data', 'storage/framework/sessions', 'storage/framework/views', 'storage/logs',]);
// ════════════════════════════════════════════════════════════════════════════// H- HTTP USER CONFIGURATION// ════════════════════════════════════════════════════════════════════════════set('http_user', '[SSH_USERNAME]'); // Default (overridden per-host below)
// ════════════════════════════════════════════════════════════════════════════// I- HOSTS CONFIGURATION// ════════════════════════════════════════════════════════════════════════════
host('staging') ->set('hostname', '[STAGING_HOST]') ->set('remote_user', '[STAGING_USER]') ->set('port', '[STAGING_PORT]') ->set('identity_file', '[STAGING_SSH_KEY]') ->set('branch', 'staging') ->set('deploy_path', '~/domains/[STAGING_DOMAIN]/deploy') ->set('http_user', '[STAGING_USER]') ->set('labels', ['stage' => 'staging']) ->set('health_check_url', '[STAGING_URL]') ->set('ssh_arguments', ['-o StrictHostKeyChecking=no', '-o UserKnownHostsFile=/dev/null']) ->set('shell', 'bash -s') ->set('ssh_multiplexing', false) ->set('env', [ 'COMPOSER_PHP' => '[PHP_BINARY_PATH]', 'PATH' => '/home/[STAGING_USER]/bin:/usr/local/bin:/usr/bin:/bin' ]);
host('production') ->set('hostname', '[PRODUCTION_HOST]') ->set('remote_user', '[PRODUCTION_USER]') ->set('port', '[PRODUCTION_PORT]') ->set('identity_file', '[PRODUCTION_SSH_KEY]') ->set('branch', 'production') ->set('deploy_path', '~/domains/[PRODUCTION_DOMAIN]/deploy') ->set('http_user', '[PRODUCTION_USER]') ->set('labels', ['stage' => 'production']) ->set('health_check_url', '[PRODUCTION_URL]') ->set('ssh_arguments', ['-o StrictHostKeyChecking=no', '-o UserKnownHostsFile=/dev/null']) ->set('shell', 'bash -s') ->set('ssh_multiplexing', false) ->set('env', [ 'COMPOSER_PHP' => '[PHP_BINARY_PATH]', 'PATH' => '/home/[PRODUCTION_USER]/bin:/usr/local/bin:/usr/bin:/bin' ]);
// ════════════════════════════════════════════════════════════════════════════// J- CI/CD CONFIGURATION// ════════════════════════════════════════════════════════════════════════════set('cicd_audit_log_dir', 'Domain-Admin/Logs');set('cicd_audit_log_file', 'deploy-history.log');set('cicd_max_rollback_preview', 5);set('domain_admin_dir', 'Domain-Admin');set('backup_retention_count', 10);set('aws_backup_enabled', false); // true = mysqldump to S3 before production deploys
// ════════════════════════════════════════════════════════════════════════════// K- 📊 MIGRATION SAFETY CONFIGURATION// ════════════════════════════════════════════════════════════════════════════// Many CodeCanyon apps load migrations from custom paths OUTSIDE database/migrations/.// extra_migration_paths tells the deployer where else to scan for orphans.set('extra_migration_paths', [ // CUSTOMIZE: uncomment/adapt the pattern that matches your app: // 'packages/workdo/*/src/Database/Migrations', // WorkDo packages // 'Modules/*/Database/Migrations', // nWidart/laravel-modules // 'database/migrations-zaj', // Custom migrations dir]);
// true → Enable orphaned migration detection (WorkDo, CodeCanyon installer apps)// false → Skip extra checks (plain Laravel apps without installers)set('has_migration_installer_check', true);
// ════════════════════════════════════════════════════════════════════════════// L- 🗺️ ATLAS SCHEMA VERIFICATION (Optional — requires Atlas CLI)// ════════════════════════════════════════════════════════════════════════════// Atlas compares DB schemas across environments and can BLOCK a deploy on// destructive changes. Set atlas_enabled = false if Atlas is not installed.set('atlas_enabled', true);set('atlas_env_file', '.env.atlas');set('atlas_block_on_destructive', false); // true = block on DROP TABLE/COLUMNset('atlas_strict_mode', false); // true = block on ANY schema differenceset('atlas_verify_strict', false); // true = block if schemas differ AFTER migrate
// ════════════════════════════════════════════════════════════════════════════// END OF PROJECT-SPECIFIC CONFIGURATION// ════════════════════════════════════════════════════════════════════════════// Below this line: universal deployment tasks (rarely need changes).What the universal section gives you
Section titled “What the universal section gives you”Below the project block, the file defines roughly 30 Deployer tasks and wires them into the deploy flow with before() / after() hooks. You do not edit these — they are what makes the deploy safe. The highlights:
| Task | When it runs | What it guarantees |
|---|---|---|
deploy:verify_remote_commit | before code update | Deploys the exact remote commit, not a stale cached branch |
deploy:preflight | before prepare | Server is reachable and writable before anything ships |
deploy:atlas_preflight | before prepare | (Optional) No destructive schema change slips through |
deploy:backup_database / deploy:aws_backup | before migrate | A DB snapshot exists before migrations run |
deploy:prepare_migrations | before migrate | Stale caches cleared so migrations see the real schema |
deploy:smoke_test | before symlink | The new release boots — if it errors, the live site is untouched |
deploy:security_check | after symlink | APP_DEBUG=false is enforced in production |
deploy:health_check + deploy:verify_health | after go-live | artisan runs and the URL returns HTTP 200 |
deploy:summary + Slack/Discord notify | on success/failure | A one-line deploy record and a team notification |
The key safety property: the smoke test runs before the symlink flips. If the new release fails to boot, the old release stays live and the deploy aborts cleanly — no broken site.
Running it
Section titled “Running it”dep deploy staging# Reviewed pending migrations by hand? add --allow-pending# Atlas not set up yet? add --skip-atlasdep deploy productiondep rollback production # flips the symlink back to the previous release