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 Error objects, and PHP 8 turned many former warnings of built-in functions into TypeError and ValueError.

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:

  • Exception and 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).
  • Error and 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, a match without a matching arm, calling an undefined function. They usually point to a bug, not to bad luck.
The Throwable hierarchy in PHP 8. Throwable is an interface with two branches. Error has the subclasses TypeError (with ArgumentCountError below it), ValueError, ArithmeticError (with DivisionByZeroError below it) and UnhandledMatchError. Exception has LogicException (with InvalidArgumentException and DomainException), RuntimeException (with PDOException), JsonException and ErrorException. Throwable Error TypeError ArgumentCountError ValueError ArithmeticError DivisionByZeroError UnhandledMatchError Exception LogicException InvalidArgumentException DomainException RuntimeException PDOException JsonException ErrorException
A selection of PHP 8's built-in throwables. A 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_reporting chooses which levels are reported at all. Keep it at E_ALL, also in production; you want to know about every problem.
  • display_errors chooses 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.