Skip to content
System nominalUTC --:--:--Articles 041
MODULE 04 Lesson 10 / 15 30 MIN

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.

SettingDevelopmentProductionWhy
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.

Lab 25 minutes

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.