Errors and resources
Error Levels and What PHP Actually Reports
PHP reports failure through levels, not exceptions. Learn which levels stop execution, why display_errors must be off in production, and how to log what it hides.
A production site starts printing Warning: Undefined array key "coupon"
above the product title, halfway down the page. Nobody notices for a week because it
only happens for one cart state. When it is finally reported, the fastest available fix
is to add error_reporting(0) at the top of the bootstrap file. The warning
disappears, and so does every other signal PHP was trying to send.
That sequence is common because PHP's error system is older than its exception
system and behaves nothing like it. A warning is not thrown. It is printed, execution
continues, and the value you were about to use is whatever PHP substituted for the
missing one. Nothing in the call stack knows that anything went wrong. If you do not
configure the reporting, you get whatever php.ini on that machine happened
to say.
Everything below was run on PHP 8.5.6 from the command line. Paths are shortened to
/path/to/ because yours differ from mine, and every output block is the
real output, including the ugly parts.
Error levels are bit flags, not an enum
Each level is a power of two held in a constant, and error_reporting is
a bitmask over them. That is why the setting is written as an expression rather than a
name: E_ALL & ~E_DEPRECATED means every level except deprecations.
Printing the values makes the arithmetic obvious.
<?php
declare(strict_types=1);
printf("E_ERROR %6d\n", E_ERROR);
printf("E_WARNING %6d\n", E_WARNING);
printf("E_PARSE %6d\n", E_PARSE);
printf("E_NOTICE %6d\n", E_NOTICE);
printf("E_DEPRECATED %6d\n", E_DEPRECATED);
printf("E_USER_ERROR %6d\n", E_USER_ERROR);
printf("E_USER_WARNING %6d\n", E_USER_WARNING);
printf("E_USER_NOTICE %6d\n", E_USER_NOTICE);
printf("E_ALL %6d\n", E_ALL);
E_ERROR 1
E_WARNING 2
E_PARSE 4
E_NOTICE 8
E_DEPRECATED 8192
E_USER_ERROR 256
E_USER_WARNING 512
E_USER_NOTICE 1024
E_ALL 30719
The E_USER_* levels are the ones your own code raises with
trigger_error(). They exist because a library often wants to report a
problem at a severity the caller chooses to care about, and the user levels are the only
ones a program is allowed to raise by hand. The rest come from the engine: a missing
array key is an E_WARNING, a call to a function that was removed two
releases ago is an E_DEPRECATED.
One level disappeared recently enough that tutorials still teach it.
E_STRICT was deprecated in PHP 8.4 because the level had been removed
earlier, so touching the constant now produces a deprecation of its own:
Deprecated: Constant E_STRICT is deprecated since 8.4, the error level was removed in
/path/to/levels.php on line 10
If you have an old bootstrap that sets E_ALL & ~E_STRICT, replace it.
The mask still evaluates, but reading the constant is now a reportable event, which is a
strange way to configure reporting.
Warnings continue, fatals stop, and only some of them are interceptable
What matters operationally is not severity, it is whether the script reaches its next statement. A warning does. Here is a script that reads a key nobody defined, then keeps going:
<?php
declare(strict_types=1);
echo "start", PHP_EOL;
echo $missing['key'], PHP_EOL;
echo "end", PHP_EOL;
start
Warning: Undefined variable $missing in /path/to/warn2.php on line 4
Warning: Trying to access array offset on null in /path/to/warn2.php on line 4
end
Two warnings, exit code 0, and end printed. The warning text went to
standard output because display_errors is on in this environment, and a
copy with the PHP Warning: prefix went to the error log. In a web request
the first copy lands in the HTML document.
A fatal is different. This is the same shape of mistake, one argument short:
<?php
declare(strict_types=1);
function total(int $a, int $b): int
{
return $a + $b;
}
echo total(1, 2), PHP_EOL;
echo total(1), PHP_EOL;
echo "never reached", PHP_EOL;
3
Fatal error: Uncaught ArgumentCountError: Too few arguments to function total(), 1 passed in /path/to/fatal.php on line 10 and exactly 2 expected in /path/to/fatal.php:4
Stack trace:
#0 /path/to/fatal.php(10): total(1)
#1 {main}
thrown in /path/to/fatal.php on line 4
The script exits with status 255 and never reached is never printed.
Since PHP 7 the engine raises Error, which implements
Throwable, for this class of mistake, so the message says
Uncaught ArgumentCountError rather than a bare fatal.
That shape gives you a way to reason about the whole list. Anything PHP raises as a
Throwable subclass can be caught by try. Everything else is
handed to the error system, which prints and continues or prints and dies. There is one
exception to that rule, and it catches people out: a custom error handler installed with
set_error_handler is consulted even for E_USER_ERROR, so a
handler that returns true turns what the manual calls a fatal into a
non-event.
<?php
declare(strict_types=1);
set_error_handler(static function (int $severity, string $message): bool {
fwrite(STDERR, "[handler] severity {$severity}: {$message}\n");
return true; // handled, do not run the internal handler
});
echo "before", PHP_EOL;
trigger_error('Disk almost full', E_USER_WARNING);
echo "after E_USER_WARNING", PHP_EOL;
trigger_error('Cannot continue', E_USER_ERROR);
echo "after E_USER_ERROR", PHP_EOL;
before
[handler] severity 512: Disk almost full
after E_USER_WARNING
[handler] severity 8192: Passing E_USER_ERROR to trigger_error() is deprecated since 8.4, throw an exception or call exit with a string message instead
[handler] severity 256: Cannot continue
after E_USER_ERROR
exit=0
Read the last four lines carefully. The second call raises two events: a deprecation
because passing E_USER_ERROR to trigger_error() was itself
deprecated in PHP 8.4, and then the error you asked for. Both reach the handler, both
are absorbed, and the script finishes with status 0. Do not build on this. The
recommended replacement, stated in the deprecation message, is to throw an exception or
call exit with a string.
Development and production need opposite settings
error_reporting decides which events are reported at all.
display_errors decides whether the rendered report is echoed to the
response body. They are separate because the useful answers for each differ by
environment, and the common failure is treating them as one switch.
| Setting | Development | Production | Why |
|---|---|---|---|
error_reporting |
E_ALL |
E_ALL |
You want to hear about everything in both. Turning it down in production hides the bug report that would have named the file and line. |
display_errors |
On |
Off |
A path, a query fragment or a stack trace in the response body is a leak. It also corrupts any response with a strict shape, such as JSON. |
log_errors |
On |
On |
With display off, the log is the only place the event exists. |
error_log |
unset, so it goes to the SAPI log | an absolute path or syslog | Production needs a destination that survives the request and that your log pipeline already reads. |
The pairing to avoid is display_errors=Off with no log destination.
That configuration is silent, and silence is indistinguishable from correctness until a
customer reports the wrong price. Here is what the two halves look like when they are set
deliberately:
<?php
declare(strict_types=1);
ini_set('display_errors', '0');
ini_set('error_log', '/path/to/fatal.log');
trigger_error('cache directory is not writable', E_USER_WARNING);
strlen();
Standard output is empty. The error log receives both events:
[16-Oct-2026 18:29:12 UTC] PHP Warning: cache directory is not writable in /path/to/displayoff.php on line 8
[16-Oct-2026 18:29:12 UTC] PHP Fatal error: Uncaught ArgumentCountError: strlen() expects exactly 1 argument, 0 given in /path/to/displayoff.php:9
Stack trace:
#0 /path/to/displayoff.php(9): strlen()
#1 {main}
thrown in /path/to/displayoff.php on line 9
Note the timestamp prefix and the PHP tag. Those are added by the logger,
not by your message, which is how you tell a logged engine error from a line your own
code wrote.
The error page still needs to render
Turning display_errors off does not give you a friendly error page. It
gives you a blank or half-rendered page. Raising display_errors to
1 on a staging machine is not a substitute for catching the exception and
returning a 500 with your own body.
set_error_handler is a bridge from warnings to exceptions
You cannot catch a warning. You can intercept it and throw something catchable, which
is the only way to make a library that predates exceptions behave under a
try block. The handler receives the severity, the message, the file and the
line, and ErrorException has a constructor that takes exactly those
fields.
<?php
declare(strict_types=1);
set_error_handler(static function (int $severity, string $message, string $file, int $line): never {
throw new ErrorException($message, 0, $severity, $file, $line);
});
function readConfig(string $path): array
{
$raw = file_get_contents($path);
$config = json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
return $config['database'] ?? [];
}
try {
readConfig('/path/to/missing.json');
} catch (ErrorException $e) {
printf(
"caught %s: %s (severity %d) at %s:%d\n",
$e::class,
$e->getMessage(),
$e->getSeverity(),
basename($e->getFile()),
$e->getLine(),
);
}
caught ErrorException: file_get_contents(/path/to/missing.json): Failed to open stream: No such file or directory (severity 2) at conv.php:10
Severity 2 is E_WARNING, basename() on the file gives
conv.php, and line 10 is the file_get_contents call, not the
line inside the handler. The exception was constructed with the original location, so
the trace points at the code that caused the problem.
The handler is installed globally for the process, and installing one replaces the
previous one. set_error_handler returns the handler it displaced, which is
how you restore it. A wrapper that does this correctly is worth writing once:
<?php
declare(strict_types=1);
function legacyErrorHandler(int $severity, string $message, string $file, int $line): bool
{
fwrite(STDERR, sprintf("[legacy] %s (%s:%d)\n", $message, basename($file), $line));
return false;
}
function withLegacyHandler(callable $work): mixed
{
$previous = set_error_handler('legacyErrorHandler');
try {
return $work();
} finally {
restore_error_handler();
fwrite(STDERR, sprintf(
"[cleanup] restored: %s\n",
$previous === null ? 'the internal handler' : 'an earlier handler',
));
}
}
Two details in that fragment decide whether it works in a real codebase. Returning
false from a handler tells PHP to run its own handler as well, so the
warning is still logged and displayed instead of vanishing. And because
set_error_handler replaces rather than stacks, a function that installs one
shadows whatever the caller had installed. That second point is the reason
restore_error_handler() belongs in a finally, and the reason a
framework's handler can disappear for the rest of the request when a library installs
its own and never restores it.
Fatal errors are only visible from a shutdown function
Nothing in the normal flow runs after a fatal. There is no catch, no destructor you
can rely on, and no return value. What does run is a function you registered with
register_shutdown_function(), and error_get_last() tells it
what killed the process.
<?php
declare(strict_types=1);
register_shutdown_function(static function (): void {
$error = error_get_last();
if ($error === null || ($error['type'] & (E_ERROR | E_PARSE)) === 0) {
return;
}
fwrite(STDERR, sprintf(
"[shutdown] fatal of type %d: %s in %s:%d\n",
$error['type'],
$error['message'],
$error['file'],
$error['line'],
));
});
echo "start", PHP_EOL;
missing_function(42);
echo "end", PHP_EOL;
start
[shutdown] fatal of type 1: Uncaught Error: Call to undefined function missing_function() in /path/to/shutdown2.php:21
The type is 1, which is E_ERROR, and the message is the whole uncaught
exception message including its stack trace, because that is what the engine handed the
logger before dying. A shutdown function runs on every exit, including a clean one, so
the mask check is not optional: without it you will report something on every request
that finishes normally.
What a shutdown function cannot do is recover. The output buffer may already be flushed, half a JSON document may be on the wire, and the connection to the database is gone. Treat this as a reporting hook, the place where you write one structured log line with the request id, and not as a try block.
error_log has three destinations and one good default
The error_log() function routes by an integer, and the values are
unhelpful enough that most people only ever use the first. On this machine, with
ini_set('error_log', '/path/to/app.log') in effect:
<?php
declare(strict_types=1);
ini_set('log_errors', '1');
ini_set('error_log', '/path/to/app.log');
ini_set('display_errors', '0');
error_log('order 1043 rejected: unknown SKU');
error_log('[warn] retry budget exhausted for queue emails');
trigger_error('Cart subtotal recomputed', E_USER_NOTICE);
[16-Oct-2026 18:27:26 UTC] order 1043 rejected: unknown SKU
[16-Oct-2026 18:27:26 UTC] [warn] retry budget exhausted for queue emails
[16-Oct-2026 18:27:26 UTC] PHP Notice: Cart subtotal recomputed in /path/to/logtofile.php on line 11
Your own two lines are prefixed with a timestamp and nothing else. The
trigger_error line carries PHP Notice: and the file and line,
because the engine formatted it. If you parse your logs, that distinction is what tells
you which entries already have structure and which are a string you wrote.
Passing 0 as the second argument sends the line to the SAPI logger,
which for a web request means the web server's error log. Passing 4 uses
the SAPI logger directly as well, whichever SAPI you are under. Passing 3
appends to the file you name and adds no timestamp, which is what you want when you are
already writing a structured line:
<?php
declare(strict_types=1);
error_log("order 1045 rejected\n", 3, '/path/to/typed.log');
error_log('order 1045 retried', 4);
# /path/to/typed.log, no timestamp added
order 1045 rejected
# the SAPI log
order 1045 retried
Pick one destination per environment and set it in php.ini or in the
PHP-FPM pool configuration rather than in application code. A file path that a
deployment rewrites is better than one baked into a source file, and syslog
is a legitimate answer if your platform already ships syslog. What you should not do is
leave it unset in production, because the default is stderr for the CLI and
the SAPI logger for the web, neither of which is a long-term store.
Two things this approach does not solve. A custom error handler cannot intercept a
parse error, because a file is compiled before the handler that would report it exists.
A shutdown function registered earlier does catch E_PARSE when the broken
file is a later require, and reports type 4:
<?php
declare(strict_types=1);
register_shutdown_function(static function (): void {
$error = error_get_last();
if ($error === null || ($error['type'] & (E_ERROR | E_PARSE)) === 0) {
return;
}
fwrite(STDERR, sprintf("[shutdown] type %d: %s (%s:%d)\n",
$error['type'], $error['message'], $error['file'], $error['line']));
});
echo "about to require a file with a syntax error\n";
require '/path/to/brokensnippet.php';
echo "not reached\n";
about to require a file with a syntax error
Parse error: syntax error, unexpected token ":", expecting variable in /path/to/brokensnippet.php on line 2
[shutdown] type 4: syntax error, unexpected token ":", expecting variable (/path/to/brokensnippet.php:2)
Put the syntax error in the main script and nothing runs at all, not even the registration, so there is no shutdown report to write. The second unsolved problem is request correlation. The levels tell you what broke, not which of forty concurrent requests it belonged to; that needs a request id attached to every entry, which is a logging concern rather than an error-reporting one.
Write a single file, guarded.php, that installs the following three
things before any work happens: an error handler that throws
ErrorException, a shutdown function that writes fatal errors to
STDERR with their type and location, and a deliberate choice between
display_errors and error_log.
Then call it three times with a command-line switch: once so that it triggers a
warning from file_get_contents() on a path that does not exist, once so
that it calls an undefined function, and once so that it exits cleanly. For each run,
record the exit code, whether anything reached standard output, and what reached the
log.
Answer two questions from the results. Which of the three runs produced a shutdown
report, and why did the other two stay quiet? If the warning run had been wrapped in
try without the error handler installed, what would the exit code have
been?
Show the solution and the reasoning
The shape that matters is the order of installation. The error handler and the
shutdown function have to be registered before the work, and the ini changes have to
happen before any output, because display_errors is read when the error
is formatted.
<?php
declare(strict_types=1);
$mode = $argv[1] ?? 'clean';
ini_set('display_errors', '0');
ini_set('log_errors', '1');
ini_set('error_log', '/path/to/guarded.log');
set_error_handler(static function (int $severity, string $message, string $file, int $line): never {
throw new ErrorException($message, 0, $severity, $file, $line);
});
register_shutdown_function(static function (): void {
$error = error_get_last();
if ($error === null || ($error['type'] & (E_ERROR | E_PARSE)) === 0) {
return;
}
fwrite(STDERR, sprintf("[fatal %d] %s (%s:%d)\n",
$error['type'], $error['message'], $error['file'], $error['line']));
});
try {
match ($mode) {
'warning' => file_get_contents('/path/to/does-not-exist.json'),
'fatal' => missing_function(1),
default => print("clean run\n"),
};
} catch (ErrorException $e) {
fwrite(STDERR, sprintf("[caught] %s\n", $e->getMessage()));
}
Only the fatal run produces a shutdown report. The warning run throws inside the
handler, the catch block absorbs it, and the shutdown function then sees
no error to report, because error_get_last() only holds the last engine
error and that one was handled. The clean run registers nothing. Both of those exit
with status 0, and the fatal run exits 255.
The second question is the one worth sitting with. With try and no
error handler, the warning is never thrown, so the catch never runs, and
the script continues with file_get_contents returning
false. The exit code is 0 and the failure is invisible unless you check
the return value. That is the whole argument for the bridge: warnings do not become
failures on their own, and code that ignores return values needs the conversion to
notice them at all.
A defensible variation is to have the handler return false for
E_DEPRECATED so those still land in the log with the engine's own format,
and throw for everything else. Returning false delegates to the internal
handler, so nothing is lost.
Progress
Mark this lesson done and it stays marked in this browser.