PHP Errors, Warnings, and Notices
Not every problem in a PHP script is fatal. PHP separates runtime issues into severity levels — from quiet informational messages to script-ending crashes — so that small mistakes don’t necessarily stop your whole application. Learning to read these levels, configure how PHP reports them, and respond to them correctly is one of the most useful debugging skills a PHP developer can have.
Overview: How PHP’s Error System Works
Under the hood, PHP is powered by the Zend Engine, which compiles your script into opcodes and executes them. Whenever the engine hits something unusual — a missing include file, a call to an undefined variable, an out-of-range array offset — it calls an internal routine (historically zend_error()) with a severity constant attached, such as E_WARNING or E_NOTICE. That severity constant is what determines how serious PHP considers the problem to be.
These severities fall into a rough hierarchy:
- Fatal errors (
E_ERROR,E_PARSE,E_CORE_ERROR) stop script execution immediately. A parse error means the script never even ran; a fatal error means the engine hit something it cannot recover from, like calling a function that doesn’t exist. - Warnings (
E_WARNING) indicate something went wrong, but PHP can keep running. The operation that triggered it usually fails and returnsfalseornull, and execution continues with the next statement. - Notices and Deprecated messages (
E_NOTICE,E_DEPRECATED) are informational. They flag things that are technically legal but likely unintentional (an undefined variable) or that will stop working in a future PHP version.
A crucial piece of history: prior to PHP 8.0, referencing an undefined variable or an undefined array key was only an E_NOTICE — easy to ignore. As of PHP 8.0, both were promoted to E_WARNING, because silently treating typos as null was a common source of bugs. This lesson uses PHP 8.3 behavior throughout.
PHP also has a second, newer error system layered on top of this: since PHP 7, many conditions that used to be uncatchable fatal errors — calling a method on a non-object, a type mismatch with strict_types, dividing by zero with intdiv() — are now instances of the Error class, which (together with Exception) implements the Throwable interface. That means you can catch (Throwable $e) around code that would otherwise crash your script, something that was impossible before PHP 7. The classic E_* constants and the Throwable hierarchy exist side by side; internal functions like file_get_contents() still emit old-style warnings, while language constructs like type coercion failures throw modern exceptions.
Two configuration directives decide what actually happens to a diagnostic once it’s raised: display_errors (should it be printed to the output) and log_errors (should it be written to a log file). A third, error_reporting, is a bitmask that decides which severities are even considered — anything not included in the mask is dropped entirely, whether or not display_errors is on.
Syntax
The core building blocks for working with diagnostics look like this:
<?php
error_reporting(E_ALL);
ini_set('display_errors', '1');
ini_set('log_errors', '1');
set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline): bool {
return false;
}, E_ALL);
trigger_error('Custom message', E_USER_WARNING);
restore_error_handler();
error_reporting(int $level)— sets which severities are reported for the rest of the script;E_ALLmeans report everything.ini_set('display_errors', ...)— controls whether diagnostics are printed to the output stream (use'1'locally,'0'in production).ini_set('log_errors', ...)— controls whether diagnostics are written to the log file defined by theerror_logdirective.set_error_handler(callable $handler, int $levels = E_ALL)— registers a function that intercepts matching diagnostics instead of PHP’s default handler; returningfalselets PHP’s internal handler also run afterward.trigger_error(string $message, int $level = E_USER_NOTICE)— raises your own diagnostic, using theE_USER_*family of levels.restore_error_handler()— removes the most recently registered custom handler, restoring the previous one.
| Constant | Meaning |
|---|---|
E_ERROR |
Fatal run-time error; script execution stops. |
E_WARNING |
Recoverable run-time problem; execution continues. |
E_NOTICE |
Informational message about questionable code. |
E_DEPRECATED |
Feature will be removed in a future PHP version. |
E_PARSE |
Compile-time syntax error; the script never runs. |
E_USER_ERROR |
Fatal error raised manually via trigger_error(). |
E_USER_WARNING |
Warning raised manually via trigger_error(). |
E_USER_NOTICE |
Notice raised manually via trigger_error(). |
E_ALL |
Every severity level combined. |
Examples
Example 1: An undefined variable Warning
<?php
error_reporting(E_ALL);
ini_set('display_errors', '1');
echo "Starting script\n";
echo $username;
echo "\nScript continued\n";
Output:
Starting script
Warning: Undefined variable $username in /var/www/html/script.php on line 7
Script continued
Because $username was never assigned, PHP raises an E_WARNING at the exact line it was used, prints the diagnostic inline (since display_errors is on), treats the value as null, and keeps running — the script does not stop.
Example 2: A Deprecated notice from an internal function
<?php
error_reporting(E_ALL);
ini_set('display_errors', '1');
$value = null;
$length = strlen($value);
echo "Length: " . $length . "\n";
Output:
Deprecated: strlen(): Passing null to parameter #1 ($string) of type string is deprecated in /var/www/html/script.php on line 7
Length: 0
Since PHP 8.1, passing null to a non-nullable internal function parameter is deprecated (it still works, coerced to an empty string, but will eventually be disallowed). This is exactly the kind of message you should treat as a checklist item before your next PHP upgrade.
Example 3: Converting a Warning into a catchable exception
<?php
function warningToException(int $errno, string $errstr, string $errfile, int $errline): bool
{
throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
}
set_error_handler(warningToException(...), E_WARNING);
try {
$content = file_get_contents('/var/www/html/does-not-exist.txt');
echo $content;
} catch (ErrorException $e) {
echo "Caught warning: " . $e->getMessage() . "\n";
} finally {
restore_error_handler();
}
echo "Script finished\n";
Output:
Caught warning: file_get_contents(/var/www/html/does-not-exist.txt): Failed to open stream: No such file or directory
Script finished
Here, set_error_handler() intercepts the E_WARNING that file_get_contents() would normally emit, and instead throws an ErrorException — a real, catchable exception. This is a very common pattern for turning PHP’s old-style diagnostics into structured error handling with try/catch. Note the first-class callable syntax warningToException(...), available since PHP 8.1, which passes the named function as a closure without wrapping it manually.
Under the Hood: What Happens Step by Step
When a statement like echo $username; runs, the Zend Engine evaluates the expression $username. It looks up the variable in the current symbol table and finds nothing. Rather than crash, it calls its internal diagnostic routine with the message text, the severity E_WARNING, and the current file and line number. That routine checks error_reporting: is E_WARNING included in the current bitmask? If not, the diagnostic is discarded immediately and nothing else happens.
If the severity is included, PHP checks whether a handler was registered with set_error_handler() for that severity. If one was, PHP calls it with the message, file, and line as arguments, and your handler decides what to do — log it, convert it to an exception, or ignore it. If no custom handler exists, or if your handler returns false, PHP’s default behavior takes over: it checks display_errors (print to output?) and log_errors (write to the log?) independently, and does either, both, or neither. Finally, execution resumes at the next statement — unless the severity was one of the fatal ones, in which case the script terminates instead.
Common Mistakes
Mistake 1: Suppressing diagnostics with @ instead of checking return values
<?php
$data = @file_get_contents('/var/www/html/config.json');
$config = json_decode($data, true);
echo $config['app_name'];
The @ operator silences the warning if the file is missing, so $data becomes false without any visible clue. json_decode(false, true) then returns null, and $config['app_name'] silently evaluates to null too. The script produces no output and no error message at all — the bug is now invisible, which is far worse than a loud warning.
<?php
$data = file_get_contents('/var/www/html/config.json');
if ($data === false) {
throw new RuntimeException('Unable to read configuration file.');
}
$config = json_decode($data, true, 512, JSON_THROW_ON_ERROR);
echo $config['app_name'] ?? 'Unnamed App';
The corrected version checks the return value explicitly and fails loudly with a clear exception, and uses the JSON_THROW_ON_ERROR flag so malformed JSON throws a JsonException instead of silently returning null.
Mistake 2: Displaying raw diagnostics to end users in production
<?php
ini_set('display_errors', '1');
error_reporting(E_ALL);
echo $_GET['user_id'] + 1;
With display_errors on in production, an undefined user_id parameter prints a warning — including the server’s full file path — straight into the page an end user sees. That is an information disclosure risk, and it looks unprofessional even when harmless.
<?php
ini_set('display_errors', '0');
ini_set('log_errors', '1');
ini_set('error_log', '/var/log/php/app-error.log');
error_reporting(E_ALL);
$userId = $_GET['user_id'] ?? null;
if ($userId === null) {
http_response_code(400);
exit('Missing user_id parameter.');
}
echo $userId + 1;
The fix turns off display_errors, keeps log_errors on so nothing is lost, and uses the null coalescing operator (??) to handle the missing parameter deliberately instead of letting PHP warn about it.
Best Practices
- Develop locally with
error_reporting(E_ALL)anddisplay_errorson, so you see every issue immediately. - In production, set
display_errorsto0andlog_errorsto1, and monitor the log file — never let users see raw diagnostics. - Avoid the
@suppression operator; check return values (false,null) explicitly instead, so failures stay visible in your own code. - Use
set_error_handler()to convert legacy-style warnings intoErrorExceptionobjects you can catch alongside modernThrowableexceptions. - Treat every
E_DEPRECATEDmessage as a to-do item — fix it before the feature is removed in a future PHP version. - Reach for
JSON_THROW_ON_ERROR, the null coalescing operator, anddeclare(strict_types=1)to prevent whole categories of warnings from occurring in the first place. - Run a static analysis tool such as PHPStan or Psalm in CI to catch undefined-variable and undefined-array-key bugs before they ever reach runtime.
Practice Exercises
- Write a script with
error_reporting(E_ALL)anddisplay_errorsenabled that intentionally accesses an undefined array key on an associative array, then predict and check the exact wording of the Warning it produces. - Write a custom handler with
set_error_handler()that catches everyE_WARNINGandE_NOTICE, writes the message to a string instead of printing it, and returnstrueso PHP’s default handler does not also run. - Take the configuration-loading example from Mistake 1 and wrap the
json_decode(..., JSON_THROW_ON_ERROR)call in atry/catch (JsonException $e)block that reports a friendly error instead of letting the exception go uncaught.
Summary
- PHP diagnostics come in severities: fatal errors stop execution, warnings let it continue, and notices/deprecated messages are purely informational.
error_reporting()decides which severities are considered at all;display_errorsandlog_errorsdecide where the ones that pass are sent.- As of PHP 8.0, undefined variables and undefined array keys were promoted from Notice to Warning.
- Since PHP 7, many conditions that used to be uncatchable fatal errors are instances of
Error, which implementsThrowableand can be caught withtry/catch. set_error_handler()andtrigger_error()let you intercept or raise your own diagnostics programmatically.- Never use
@as a substitute for checking return values — it hides bugs instead of fixing them. - In production, log errors instead of displaying them, to protect users and avoid leaking server file paths.
