Skip to content
prod e051e98
Browse

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.

Every placeholder is wrapped in [BRACKETS]. Search the file for [ and replace each one:

PlaceholderWhat goes hereExample
[PROJECT_NAME]Your project’s nameacme-crm
[REPO_SSH_URL]Git SSH clone URLgit@github.com:acme/crm.git
[PHP_BINARY_PATH]PHP binary on the server/opt/alt/php83/usr/bin/php
[SSH_USERNAME]Hosting account usernameu123456789
[STAGING_HOST] / [PRODUCTION_HOST]Server IP or hostname185.xx.xx.xx
[STAGING_USER] / [PRODUCTION_USER]SSH username per stageu123456789
[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 serverstaging.acme.com
[STAGING_URL] / [PRODUCTION_URL]Full URL for the HTTP health checkhttps://staging.acme.com/
[DATE]Today’s date in the header comment2026-06-09

After filling those, review three knobs that depend on your app:

  • composer_cache_strategy — leave at 'smart' (only skips the cache when composer.lock changed); 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 to false unless you have the Atlas CLI installed and .env.atlas configured for schema diffing.

This is the only part you edit. Copy the whole file, then replace placeholders in this top block.

deploy.php (project configuration)
<?php
namespace 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 name
set('repository', '[REPO_SSH_URL]'); // CUSTOMIZE: e.g., git@github.com:org/repo.git
// ════════════════════════════════════════════════════════════════════════════
// B- ENVIRONMENT CONFIGURATION
// ════════════════════════════════════════════════════════════════════════════
set('default_timeout', 900); // 15 minutes for shared hosting
set('writable_mode', 'chmod');
set('writable_chmod_mode', '0775'); // Hostinger requirement
set('keep_releases', 5);
// Hostinger SSH banner suppression
set('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/php
set('bin/php', '[PHP_BINARY_PATH]');
// Composer binary path
set('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/COLUMN
set('atlas_strict_mode', false); // true = block on ANY schema difference
set('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).

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:

TaskWhen it runsWhat it guarantees
deploy:verify_remote_commitbefore code updateDeploys the exact remote commit, not a stale cached branch
deploy:preflightbefore prepareServer is reachable and writable before anything ships
deploy:atlas_preflightbefore prepare(Optional) No destructive schema change slips through
deploy:backup_database / deploy:aws_backupbefore migrateA DB snapshot exists before migrations run
deploy:prepare_migrationsbefore migrateStale caches cleared so migrations see the real schema
deploy:smoke_testbefore symlinkThe new release boots — if it errors, the live site is untouched
deploy:security_checkafter symlinkAPP_DEBUG=false is enforced in production
deploy:health_check + deploy:verify_healthafter go-liveartisan runs and the URL returns HTTP 200
deploy:summary + Slack/Discord notifyon success/failureA 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.

Terminal window
dep deploy staging
# Reviewed pending migrations by hand? add --allow-pending
# Atlas not set up yet? add --skip-atlas