Errors and Exceptions in PHP
AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.
PHP has two mechanisms: errors for engine-level problems and exceptions for problems your code raises deliberately. Knowing which is which decides whether you catch it or fix it.
Error levels
| Level | Typical cause | Fatal |
|---|---|---|
E_ERROR |
Fatal engine error, e.g. calling an undefined function | yes |
E_WARNING |
Runtime warning, e.g. a failed include |
no |
E_NOTICE |
Undefined variable or array key | no |
E_DEPRECATED |
Feature that will be removed | no |
E_PARSE |
Syntax error | yes |
PHP 8 promoted many historical errors to Error exceptions (TypeError, DivisionByZeroError, ValueError), so try / catch now covers most failures.
The Throwable hierarchy
Throwable
├── Exception (thrown by application code)
│ ├── RuntimeException
│ ├── LogicException
│ └── ...
└── Error (engine-level, PHP 7+)
├── TypeError
├── ValueError
├── DivisionByZeroError
└── ParseError
Catching Throwable covers both, but business code should normally catch Exception — an engine-level Error usually means a bug you want to see, not swallow.
try / catch / finally
try {
$data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
error_log("Parse failed: " . $e->getMessage());
$data = [];
} finally {
// always runs: close handles, restore state
}
finally runs whether or not an exception occurred, and even if the catch block returns.
Order multiple catch blocks from specific to general:
try {
process($input);
} catch (InvalidArgumentException $e) {
echo "Bad input: " . $e->getMessage();
} catch (RuntimeException $e) {
echo "Something went wrong: " . $e->getMessage();
} catch (Throwable $e) {
error_log($e);
echo "Internal server error";
}
What an exception carries
try {
throw new RuntimeException("Connection timed out");
} catch (RuntimeException $e) {
$e->getMessage(); // "Connection timed out"
$e->getCode(); // 0 by default
$e->getFile(); // file where it was thrown
$e->getLine(); // line number
$e->getTraceAsString(); // stack trace
}
Throwing
function withdraw(float $balance, float $amount): float
{
if ($amount <= 0) {
throw new InvalidArgumentException("Withdrawal amount must be positive");
}
if ($amount > $balance) {
throw new RuntimeException("Insufficient funds", 1001);
}
return $balance - $amount;
}
Which class to pick:
InvalidArgumentException— the caller passed something wrong (aLogicException, i.e. a code bug).RuntimeException— an external condition failed: no balance, missing file, network timeout.DomainException— a value is outside the domain the business allows.
Custom exceptions
Once error handling branches on type, a dedicated class beats string matching:
class ValidationException extends RuntimeException
{
/** @param array<string, string> $errors field => message */
public function __construct(public array $errors)
{
parent::__construct("Input validation failed");
}
}
try {
$errors = validate($_POST);
if ($errors !== []) {
throw new ValidationException($errors);
}
} catch (ValidationException $e) {
foreach ($e->errors as $field => $message) {
echo htmlspecialchars($field, ENT_QUOTES, "UTF-8") . ": "
. htmlspecialchars($message, ENT_QUOTES, "UTF-8") . "<br>";
}
}
Constructor property promotion (public array $errors) declares and assigns the property in one step.
Making PHP functions throw instead of returning false
Many older functions signal failure with false. Some have a flag that converts that into an exception:
// default: null on failure, then json_last_error() to find out why
$data = json_decode($raw, true);
// PHP 7.3+: throws JsonException
$data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
PDO has the equivalent PDO::ERRMODE_EXCEPTION. filter_var still needs an explicit === false check.
A global handler as the last line of defence
An uncaught exception becomes a fatal error; a handler lets you log it and show something friendly:
set_exception_handler(function (Throwable $e): void {
error_log(sprintf(
"[%s] %s in %s:%d\n%s",
date("c"),
$e->getMessage(),
$e->getFile(),
$e->getLine(),
$e->getTraceAsString()
));
http_response_code(500);
echo "Something went wrong. Please try again later.";
});
Register it at the very top of your entry point (index.php). It only handles exceptions that nothing else caught.
Error reporting configuration
Development — show everything:
; php.ini — development
display_errors = On
error_reporting = E_ALL
log_errors = On
Production — log it, never display it (messages can leak paths, SQL and credentials):
; php.ini — production
display_errors = Off
error_reporting = E_ALL
log_errors = On
error_log = /var/log/php/error.log
Per-script overrides are possible too:
error_reporting(E_ALL);
ini_set("display_errors", "0");
Common mistakes
1. An empty catch (Exception $e) {}
The failure is swallowed. At minimum call error_log.
2. Catching Throwable and returning a default
That hides engine-level bugs along with expected failures. Catch the types you can actually handle.
3. Using @ to silence errors
The message disappears but execution continues in a broken state.
4. Leaving display_errors on in production
Paths, queries and credentials end up in the browser.
5. Returning from finally
It overrides the value returned by try or catch, almost never what you meant.
6. Printing $e->getMessage() to users
Log the detail, show a generic message.
Summary
Exceptionfor business problems,Errorfor engine problems,Throwableis their parent.- Throw
InvalidArgumentExceptionfor bad arguments,RuntimeExceptionfor external conditions. - Prefer flags like
JSON_THROW_ON_ERRORover checking return values. - Register
set_exception_handlerat the entry point for a uniform fallback. - Production:
display_errors = Off,log_errors = On.
Next: sessions and cookies — how PHP remembers a user across requests.