Local Server Setup
When a page fails, trace the request through that chain instead of changing PHP code at random.
Name The Layers
A typical web request can involve:
browser
-> local DNS or hosts entry
-> listening web server
-> static file or FastCGI decision
-> PHP runtime
-> public/index.php
-> application services
PHP's built-in server combines the listening web server and PHP runtime in one cli-server process. Apache can embed PHP through a module or forward requests to PHP-FPM. Nginx and Caddy do not execute PHP source themselves; they normally send FastCGI requests to PHP-FPM.
That distinction explains why "PHP works in my terminal" is incomplete evidence. The terminal can use a different binary, SAPI, INI files, extensions, user, environment, and working directory from the process handling HTTP.
Keep One Public Document Root
Use a layout such as:
project/
|-- config/
|-- public/
| |-- assets/
| `-- index.php
|-- src/
|-- storage/
`-- vendor/
Configure the web server's document root as public/, not the project root. Existing assets can be served directly, while application routes fall back to public/index.php.
This is not merely tidy structure. A project root can contain environment files, dependency manifests, source maps, test fixtures, local databases, backups, and private storage. File permissions and "hidden" filenames are not substitutes for placing non-public files outside the HTTP document tree.
Use The Built-In Server For A Narrow Check
For a quick local request path:
php -S 127.0.0.1:8000 -t public
The flow is:
browser -> PHP cli-server -> public/index.php
This is useful for learning and focused application work. It uses the CLI executable and configuration, runs one blocking process by default, and does not reproduce FastCGI, a reverse proxy, PHP-FPM pools, production TLS, or production filesystem identities.
Move to a fuller stack when the behavior under test belongs to one of those missing layers.
Understand What PHP-FPM Does
PHP-FPM is a FastCGI process manager. It starts and supervises PHP worker processes, groups them into pools, accepts FastCGI requests, and returns responses to a web server. It is not an HTTP server and should not be exposed as if it were one.
A pool can define:
- its listening TCP address or Unix socket;
- worker user and group;
- process-management mode and limits;
- environment handling;
- PHP administrative values;
- access, error, status, and slow-log behavior.
The official PHP documentation describes static, dynamic, and on-demand child spawning, separate pool identities, configurable logging, graceful control, status information, and slow-request tracing.
The web server and FPM pool must agree on the listener. A TCP example is 127.0.0.1:9000. A Unix socket is a filesystem path and therefore also requires compatible owner, group, and mode permissions. A correct socket pathname with the wrong permissions still produces a failed handoff.
Apache Has More Than One PHP Architecture
Apache may run PHP through an embedded module or forward .php requests to PHP-FPM through mod_proxy_fcgi.
With an embedded module, PHP code runs inside Apache worker processes and PHP_SAPI commonly identifies the Apache handler. With PHP-FPM, Apache handles HTTP and static files, then sends FastCGI parameters to a separate fpm-fcgi runtime.
An illustrative Apache and FPM virtual host can contain:
DocumentRoot "/srv/demo/public"
<Directory "/srv/demo/public">
Require all granted
AllowOverride None
FallbackResource /index.php
</Directory>
<FilesMatch "\.php$">
SetHandler "proxy:fcgi://127.0.0.1:9000"
</FilesMatch>
This example assumes an FPM pool listening on 127.0.0.1:9000 and a project located at /srv/demo. Do not paste it over an existing host without checking enabled modules, directory rules, listener ownership, and the application's routing requirements.
Some shared-hosting and legacy projects use .htaccess. Those files work only when the server permits the relevant overrides. A local setup based on .htaccess is not equivalent to a production virtual host that disables overrides.
Nginx Passes FastCGI Parameters
The Nginx flow is:
browser -> Nginx -> PHP-FPM -> public/index.php
A compact illustrative server block is:
server {
listen 127.0.0.1:8080;
root /srv/demo/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
try_files $uri =404;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass 127.0.0.1:9000;
}
}
try_files handles the static-file versus front-controller decision. fastcgi_pass selects the FPM listener. SCRIPT_FILENAME tells FPM which script file to execute. If that value points into the wrong host path, PHP can report "Primary script unknown" even though both services are running.
Treat distribution-provided FastCGI include files as configuration, not magic. Check which parameters they set before adding duplicates or assuming SCRIPT_FILENAME is already correct.
Caddy Provides A PHP-Specific Shortcut
Caddy's php_fastcgi directive is an opinionated shortcut around FastCGI proxy configuration. A simple local Caddyfile can be:
http://demo.localhost:8080 {
root * /srv/demo/public
php_fastcgi 127.0.0.1:9000
file_server
}
This also assumes an FPM pool on 127.0.0.1:9000. Caddy's directive expects a front-controller-style index.php by default and applies a try-files policy. file_server serves static files from the configured root.
Concise configuration still needs verification. Confirm the root, FastCGI gateway, fallback behavior, logs, and whether Caddy and FPM see the same filesystem paths.
Local Names And TLS Are Separate Concerns
A hostname such as demo.localhost must resolve to the local machine before the web server can select a matching site. The reserved .localhost name is useful for local work, while custom names may require a hosts-file entry or a local DNS tool.
Name resolution does not configure the web server, and a virtual host does not create a trusted TLS certificate. If HTTPS behavior matters, use a local toolchain that installs and trusts a development certificate deliberately. Do not disable certificate verification globally to silence local errors.
Cookies marked Secure, redirect URLs, trusted proxy handling, and origin rules can behave differently under HTTP and HTTPS. Test those features through an HTTPS-capable local setup when they matter.
Align Filesystem Users And Writable Paths
The web server needs read access to public static files. PHP-FPM needs read access to application code and controlled write access only where the application writes, such as selected cache, session, log, or upload directories.
Do not solve every permission error with mode 777. Identify:
- the web-server user;
- the FPM pool user and group;
- file and directory ownership;
- required read, execute, and write operations;
- whether containers or mounted volumes translate identities differently.
A 403 Forbidden from the web server, a FastCGI connection failure, and a PHP "Permission denied" warning represent different boundaries.
Containers Add Another Path Namespace
A containerized Nginx and FPM setup can have three relevant path views: the host path, the Nginx container path, and the FPM container path. FastCGI parameters must name the script where FPM sees it, not where the host or Nginx happens to see it.
Mounting the project at /app in one container and /var/www in another can break SCRIPT_FILENAME unless the configuration translates that difference deliberately. Using the same application path in both containers is often simpler.
The FPM service name and port are also network-scoped. Inside a container network, 127.0.0.1:9000 means the current container, not a neighboring FPM container. The gateway may need a service address such as php:9000, depending on the environment.
Verify The Runtime Through HTTP
A temporary local diagnostic can measure the process that handled the request:
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
$loadedIni = php_ini_loaded_file();
$scannedIni = php_ini_scanned_files();
$result = [
'php_version' => PHP_VERSION,
'sapi' => PHP_SAPI,
'loaded_ini' => $loadedIni === false ? null : $loadedIni,
'scanned_ini' => $scannedIni === false ? null : $scannedIni,
'document_root' => $_SERVER['DOCUMENT_ROOT'] ?? null,
'script_filename' => $_SERVER['SCRIPT_FILENAME'] ?? null,
'server_software' => $_SERVER['SERVER_SOFTWARE'] ?? null,
'memory_limit' => ini_get('memory_limit'),
'extensions' => [
'curl' => extension_loaded('curl'),
'intl' => extension_loaded('intl'),
'pdo' => extension_loaded('pdo'),
],
];
echo json_encode(
$result,
JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES,
) . PHP_EOL;
Compare it with CLI facts such as php -v, php --ini, and php -m. Differences are evidence, not necessarily defects. The purpose is to identify which runtime owns the behavior.
This endpoint exposes versions, paths, and configuration details. Use it only in a controlled local environment, capture the result, then remove the route. A remote-address check alone is not sufficient protection behind a reverse proxy.
Read Failures By Layer
| Symptom | Likely first boundary |
|---|---|
| Connection refused | Name, address, port, listener, or firewall |
Static file works but PHP returns 502 |
Web server to FPM listener or socket permissions |
| PHP source downloads or displays | PHP handler or FastCGI mapping is absent |
Primary script unknown |
SCRIPT_FILENAME or shared filesystem path |
Application route returns web-server 404 |
Front-controller fallback or rewrite rule |
Existing public file returns 403 |
Web-server filesystem access or directory policy |
| CLI has an extension but HTTP does not | Different binary, SAPI, or INI scan configuration |
Blank 500 response |
Application, PHP, FPM, and web-server error logs |
| Upload works locally but fails elsewhere | Body limits, temp directory, permissions, or PHP limits |
| Redirect or cookie differs under HTTPS | TLS termination, proxy headers, trusted proxy, or cookie policy |
Start at the first failed boundary. Confirm name resolution and connection, then static serving, then FastCGI handoff, then runtime identity, then application behavior.
Know Which Logs Own The Failure
A complete setup can produce:
- web-server access logs;
- web-server error logs;
- PHP-FPM master and worker errors;
- PHP-FPM access, status, and slow logs when configured;
- application logs;
- container or service-manager logs.
A 502 often appears in the web-server error log because the upstream connection failed. A PHP exception belongs in PHP or application logging. A slow FPM worker may need the pool's slow-log configuration. Looking only at the browser discards the most useful evidence.
After changing configuration, use the server's configuration test before reloading it. Reload the component that owns the changed file. Editing an Nginx host does not require restarting PHP-FPM, while changing an FPM pool does not take effect merely because Nginx reloaded.
Choose The Smallest Setup That Tests The Risk
| Need | Suitable local setup |
|---|---|
| Learn HTTP and basic routing | PHP built-in server |
Reproduce Apache directives or .htaccess |
Apache with the project's actual PHP integration |
| Reproduce Nginx FastCGI parameters | Nginx plus PHP-FPM |
| Use concise local sites and local HTTPS | Caddy plus PHP-FPM |
| Match team services and versions | A maintained container or VM environment |
| Reproduce a production-only infrastructure issue | A controlled staging environment close to production |
More components do not automatically make a setup better. They make it capable of testing more boundaries and responsible for more configuration.
What You Should Be Able To Do
After this lesson, you should be able to draw the request path for the built-in server, Apache, Nginx, or Caddy; explain PHP-FPM's role; verify document roots and FastCGI listeners; compare CLI and HTTP runtimes; diagnose common handoff failures; and choose a setup based on the behavior that actually needs testing.
Official references include the PHP FPM overview, Nginx FastCGI module, Apache mod_proxy_fcgi, and Caddy php_fastcgi.
Practice
Practice: Verify A Local PHP Request Path
Create an evidence-based verification record for one local PHP project. Use the built-in server, Apache, Nginx with PHP-FPM, or Caddy with PHP-FPM, but document the stack you actually run.
Architecture Record
Create a short Markdown record containing:
- local hostname, address, and port
- web server and PHP execution model
- absolute public document root
- request path from client to
public/index.php - static-file handling rule
- application-route fallback rule
- PHP-FPM listener and pool identity when FPM is used
- web-server, PHP/FPM, and application log locations
- directories the HTTP runtime may write
- important differences from the deployed environment
Do not claim that a component is present unless you verify it.
Runtime Endpoint
Create a temporary public/runtime-report.php endpoint that:
- uses strict types
- returns
404unlessLOCAL_DIAGNOSTIC_TOKENis a non-empty environment variable and matches theX-Diagnostic-Tokenrequest header withhash_equals() - returns JSON with the PHP binary, version, SAPI, loaded and scanned INI files, document root, script filename, server software, working directory, memory limit, upload limit, post limit, and boolean
curl,intl, andpdoextension states - converts unavailable string values to JSON
null - uses
JSON_THROW_ON_ERROR,JSON_PRETTY_PRINT, andJSON_UNESCAPED_SLASHES
Generate a temporary random token outside the public document root and provide it to the HTTP runtime. Do not commit the token.
Verification
Capture evidence that:
- the endpoint returns
404without the token - the endpoint returns
200with the token - a public static file returns
200 - an unknown application route returns the status your application intends
- the observed SAPI, INI paths, and extensions are compared with
php -v,php --ini, andphp -m - the relevant logs contain the test requests or errors
Explain every CLI-versus-HTTP difference you find. A difference may be intentional, but it must not remain unidentified.
Finally, remove runtime-report.php, remove the temporary token, and state how you confirmed neither is publicly reachable or tracked by Git.
Show solution
This example records a built-in-server setup. Replace its observations with evidence from the stack actually running on your machine.
Local URL: http://demo.localhost:8000
Listener: PHP built-in development server on 127.0.0.1:8000
PHP execution model: cli-server in the same process as the HTTP listener
Document root: /srv/demo/public
Request path: curl -> 127.0.0.1:8000 -> cli-server -> router.php -> public/index.php
Static files: router returns false only for resolved files inside /srv/demo/public
Application fallback: all other paths enter public/index.php; unknown routes return 404
PHP-FPM listener and pool: not applicable to this setup
HTTP log: terminal running php -S
Application log: /srv/demo/storage/logs/application.log
Writable path: /srv/demo/storage, limited to the local runtime user
Production differences: production uses a reverse proxy, PHP-FPM, HTTPS, service supervision, and a different filesystem user
Create public/runtime-report.php temporarily:
<?php
declare(strict_types=1);
function stringOrNull(string|false $value): ?string
{
return $value === false ? null : $value;
}
function serverString(string $key): ?string
{
$value = $_SERVER[$key] ?? null;
return is_string($value) ? $value : null;
}
$expectedToken = getenv('LOCAL_DIAGNOSTIC_TOKEN');
$providedToken = serverString('HTTP_X_DIAGNOSTIC_TOKEN');
if (
$expectedToken === false
|| $expectedToken === ''
|| $providedToken === null
|| !hash_equals($expectedToken, $providedToken)
) {
http_response_code(404);
exit;
}
header('Content-Type: application/json; charset=utf-8');
$report = [
'php_binary' => PHP_BINARY,
'php_version' => PHP_VERSION,
'sapi' => PHP_SAPI,
'loaded_ini' => stringOrNull(php_ini_loaded_file()),
'scanned_ini' => stringOrNull(php_ini_scanned_files()),
'document_root' => serverString('DOCUMENT_ROOT'),
'script_filename' => serverString('SCRIPT_FILENAME'),
'server_software' => serverString('SERVER_SOFTWARE'),
'working_directory' => stringOrNull(getcwd()),
'memory_limit' => stringOrNull(ini_get('memory_limit')),
'upload_max_filesize' => stringOrNull(ini_get('upload_max_filesize')),
'post_max_size' => stringOrNull(ini_get('post_max_size')),
'extensions' => [
'curl' => extension_loaded('curl'),
'intl' => extension_loaded('intl'),
'pdo' => extension_loaded('pdo'),
],
];
echo json_encode(
$report,
JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES,
) . PHP_EOL;
On a POSIX shell, generate a token file outside public/ and start the server:
php -r "file_put_contents('.runtime-token', bin2hex(random_bytes(32)));"
LOCAL_DIAGNOSTIC_TOKEN="$(cat .runtime-token)" php -S 127.0.0.1:8000 -t public router.php
PowerShell uses process-environment syntax instead:
php -r "file_put_contents('.runtime-token', bin2hex(random_bytes(32)));"
$env:LOCAL_DIAGNOSTIC_TOKEN = Get-Content .runtime-token
php -S 127.0.0.1:8000 -t public router.php
From another terminal, prove both authorization branches:
curl -i http://127.0.0.1:8000/runtime-report.php
curl -i \
-H "X-Diagnostic-Token: $(cat .runtime-token)" \
http://127.0.0.1:8000/runtime-report.php
The first response must be 404. The second must be 200 with valid JSON. For PowerShell, construct the header with "X-Diagnostic-Token: $(Get-Content .runtime-token)".
Collect the comparison evidence:
php -v
php --ini
php -m
curl -i http://127.0.0.1:8000/assets/app.css
curl -i http://127.0.0.1:8000/a-route-that-does-not-exist
For this built-in setup, sapi should be cli-server, and CLI and HTTP commonly share INI paths because the same CLI binary launched the server. Under Apache or PHP-FPM, different values are expected and should be recorded with the relevant service configuration and logs.
After capturing the report, delete public/runtime-report.php and .runtime-token, stop or reload any temporary environment configuration, and run:
git status --short
curl -i http://127.0.0.1:8000/runtime-report.php
The files must not appear in Git status, and the diagnostic path must no longer execute the report. The token gate reduces accidental local exposure, but it is not a reason to retain a sensitive diagnostic endpoint in a deployed application.