Errors vs Exceptions
Modern PHP unifies both under the Throwable interface:
Throwable (interface)
├── Error (engine-level: TypeError, ParseError, ...)
└── Exception (user-thrown: RuntimeException, LogicException, ...)
try / catch / finally
<?php
$pdo = new PDO("sqlite:db.sqlite");
try {
$pdo->beginTransaction();
$pdo->exec("INSERT INTO users (name) VALUES ('Alice')");
$pdo->exec("INSERT INTO orders (user_id, total) VALUES (1, 99)");
$pdo->commit();
echo "Success";
} catch (PDOException $e) {
$pdo->rollBack();
echo "Database error: " . $e->getMessage();
} finally {
// Always runs - even after return or rethrow
$pdo = null;
}
Exception Hierarchy
| Class | Use for |
|---|---|
RuntimeException | Errors that arise only at runtime (network, DB, file) |
LogicException | Bugs that should be caught in development |
InvalidArgumentException | Bad argument passed to a function |
OutOfRangeException | Index out of range |
TypeError | Wrong type for parameter or return (engine-thrown) |
ValueError | Value out of expected set (PHP 8.0+) |
UnhandledMatchError | match() arm didn\'t match (PHP 8.0+) |
Throwing Exceptions
<?php
function divide(int $a, int $b): float {
if ($b === 0) {
throw new InvalidArgumentException("Division by zero");
}
return $a / $b;
}
// Throw is now an expression (PHP 8.0+)
$user = $repo->find($id) ?? throw new NotFoundException("User $id not found");
Multi-catch (PHP 8.0+)
<?php
try {
$data = json_decode(file_get_contents($path), flags: JSON_THROW_ON_ERROR);
} catch (RuntimeException | JsonException $e) {
// handle both file errors and JSON errors the same way
logger()->error($e->getMessage());
}
// Catch without variable - "I dont need details"
try {
$cache->refresh();
} catch (Throwable) {
// best effort - ignore failures
}
Custom Exception Classes
<?php
namespace App\Exception;
class NotFoundException extends \RuntimeException {
public function __construct(
public readonly string $resource,
public readonly int|string $id,
) {
parent::__construct("$resource #$id not found", 404);
}
}
class ValidationException extends \RuntimeException {
public function __construct(public readonly array $errors) {
parent::__construct("Validation failed", 422);
}
}
// Usage
throw new NotFoundException("User", 42);
try {
// ...
} catch (NotFoundException $e) {
http_response_code($e->getCode());
echo json_encode(["error" => $e->getMessage(), "id" => $e->id]);
}
Error Reporting & Logging
<?php
// Development - show everything
error_reporting(E_ALL);
ini_set("display_errors", "1");
ini_set("log_errors", "1");
ini_set("error_log", __DIR__ . "/error.log");
// Production - log only, never display to users
error_reporting(E_ALL);
ini_set("display_errors", "0");
ini_set("log_errors", "1");
ini_set("error_log", "/var/log/app/php-errors.log");
// Log directly
error_log("Payment failed for user $userId", 3, "payments.log");
Stack traces leak file paths, query syntax, and sometimes credentials. display_errors = Off in production. Log to a file/service and show users a generic error page.
Global Error/Exception Handlers
<?php
// Convert PHP warnings/notices into exceptions
set_error_handler(function ($severity, $message, $file, $line) {
throw new ErrorException($message, 0, $severity, $file, $line);
});
// Catch anything that escapes top-level
set_exception_handler(function (Throwable $e) {
error_log($e);
http_response_code(500);
echo "Internal Server Error";
});
// Catch fatal errors (parse/memory) - last chance
register_shutdown_function(function () {
$err = error_get_last();
if ($err && in_array($err["type"], [E_ERROR, E_PARSE, E_CORE_ERROR])) {
error_log("FATAL: " . $err["message"]);
}
});
Best Practices
- Throw early, catch late. Validate at boundaries and catch at the request/CLI boundary.
- Catch specific exception classes when you can recover from them.
- Always log - never swallow exceptions silently.
- Don't use exceptions for control flow - they're slow and obscure intent.
- Chain exceptions with
new HighLevelException("msg", 0, $e)to preserve the cause. - Use
finallyfor cleanup that must always run.