Web Server #

Running PHP on a web server sounds simple but is full of nuances — a wrong configuration choice can cause poor performance, security holes, or unexpected behavior in production. PHP can run in several modes: as an Apache module (mod_php), through FastCGI (PHP-FPM) connected to Nginx or Apache, or with PHP’s built-in server for development. This article covers all these modes in depth — how PHP-FPM works and why it’s the standard choice for modern production, correct Nginx and Apache configuration for PHP, critical php.ini settings, OPcache tuning for optimal performance, and safe deployment practices.

Architecture: How PHP Gets Executed #

PHP can be executed in several ways that have very different performance and architecture implications:

flowchart TD
    Internet[Request from\nInternet] --> WS

    subgraph WS[Web Server Layer]
        Nginx[Nginx\nApache]
    end

    WS --> M1[Mode 1: mod_php\nPHP as an Apache module\nOne process per request]
    WS --> M2[Mode 2: PHP-FPM\nFastCGI Process Manager\nPool of workers]
    WS --> M3[Mode 3: Built-in Server\nDevelopment only]

    M2 --> FPM[PHP-FPM Pool\nWorker 1\nWorker 2\nWorker 3\n...\nWorker N]

    FPM --> Cache[OPcache\nCompiled bytecode\nShared across workers]

    style M2 fill:#dcfce7,stroke:#16a34a
    style M3 fill:#fee2e2
    style Cache fill:#fef9c3

PHP-FPM (FastCGI Process Manager) is the recommended architecture for production. PHP-FPM manages a pool of worker processes that handle PHP requests, separate from the web server. Nginx or Apache acts as a reverse proxy forwarding PHP requests to PHP-FPM via the FastCGI protocol.


The PHP Built-in Development Server #

PHP includes a simple web server suitable for local development — no need to install Nginx or Apache:

# Run the server on port 8000, root at the current directory
php -S localhost:8000

# With a specific document root
php -S localhost:8000 -t /path/to/public

# With a router script — for SPAs or frameworks
php -S localhost:8000 index.php

# Bind to all interfaces (accessible from other machines on the network)
php -S 0.0.0.0:8000 -t public/
The PHP built-in server is for development only. It’s single-threaded (can only handle one request at a time), has no production security features, and isn’t designed to handle real traffic. Never use it in production or in publicly accessible staging.

Router Script for Frameworks #

PHP frameworks like Laravel and Symfony use a single entry point (public/index.php) for all requests. To make the built-in server work with this pattern:

<?php
// router.php — the script deciding which requests PHP handles
// and which are served as static files

$uri = urldecode(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH));

// If a static file exists, serve it directly
if ($uri !== '/' && file_exists(__DIR__ . '/public' . $uri)) {
    return false; // the built-in server handles it as a static file
}

// All other requests are forwarded to index.php
$_SERVER['SCRIPT_FILENAME'] = __DIR__ . '/public/index.php';
require __DIR__ . '/public/index.php';
php -S localhost:8000 router.php

PHP-FPM — Configuration and Tuning #

PHP-FPM manages a pool of worker processes. Correct pool configuration greatly affects application performance and stability.

Basic Pool Configuration #

; /etc/php/8.3/fpm/pool.d/www.conf

[www]
; User and group running the workers
user  = www-data
group = www-data

; Unix socket (faster than TCP for local communication)
listen = /run/php/php8.3-fpm.sock
listen.owner = www-data
listen.group = www-data
listen.mode  = 0660

; Or use TCP if PHP-FPM is on a different server than Nginx
; listen = 127.0.0.1:9000

; Process management
pm = dynamic            ; static, dynamic, or ondemand

; dynamic — worker count varies with load
pm.max_children      = 50   ; maximum workers
pm.start_servers     = 5    ; workers at startup
pm.min_spare_servers = 5    ; minimum idle workers
pm.max_spare_servers = 35   ; maximum idle workers

; Restart workers after N requests (prevents slow memory leaks)
pm.max_requests = 500

; Request timeout (seconds) — kill the worker if it takes too long
request_terminate_timeout = 60

; Log slow requests
slowlog = /var/log/php8.3-fpm-slow.log
request_slowlog_timeout = 5s  ; log if > 5 seconds

; Environment variables available to PHP scripts
env[PATH]           = /usr/local/bin:/usr/bin:/bin
env[TMPDIR]         = /tmp
env[APP_ENV]        = production

; php.ini overrides specific to this pool
php_admin_value[error_log]     = /var/log/php/error.log
php_admin_flag[log_errors]     = on
php_admin_value[memory_limit]  = 256M

Calculating pm.max_children #

A simple formula for determining the optimal worker count:

pm.max_children = (RAM available for PHP) / (average memory per PHP process)

Example:
  - Total server RAM: 4 GB
  - RAM for OS and Nginx: ~500 MB
  - RAM available for PHP: ~3.5 GB
  - Average PHP process: ~50 MB (check with: ps aux | grep php-fpm)
  - pm.max_children = 3500 / 50 = 70
# Check the average memory per PHP-FPM worker
ps aux | grep php-fpm | awk '{sum += $6; count++} END {print sum/count/1024 " MB"}'

# Check the PHP-FPM pool status live
# Enable pm.status_path = /status in the pool config
curl http://127.0.0.1/status?full

Nginx Configuration for PHP #

Nginx is the most common web server combined with PHP-FPM in production.

Basic Virtual Host Configuration #

# /etc/nginx/sites-available/myapp.conf

server {
    listen 80;
    server_name myapp.example.com www.myapp.example.com;

    # Root directory — always point to the public/ folder
    root /var/www/myapp/public;
    index index.php index.html;

    # Logs
    access_log /var/log/nginx/myapp-access.log;
    error_log  /var/log/nginx/myapp-error.log;

    # Maximum upload size
    client_max_body_size 64M;

    # Timeout
    fastcgi_read_timeout 60;

    # Location for all requests
    location / {
        # Try to serve static files first, fall back to index.php
        try_files $uri $uri/ /index.php?$query_string;
    }

    # Handle PHP — forward to PHP-FPM
    location ~ \.php$ {
        # Security: reject requests to non-existent PHP files
        try_files $uri =404;

        # Split the script name from the path info
        fastcgi_split_path_info ^(.+\.php)(/.+)$;

        # Forward to PHP-FPM via Unix socket
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        fastcgi_index index.php;

        # Standard FastCGI parameters
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        fastcgi_param PATH_INFO       $fastcgi_path_info;

        # Security header
        fastcgi_param HTTPS on;  # if behind an HTTPS proxy
    }

    # Block access to sensitive files
    location ~ /\. {
        deny all;  # hide .env, .git, etc.
    }

    location ~ \.(env|log|sql|bak)$ {
        deny all;
    }

    # Cache static files
    location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf)$ {
        expires 30d;
        add_header Cache-Control "public, no-transform";
        access_log off;
    }
}

Configuration with HTTPS (Let’s Encrypt) #

server {
    listen 80;
    server_name myapp.example.com;
    # Redirect all HTTP to HTTPS
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name myapp.example.com;

    # Let's Encrypt certificate
    ssl_certificate     /etc/letsencrypt/live/myapp.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/myapp.example.com/privkey.pem;

    # Modern TLS configuration
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers   ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:...;
    ssl_prefer_server_ciphers off;

    # HSTS — force browsers to use HTTPS for a year
    add_header Strict-Transport-Security "max-age=31536000" always;

    # Other security headers
    add_header X-Content-Type-Options  "nosniff" always;
    add_header X-Frame-Options         "SAMEORIGIN" always;
    add_header X-XSS-Protection        "1; mode=block" always;
    add_header Referrer-Policy         "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy      "camera=(), microphone=(), geolocation=()" always;

    root /var/www/myapp/public;
    index index.php;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        try_files $uri =404;
        fastcgi_pass   unix:/run/php/php8.3-fpm.sock;
        fastcgi_index  index.php;
        include        fastcgi_params;
        fastcgi_param  SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        fastcgi_param  HTTPS on;
    }

    location ~ /\. { deny all; }
}
# Install Certbot and get a certificate
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d myapp.example.com

# Auto-renewal (cron)
sudo certbot renew --dry-run
# Certbot automatically creates a cron/systemd timer for renewal

Apache Configuration for PHP #

Apache can run PHP via mod_php (easier but less flexible) or via PHP-FPM (better for production):

# /etc/apache2/sites-available/myapp.conf

<VirtualHost *:80>
    ServerName myapp.example.com
    DocumentRoot /var/www/myapp/public

    # Enable mod_proxy_fcgi and PHP-FPM
    <FilesMatch \.php$>
        SetHandler "proxy:unix:/run/php/php8.3-fpm.sock|fcgi://localhost"
    </FilesMatch>

    # URL rewriting for frameworks
    <Directory /var/www/myapp/public>
        Options -Indexes +FollowSymLinks
        AllowOverride All
        Require all granted
    </Directory>

    # Hide .env and sensitive files
    <FilesMatch "\.(env|log|sql|bak)$">
        Require all denied
    </FilesMatch>

    <DirectoryMatch "\.git">
        Require all denied
    </DirectoryMatch>

    ErrorLog  ${APACHE_LOG_DIR}/myapp-error.log
    CustomLog ${APACHE_LOG_DIR}/myapp-access.log combined
</VirtualHost>
# Enable the required modules
sudo a2enmod proxy_fcgi setenvif rewrite headers
sudo a2ensite myapp.conf
sudo systemctl reload apache2

.htaccess for PHP Frameworks #

# /var/www/myapp/public/.htaccess

Options -MultiViews -Indexes
RewriteEngine On

# Redirect to HTTPS
RewriteCond %{HTTPS} off
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]

# Route all requests to index.php unless a file/directory exists
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]

# Hide server information
Header unset X-Powered-By
ServerSignature Off

php.ini Configuration for Production #

; /etc/php/8.3/fpm/php.ini

;;; ERROR HANDLING ;;;
; In production: TURN OFF display_errors
display_errors         = Off
display_startup_errors = Off
; Log all errors to a file
log_errors             = On
error_log              = /var/log/php/error.log
; Report all errors but don't display them
error_reporting        = E_ALL

;;; PERFORMANCE ;;;
; Memory limit per request
memory_limit          = 256M
; Maximum execution time (seconds)
max_execution_time    = 30
; Maximum input parsing time (uploads, etc.)
max_input_time        = 60

;;; UPLOAD ;;;
; Maximum file size that can be uploaded
upload_max_filesize = 64M
post_max_size       = 64M
; Maximum number of files in one upload
max_file_uploads    = 20

;;; SESSION ;;;
session.cookie_httponly = On   ; prevent session cookie access from JavaScript
session.cookie_secure   = On   ; cookies only over HTTPS
session.cookie_samesite = Lax  ; prevent CSRF
session.use_strict_mode = On   ; reject unrecognized session IDs
session.gc_maxlifetime  = 1440 ; sessions expire after 24 minutes idle

;;; TIMEZONE ;;;
date.timezone = Asia/Jakarta

;;; SECURITY ;;;
; Hide the PHP version from headers
expose_php = Off
; Disable dangerous functions
disable_functions = exec,passthru,shell_exec,system,proc_open,popen,curl_exec,curl_multi_exec,parse_ini_file,show_source

;;; REALPATH CACHE ;;;
; Speed up path resolution (very useful for frameworks)
realpath_cache_size = 4096K
realpath_cache_ttl  = 600

OPcache — The PHP Accelerator #

OPcache stores already-compiled PHP bytecode in memory — every request doesn’t need to parse and recompile PHP files from scratch. This is the most significant performance improvement you can make for PHP.

flowchart LR
    R[Request] --> C{OPcache\nhit?}
    C -- Yes --> E[Execute\nbytecode]
    C -- No --> P[Parse PHP\nsource]
    P --> K[Compile to\nbytecode]
    K --> S[Store in\nOPcache]
    S --> E
    E --> Resp[Response]

    style C fill:#fef9c3
    style S fill:#dcfce7
; Recommended OPcache configuration for production

[opcache]
opcache.enable            = 1
opcache.enable_cli        = 0          ; disable for CLI

; Memory size for storing bytecode
opcache.memory_consumption      = 256  ; MB — adjust to your application size

; Memory size for interned strings
opcache.interned_strings_buffer = 16   ; MB

; Maximum files that can be cached
opcache.max_accelerated_files   = 20000 ; more than your application's PHP file count

; Check file timestamps to detect changes
; In production: disable for maximum performance
; In development: enable so changes are detected immediately
opcache.validate_timestamps     = 0    ; disable in production

; Timestamp check interval (seconds) — if validate_timestamps=1
opcache.revalidate_freq         = 0

; Compile files at startup (preloading — PHP 7.4+)
; opcache.preload = /var/www/myapp/preload.php
; opcache.preload_user = www-data

; Optimization aggressiveness (0-3, higher = slower compile but faster run)
opcache.optimization_level      = 0x7FFEBFFF  ; all optimizations

; JIT — Just-In-Time compilation (PHP 8.0+)
opcache.jit               = tracing   ; 'tracing' for web, 'function' for CLI
opcache.jit_buffer_size   = 64M

Invalidating OPcache on Deploy #

Because validate_timestamps=0, OPcache won’t automatically detect file changes after a deploy. You must invalidate manually:

<?php
// opcache_reset.php — run after deploy
if (function_exists('opcache_reset')) {
    opcache_reset();
    echo "OPcache successfully reset\n";
} else {
    echo "OPcache is not active\n";
}
# Or via CLI
php -r "opcache_reset();"

# Or via curl to a protected endpoint
curl -X POST https://myapp.example.com/opcache-reset \
     -H "Authorization: Bearer deploy...oken"

Preloading — PHP 7.4+ #

Preloading loads PHP files into memory when PHP-FPM starts — all workers immediately have ready bytecode without any parsing:

<?php
// preload.php — list of files preloaded when PHP-FPM starts
// Only files needed on every request

$files = [
    // Core framework
    __DIR__ . '/vendor/autoload.php',
    __DIR__ . '/vendor/psr/http-message/src/RequestInterface.php',
    __DIR__ . '/vendor/psr/log/Psr/Log/LoggerInterface.php',
    // ... the most frequently used files
];

foreach ($files as $file) {
    if (file_exists($file)) {
        opcache_compile_file($file);
    }
}
; php.ini
opcache.preload      = /var/www/myapp/preload.php
opcache.preload_user = www-data

Safe Deployment #

Safe Directory Structure #

/var/www/myapp/
  ├── public/           ← Nginx/Apache document root (the only accessible one)
  │   ├── index.php
  │   ├── css/
  │   ├── js/
  │   └── uploads/
  ├── src/              ← source code (not web-accessible)
  ├── config/           ← configuration (not accessible)
  ├── storage/          ← logs, cache, sessions (not accessible)
  ├── vendor/           ← Composer dependencies (not accessible)
  └── .env              ← credentials (not accessible, not committed to Git)
Make sure the Nginx/Apache document root only points to the public/ folder, not the project root. If the project root is the document root, all .env, vendor/, config/, and source code files become publicly accessible — including every password and API key.

Production Deployment Checklist #

BEFORE DEPLOY:
  □ composer install --no-dev --optimize-autoloader
  □ php artisan config:cache (Laravel) or cache the framework config
  □ php artisan route:cache
  □ php artisan view:cache
  □ Set APP_ENV=production and APP_DEBUG=false

SERVER CONFIGURATION:
  □ document root points to public/ (not the project root)
  □ display_errors = Off in php.ini
  □ expose_php = Off in php.ini
  □ Block web access to .env, .git, vendor/
  □ HTTPS active with a valid certificate
  □ Security headers (HSTS, X-Content-Type-Options, etc.) installed

OPCACHE:
  □ opcache.enable = 1
  □ opcache.validate_timestamps = 0
  □ OPcache reset script after deploy

MONITORING:
  □ PHP error log active and monitored
  □ PHP-FPM slow log active (request_slowlog_timeout = 5s)
  □ Alerts for high error rates

Zero-Downtime Deployment #

#!/bin/bash
# deploy.sh — zero-downtime deployment with the symlink pattern

APP_DIR="/var/www/myapp"
RELEASE_DIR="/var/www/releases/$(date +%Y%m%d%H%M%S)"

# 1. Create a new release directory
mkdir -p $RELEASE_DIR

# 2. Clone/copy the code to the release dir
git clone https://github.com/myteam/myapp.git $RELEASE_DIR
# or: rsync -az --exclude='.git' ./ $RELEASE_DIR/

# 3. Install dependencies in the new release
cd $RELEASE_DIR
composer install --no-dev --optimize-autoloader --no-interaction

# 4. Symlink files shared across releases
ln -nfs $APP_DIR/shared/.env $RELEASE_DIR/.env
ln -nfs $APP_DIR/shared/storage $RELEASE_DIR/storage

# 5. Build (if needed)
# npm run build, etc.

# 6. Cache warming
php artisan config:cache
php artisan route:cache

# 7. Atomically switch the current symlink
# ln -nfs = doesn't follow an existing symlink, atomic on Linux
ln -nfs $RELEASE_DIR $APP_DIR/current

# 8. Reload PHP-FPM (graceful — doesn't drop existing connections)
sudo systemctl reload php8.3-fpm

# 9. Reset OPcache
php -r "opcache_reset();"

# 10. Remove old releases (keep the last 5)
ls -dt /var/www/releases/*/ | tail -n +6 | xargs rm -rf

echo "Deploy complete: $RELEASE_DIR"

Performance Monitoring #

# View PHP-FPM status (enable pm.status_path = /fpm-status in the pool config)
curl http://127.0.0.1/fpm-status

# Output:
# pool:                 www
# process manager:      dynamic
# start time:           15/Mar/2024:10:00:00 +0700
# accepted conn:        12847
# listen queue:         0
# max listen queue:     0
# listen queue len:     511
# idle processes:       8
# active processes:     2
# total processes:      10
# max active processes: 25
# max children reached: 0
# slow requests:        3

# Monitor the PHP-FPM log
tail -f /var/log/php8.3-fpm-slow.log

# Check memory usage per worker
watch -n 2 'ps aux | grep php-fpm | grep -v grep | awk "{print \$6/1024 \" MB\", \$11}"'

# View OPcache status from PHP
php -r "var_dump(opcache_get_status());"

Summary #

  • PHP-FPM is the standard production architecture — managing a pool of worker processes that can be tuned to your needs. Use a Unix socket (not TCP) for Nginx ↔ PHP-FPM communication on the same machine; it’s faster with no network overhead.
  • The document root must be the public/ folder — not the project root. This is the most fundamental security rule preventing access to .env, source code, and dependencies.
  • OPcache is the biggest acceleration — enable it with validate_timestamps=0 in production and an OPcache reset script after every deploy.
  • display_errors = Off in production — errors must be logged to a file, not displayed in the browser. Displaying errors exposes your application’s structure and sensitive information.
  • expose_php = Off removes the X-Powered-By: PHP/x.x.x header that tells attackers which PHP version you’re using.
  • Zero-downtime deployment with the symlink pattern — deploy to a new release directory, switch the symlink atomically, reload PHP-FPM (graceful), remove old releases.
  • PHP-FPM’s pm.max_children is calculated from available RAM divided by the average memory per worker. Too small causes queues; too large causes swapping.
  • HTTP security headers (HSTS, X-Content-Type-Options, X-Frame-Options) must be set in Nginx/Apache — not in PHP — so they apply to all responses including static files.

← Previous: Web Socket   Next: Unit Test →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact