Errors and Exceptions
When things go wrong in PHP: warnings, notices and fatal errors, exceptions with try, catch and finally, the Throwable hierarchy, error_reporting and display_errors, and logging.
Two ways PHP reports problems
The general idea of an exception — an object that interrupts normal flow and travels up the call stack until something handles it — is the same in PHP as in Python and JavaScript. PHP is older than its exceptions, though, so it has two systems side by side:
-
Error messages with a level. The engine and many
built-in functions report a problem by printing or logging a
message such as
Warning: Undefined variable $total. Depending on the level, the script continues or stops. -
Exceptions. Objects that are thrown and can be
caught. Since PHP 7 most fatal errors are thrown as
Errorobjects, and PHP 8 turned many former warnings of built-in functions intoTypeErrorandValueError.
Older tutorials often describe situations that gave a warning or
even silently returned false where PHP 8 throws. For
example, 1 / 0 now throws a
DivisionByZeroError and "abc" * 1 a
TypeError.
Error levels
Each classic error message has a level, a constant that starts with
E_. The ones you will see in logs:
| Label in the message | Constant | Script continues? | Typical cause |
|---|---|---|---|
| Parse error | E_PARSE |
no — nothing in the file runs | a missing ; or bracket |
| Fatal error | E_ERROR |
no | an uncaught exception, memory limit or time limit exceeded |
| Warning | E_WARNING |
yes | undefined variable or array key, a file that cannot be opened |
| Notice | E_NOTICE |
yes | minor issues; few remain in PHP 8 |
| Deprecated | E_DEPRECATED |
yes | code that a future PHP version will reject |
E_ALL means all levels together. Your own code can
raise messages with trigger_error(), which uses the
E_USER_… levels, but in new code an exception is
usually the better choice. Do not ignore deprecations: they are a
free list of what will break at your next PHP upgrade.
Warnings
A warning reports a problem
that PHP can work around, so the script continues — usually with
null or false in place of the value it
could not get. That is what makes warnings dangerous: the program
keeps running with wrong data. Since PHP 8.0, an undefined variable
or array key is a warning (it used to be a notice).
<?php
$tariffs = ['day' => 0.25, 'night' => 0.18];
$price = $tariffs['weekend']; // no such key: a warning, then null
var_dump($price);
echo "Invoice total: ", 120 * $price, " euro\n";
NULL Invoice total: 0 euro
The output above is what the script prints. The warning itself is written to the error output, so in a terminal you would also see:
Warning: Undefined array key "weekend" in /var/www/portal/tariffs.php on line 3
A customer billed 0 euro is a logic error that no exception would have caught. Treat every warning in a log as a bug to fix.
The @ operator
The
error suppression operator
@, placed before an expression, hides any warning or
notice that expression raises:
@$tariffs['weekend'] returns null without
a message. It hides the symptom, not the problem, and it also hides
warnings you did not expect. Better alternatives:
-
$tariffs['weekend'] ?? 0.25— the null coalescing operator gives a default for a missing key or variable, without a warning. -
Check first:
array_key_exists(),isset(),is_file(). - Let the function throw, or convert warnings into exceptions with an error handler.
Since PHP 8.0, @ no longer hides fatal errors.
try, catch and finally
Code that may throw goes in a try block. If it throws,
PHP jumps to the first catch block whose type matches
the exception, and the exception object is available in the variable
you name. A finally block runs in every case: after
normal completion, after a catch, and even when the exception is not
caught.
<?php
function averageUsage(array $readings): float
{
return array_sum($readings) / count($readings);
}
foreach ([[412.5, 415.8], []] as $readings) {
try {
$average = averageUsage($readings);
echo "Average: $average kWh\n";
} catch (DivisionByZeroError $e) {
echo "No readings: ", $e->getMessage(), "\n";
} finally {
echo "Checked ", count($readings), " readings\n";
}
}
Average: 414.15 kWh Checked 2 readings No readings: Division by zero Checked 0 readings
The exception object tells you what happened:
getMessage(), getCode(),
getFile() and getLine(), and
getTraceAsString() for the
stack trace. Keep the try block small, so it is clear which line
you expect to fail.
Catching several types
One catch can list several types separated by
|, and a try can have several
catch blocks; PHP uses the first that matches, so put
specific types before general ones. Since PHP 8.0 the variable is
optional: catch (JsonException) when you do not need
the object.
<?php
foreach (["412.5", "n/a", "-3"] as $input) {
try {
$kwh = $input * 1;
if ($kwh < 0) {
throw new DomainException("Reading cannot be negative: $input");
}
echo "OK: $kwh\n";
} catch (TypeError | DomainException $e) {
echo get_class($e), ": ", $e->getMessage(), "\n";
}
}
OK: 412.5 TypeError: Unsupported operand types: string * int DomainException: Reading cannot be negative: -3
Uncaught exceptions
If nothing catches an exception, PHP stops the script with a
Fatal error: Uncaught … message that names the type,
the message, the file and the line, followed by a stack trace. The
lines before have already run:
<?php
echo "Importing readings...\n";
$readings = json_decode('{"kwh": 412.5', true, flags: JSON_THROW_ON_ERROR);
echo "Done\n";
Importing readings... Fatal error: Uncaught JsonException: Syntax error in example.php:3
The Throwable hierarchy
Only objects that implement the Throwable interface can be thrown and caught. You cannot implement it yourself; instead every throwable object extends one of two built-in classes:
-
Exceptionand its subclasses: problems that your code or a library expects might happen and throws on purpose — invalid input, a missing record, a failed database connection (PDOException), invalid JSON (JsonException). -
Errorand its subclasses: problems the PHP engine detects in the code itself — a wrong type (TypeError), a wrong value for a built-in function (ValueError), division by zero, amatchwithout a matching arm, calling an undefined function. They usually point to a bug, not to bad luck.
catch for a class also catches everything below it;
catch (Exception $e) never sees the
Error branch.
Because the two branches are separate, a
catch (Exception $e) written in PHP 5 style does not
catch a TypeError:
<?php
function parseKwh(string $value): float
{
return $value * 1;
}
try {
echo parseKwh("412.5"), "\n";
echo parseKwh("n/a"), "\n";
} catch (Exception $e) {
echo "Caught: ", $e->getMessage(), "\n";
}
412.5 Fatal error: Uncaught TypeError: Unsupported operand types: string * int in example.php:4
Catch Throwable only at the very top of an application,
in the one place that logs every unexpected problem and shows a
friendly error page. Elsewhere, catch the specific types you can
actually handle and let the rest travel up. This page's
Python counterpart
makes the same point: catching too broadly hides bugs.
Throwing your own exceptions
throw raises an exception object. PHP's standard
library (SPL) offers a set of ready-made classes; pick the one whose
name describes the problem, such as
InvalidArgumentException for a bad argument or
RuntimeException for a failure outside the program's
control. Since PHP 8.0, throw is an expression, so it
can appear after ??:
<?php
$meterId = $_GET['meter'] ?? throw new InvalidArgumentException("No meter ID given");
Custom exception classes and chaining
For problems specific to your application, extend a built-in
exception class. Callers can then catch exactly your problem. When
you catch a low-level exception and throw your own, pass the
original as previous so the root cause is not lost —
the same idea as JavaScript's
error cause.
<?php
class MeterReadingException extends RuntimeException
{
}
function loadReadings(string $json): array
{
try {
return json_decode($json, true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw new MeterReadingException("Meter file M-1042 is not valid JSON", previous: $e);
}
}
try {
loadReadings('{"kwh": 412.5');
} catch (MeterReadingException $e) {
echo $e->getMessage(), "\n";
$cause = $e->getPrevious();
echo "Cause: ", get_class($cause), ": ", $cause->getMessage(), "\n";
}
Meter file M-1042 is not valid JSON Cause: JsonException: Syntax error
An uncaught chained exception shows both in its fatal error message ("Next MeterReadingException…"), and a good logger records both.
Turning warnings into exceptions
Warnings cannot be caught with try/catch.
set_error_handler() registers a function that PHP calls
for every warning, notice and deprecation instead of printing it. A
common pattern throws an ErrorException from that
handler, so that every warning becomes an exception your normal
error handling sees; the first line skips levels excluded by
error_reporting, including those silenced with
@. Frameworks such as Laravel and Symfony do this for
you.
<?php
set_error_handler(function (int $severity, string $message, string $file, int $line): bool {
if (!(error_reporting() & $severity)) {
return false; // level excluded by error_reporting, e.g. silenced with @
}
throw new ErrorException($message, 0, $severity, $file, $line);
});
$tariffs = ['day' => 0.25, 'night' => 0.18];
try {
$price = $tariffs['weekend'];
echo "Price: $price\n";
} catch (ErrorException $e) {
echo "Caught: ", $e->getMessage(), "\n";
}
Caught: Undefined array key "weekend"
Its counterpart, set_exception_handler(), registers a
last-resort function for exceptions nobody caught: the place to log
the problem and send a generic "something went wrong" page with
status code 500.
error_reporting and display_errors
Two php.ini settings decide what happens to error messages:
-
error_reportingchooses which levels are reported at all. Keep it atE_ALL, also in production; you want to know about every problem. -
display_errorschooses whether reported errors are printed into the output — for a web request, into the HTML page the visitor sees.
Showing errors is useful on your own machine and dangerous on a live
site: a message such as
Warning: … in /var/www/portal/lib/db.php on line 12
reveals file paths, and an uncaught PDOException can
reveal database details. Attackers use such messages to map an
application, and the
OWASP Top 10
(2025) lists "Mishandling of Exceptional Conditions" as its own
category.
error_reporting = E_ALL
display_errors = Off
display_startup_errors = Off
log_errors = On
error_log = /var/log/php/portal-error.log
PHP ships two templates, php.ini-development (errors
displayed) and php.ini-production (errors logged, not
displayed). The production template sets
error_reporting to E_ALL minus
deprecations; many teams keep deprecations in the log so upgrades
hold no surprises.
Logging errors
With log_errors=On, PHP writes every reported error to
the error log: the file named in
the error_log setting or, if that is empty, the log of
the program that runs PHP (for example the Apache error log, or
PHP-FPM's log — see Deploying PHP).
Each line has a timestamp, the level, the message, the file and the
line:
[02-Oct-2026 09:14:03 UTC] PHP Warning: Undefined array key "weekend" in /var/www/portal/tariffs.php on line 3 [02-Oct-2026 09:15:41 UTC] PHP Fatal error: Uncaught PDOException: SQLSTATE[HY000] [2002] Connection refused in /var/www/portal/lib/db.php:12
For a service desk or operations team, this log is the first place to look when a PHP site shows a blank page or a "500 Internal Server Error": the visitor sees nothing useful by design, but the log says exactly what failed and where. Read from the first error, not the last.
Your own code can write to the log with
error_log("message"). With message type 3 it appends to
a file you name, which this example then reads back:
<?php
error_log("Meter M-1042: reading 412.5 rejected (lower than last month)\n", 3, "import.log");
error_log("Meter M-2210: reading missing\n", 3, "import.log");
echo file_get_contents("import.log");
Meter M-1042: reading 412.5 rejected (lower than last month) Meter M-2210: reading missing
Larger applications use a logging library — usually Monolog, which Laravel and Symfony include — for log levels, structured JSON logs and sending logs to a central system. Never log passwords, session IDs or full card numbers.