Getting PHP from your machine to a server without panic, downtime rituals, or “it worked on my laptop” as the deployment strategy. PHP-FPM, environment config, and a migrations mindset – the boring infrastructure that keeps sites running. Is one of those topics that pays off as soon as you use it on a real page.
What you are deploying
- Application code (
git pullor CI artefact) - Composer dependencies (
composer install --no-dev --optimize-autoloader) - Environment config (never commit secrets)
- Database schema changes (migrations – run deliberately)
- Web server + PHP-FPM restart when opcodes or config change
PHP-FPM in one paragraph
Nginx or Apache receives HTTP. PHP-FPM runs your PHP workers as a separate pool. The web server forwards PHP requests to FPM via a socket or TCP port. Tuning pm.max_children matters – too few and requests queue; too many and memory explodes.
; pool snippet - www.conf
pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6
After deploy, reload FPM so workers pick up new code: sudo systemctl reload php8.2-fpm (version varies).
Environment config
One codebase, different settings per environment. Load from env vars or a local .env file that never hits git:
return [
'app_env' => getenv('APP_ENV') ?: 'local',
'db' => [
'host' => getenv('DB_HOST') ?: '127.0.0.1',
'name' => getenv('DB_NAME') ?: 'app',
'user' => getenv('DB_USER') ?: 'root',
'pass' => getenv('DB_PASS') ?: '',
],
'debug' => (getenv('APP_ENV') ?: 'local') !== 'production',
];
Production: APP_ENV=production, debug=false, display_errors off, log to file or syslog. Secrets live in the host environment or a vault – not in the repo.
Migrations mindset
Schema changes are versioned scripts applied in order. Each migration runs once, tracked in a table:
-- migrations/001_create_users.sql
CREATE TABLE users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(190) NOT NULL UNIQUE,
created_at DATETIME NOT NULL
);
-- migrations/002_add_role.sql
ALTER TABLE users ADD COLUMN role VARCHAR(20) NOT NULL DEFAULT 'user';
final class Migrator
{
public function run(string $dir, PDO $pdo): void
{
$pdo->exec('CREATE TABLE IF NOT EXISTS migrations (name VARCHAR(255) PRIMARY KEY, ran_at DATETIME NOT NULL)');
$done = $pdo->query('SELECT name FROM migrations')->fetchAll(PDO::FETCH_COLUMN);
foreach (glob($dir . '/*.sql') as $file) {
$name = basename($file);
if (in_array($name, $done, true)) {
continue;
}
$pdo->exec(file_get_contents($file));
$pdo->prepare('INSERT INTO migrations (name, ran_at) VALUES (?, NOW())')->execute([$name]);
}
}
}
Run migrations before switching traffic to new code that depends on new columns. Backwards-compatible deploys (add column nullable first, deploy code, backfill, add constraint) avoid hard downtime.
A sane deploy checklist
- Put site in maintenance mode if needed (brief flag file or env)
- Pull code / deploy artefact
composer install --no-dev --optimize-autoloader- Run pending migrations
- Clear application cache
- Reload PHP-FPM
- Smoke test login and one critical path
- Watch error logs for five minutes
Automate this in CI when you can. Manual SSH deploys work until they do not – usually at 2 a.m.

