Codeskill

Learn to code, step by step

Deployment basics – PHP-FPM, env, migrations mindset

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 pull or 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.

PreviousPerformance profiling and N+1 query hunting