TL;DR

Deploy PHP applications to production. Learn Composer autoloading, environment config, PHP-FPM, nginx, error logging, and opcache.

Key concepts

  • PHP deployment
  • PHP production setup
  • PHP-FPM nginx
  • PHP opcache

Deployment

Building a PHP application is only half the journey. Getting it running reliably in production requires configuring web servers, managing environment variables, handling errors gracefully, and optimizing performance. In this lesson you will learn the key concepts and tools for deploying PHP applications: Composer autoloading, environment configuration, PHP-FPM, nginx, error logging, and opcache. These skills apply whether you are deploying to a VPS, a managed platform, or a containerized environment.

What You'll Learn

  • Composer autoloading and dependency management
  • Environment configuration with .env files
  • PHP-FPM and nginx configuration basics
  • Error logging and log levels for production
  • Performance optimization with opcache
  • Deployment checklists and strategies

Composer Autoloading

Composer is PHP's dependency manager, but its autoloader is equally important. The PSR-4 autoloader maps namespaces to directories, eliminating the need for manual require statements:

<?php

// Simulating Composer's PSR-4 autoloader behavior
// In a real project, you just do: require __DIR__ . '/vendor/autoload.php';

class SimpleAutoloader {
    private array $namespaces = [];

    public function register(string $namespace, string $directory): void {
        $this->namespaces[$namespace] = $directory;
    }

    public function resolve(string $className): ?string {
        foreach ($this->namespaces as $namespace => $directory) {
            if (str_starts_with($className, $namespace)) {
                $relative = substr($className, strlen($namespace));
                $path = $directory . str_replace('\\', '/', $relative) . '.php';
                return $path;
            }
        }
        return null;
    }
}

$autoloader = new SimpleAutoloader();

// PSR-4 mapping: namespace prefix => directory
$autoloader->register('App\\', '/var/www/myapp/src/');
$autoloader->register('App\\Http\\Controllers\\', '/var/www/myapp/src/Http/Controllers/');
$autoloader->register('App\\Models\\', '/var/www/myapp/src/Models/');

// See how class names resolve to file paths
$classes = [
    'App\\Http\\Controllers\\UserController',
    'App\\Models\\User',
    'App\\Services\\PaymentGateway',
    'App\\Config\\Database',
];

echo "PSR-4 Autoloader Resolution:\n\n";
echo str_pad("Class", 45) . "File Path\n";
echo str_repeat("-", 90) . "\n";

foreach ($classes as $class) {
    $path = $autoloader->resolve($class);
    echo str_pad($class, 45) . ($path ?? 'not found') . "\n";
}

echo "\n";

// composer.json autoload section example
$composerJson = [
    'autoload' => [
        'psr-4' => [
            'App\\' => 'src/',
        ],
        'files' => [
            'src/helpers.php',
        ],
    ],
    'autoload-dev' => [
        'psr-4' => [
            'Tests\\' => 'tests/',
        ],
    ],
];

echo "Example composer.json autoload config:\n";
echo json_encode($composerJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
echo "\nAfter changes, run: composer dump-autoload --optimize\n";

The --optimize flag generates a class map for faster lookups in production. Run composer install --no-dev --optimize-autoloader when deploying to exclude development dependencies and build an optimized autoloader.

Environment Configuration

Never hardcode credentials or environment-specific settings. Use .env files to separate configuration from code:

<?php

// Simple .env file parser (in production, use vlucas/phpdotenv)
class EnvLoader {
    private array $vars = [];

    public function load(string $content): void {
        $lines = explode("\n", $content);
        foreach ($lines as $line) {
            $line = trim($line);
            // Skip comments and empty lines
            if ($line === '' || str_starts_with($line, '#')) {
                continue;
            }
            if (str_contains($line, '=')) {
                [$key, $value] = explode('=', $line, 2);
                $key = trim($key);
                $value = trim($value);
                // Remove surrounding quotes
                $value = trim($value, '"\'');
                $this->vars[$key] = $value;
            }
        }
    }

    public function get(string $key, string $default = ''): string {
        return $this->vars[$key] ?? $default;
    }

    public function getAll(): array {
        return $this->vars;
    }

    public function require(array $keys): array {
        $missing = [];
        foreach ($keys as $key) {
            if (!isset($this->vars[$key]) || $this->vars[$key] === '') {
                $missing[] = $key;
            }
        }
        return $missing;
    }
}

// Example .env file content
$envContent = <<<'ENV'
# Application
APP_NAME=MyPHPApp
APP_ENV=production
APP_DEBUG=false
APP_URL=https://myapp.example.com

# Database
DB_HOST=localhost
DB_PORT=3306
DB_DATABASE=myapp_prod
DB_USERNAME=myapp_user
DB_PASSWORD="s3cret_p@ssw0rd"

# Cache
CACHE_DRIVER=redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379

# Mail
MAIL_DRIVER=smtp
MAIL_HOST=smtp.mailgun.org
ENV;

$env = new EnvLoader();
$env->load($envContent);

echo "Loaded environment variables:\n\n";
foreach ($env->getAll() as $key => $value) {
    // Mask sensitive values
    $display = str_contains(strtolower($key), 'password') || str_contains(strtolower($key), 'secret')
        ? str_repeat('*', strlen($value))
        : $value;
    echo "  {$key}={$display}\n";
}

echo "\n";

// Check required variables
$missing = $env->require(['APP_NAME', 'DB_HOST', 'DB_DATABASE', 'STRIPE_KEY']);
if (!empty($missing)) {
    echo "Missing required variables: " . implode(', ', $missing) . "\n";
} else {
    echo "All required variables are set\n";
}

echo "\n";

// Configuration class that uses environment variables
class Config {
    private static EnvLoader $env;

    public static function init(EnvLoader $env): void {
        self::$env = $env;
    }

    public static function isProduction(): bool {
        return self::$env->get('APP_ENV') === 'production';
    }

    public static function isDebug(): bool {
        return self::$env->get('APP_DEBUG', 'false') === 'true';
    }

    public static function getDsn(): string {
        $host = self::$env->get('DB_HOST');
        $port = self::$env->get('DB_PORT', '3306');
        $db = self::$env->get('DB_DATABASE');
        return "mysql:host={$host};port={$port};dbname={$db};charset=utf8mb4";
    }
}

Config::init($env);
echo "Production: " . (Config::isProduction() ? 'yes' : 'no') . "\n";
echo "Debug mode: " . (Config::isDebug() ? 'yes' : 'no') . "\n";
echo "DSN: " . Config::getDsn() . "\n";

Always add .env to your .gitignore. Commit a .env.example file with placeholder values so new developers know which variables are required. In production, set environment variables through your hosting platform or deployment tool rather than relying on a .env file on disk.

PHP-FPM and Nginx Configuration

PHP-FPM (FastCGI Process Manager) runs PHP as a pool of worker processes. Nginx acts as the web server that forwards PHP requests to FPM:

<?php

// PHP-FPM pool configuration explained
$fpmConfig = [
    'pool_name' => 'www',
    'settings' => [
        'user' => 'www-data',
        'group' => 'www-data',
        'listen' => '/run/php/php8.3-fpm.sock',
        'pm' => 'dynamic',
        'pm.max_children' => 50,
        'pm.start_servers' => 5,
        'pm.min_spare_servers' => 5,
        'pm.max_spare_servers' => 35,
        'pm.max_requests' => 500,
    ],
];

echo "=== PHP-FPM Configuration ===\n\n";
echo "[{$fpmConfig['pool_name']}]\n";
foreach ($fpmConfig['settings'] as $key => $value) {
    echo "{$key} = {$value}\n";
}

echo "\n--- What each setting means ---\n";
$explanations = [
    'pm = dynamic' => 'Scale workers up/down based on demand',
    'pm.max_children = 50' => 'Maximum 50 simultaneous PHP processes',
    'pm.start_servers = 5' => 'Start with 5 worker processes',
    'pm.min_spare_servers = 5' => 'Always keep at least 5 idle workers',
    'pm.max_spare_servers = 35' => 'Never keep more than 35 idle workers',
    'pm.max_requests = 500' => 'Recycle workers after 500 requests (prevents memory leaks)',
];

foreach ($explanations as $setting => $explanation) {
    echo "  {$setting}\n    -> {$explanation}\n";
}

echo "\n=== Nginx Configuration ===\n\n";

$nginxConfig = <<<'NGINX'
server {
    listen 80;
    server_name myapp.example.com;
    root /var/www/myapp/public;
    index index.php;

    # Security headers
    add_header X-Frame-Options "SAMEORIGIN";
    add_header X-Content-Type-Options "nosniff";
    add_header X-XSS-Protection "1; mode=block";

    # Handle static files directly
    location ~* \.(css|js|jpg|png|gif|ico|svg|woff2?)$ {
        expires 30d;
        access_log off;
    }

    # Send all other requests to index.php (front controller)
    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    # Pass PHP requests to FPM
    location ~ \.php$ {
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }

    # Block access to hidden files
    location ~ /\. {
        deny all;
    }
}
NGINX;

echo $nginxConfig . "\n";

echo "\n--- Key Nginx directives ---\n";
$directives = [
    'root' => 'Points to the public/ directory, not the project root',
    'try_files' => 'Tries static file first, then falls back to index.php',
    'fastcgi_pass' => 'Forwards PHP requests to FPM via Unix socket',
    'expires 30d' => 'Browser-caches static assets for 30 days',
    'deny all' => 'Blocks access to .env, .git, and other hidden files',
];
foreach ($directives as $directive => $explanation) {
    echo "  {$directive}: {$explanation}\n";
}

The front controller pattern routes all requests through a single index.php file. Nginx serves static files directly and only invokes PHP-FPM for dynamic requests, which is significantly faster than Apache's mod_php for high-traffic sites.

Error Logging in Production

In production, errors should be logged to files, not displayed to users. PHP provides flexible configuration for controlling error output:

<?php

// Production error settings (normally in php.ini)
// display_errors = Off
// log_errors = On
// error_log = /var/log/php/error.log
// error_reporting = E_ALL

class Logger {
    private array $logs = [];

    public function emergency(string $message, array $context = []): void {
        $this->log('EMERGENCY', $message, $context);
    }

    public function error(string $message, array $context = []): void {
        $this->log('ERROR', $message, $context);
    }

    public function warning(string $message, array $context = []): void {
        $this->log('WARNING', $message, $context);
    }

    public function info(string $message, array $context = []): void {
        $this->log('INFO', $message, $context);
    }

    public function debug(string $message, array $context = []): void {
        $this->log('DEBUG', $message, $context);
    }

    private function log(string $level, string $message, array $context): void {
        $timestamp = date('Y-m-d H:i:s');

        // Interpolate context values into message
        foreach ($context as $key => $value) {
            $message = str_replace("{{$key}}", (string) $value, $message);
        }

        $entry = "[{$timestamp}] [{$level}] {$message}";
        $this->logs[] = $entry;

        // In production: error_log($entry . "\n", 3, '/var/log/php/app.log');
    }

    public function getLogs(): array {
        return $this->logs;
    }
}

$logger = new Logger();

// Simulate application events
$logger->info("Application started");
$logger->info("User {user} logged in from {ip}", ['user' => 'alice', 'ip' => '192.168.1.1']);
$logger->warning("Slow query detected: {ms}ms", ['ms' => '1250']);
$logger->error("Failed to connect to cache: {reason}", ['reason' => 'Connection refused']);
$logger->debug("Query: SELECT * FROM users WHERE id = {id}", ['id' => '42']);

echo "=== Application Log ===\n\n";
foreach ($logger->getLogs() as $entry) {
    echo $entry . "\n";
}

echo "\n=== Error Handler for Production ===\n\n";

// Custom exception handler for production
function productionExceptionHandler(Throwable $e, Logger $logger): void {
    // Log the full error with stack trace
    $logger->error("Uncaught {class}: {message} in {file}:{line}", [
        'class' => get_class($e),
        'message' => $e->getMessage(),
        'file' => $e->getFile(),
        'line' => $e->getLine(),
    ]);

    // Show a generic message to the user
    $isApi = true; // Would check Accept header in real app
    if ($isApi) {
        echo json_encode([
            'status' => 'error',
            'message' => 'An internal error occurred. Please try again later.',
        ], JSON_PRETTY_PRINT);
    }
}

// Simulate an uncaught exception
try {
    throw new RuntimeException("Database connection timeout after 30s");
} catch (Throwable $e) {
    productionExceptionHandler($e, $logger);
}

echo "\n\n--- Latest log entries ---\n";
$logs = $logger->getLogs();
$recent = array_slice($logs, -2);
foreach ($recent as $entry) {
    echo $entry . "\n";
}

Use structured logging with contextual data so you can search and filter logs effectively. In production, ship logs to a centralized service like Papertrail, Datadog, or the ELK stack. Never display stack traces or internal error details to end users.

Performance Optimization with OPcache

OPcache stores compiled PHP bytecode in shared memory, eliminating the need to parse and compile scripts on every request. This is the single most impactful performance optimization for PHP:

<?php

// OPcache configuration (php.ini settings)
$opcacheSettings = [
    'opcache.enable' => ['1', 'Enable OPcache (must be on in production)'],
    'opcache.memory_consumption' => ['256', 'MB of shared memory for compiled scripts'],
    'opcache.interned_strings_buffer' => ['16', 'MB for interned strings (class/function names)'],
    'opcache.max_accelerated_files' => ['20000', 'Maximum number of cached scripts'],
    'opcache.validate_timestamps' => ['0', 'Do not check file changes (fastest, requires restart on deploy)'],
    'opcache.revalidate_freq' => ['0', 'Seconds between file change checks (when validate_timestamps=1)'],
    'opcache.save_comments' => ['1', 'Keep docblock comments (needed by some frameworks)'],
    'opcache.enable_cli' => ['0', 'Disable for CLI (not useful for one-off scripts)'],
];

echo "=== OPcache Configuration for Production ===\n\n";
foreach ($opcacheSettings as $key => [$value, $description]) {
    echo str_pad($key, 40) . "= {$value}\n";
    echo str_pad('', 40) . "  ; {$description}\n";
}

echo "\n=== PHP Performance Checklist ===\n\n";

$checklist = [
    ['OPcache enabled', true, 'Caches compiled bytecode in shared memory'],
    ['validate_timestamps off', true, 'Skips file stat calls on every request'],
    ['Realpath cache increased', true, 'realpath_cache_size=4096K reduces filesystem calls'],
    ['Output buffering on', true, 'output_buffering=4096 reduces write syscalls'],
    ['Session handler = Redis', true, 'Faster than file-based sessions, enables scaling'],
    ['display_errors = Off', true, 'Never show errors to users in production'],
    ['Composer autoloader optimized', true, 'composer dump-autoload --optimize --classmap-authoritative'],
    ['Xdebug disabled', true, 'Xdebug slows execution by 50-100% -- remove in production'],
];

foreach ($checklist as [$item, $done, $note]) {
    $icon = $done ? '[x]' : '[ ]';
    echo "  {$icon} {$item}\n";
    echo "      {$note}\n";
}

echo "\n=== Deployment Script Example ===\n\n";

$deploySteps = [
    'git pull origin main',
    'composer install --no-dev --optimize-autoloader --classmap-authoritative',
    'php artisan config:cache    # Cache configuration (Laravel)',
    'php artisan route:cache     # Cache routes (Laravel)',
    'php artisan view:cache      # Compile Blade templates (Laravel)',
    'php artisan migrate --force # Run database migrations',
    'sudo systemctl reload php8.3-fpm  # Reload FPM to clear OPcache',
];

echo "#!/bin/bash\nset -e\n\n";
foreach ($deploySteps as $i => $step) {
    echo "echo \"Step " . ($i + 1) . ": {$step}\"\n";
    echo "{$step}\n\n";
}
echo "echo \"Deployment complete!\"\n";

When validate_timestamps is set to 0, OPcache will never check if files have changed on disk. This is the fastest setting but means you must reload PHP-FPM after each deployment to clear the cache. This is standard practice in production and is handled automatically by most deployment tools.

Try It Yourself

Combine the configuration and deployment concepts into a deployment readiness checker that validates your application is properly configured for production:

<?php

class DeploymentChecker {
    private array $results = [];

    public function check(string $name, bool $passed, string $suggestion = ''): void {
        $this->results[] = [
            'name' => $name,
            'passed' => $passed,
            'suggestion' => $suggestion,
        ];
    }

    public function checkEnvironment(array $env): void {
        $this->check(
            'APP_ENV is production',
            ($env['APP_ENV'] ?? '') === 'production',
            'Set APP_ENV=production in your .env file'
        );

        $this->check(
            'Debug mode is off',
            ($env['APP_DEBUG'] ?? 'true') === 'false',
            'Set APP_DEBUG=false to hide error details'
        );

        $this->check(
            'App URL uses HTTPS',
            str_starts_with($env['APP_URL'] ?? '', 'https://'),
            'Use HTTPS in production: APP_URL=https://...'
        );

        $this->check(
            'Database credentials are set',
            !empty($env['DB_HOST']) && !empty($env['DB_DATABASE']),
            'Ensure DB_HOST and DB_DATABASE are configured'
        );

        $this->check(
            'Session driver is not file-based',
            ($env['SESSION_DRIVER'] ?? 'file') !== 'file',
            'Use redis or database session driver for scalability'
        );

        $this->check(
            'Cache driver is not file-based',
            ($env['CACHE_DRIVER'] ?? 'file') !== 'file',
            'Use redis or memcached for better cache performance'
        );
    }

    public function checkPhpSettings(): void {
        // Simulating php.ini checks
        $settings = [
            'display_errors' => '0',
            'log_errors' => '1',
            'error_reporting' => (string) E_ALL,
            'opcache.enable' => '1',
            'expose_php' => '0',
            'session.cookie_secure' => '1',
            'session.cookie_httponly' => '1',
        ];

        foreach ($settings as $key => $expected) {
            $actual = $expected; // In real app: ini_get($key)
            $this->check(
                "php.ini: {$key} = {$expected}",
                $actual === $expected,
                "Set {$key} = {$expected} in php.ini"
            );
        }
    }

    public function report(): void {
        $passed = 0;
        $failed = 0;

        echo "=== Deployment Readiness Report ===\n\n";

        foreach ($this->results as $result) {
            $status = $result['passed'] ? 'PASS' : 'FAIL';
            $marker = $result['passed'] ? '[x]' : '[ ]';
            echo "  {$marker} {$result['name']}\n";
            if (!$result['passed'] && $result['suggestion']) {
                echo "      -> {$result['suggestion']}\n";
            }
            $result['passed'] ? $passed++ : $failed++;
        }

        echo "\n" . str_repeat("-", 50) . "\n";
        echo "Results: {$passed} passed, {$failed} failed out of " . count($this->results) . " checks\n";
        echo "Status: " . ($failed === 0 ? 'READY TO DEPLOY' : 'NOT READY - fix issues above') . "\n";
    }
}

// Simulate a production environment
$env = [
    'APP_ENV' => 'production',
    'APP_DEBUG' => 'false',
    'APP_URL' => 'https://myapp.example.com',
    'DB_HOST' => 'localhost',
    'DB_DATABASE' => 'myapp',
    'SESSION_DRIVER' => 'redis',
    'CACHE_DRIVER' => 'file',  // intentionally wrong
];

$checker = new DeploymentChecker();
$checker->checkEnvironment($env);
$checker->checkPhpSettings();
$checker->report();

Key Takeaways

  • Use Composer's PSR-4 autoloading and run --optimize-autoloader in production
  • Store all environment-specific configuration in .env files, never in code
  • Configure PHP-FPM with pm = dynamic and tune worker counts based on available memory
  • Use nginx as a reverse proxy with a front controller pattern that routes all requests through index.php
  • Set display_errors = Off and log_errors = On in production, and log to files or a centralized service
  • Enable OPcache with validate_timestamps = 0 for maximum performance
  • Reload PHP-FPM after every deployment to clear the OPcache
  • Remove Xdebug and development dependencies from production servers

Next Steps

You can now configure Nginx with PHP-FPM, manage environment variables, set up production error logging, and optimize performance with OPcache. Your applications are ready for real-world hosting.

It is time to put everything together. The capstone project walks you through building a complete blog application from scratch -- authentication, database CRUD, template rendering, a REST API, and security hardening -- combining every skill from the first fourteen lessons into a single, working project. This is where concepts become confidence.

Continue to Capstone Project -->

Pro Tip: Automate your deployments from the start. Even a simple shell script that runs git pull, composer install --no-dev, clears caches, and reloads PHP-FPM is better than deploying manually. As your application grows, adopt zero-downtime deployment tools like Deployer, Envoy, or a CI/CD pipeline with GitHub Actions. Automated deployments eliminate human error and let you deploy confidently, even on Fridays.