Notices, Warnings, Deprecations: The Errors That Aren't Exceptions
Before writing a handler, you need the taxonomy PHP actually uses. A vast amount of "error" output in PHP is not an exception — it is a leveled diagnostic that, by default, prints and lets execution continue:
| Level constant | Meaning | Default behavior |
|---|---|---|
E_NOTICE |
suspicious but non-breaking (undefined array key) | printed, script continues |
E_WARNING |
operation failed but returned (fopen on missing file → false) |
printed, result is false |
E_DEPRECATED / E_USER_DEPRECATED |
feature is on the way out | printed, script continues |
E_USER_NOTICE / E_USER_WARNING |
the same, raised by your trigger_error() |
printed, script continues |
E_RECOVERABLE_ERROR |
serious, but catchable (type mismatch on an argument with a default) | printed, script continues |
E_ERROR / E_PARSE / E_CORE_* / E_COMPILE_* |
fatal — parse errors, out of memory, engine startup | script stops |
The top group is the reason legacy PHP code is full of bugs that "work": echo $user['emial']; emits Undefined array key "emial", prints an empty string, and keeps going. Three ini directives shape what you ever see of any of it:
error_reporting = E_ALL ; which levels are considered at all
log_errors = On ; record them...
display_errors = Off ; ...instead of printing (production rule)
display_startup_errors = On ; show engine-startup errors even with display off
The standard production split — E_ALL reporting, logging on, display off — means every notice lands in the log where an operator can aggregate it, while the user sees a clean response. The standard development split keeps display on, because a visible notice is a free bug report.
The critical structural fact for this topic: these non-fatal levels are not exceptions, so try/catch never sees them, and the engine offers a separate interception point for exactly the non-fatal group. Fatal levels — E_ERROR, E_PARSE, the E_CORE_* and E_COMPILE_* family — terminate the process before any handler runs; they are the domain of shutdown functions and process monitors, not error handlers. Everything in between — notices, warnings, user errors, deprecations, recoverable errors — can be routed through one function: set_error_handler(). The reasons it stops at that boundary, and what its callback signature demands, are the next chapter's problem — and knowing the split is what separates code that merely suppresses output from code that actually handles failures.
The behavior differences between levels matter when you design around them. A warning typically signals a failed operation whose return value announces the failure — fopen returning false, preg_match returning false — so production code that ignores return values is running on the honor system, with the warning as its only witness. A notice signals a guess: an undefined array key means the code assumed a shape the data did not have, and PHP's null fallback lets the run continue on fabricated data. A deprecation signals a calendar: your dependency's support window is shrinking. Treating the three as exceptions makes each category actionable at the right time — the guess fails in CI, the failed operation surfaces in staging, and the calendar item becomes a ticket. trigger_error with E_USER_* levels lets your own code raise the same diagnostics deliberately, which is the bridge to the handler next.
set_error_handler: Intercepting the Non-Fatal Errors
set_error_handler() replaces PHP's print-and-continue behavior for the non-fatal levels with your own function:
set_error_handler(
function (int $errno, string $errstr, string $errfile, int $errline): bool {
if (!(error_reporting() & $errno)) {
return false; // silenced with @ or error_reporting — not ours to handle
}
throw new \ErrorException($errstr, 0, $errno, $errfile, $errline);
}
);
try {
echo $config['debug_mode']; // Undefined array key -> ErrorException, caught below
} catch (\ErrorException $e) {
$log->warning($e->getMessage(), ['line' => $e->getLine()]);
}
The callback receives four arguments — severity, message, file, line (a fifth, $errcontext, existed before 8.0 and is gone) — and its return value is protocol, not style: return true (or anything truthy) to tell PHP "handled, suppress the normal processing"; return false to pass the error back to PHP's standard handler, which will print or log it per the ini settings. That passthrough is why the first check above matters: the @ operator (which in PHP 8 sets the reporting mask to a fatal-only floor rather than zero) combined with a handler that throws would turn suppressed operations into exceptions anyway. Testing error_reporting() & $errno and returning false for silenced levels restores the contract @ promises.
Which levels arrive here? Everything non-fatal: E_NOTICE, E_WARNING, E_DEPRECATED, E_USER_*, E_RECOVERABLE_ERROR. What never does: E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR — the handler is not invoked for fatal conditions, so memory exhaustion and parse errors remain outside its reach. That boundary is fixed by the engine and is the single most-asked interview detail about the function.
Two operational rules complete the tool. Registration is stack-like and per-scope: the handler you set stays until you replace or pop it, so long-running code should bracket its own with restore_error_handler() — which returns to the previously registered handler, nesting safely:
$previous = set_error_handler($strictHandler);
try {
runUntrustedImport($csv);
} finally {
restore_error_handler(); // back to whatever the framework had
}
And note the severity your handler passes into ErrorException's third constructor slot ($errno) — it is what a downstream logger filters on, so preserving it keeps E_WARNING distinguishable from E_DEPRECATED in the aggregated logs. The handler above is already the famous "errors-to-exceptions" converter that Symfony and Laravel install globally; the next chapter builds it properly, including what to do about the deprecation half of the stream.
Ordering inside the handler is a correctness detail, not style. The mask check must run before anything else — including before logging — because a silenced error logged anyway reopens the hole the @ operator was meant to close. After it, branch by severity, and keep the branches cheap: handlers run on hot paths whenever a notice fires inside a loop, so a handler performing HTTP calls or complex formatting turns one deprecation per iteration into an outage. Escalate instead — write a minimal line and let the log shipper enrich it. And when several libraries each install their own handler, remember the stack: set_error_handler returns the previous callable, so a well-behaved library either chains to it or documents that it replaces it. Framework kernels ultimately own that stack; test suites typically reset it per test so one test's strict handler cannot leak into another's expectations.
From Global Handler to Production Practice
With the converter from the previous chapter in hand, three decisions separate a working handler from a production-grade one: which levels convert, where the handler is installed, and what it logs when it does not throw.
Not every non-fatal level should become an exception. A warning raised inside a third-party library's fallback path may be recoverable by that library — throwing there aborts code that would have handled the situation itself. The tuned version distinguishes:
function strictErrorHandler(int $errno, string $errstr, string $file, int $line): bool
{
if (!(error_reporting() & $errno)) {
return false;
}
if ($errno === E_USER_DEPRECATED || $errno === E_DEPRECATED) {
$log->notice('deprecation', ['msg' => $errstr, 'file' => "$file:$line"]);
return true; // record, do NOT throw — deprecations are informational
}
throw new \ErrorException($errstr, 0, $errno, $file, $line);
}
That split mirrors how frameworks matured: notices and warnings (real defects — undefined keys, failed opens) become exceptions and fail tests loudly in CI; deprecations are logged at notice level so an upgrade path is visible without breaking every pipeline on a library's roadmap item. Where does it get installed? At the very top of the bootstrap — before autoloading, before the framework's own handler — and it stays for the process lifetime, wrapped in nothing; per-call scoping (restore_error_handler in a finally) is for temporary escalation around untrusted code, as in the last chapter.
The non-throwing branch is where structured logging earns its place. error_log($msg) writes to the ini-configured destination (a file for error_log = /var/log/php-errors.log, syslog with type 3, SAPI-specific sinks otherwise) and is fine for a one-file app; real services route through Monolog, where the handler's arguments map onto a proper record:
$handler = new \Monolog\Handler\StreamHandler('/var/log/app.log', \Monolog\Level::Warning);
$log = new \Monolog\Logger('php', [$handler]);
// inside strictErrorHandler's deprecation branch:
$log->notice($errstr, ['errno' => $errno, 'file' => $file, 'line' => $line]);
Monolog gives you channels ('php', 'payments', 'mail'), levels on a common scale, handlers that fan out (file + stderr + a remote service), and JSON formatters that make file:line queryable instead of buried in prose — the operational counterpart to the exception-context discipline from Custom Exception Hierarchies. One last honesty note about coverage: even the best handler never sees fatals. E_ERROR, parse failures, and OOM kill the process with no callback, so production pairs this handler with log_errors (engine-level capture of what the handler missed) and a shutdown function for the fatal tail. The handler improves detection; the guarantee that cleanup still happens on every path — throwing or not — is the one mechanism PHP gives you for that: Cleanup with finally Blocks.
Measuring the handler's value closes the loop. A useful monthly query on your log platform: record counts by severity and channel, tracked over time. A healthy migration shows warnings trending down as defects get fixed, deprecations forming visible steps before each PHP or dependency upgrade, and the fatal-error rate — invisible to handlers, visible through log_errors — flat near zero. When warnings plateau instead of falling, the handler is recording without anyone acting, which is a process problem the numbers expose. Pair the metric with a CI policy: the strict handler converts notices and warnings into test failures, so the trend line is enforced at the merge queue rather than merely observed in production.
Quiz
1. Which PHP errors can a set_error_handler callback receive?
2. What is the significance of returning false from a set_error_handler callback?
3. Why does the canonical errors-to-exceptions handler first check `error_reporting() & $errno`?
4. How should deprecations (E_DEPRECATED / E_USER_DEPRECATED) typically be treated in a production error handler?
Flashcards
Question
The PHP error taxonomy a handler sees — and the group it never sees
Click to reveal answer
Answer
Handler-reachable (non-fatal): E_NOTICE, E_WARNING, E_DEPRECATED, E_USER_*, E_RECOVERABLE_ERROR — the print-and-continue diagnostics that try/catch cannot intercept. Never reachable: E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR — these kill the process without invoking any callback.
Question
set_error_handler callback signature and return protocol
Click to reveal answer
Answer
function(int $errno, string $errstr, string $errfile, int $errline): bool — the old fifth $errcontext argument is gone in 8.0. Return true/truthy = handled, suppress normal processing; return false = pass back to PHP's default print/log behavior per ini settings.
Question
Why check error_reporting() & $errno at the top of an errors-to-exceptions handler?
Click to reveal answer
Answer
Because @ and error_reporting() masks express 'do not act on this level right now'. PHP 8's @ sets a narrow mask rather than zero; without the check, a silenced warning would still be thrown as ErrorException, violating the suppression contract. Masked errors should return false.
Question
How is a global error handler scoped and restored?
Click to reveal answer
Answer
Registration is stack-like: set_error_handler() pushes yours and returns the previous callable; restore_error_handler() pops back. Install the production converter at bootstrap and leave it; around untrusted code, bracket temporarily with set/try/finally-restore so nested handlers compose safely.
Question
Production split: what converts, what logs, and what covers fatals
Click to reveal answer
Answer
Notices/warnings → throw ErrorException (real defects must fail loudly); deprecations → log at notice level and return true (informational, don't break CI). error_log() covers simple sinks; Monolog channels/levels/handlers give structured records. Fatal E_ERROR/OOM never reach any handler — log_errors plus a shutdown function close that gap.
Revision Notes
Key Takeaways
- 1. PHP's error levels split into interceptable non-fatal diagnostics and fatal conditions that kill the process without any callback
- 2. set_error_handler receives (errno, errstr, errfile, errline); its return value is protocol — truthy suppresses, false hands back to PHP's default handling
- 3. The error_reporting() mask check is what keeps @-silenced errors from becoming exceptions against the silencer's intent
- 4. Convert defects (notices, warnings) into ErrorException; log deprecations instead of throwing, so upstream roadmaps don't break your CI
- 5. Handlers improve detection only — fatals need log_errors plus a shutdown function, and cleanup correctness belongs to finally blocks
Interview Tips
- • "Which errors can set_error_handler catch?" — all non-fatal levels; never E_ERROR/E_PARSE/E_CORE_*/E_COMPILE_*. Name the fatal-coverage gap and your answer (log_errors + shutdown function) unprompted
- • "What do the handler's return values mean?" — truthy: handled, skip default processing; false: pass back to PHP's print/log behavior. Tie it to the error_reporting() mask check for @-silenced levels
- • "How would you make warnings fail your test suite?" — install the errors-to-exceptions converter in test bootstrap (PHPUnit does this via convertWarningsToExceptions-era settings); mention carving deprecations out so third-party roadmaps don't break CI
- • "Handler vs try/catch — why both?" — try/catch handles thrown Exceptions; the handler covers the diagnostic levels that never throw; finally still owns cleanup on every path, which neither of the other two guarantees
Cheat Sheet
Custom Error Handlers
Levels: non-fatal diagnostics (notice, warning, deprecated, user_*, recoverable) print-and-continue and CAN be intercepted; fatal levels (E_ERROR, E_PARSE, E_CORE_*, E_COMPILE_*) stop the process with NO callback.
function strictErrorHandler(int $errno, string $errstr, string $file, int $line): bool
{
if (!(error_reporting() & $errno)) {
return false; // silenced/masked -> PHP default handling
}
if ($errno === E_DEPRECATED || $errno === E_USER_DEPRECATED) {
$log->notice($errstr, ['file' => "$file:$line"]);
return true; // informational: record, don't throw
}
throw new \ErrorException($errstr, 0, $errno, $file, $line);
}
set_error_handler('strictErrorHandler'); // bootstrap, before anything else
// temporary escalation:
try { runUntrusted(); } finally { restore_error_handler(); }
Return protocol: truthy = handled; false = pass back to PHP's default handler.
ini split:
| Setting | Production value | Role |
|---|---|---|
error_reporting |
E_ALL |
consider every level |
log_errors |
On |
engine-level record incl. fatal tail |
display_errors |
Off |
users never see diagnostics |
Coverage ceiling: handlers never see fatals — pair with log_errors + register_shutdown_function. Cleanup guarantees come from finally, not handlers.