036 —PHP
Reading PHP error logs like bloodwork: a triage field guide
A 16:47 Friday message: site is throwing 500s, sometimes. You SSH in, tail the log, see eight hundred lines of red. This is how to triage them in order.
A client message lands at 16:47 on a Friday. "Site is throwing 500s on the checkout page, sometimes. Not always. Can you take a look before the weekend?" You SSH into the server, tail the log, and see roughly eight hundred lines of red. Some of it is the actual problem. Most of it is noise that has been sitting there for months. Triage starts with knowing which is which.
This is the part of the job that gets less attention than it deserves. PHP error logs are a vital-signs panel for a running site. They tell you what is breaking, what is about to break, and what was already broken before you logged in. Read them the way a doctor reads bloodwork: not line by line, but by pattern, severity, and rate of change.
Locate the log before anything else
Half the battle on an unfamiliar server is finding where PHP is actually writing. The configured path lives in the loaded php.ini. Ask PHP directly:
php -i | grep -E 'error_log|log_errors|display_errors'
Output looks something like this:
error_log => /var/log/php/error.log => /var/log/php/error.log
log_errors => On => On
display_errors => Off => Off
On a CMS install, there is usually a second log. WordPress writes to wp-content/debug.log when WP_DEBUG_LOG is true. Drupal stores entries in the watchdog table by default, viewable at /admin/reports/dblog. Magento splits across var/log/system.log, var/log/exception.log, and var/log/debug.log. Tail all of them at once or you will miss half the picture:
tail -F /var/log/php/error.log \
/var/www/wp-content/debug.log \
/var/log/nginx/error.log
The severity ladder
PHP groups errors into categories that matter in triage. Treating the PHP error log as one undifferentiated blob is the same mistake as looking at a CBC and only noting that "some numbers are out of range." Each category points to a different kind of failure.
- E_PARSE: the file did not compile. A deploy went wrong. The site is hard down.
- E_ERROR / E_FATAL: runtime aborted. Missing class, undefined function, memory exhausted, max execution time hit. Some requests are 500ing.
- E_WARNING: PHP kept going, but the operation failed. File not found, database connection refused, division by zero on older versions. Often the actual signal.
- E_NOTICE: undefined index, undefined variable. Mostly noise, but a flood of them is a clue the code is reading state that was never written.
- E_DEPRECATED: works today, breaks on the next PHP minor. Read these before any 8.x upgrade.
- E_USER_*: someone called
trigger_error()in application code. Treat as deliberate.
The official constants reference lists the full set. Bookmark it.
Rate, not absolute count
A single fatal at 03:14 means one user, one bug, probably already gone. Four thousand warnings per hour means something in a hot path is broken, and it has been broken since whenever the rate started climbing. The first thing to compute when reading PHP error logs is the rate, not the count.
awk '{print substr($0, 1, 13)}' /var/log/php/error.log \
| sort | uniq -c | tail -24
That gives you hourly buckets for the past day. A line like [26-May-2026 14 with 3812 in front of it is the hour the spike started. Cross-reference against the deploy log, the cron schedule, and the traffic dashboard. Nine times out of ten the spike lines up with one of the three.
To find the worst offenders by message:
grep -oE 'PHP [A-Z][a-z]+( [a-z]+)*:.*' /var/log/php/error.log \
| sed 's/line [0-9]\+/line N/' \
| sort | uniq -c | sort -rn | head -20
The sed normalises line numbers so the same error from a templated loop collapses into one bucket. Otherwise you get eighty variants of the same warning and the signal disappears.
Common syndromes and what they mean
After a few hundred sites, certain error messages become diagnostic shortcuts. The same way a low MCV plus a low ferritin reliably means iron-deficiency anaemia, certain PHP messages reliably point at one root cause.
Memory and time
Allowed memory size of 268435456 bytes exhausted (tried to allocate 4194304 bytes). The number in parentheses is the diagnostic. If PHP tried to allocate four megabytes when it died, the leak is gradual: a loop accumulating objects, a recursive walk that never frees. If it tried to allocate 200MB in one shot, you are loading something unbounded, usually a query without LIMIT or a CSV read into memory.
Maximum execution time of 30 seconds exceeded. Usually a slow query, occasionally a blocking HTTP call to a third-party API that timed out. The stack trace tells you which. If the trace ends in PDOStatement->execute(), it is the database. If it ends in stream_socket_client or curl_exec, it is the network.
Headers and output
Cannot modify header information - headers already sent by (output started at /path/to/file.php:1). The "line 1" almost always means a UTF-8 BOM or whitespace before the opening <?php tag. Open the file in a hex viewer if you cannot see it:
head -c 8 /path/to/file.php | xxd
If the first three bytes are ef bb bf, the file has a BOM. Strip it and the error goes away.
Database
MySQL server has gone away means the connection died mid-query. Two common causes: a query took longer than wait_timeout (often 28800 seconds, but plenty of managed hosts set it to 60), or the query exceeded max_allowed_packet. The MySQL error log on the database host tells you which.
SQLSTATE[HY000] [2002] No such file or directory means PDO tried to connect over a Unix socket that does not exist. The fix is either to point at 127.0.0.1 instead of localhost, or to set unix_socket in the DSN explicitly. The PDO MySQL DSN reference spells it out.
Classes and autoloading
Class "App\Foo\Bar" not found on a site that worked yesterday almost always means a Composer autoload that did not get regenerated after a deploy. Run composer dump-autoload -o and the symptom clears. If it does not, the file is actually missing, and you need to look at what the deploy script did, not what the application code does.
The triage worksheet
When the PHP error log is overwhelming, work through these five questions in order. Do not skip ahead. Most of the time the answer falls out at step two or three.
- Severity distribution. How many fatals, warnings, notices in the last hour? If there are zero fatals, the site is up and you are looking at chronic noise, not an outage.
- Time clustering. Did the rate change? When? Match against deploys and cron.
- URL clustering. Pair the error log with the access log on the same timestamps. Which endpoint is failing?
- User clustering. Is it one user (look at session cookies or IPs in the access log) or everyone? One user usually means input the code did not expect.
- Stack trace fingerprint. If three traces share the same top frame, you have one bug, not three.
What to silence, and what never to silence
The instinct after a triage session is to make the noise go away. Resist it past a point. error_reporting in production should be at least E_ALL & ~E_DEPRECATED & ~E_STRICT. display_errors stays off in production. log_errors stays on. The error handling configuration reference is the canonical source.
What never to silence:
- E_DEPRECATED in the six months before a PHP major upgrade. These are the upgrade backlog, served free.
- E_WARNING from database or filesystem calls. They are the early signal that a credential, a path, or a permission broke.
- Anything from
trigger_error()in code you own. Your past self left those there for a reason.
What is safe to silence:
- E_NOTICE about undefined indexes in third-party plugin code you do not own and cannot patch. Filter them with a custom error handler rather than turning the whole category off globally.
Keeping the log usable over time
A 2GB error log is a sign that nobody has looked at it in a year. Rotate it. On a Debian or Ubuntu host, drop a file into /etc/logrotate.d/php:
/var/log/php/error.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
create 0640 www-data adm
postrotate
/usr/sbin/service php8.2-fpm reload > /dev/null 2>&1 || true
endscript
}
Two weeks of retention is enough to correlate against most deploys. If you need longer, ship to a central log host rather than letting the file grow past a gigabyte.
On a working legacy site, the PHP error log is also the cheapest possible monitoring. A nightly cron that greps for new fatal patterns and emails the diff catches the slow drift that an uptime monitor will never see, because the site is still serving 200s.
How we ended up handling this
When we built Pier we ran into this exact thing on customer sites: agencies inheriting a PHP application with a log nobody had read since the original developer left. The way we ended up handling it was to make the error log a first-class panel inside the app, alongside the MySQL editor and the version history, so triaging a Friday-afternoon 500 does not require three SSH sessions and a private terminal cheat-sheet.
One small thing to do today
Open the PHP error log on one production site you maintain. Run the hourly-rate awk one-liner above. If the highest hour in the last 24 has more than a hundred entries, you have something worth ten minutes of investigation before the week is out. That is the whole field test.
— Questions —
Where does PHP actually write its error log?
It depends on the host. Run php -i | grep error_log to see the configured path. On WordPress also check wp-content/debug.log; on Magento, var/log/system.log and var/log/exception.log.
Is it safe to disable E_DEPRECATED in production?
Yes for day-to-day noise, but turn them back on at least six months before any PHP major upgrade. Deprecation warnings are the cheapest possible upgrade backlog.
Why is my PHP error log empty on a busy site?
Almost always a writability issue. Check that the configured path exists, that log_errors is On, and that the PHP-FPM user can write the file. A silent log is more suspicious than a noisy one.
How long should PHP error logs be retained?
Two weeks is enough to correlate with most deploys and cron runs. If you need longer, ship to a central log host rather than letting a single file grow past a gigabyte.