Emmanuel Corels
← Blog
Web Infrastructure

Trace a PHP 500 error without exposing production details

Keep browser errors generic, correlate the failed request with Nginx and PHP-FPM logs, verify the active configuration and roll back from evidence.

By Emmanuel Corels

A blank page or generic 500 Internal Server Error means the request crossed at least one boundary and failed somewhere behind it. Turning display_errors on globally may reveal the cause—but it can also reveal file paths, SQL fragments, class names and secrets to every visitor. Production diagnosis should make the server more observable, not the response more informative to strangers.

Capture one failed request precisely

curl -sS -D /tmp/response.headers   -o /tmp/response.body   -w 'status=%{http_code} time=%{time_total}
'   https://example.com/account

Record the UTC time, hostname, route, method and any request ID returned in headers. Do not place credentials or personal data in a shared incident note. If the endpoint needs authentication, reproduce through an approved test account and protect the captured response.

Determine which layer generated the 500

sudo nginx -t
sudo systemctl status nginx php8.3-fpm --no-pager
sudo journalctl -u nginx -u php8.3-fpm   --since '10 minutes ago' --no-pager

An Nginx error such as an unavailable upstream, permission denial or malformed FastCGI response points to a different repair than a PHP fatal exception. Check the configured log destinations rather than assuming a distribution-default filename:

sudo nginx -T 2>&1 | grep -nE 'error_log|access_log|fastcgi_pass'
php-fpm8.3 -tt 2>&1 | grep -E 'error_log|catch_workers_output'

Command names vary by distribution and installed PHP version. The active service and configuration are authoritative.

Keep production display off and logging on

; production php.ini or pool-appropriate configuration
display_errors = Off
display_startup_errors = Off
log_errors = On
error_reporting = E_ALL
error_log = /var/log/php/app-error.log

PHP distinguishes whether an error is reported, displayed and logged. error_reporting selects levels; display_errors controls response output; log_errors sends reportable errors to the configured log. Restart or reload the correct PHP-FPM service after a validated configuration change.

sudo install -o www-data -g adm -m 0640 /dev/null   /var/log/php/app-error.log
sudo systemctl reload php8.3-fpm

Choose ownership for the actual FPM user and logging policy. A log path PHP cannot write creates silence precisely when evidence is needed.

Use a local or isolated environment for visible errors

For a developer-only environment, runtime settings can be useful:

ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
error_reporting(E_ALL);

Place this before application bootstrap only in an isolated environment. PHP’s manual notes that changing display_errors at runtime cannot reveal fatal errors that occur before the call executes. Startup and parse failures are better captured through configuration-level logging or CLI linting.

Lint the deployed code before chasing runtime state

find /var/www/example/releases/current   -type f -name '*.php' -print0 |
  xargs -0 -n1 php -l

Use the same PHP major/minor version and extensions as FPM. A CLI binary from another version can pass code the web runtime rejects—or fail code the web runtime accepts. Check both:

php --ini
php -v
sudo -u www-data php -r   'echo PHP_VERSION, PHP_EOL; print_r(get_loaded_extensions());'

Correlate, do not merely tail

sudo tail -F /var/log/nginx/example-access.log   /var/log/nginx/example-error.log   /var/log/php/app-error.log

Reproduce once, then align timestamp, URI, upstream and request ID. On busy systems, unrestricted tail output can bury the event. Structured application logging should include a correlation ID and exception class while excluding passwords, tokens, session cookies and full request bodies.

EvidenceLikely faultNext check
Nginx cannot connect to upstreamFPM stopped, wrong socket or permissionService state and fastcgi_pass
PHP parse errorInvalid deployed source or version mismatchLint release with matching runtime
Class/function not foundMissing dependency, autoload or extensionRelease artifact and Composer/extension state
Permission deniedRuntime user cannot read/write required pathOwnership, mode, parent traversal and policy controls
Memory exhaustedUnbounded workload or insufficient limitInput size, allocation path and configured limit

Compare the failed release with the last known good one

readlink -f /var/www/example/current
git -C /var/www/example/current rev-parse HEAD
git -C /var/www/example/current diff --stat <good-revision>..HEAD
composer check-platform-reqs --no-dev

Verify environment, migrations, generated configuration, filesystem permissions and dependencies—not only source diffs. If impact is active and the previous immutable release is known good, repoint through the deployment system and verify the external route. Preserve logs and the failed artifact for the follow-up.

Close the loop

After correction, repeat the exact request, confirm the expected status and content, ensure no new fatal event appears, and test an adjacent route. Then remove any temporary diagnostic verbosity and confirm production still returns a generic error for an intentional controlled failure.

The browser should receive a safe failure. Operators should receive enough correlated evidence to explain it.

Sources: the solved Stack Overflow discussion (CC BY-SA), PHP’s official error configuration reference, and the error-handling manual.

Primary source: Review the official reference ↗