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:#fef9c3PHP-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):
Apache with PHP-FPM (Recommended) #
# /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 thepublic/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=0in production and an OPcache reset script after every deploy.display_errors = Offin 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 = Offremoves theX-Powered-By: PHP/x.x.xheader 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_childrenis 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.