Errors, Warnings and the Error Handler
Warnings travel on PHP's own channel, not up the stack; handlers and ini settings decide who sees them and where they are written.
set_error_handler(?callable $callback, int $error_levels = E_ALL): ?callablePHP reports trouble on two channels. Throwables travel up the call stack: Exception for what your code reports, Error for what the engine reports, both implementing Throwable and both catchable. Diagnostics such as E_WARNING, E_NOTICE and E_DEPRECATED do not travel at all. They are written out, and the line after carries on running.
Taking over the channels
set_error_handler() receives those diagnostics. Returning true means handled; returning false lets PHP's own handler run as well. The usual move is to turn them into ErrorException, so a missing array key fails where it happens instead of printing a line nobody reads. set_exception_handler() receives throwables that nothing caught, and the script ends once it returns. PHP 8.5 added get_error_handler() and get_exception_handler() to read back what is installed.
Which levels, and who sees them
error_reporting(E_ALL) selects everything. The longhand for everything except deprecations is E_ALL & ~E_DEPRECATED, the same value written as a bitmask.
display_errors decides whether the text reaches the browser. In production it must be Off. Those messages carry absolute paths, fragments of queries and argument values, which together are a map of the application for anyone who can trigger a failure. Turn log_errors on and point error_log at a file instead, so you get the same detail and the visitor gets a plain page.
Example
When to use it
- Make an undefined array key stop the request in development instead of printing a warning.
- Keep file paths and SQL fragments off a production page by turning display_errors off.
- Log every uncaught throwable from one place and still return a clean 500.
- Record a memory-limit fatal error that no catch block can reach.
More examples
The production settings, and their php.ini form
Report everything, show nothing, write everything down. The two forms are the same values; the difference is when they take effect, and that difference decides the matter. A syntax error is reported before your first line runs, so only php.ini can keep it off the page. Use ini_set() for a quick local check, never as the production answer. In development the usual pair is display_errors = On with log_errors = On.
<?php
declare(strict_types=1);
ini_set('display_errors', '0');
ini_set('log_errors', '1');
ini_set('error_log', '/var/log/php/app.log');
error_reporting(E_ALL);
// The same four settings in php.ini, which is where they belong in production.
// Set there, they also cover a parse error in this very file, which ini_set()
// cannot: the file never starts running.
//
// display_errors = Off
// log_errors = On
// error_log = /var/log/php/app.log
// error_reporting = E_ALLThe last stop for an uncaught throwable
This is the safety net under the front controller, for anything a try block missed. The handler cannot resume the script: PHP terminates once it returns, so there is no point trying to continue. Keep it short and make it unable to fail, because an exception thrown inside an exception handler leaves you with nothing. Note the asymmetry on purpose: full detail to the log, one fixed sentence to the visitor.
<?php
declare(strict_types=1);
set_exception_handler(static function (Throwable $e): void {
error_log(sprintf(
'%s: %s in %s:%d',
$e::class,
$e->getMessage(),
$e->getFile(),
$e->getLine()
));
http_response_code(500);
echo 'Something went wrong. Please try again.';
});
throw new RuntimeException('the queue is unreachable');
// The handler runs, then the script ends. No code after the throw runs.Seeing a fatal error the handler cannot see
An error handler cannot intercept the fatal levels, and running out of memory is one of them. A shutdown function runs even then, and error_get_last() returns the last diagnostic as an array with type, message, file and line. The bitmask test keeps this quiet about the warnings the normal handler already covered. Related: passing E_USER_ERROR to trigger_error() has been deprecated since PHP 8.4, so raise your own fatal failures by throwing instead.
<?php
declare(strict_types=1);
register_shutdown_function(static function (): void {
$last = error_get_last();
if ($last === null) {
return;
}
if (($last['type'] & (E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR)) === 0) {
return;
}
error_log(sprintf(
'fatal: %s in %s on line %d',
$last['message'],
$last['file'],
$last['line']
));
});
Discussion