Warnings, Errors and Exceptions Are Three Different Things
PHP has several ways of telling you something went wrong, and they behave differently. Sorting them out early makes error messages far less intimidating.
A warning or notice is a complaint. PHP prints or logs it and carries on running. Reading an undefined array key produces a warning in PHP 8; so does calling file_get_contents() on a file that does not exist. Your script continues with a null or a false, which is exactly how a small mistake turns into a confusing failure ten lines later. Treat warnings as bugs, not as background noise.
An exception is a deliberate signal that something could not be completed. It stops the current flow and travels up through the calling functions until some catch block handles it. If nothing does, the script dies with a fatal error. Exceptions are how modern PHP libraries report problems, and PDO throws one for every database failure.
An Error — with a capital E — is what PHP throws for problems in the code itself, such as calling a method on null, passing the wrong type to a typed parameter, or dividing by zero. Confusingly, an Error is thrown and caught exactly like an exception; it simply does not extend the Exception class.
Both descend from the Throwable interface, which is the key to the whole hierarchy. catch (Exception $e) catches only exceptions, not Errors — which surprises people whose try block does not seem to catch a TypeError. catch (Throwable $e) catches everything that can be thrown.
Throwable— the interface at the top; catching this catches everythingError— problems with the code:TypeError,ValueError,ArgumentCountError,DivisionByZeroError,UnhandledMatchErrorException— problems with the situation:RuntimeException,LogicException,InvalidArgumentException,JsonException,PDOExceptionLogicExceptionand its children mean "the program is wrong" — a bug you should fix, not a case to handle at runtimeRuntimeExceptionand its children mean "the world did not cooperate" — a missing file, a refused connection, a rejected payment- Parse errors are different again: the file could not be compiled, so no code ran at all and nothing can catch them
- PHP 8 turned a number of things that used to be quiet warnings into thrown Errors — most usefully, passing a completely non-numeric string into an arithmetic operation. That is a good change: a loud failure at the right line beats a silent zero flowing into your totals.
try, catch and finally
Code that might throw goes in a try block. Each catch names a type it handles, and PHP uses the first matching one, so order matters: put specific types before general ones. A catch (Throwable $e) placed first will swallow everything and make the blocks below it unreachable.
You can catch several unrelated types in one block by separating them with a vertical bar, which avoids duplicating identical handling. And since PHP 8 you may omit the variable entirely when you do not need the details — catch (JsonException) reads cleanly when the response is simply a default value.
A finally block runs whether or not an exception occurred, and even if the try block returned. Its job is cleanup that must happen either way: closing a file handle, releasing a lock, rolling back a transaction that was left open. Because a return inside try still runs the finally first, it is a reliable place to put that work.
The most important judgement is what to catch. Catch an exception only when you can do something meaningful about it — supply a default, retry, show a helpful message, log and continue with reduced functionality. If you cannot, let it propagate. Code that catches everything and continues blindly turns one clear failure into several confusing ones further downstream.
Notice the two-audience pattern in the example. The visitor sees a short, calm sentence; the log gets the real message and stack trace. Doing the opposite — printing the technical detail on screen — is both unhelpful to users and a genuine information leak.
<?php
try {
$stmt = $pdo->prepare('SELECT * FROM students WHERE id = ?');
$stmt->execute([$id]);
$student = $stmt->fetch();
} catch (PDOException $e) {
error_log('Student lookup failed: ' . $e->getMessage()); // for you
http_response_code(500);
exit('Sorry, we could not load that page.'); // for them
}
// Specific first, general last
try {
$data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
$result = 100 / (int) $data['divisor'];
} catch (JsonException $e) {
echo 'That was not valid JSON.';
} catch (DivisionByZeroError $e) {
echo 'The divisor cannot be zero.';
} catch (Throwable $e) { // the safety net, last
error_log($e->getMessage());
echo 'Something went wrong.';
}
// Several types, one handler
try {
// ...
} catch (InvalidArgumentException | RangeException $e) {
echo 'Invalid input: ' . htmlspecialchars($e->getMessage());
}
// No variable needed (PHP 8)
try {
$config = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
$config = [];
}
// finally: cleanup that must happen either way
$handle = fopen($path, 'r');
try {
return processFile($handle);
} finally {
fclose($handle); // runs even though we returned
}
// What an exception can tell you
try {
riskyOperation();
} catch (Throwable $e) {
error_log(sprintf(
"%s in %s:%d - %s\n%s",
get_class($e), $e->getFile(), $e->getLine(),
$e->getMessage(), $e->getTraceAsString()
));
} - An empty
catchblock is almost always a bug.catch (Exception $e) {}means "if this fails, pretend it did not", and the result is a page that reports success while nothing was saved. If you genuinely want to ignore a failure, at least log it and add a comment saying why.
Throwing Your Own
Throwing is how a function says "I cannot do what you asked, and returning a value would be a lie". It is usually better than returning false, because false can be ignored by the caller and an exception cannot.
Choose the class deliberately, because the class is part of the message. InvalidArgumentException says the caller passed something wrong — a programming mistake. RuntimeException says the situation failed — the file was missing, the API refused. That distinction lets a caller catch one and not the other, which is the whole point of having types.
Writing your own exception class is worth it as soon as callers need to react differently. A InsufficientStockException can carry the sku and the quantity available, so the catch block can show a genuinely useful message instead of parsing text out of getMessage(). Extend the closest built-in class rather than Exception directly, so that existing catch blocks still work.
When you catch an exception and throw a different one, pass the original as the third constructor argument. It becomes the previous exception and stays attached, so your log can show the whole chain — the high-level failure and the low-level cause. Throwing away the original is how a stack trace stops being useful.
One rule that saves grief: never put sensitive data in an exception message. Messages end up in logs, in error trackers, and sometimes on screen. A message that helpfully includes the failing password or API key has now written it to three places you did not intend.
<?php
declare(strict_types=1);
final class InsufficientStockException extends RuntimeException
{
public function __construct(
public readonly string $sku,
public readonly int $requested,
public readonly int $available,
) {
parent::__construct(
sprintf('Only %d units of %s remain.', $available, $sku)
);
}
}
final class Inventory
{
public function __construct(private PDO $db) {}
public function reserve(string $sku, int $qty): void
{
if ($qty < 1) {
// The caller's code is wrong - a bug, not a situation
throw new InvalidArgumentException('Quantity must be at least 1.');
}
$stmt = $this->db->prepare('SELECT stock FROM products WHERE sku = ?');
$stmt->execute([$sku]);
$stock = $stmt->fetchColumn();
if ($stock === false) {
throw new RuntimeException("Unknown product: $sku");
}
if ((int) $stock < $qty) {
throw new InsufficientStockException($sku, $qty, (int) $stock);
}
$this->db->prepare('UPDATE products SET stock = stock - ? WHERE sku = ?')
->execute([$qty, $sku]);
}
}
// The caller reacts differently to different failures
try {
$inventory->reserve($sku, $qty);
} catch (InsufficientStockException $e) {
echo "Sorry - only {$e->available} left. Reduce the quantity and try again.";
} catch (RuntimeException $e) {
error_log($e->getMessage());
echo 'That product is not available right now.';
}
// Wrapping, without losing the cause
try {
$rows = $pdo->query('SELECT ...')->fetchAll();
} catch (PDOException $e) {
throw new RuntimeException('Could not load the report.', 0, $e);
} $e->getPrevious()walks back down the chain to the original exception. Logging the chain rather than only the outermost message is often the difference between a five-minute fix and an hour of guessing.
Turning Silent Failures Into Loud Ones
Several PHP functions still report failure the old way — by returning false and emitting a warning — and those are the failures most likely to slip through. The best defence is to convert them into exceptions wherever the language offers a switch.
Three switches cover most of it. Set PDO's error mode to ERRMODE_EXCEPTION, which is the default from PHP 8.0. Pass JSON_THROW_ON_ERROR to json_encode() and json_decode(), so malformed JSON raises a JsonException instead of returning null. And check the return value of file functions explicitly, since they have no such flag.
The @ operator deserves a warning of its own. It suppresses error messages from a single expression, and it is almost always the wrong tool. Code such as @file_get_contents($url) hides the reason for the failure while doing nothing about the failure itself, and you are left debugging a variable that is false for reasons PHP tried to tell you and was silenced about. If a failure is expected, handle it with a check; do not hide the message.
The other habit to avoid is die() and exit() as error handling deep inside functions. die('DB error') stops everything, cannot be caught, cannot be tested, cannot be logged consistently, and leaves the visitor with a blank page containing three words. Throw an exception and let one place near the top of your application decide what the visitor should see.
None of this means you should never let a script stop. It means the decision to stop belongs at the edge of your application, where you know whether the response should be an HTML page, a JSON error or a command-line message — not buried in a function that has no idea who called it.
<?php
// Make failures throw
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
try {
$data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
error_log('Bad JSON from the API: ' . $e->getMessage());
$data = [];
}
// File functions have no flag - check the return value
$contents = file_get_contents($path);
if ($contents === false) {
throw new RuntimeException("Could not read $path");
}
// Suppression hides the message but not the problem
// $contents = @file_get_contents($path); // don't
// if ($contents === false) { /* why? you no longer know */ }
// die() inside a function is a dead end
function loadConfigBad(string $path): array {
if (!is_file($path)) {
die('Config missing'); // untestable, unloggable, ugly
}
return require $path;
}
// Throwing lets the caller decide
function loadConfig(string $path): array {
if (!is_file($path)) {
throw new RuntimeException("Config file not found: $path");
}
return require $path;
}
// Converting warnings into exceptions, application-wide
set_error_handler(function (int $severity, string $message, string $file, int $line) {
if (!(error_reporting() & $severity)) {
return false; // this severity is switched off; let PHP handle it
}
throw new ErrorException($message, 0, $severity, $file, $line);
}); - That last handler is a strong choice: it makes every warning stop the request. It is excellent in development, where you want to find these immediately, and should be adopted on a live site only once you are confident the code is clean — otherwise a harmless notice on an unrelated page becomes a 500 error for a real user.
One Place to Catch Everything
However careful you are, something will eventually throw where you did not expect it. Without a plan, the visitor sees either a stack trace — which leaks your file paths and query structure — or a completely blank page, which tells them nothing and tells you nothing either.
set_exception_handler() registers a function that runs for any uncaught throwable. It is your last line of defence: log the details, send a proper 500 status code, and render a friendly error page. Note that the script terminates afterwards, so this is for reporting, not for recovery.
Some fatal errors bypass that handler entirely — running out of memory, for instance. register_shutdown_function() runs at the end of every request no matter how it ended, and error_get_last() tells you whether it ended badly. Together they let you log the failures nothing else can see.
Set both up in a single bootstrap file, along with the environment switch for error display. In development, show everything on screen so you can fix it. In production, display nothing and log everything. Getting this pair the wrong way round is one of the most common configuration mistakes in deployed PHP: a live site printing warnings is both unprofessional and an information leak.
For anything beyond a small project, send those logs somewhere you will actually read. A file on the server that nobody opens is only marginally better than no logging at all — the point is to learn about failures before a user reports them.
<?php
// bootstrap.php - required first by every entry point
declare(strict_types=1);
const IS_PRODUCTION = true; // drive this from an environment variable
if (IS_PRODUCTION) {
ini_set('display_errors', '0');
ini_set('log_errors', '1');
error_reporting(E_ALL); // log everything, show nothing
} else {
ini_set('display_errors', '1');
error_reporting(E_ALL); // show everything while developing
}
set_exception_handler(function (Throwable $e): void {
error_log(sprintf(
"Uncaught %s: %s in %s:%d\n%s",
get_class($e), $e->getMessage(), $e->getFile(), $e->getLine(),
$e->getTraceAsString()
));
if (!headers_sent()) {
http_response_code(500);
}
if (IS_PRODUCTION) {
require __DIR__ . '/views/error-500.php';
} else {
echo '<pre>' . htmlspecialchars((string) $e, ENT_QUOTES, 'UTF-8') . '</pre>';
}
});
// Catches fatals that the exception handler cannot see
register_shutdown_function(function (): void {
$last = error_get_last();
if ($last !== null && in_array($last['type'], [E_ERROR, E_PARSE, E_CORE_ERROR], true)) {
error_log("Fatal: {$last['message']} in {$last['file']}:{$last['line']}");
}
});
// Your own log lines go to the same place
error_log('Payment gateway returned an unexpected status for order 42'); - Give your error page a plain, honest message and a way forward — a link home, a support address. Users do not need to know what failed; they need to know it was not their fault and what to do next. Save the detail for the log.
Reading Errors, and a Practical Checklist
Most of debugging is reading the message properly. PHP tells you the type of problem, the file, the line and usually enough context to identify the cause. The habit worth building is to read all of that before changing anything — a surprising amount of time is lost to guessing at a fix while the message says exactly what is wrong.
Remember that the reported line is where the problem was noticed, which is not always where it was caused. A missing semicolon is reported on the following line. A TypeError is reported inside the function, while the mistake is in the call. A null property access is reported where you used the value, while the cause is the lookup that found nothing.
The list below covers the messages you are most likely to meet in your first few projects, along with what they usually mean in practice.
- Parse error: syntax error, unexpected ... — a typo, usually a missing semicolon, bracket or quote on or just before the reported line; nothing in the file ran
- Warning: Undefined variable $x — a typo or a variable used before it was assigned; remember names are case-sensitive
- Warning: Undefined array key "x" — the key was not sent or is spelled differently; use
??with a default - Fatal error: Uncaught TypeError — a value of the wrong type reached a typed parameter; look at the caller, not the function
- Fatal error: Call to a member function x() on null — a lookup returned null and you used it anyway; check the result before calling methods on it
- Cannot modify header information — headers already sent by ... — output happened before
header()orsession_start(); the message names the exact file and line where output began - Fatal error: Allowed memory size exhausted — usually
fetchAll()orfile_get_contents()on something far larger than you expected - SQLSTATE[42S22]: Column not found — a typo in a column name, or a query written against a table that has since changed
- When a message genuinely makes no sense,
var_dump()the values just above the failing line and confirm they are what you assumed. Half of all "impossible" bugs turn out to be a variable holdingfalse, an empty array, or a string where a number was expected — and one dump shows all three instantly.
