Production pitfall: catching Throwable too broadly masks severe engine errors like ParseError
Biggest mistake: using exceptions for normal control flow instead of return codes
Plain-English First
Imagine you're a pilot going through a pre-flight checklist. If the fuel gauge is broken, you don't just ignore it and take off — you have a procedure: stop, report the problem, and decide what to do next. PHP exceptions work exactly the same way. When something goes wrong in your code, instead of silently crashing or spitting out gibberish to the user, you 'throw' the problem upward so something smarter can catch it and handle it gracefully. The 'try' block is your flight attempt, the 'catch' block is your emergency protocol, and 'finally' is the post-flight shutdown you always do, no matter what happened up there.
Most PHP developers spend their early days hoping their code just works. But in production — where real users, real databases, and real network failures live — 'hoping' isn't a strategy. A user submits a payment form and your database is temporarily unreachable. A file upload hits a disk quota limit. A third-party API times out. Without exception handling, every one of these scenarios either crashes your app visibly or, worse, fails silently and corrupts data. Exception handling is the engineering discipline that separates apps you'd trust with your credit card from ones you wouldn't.
Before exceptions existed in PHP (they were introduced properly in PHP 5), error handling was a patchwork of return codes, global error flags, and the dreaded die() call. You'd check if ($result === false) after every function call and hope you didn't miss one. The problem is that error-checking code and business logic got tangled together until neither was readable. Exceptions fixed this by separating 'what you want to do' from 'what happens when it goes wrong' — two very different concerns that deserve to live in different places.
By the end of this article you'll be able to write try/catch/finally blocks that actually mean something, build your own custom exception hierarchy for a real application, chain exceptions so you never lose diagnostic context, and avoid the three mistakes that make exception handling worse than useless.
How try, catch, and finally Actually Work Together
The try block wraps code that might fail. The moment an exception is thrown inside it — whether by your code or a library you're calling — PHP immediately stops executing that block and jumps to the matching catch. Nothing in try after the throw line runs. That's critical to understand: execution doesn't resume where it left off.
A catch block declares which exception type it handles. If the thrown exception matches (or is a subclass of) the declared type, the catch runs. You can stack multiple catch blocks to handle different failure types differently — more on that in a moment.
finally is the block that runs unconditionally — whether the try succeeded, whether an exception was caught, even if the catch block itself threw another exception. This makes it perfect for cleanup work: closing file handles, releasing database connections, or resetting state that must be tidied regardless of outcome.
Think of finally as the janitor who locks up the building every night, whether the workday went smoothly or ended in a fire drill.
DatabaseConnectionExample.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<?php
/**
* Simulates reading a user record from a database.
* This mirrors what happens in real apps when DB connections are involved.
*/
functionfetchUserById(int $userId, bool $simulateFailure = false): array
{
// Pretend we opened a database connection hereecho"[DB] Opening connection...\n";
try {
if ($simulateFailure) {
// This is what happens when the DB is unreachable or the query failsthrownewRuntimeException(
"Could not fetch user #$userId: connection timed out after 30s"
);
}
// Happy path: query succeedsecho"[DB] Query executed successfully.\n";
return ['id' => $userId, 'name' => 'Alice Nguyen', 'email' => 'alice@example.com'];
} catch (RuntimeException $exception) {
// We catch the specific exception type — not just any throwable// This lets other exception types bubble up if we don't handle themecho"[ERROR] Database error caught: " . $exception->getMessage() . "\n";
// Return a safe default so the caller doesn't receive null unexpectedlyreturn [];
} finally {
// This block ALWAYS runs — connection is always cleaned up// Even if catch() threw another exception, this still executesecho"[DB] Closing connection (finally block ran).\n";
}
}
// --- Run 1: Normal operation ---echo"=== Successful Fetch ===\n";
$user = fetchUserById(42, simulateFailure: false);
echo"User data: " . json_encode($user) . "\n\n";
// --- Run 2: Simulated failure ---echo"=== Failed Fetch ===\n";
$user = fetchUserById(42, simulateFailure: true);
echo"User data: " . json_encode($user) . "\n";
Output
=== Successful Fetch ===
[DB] Opening connection...
[DB] Query executed successfully.
[DB] Closing connection (finally block ran).
User data: {"id":42,"name":"Alice Nguyen","email":"alice@example.com"}
=== Failed Fetch ===
[DB] Opening connection...
[ERROR] Database error caught: Could not fetch user #42: connection timed out after 30s
[DB] Closing connection (finally block ran).
User data: []
Watch Out: finally runs even when catch re-throws
If your catch block throws a new exception (or re-throws the original), finally still runs before that new exception propagates. This is correct behaviour, not a bug — but it means any state changes you make inside finally are visible to whatever catches the re-thrown exception upstream. Never put logic in finally that depends on the try having succeeded.
Production Insight
The finally block is the only place guaranteed to run, even if a power failure kills the process mid-execution.
When a catch re-throws, finally still executes before the re-thrown exception propagates.
Rule: use finally for resource cleanup only — never for result-dependent logic.
Key Takeaway
Try stops at the throw, catch handles specific types, finally always runs.
The order: try → catch (if matched) → finally.
The golden rule: always run cleanup in finally.
Building a Custom Exception Hierarchy for Real Applications
PHP's built-in exceptions — RuntimeException, InvalidArgumentException, OverflowException — are useful, but they're generic. When you're building a payment processing module, catching a RuntimeException doesn't tell you whether it was a declined card, a network timeout, or a configuration error. Each of those failures needs a different response.
The solution is a custom exception hierarchy. You create a base exception class for your domain (e.g., PaymentException), then extend it into specific types. Callers can catch the base type to handle all payment errors uniformly, or catch a specific subtype to handle it precisely.
This mirrors how real-world software is built. Laravel does exactly this — it has HttpException as a base with NotFoundHttpException, AuthorizationException, and others branching from it. Symfony, Doctrine, and every serious PHP library follow the same pattern.
The other power move: custom exceptions can carry extra context. A plain RuntimeException holds a message and a code. Your PaymentDeclinedException can also hold the card's last four digits, the processor's response code, and a suggested retry strategy. That context is invaluable for logging and for deciding what to show the user.
PaymentExceptionHierarchy.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
<?php
// ─────────────────────────────────────────────// EXCEPTION HIERARCHY// ─────────────────────────────────────────────
/**
* BaseclassforALL payment-related errors in this system.
* Catching this type handles any payment failure — useful at the controller layer.
*/
classPaymentExceptionextendsRuntimeException {}
/**
* Thrown when the card processor declines the charge.
* Carries extra context beyond a plain message.
*/
classPaymentDeclinedExceptionextendsPaymentException
{
publicfunction__construct(
privatereadonly string $declineReason,
privatereadonly string $cardLastFour,
int $processorCode,
\Throwable $previous = null // $previous links to the original low-level error
) {
// Build a human-readable message for logs
$message = "Card ending $cardLastFour declined: $declineReason (code: $processorCode)";
parent::__construct($message, $processorCode, $previous);
}
publicfunctiongetDeclineReason(): string { return $this->declineReason; }
publicfunctiongetCardLastFour(): string { return $this->cardLastFour; }
}
/**
* Thrown when we can't even reach the payment gateway — not a card problem.
*/
classPaymentGatewayUnavailableExceptionextendsPaymentException {}
// ─────────────────────────────────────────────// SERVICE LAYER// ─────────────────────────────────────────────classPaymentService
{
/**
* Attempts to charge a card. Throws typed exceptions instead of returning
* ambiguous booleans ornull values that callers might forget to check.
*/
publicfunctionchargeCard(string $cardLastFour, float $amountGbp): void
{
echo"[PaymentService] Attempting to charge £$amountGbp to card ending $cardLastFour\n";
// Simulate: card ending 0000 always gets declinedif ($cardLastFour === '0000') {
thrownewPaymentDeclinedException(
declineReason: 'Insufficient funds',
cardLastFour: $cardLastFour,
processorCode: 1051
);
}
// Simulate: card ending 9999 means gateway is downif ($cardLastFour === '9999') {
thrownewPaymentGatewayUnavailableException(
"Stripe gateway returned HTTP 503 — try again in 30 seconds"
);
}
echo"[PaymentService] Charge successful. Reference: TXN-" . strtoupper(bin2hex(random_bytes(4))) . "\n";
}
}
// ─────────────────────────────────────────────// CONTROLLER / CALLER LAYER// ─────────────────────────────────────────────functionhandleCheckout(string $cardLastFour, float $amount): void
{
$paymentService = newPaymentService();
try {
$paymentService->chargeCard($cardLastFour, $amount);
echo"[Checkout] Payment complete. Sending confirmation email.\n";
} catch (PaymentDeclinedException $e) {
// We know specifically WHY it failed — we can give useful feedbackecho"[Checkout] Card declined: " . $e->getDeclineReason() . "\n";
echo"[Checkout] Ask customer to try a different card ending in " . $e->getCardLastFour() . "\n";
} catch (PaymentGatewayUnavailableException $e) {
// Different failure, different response — queue a retry insteadecho"[Checkout] Gateway down. Queuing payment for retry: " . $e->getMessage() . "\n";
} catch (PaymentException $e) {
// Fallback: catches any PaymentException subclass we didn't anticipateecho"[Checkout] Unexpected payment error: " . $e->getMessage() . "\n";
}
}
echo"=== Test 1: Successful charge ===\n";
handleCheckout('4242', 79.99);
echo"\n=== Test 2: Declined card ===\n";
handleCheckout('0000', 79.99);
echo"\n=== Test 3: Gateway unavailable ===\n";
handleCheckout('9999', 79.99);
Output
=== Test 1: Successful charge ===
[PaymentService] Attempting to charge £79.99 to card ending 4242
[PaymentService] Attempting to charge £79.99 to card ending 0000
[Checkout] Card declined: Insufficient funds
[Checkout] Ask customer to try a different card ending in 0000
=== Test 3: Gateway unavailable ===
[PaymentService] Attempting to charge £79.99 to card ending 9999
[Checkout] Gateway down. Queuing payment for retry: Stripe gateway returned HTTP 503 — try again in 30 seconds
Pro Tip: Always pass $previous when wrapping exceptions
When you catch a low-level exception (e.g., a PDO database exception) and throw a domain-level one (e.g., UserRepositoryException), pass the original as the third constructor argument: throw new UserRepositoryException('...', 0, $pdoException). This chains the exceptions so that your logging infrastructure can call getPrevious() and see the full stack trace of the root cause — not just your wrapper.
Production Insight
Without a custom hierarchy, you're catching generic RuntimeExceptions and guessing what happened.
In production, that leads to either insufficient logging or overly broad catch blocks.
Rule: one base exception per domain module, one subclass per failure type.
Always pass $previous to preserve the full failure chain.
Catch specific types first, base type as fallback.
Exception Chaining and Global Handlers — Catching What You Missed
Even the best-written code has blind spots. An exception can bubble up through several function calls before being caught — or it can reach the top of the call stack without any catch at all. PHP gives you two safety nets for this: exception chaining (for preserving diagnostic context) and global exception/error handlers.
set_exception_handler() registers a callback that PHP calls for any uncaught exception. This is where you log the full stack trace, return a clean error page to the user, and alert your monitoring system — instead of PHP printing a raw exception message (or worse, a blank page in production).
For PHP 7+ errors that aren't traditional exceptions (like TypeError, DivisionByZeroError, ParseError), these are subclasses of Error, not Exception. Both Error and Exception implement the Throwable interface. Catching Throwable catches both — useful in global handlers, but be cautious about overusing it in normal code, as it can mask serious engine-level problems you'd rather know about immediately.
Finally, set_error_handler() converts old-style PHP warnings and notices into exceptions, which is essential for treating legacy PHP errors with the same seriousness as modern exceptions.
GlobalExceptionHandler.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
<?php
/**
* A minimal but production-realistic global exception handler setup.
* In a real app, $logger would be a Monolog instance or similar PSR-3 logger.
*/
// ─────────────────────────────────────────────// STEP 1: Convert legacy PHP errors into ErrorException// This means a missing array key or a deprecated function call// becomes a catchable exception rather than a silent warning.// ─────────────────────────────────────────────set_error_handler(function (int $severity, string $message, string $file, int $line): bool {
// Only convert errors that match the current error_reporting levelif (!(error_reporting() & $severity)) {
return false; // Let PHP handle it normally
}
thrownewErrorException($message, 0, $severity, $file, $line);
});
// ─────────────────────────────────────────────// STEP 2: Register a global handler for UNCAUGHT exceptions// This is your last line of defence — anything not caught elsewhere lands here.// ─────────────────────────────────────────────set_exception_handler(function (Throwable $uncaughtException): void {
// In production: log to file/service, show generic error page// In development: show full details
$isDevelopment = (getenv('APP_ENV') === 'development');
$logEntry = sprintf(
"[UNCAUGHT] %s: %s in %s on line %d\nStack trace:\n%s",
get_class($uncaughtException),
$uncaughtException->getMessage(),
$uncaughtException->getFile(),
$uncaughtException->getLine(),
$uncaughtException->getTraceAsString()
);
// Simulate logging (in real app: $logger->critical($logEntry))echo"[LOG] " . $logEntry . "\n";
if ($isDevelopment) {
echo"\n[DEV] Exception chained from: ";
$previous = $uncaughtException->getPrevious();
echo $previous ? $previous->getMessage() : "(none)";
echo"\n";
} else {
echo"\n[USER] Something went wrong. Our team has been notified.\n";
}
// Always exit with a non-zero code so process managers know something failedexit(1);
});
// ─────────────────────────────────────────────// STEP 3: Demonstrate exception chaining in action// ─────────────────────────────────────────────functionloadConfigFile(string $filePath): array
{
// In real code this would be: file_get_contents(), json_decode(), etc.// We simulate the low-level failure:thrownewRuntimeException("File not found: $filePath");
}
functionbootApplication(): void
{
try {
$config = loadConfigFile('/etc/myapp/config.json');
} catch (RuntimeException $lowLevelError) {
// Wrap the low-level error in a domain-meaningful exception.// Pass $lowLevelError as $previous so the full chain is preserved.thrownewRuntimeException(
"Application failed to boot: configuration could not be loaded",
500,
$lowLevelError // <-- this is the chain link
);
}
}
// This exception is not caught anywhere — the global handler will catch itputs_("--- Triggering uncaught exception via exception chain ---\n");
function puts_(string $text): void { echo $text; } // helper for claritybootApplication(); // throws → global handler fires
Output
--- Triggering uncaught exception via exception chain ---
[LOG] [UNCAUGHT] RuntimeException: Application failed to boot: configuration could not be loaded in GlobalExceptionHandler.php on line 68
[USER] Something went wrong. Our team has been notified.
Interview Gold: Throwable vs Exception
In PHP 7+, Error and Exception both implement Throwable but are separate hierarchies. TypeError, ArithmeticError, and ParseError are all Error subclasses — they won't be caught by catch (Exception $e). Use catch (Throwable $e) only in global handlers or framework bootstrapping code, not in normal business logic. Interviewers love this distinction.
Production Insight
Uncaught Exceptions in production lead to blank HTTP 500 pages and no error log entry.
set_exception_handler converts that into a logged, sanitized error page.
Rule: always combine set_error_handler with set_exception_handler for full coverage.
Key Takeaway
set_exception_handler is your last line of defense — log the full trace, show a safe page, exit non-zero.
Error and Exception both implement Throwable but are separate trees.
Catch Throwable only in global scope — not in business logic.
Multiple catch Blocks, catch Union Types, and When to Re-throw
Catching the right exception at the right layer is the real skill. A database layer shouldn't know about HTTP status codes; a controller shouldn't know about SQL error codes. Each layer should catch what it can meaningfully handle and re-throw (or wrap and re-throw) everything else.
Re-throwing is done with a bare throw; inside a catch block — this preserves the original exception's stack trace, which is vital for debugging. If you throw $e; (with the variable), PHP resets the stack trace to the current line. Always use bare throw; when re-throwing.
PHP 8.0 introduced catch union types, letting you catch multiple unrelated exception types in a single block when you'd handle them identically. This keeps code DRY without forcing an artificial class hierarchy.
The golden rule: catch exceptions at the layer that has enough context to do something useful with them. If you can log it, recover from it, or convert it into something the next layer understands — catch it. If all you can do is re-throw it, let it bubble.
MultiCatchAndRethrow.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
<?php
classUserNotFoundExceptionextendsRuntimeException {}
classUserSuspendedExceptionextendsRuntimeException {
publicfunction__construct(string $username, privatereadonly \DateTimeImmutable $suspendedUntil) {
parent::__construct("User '$username' is suspended until " . $suspendedUntil->format('Y-m-d'));
}
publicfunctiongetSuspendedUntil(): \DateTimeImmutable { return $this->suspendedUntil; }
}
classDatabaseUnavailableExceptionextendsRuntimeException {}
classInvalidCredentialsExceptionextendsRuntimeException {}
/**
* Simulates an authentication service with multiple failure modes.
*/
functionauthenticateUser(string $username, string $password): string
{
// Simulate different failure scenarios based on usernamereturnmatch($username) {
'ghost' => thrownewUserNotFoundException("No account found for '$username'"),
'banned' => thrownewUserSuspendedException('banned', new \DateTimeImmutable('+7 days')),
'wrongpass' => thrownewInvalidCredentialsException("Password incorrect for '$username'"),
'dbdown' => thrownewDatabaseUnavailableException("Auth DB unreachable"),
default => "session_token_" . md5($username . time())
};
}
/**
* Handles login — catches what it can recover from, re-throws what it can't.
*/
functionhandleLoginRequest(string $username, string $password): void
{
try {
$sessionToken = authenticateUser($username, $password);
echo"[Auth] Login successful. Token: $sessionToken\n";
} catch (UserSuspendedException $e) {
// We have specific context — we can give a targeted message
$until = $e->getSuspendedUntil()->format('d M Y');
echo"[Auth] Account suspended until $until. Contact support@example.com\n";
} catch (UserNotFoundException | InvalidCredentialsException $e) {
// PHP 8 union catch: both failures get the same vague response// (intentionally vague — never tell attackers which one failed)echo"[Auth] Invalid username or password.\n";
} catch (DatabaseUnavailableException $e) {
// This layer can't fix a DB outage — re-throw so the framework// can log it and show a 503 pageecho"[Auth] DB unavailable — re-throwing for framework to handle\n";
throw; // bare throw — preserves original stack trace!
}
}
$testCases = [
['alice', 'correct_password'],
['ghost', 'any_password'],
['banned', 'any_password'],
['wrongpass', 'wrong_password'],
];
foreach ($testCases as [$username, $password]) {
echo"\n--- Login attempt: $username ---\n";
try {
handleLoginRequest($username, $password);
} catch (\Throwable $e) {
// Top-level catch for anything re-thrown (like DatabaseUnavailableException)echo"[FRAMEWORK] Unhandled error logged and 503 returned: " . $e->getMessage() . "\n";
}
}
// Test the DB-down case separatelyecho"\n--- Login attempt: dbdown ---\n";
try {
handleLoginRequest('dbdown', 'any');
} catch (\Throwable $e) {
echo"[FRAMEWORK] Unhandled error logged and 503 returned: " . $e->getMessage() . "\n";
}
[Auth] Account suspended until 15 Jul 2025. Contact support@example.com
--- Login attempt: wrongpass ---
[Auth] Invalid username or password.
--- Login attempt: dbdown ---
[Auth] DB unavailable — re-throwing for framework to handle
[FRAMEWORK] Unhandled error logged and 503 returned: Auth DB unreachable
Pro Tip: throw as an expression (PHP 8+)
PHP 8.0 made throw an expression, so you can use it in arrow functions, ternaries, and match arms: $value = $input ?? throw new InvalidArgumentException('Input required');. This is especially clean for guard clauses at the top of functions — replaces the old if (!$input) { throw ... } pattern with a single, readable line.
Production Insight
A bare throw; inside catch preserves the original stack trace — throw $e; resets it to the current line.
This wasted our team 2 hours once — trace pointed to the rethrow line, not the actual failure.
Rule: always use bare throw; when re-throwing the same exception.
Key Takeaway
Union catch reduces duplication for same-handling exceptions.
Bare throw preserves original stack trace.
Catch at the layer that has context — let everything else bubble.
Exception Handling Best Practices and Design Patterns in PHP
You've seen the mechanics. Now here's the philosophy that separates robust PHP apps from fragile ones.
Catch at the right layer. The rule: if you can log it, recover, or convert it, catch it. Otherwise let it bubble. A repository layer should catch PDO exceptions and throw a domain-level UserRepositoryException. A controller should catch UserNotFoundException and return a 404 response. Never catch an exception just to re-throw it unchanged — that's noise.
Use try-finally without catch. Sometimes you don't want to handle the exception, you just want to guarantee cleanup. The pattern is:
``php $db = $this->getConnection(); try { $db->beginTransaction(); // do stuff $db->commit(); } finally { $db->close(); } ` If an exception escapes, close()` still runs. No catch needed.
Log exceptions exactly once. Double logging — once in a catch and again in a global handler — corrupts alerting. If you catch it, log it. If you re-throw, don't log it — let the upstream handler log it.
Don't suppress exceptions for 'clean' return paths. Returning false or null when something actually went wrong forces callers to check every return value — exactly the pattern exceptions were designed to replace. Throw instead.
Prefer specific exception types over generic. A ValidationException is more useful than a plain InvalidArgumentException because it can carry field-level error details.
Consider a Result object pattern for expected failures. If a failure is part of normal flow (like 'user not found' in a search), returning a Result object with a isSuccess() method can be cleaner than throwing. Save exceptions for truly unexpected conditions.
TransactionWithFinally.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
<?php
interfaceConnectionInterface {
publicfunctionbeginTransaction(): void;
publicfunctioncommit(): void;
publicfunctionrollback(): void;
publicfunctionclose(): void;
}
classDatabaseConnectionimplementsConnectionInterface {
publicfunctionbeginTransaction(): void {
echo"[DB] Begin transaction\n";
}
publicfunctioncommit(): void {
echo"[DB] Commit\n";
}
publicfunctionrollback(): void {
echo"[DB] Rollback\n";
}
publicfunctionclose(): void {
echo"[DB] Close connection\n";
}
}
functiontransferFunds(ConnectionInterface $db, int $from, int $to, float $amount): void
{
$db->beginTransaction();
try {
// simulate debit/credit operationsif ($amount <= 0) {
thrownew \InvalidArgumentException("Transfer amount must be positive");
}
echo"[Transfer] Debit $amount from account $from\n";
echo"[Transfer] Credit $amount to account $to\n";
$db->commit();
} catch (\Throwable $e) {
// Only catch here if you can recover or need to wrap
$db->rollback();
// Wrap in domain exceptionthrownew \RuntimeException("Transfer failed: " . $e->getMessage(), 0, $e);
} finally {
$db->close(); // Always close
}
}
$db = newDatabaseConnection();
try {
transferFunds($db, 1001, 1002, 50.0);
} catch (\Throwable $e) {
echo"[App] Error: " . $e->getMessage() . "\n";
}
echo"\n=== Test invalid amount ===\n";
try {
transferFunds($db, 1001, 1002, -10.0);
} catch (\Throwable $e) {
echo"[App] Error: " . $e->getMessage() . "\n";
}
Output
[DB] Begin transaction
[Transfer] Debit 50 from account 1001
[Transfer] Credit 50 to account 1002
[DB] Commit
[DB] Close connection
[App] Transfer complete
=== Test invalid amount ===
[DB] Begin transaction
[DB] Rollback
[DB] Close connection
[App] Error: Transfer failed: Transfer amount must be positive
The Layer Cake Model of Exception Handling
Low-level (DB, filesystem): throw raw exceptions (PDOException, RuntimeException).
Service/repository: wrap low-level exceptions into domain-specific ones (UserNotFoundException).
Controller: catch domain exceptions and translate to HTTP responses (404, 500).
Global handler: catch any uncaught Throwable -> log and return a generic error page.
Rule: never let a raw DB exception escape to the controller.
Production Insight
Double-logging exceptions corrupts monitoring dashboards and drowns real signals.
Use a single logging point (global handler) for all uncaught exceptions.
If you catch and wrap, log at the wrapper level only.
Key Takeaway
Catch at the layer that has context — rollback DB transactions in a catch, wrap in domain exception.
Use try-finally for cleanup without catching.
Log exceptions exactly once.
● Production incidentPOST-MORTEMseverity: high
The Silent Data Corruption: When an Uncaught TypeError Update the Wrong Row
Symptom
User profile updates would sometimes randomly replace fields with null values. Reports showed inconsistent data, but no errors in logs.
Assumption
The team assumed the problem was a race condition or database corruption.
Root cause
The update code called a function with a wrong argument type (string instead of int). PHP 7 threw a TypeError, but the global handler only caught \Exception. The TypeError bubbled past and caused the DB transaction to commit halfway through, leaving nulls in the column.
Fix
Changed all global exception handlers to catch \Throwable instead of \Exception. Also added strict types declaration and type validation before DB writes.
Key lesson
In PHP 7+, always catch Throwable in global handlers — never rely on Exception alone.
Partial commits are far more dangerous than a full failure. Use transactions and rollback on any uncaught exception.
Type errors are not edge cases — they are indicators of deeper code issues.
Production debug guideSymptom → action table for the most common exception-related failures4 entries
Symptom · 01
Blank white page or HTTP 500 with no log entry
→
Fix
Check if display_errors is Off and no exception handler is set. Add a global try/catch around the bootstrap and log with error_log().
Symptom · 02
Exception caught but stack trace points to throw statement, not the real cause
→
Fix
Check if you used throw $e; instead of bare throw;. Bare 'throw;' preserves original trace. Rewrite to bare throw.
Symptom · 03
Custom exception message does not appear in logs but generic message does
→
Fix
Ensure the exception handler calls getMessage() on the Throwable object, not hardcoded text. Test with a mock exception.
Symptom · 04
A catch block silently swallows an exception; downstream code receives wrong input
→
Fix
Search for empty catch blocks. Every catch must at minimum log the exception. Use monolog or error_log for visibility.
★ Quick Debug Cheat Sheet for PHP ExceptionsWhen something breaks right now, use these targeted commands and fixes to recover fast.
Uncaught exception in production−
Immediate action
Check php error log and also enable display_errors temporarily with ini_set('display_errors', 1) in a debug endpoint.
Inspect cleanup code for operations that could throw (e.g., close() on a failed resource).
Fix now
Never let an exception escape from finally. Wrap it and re-throw the original (not the finally one) if needed.
Exception vs Error in PHP 7+
Aspect
Exception (extends Exception)
Error (extends Error)
Hierarchy root
Exception implements Throwable
Error implements Throwable
Typical source
Application/library logic
PHP engine / type system
Common examples
RuntimeException, InvalidArgumentException
TypeError, DivisionByZeroError, ParseError
Caught by catch(Exception $e)
Yes
No — will bubble past
Caught by catch(Throwable $e)
Yes
Yes
When to catch in business code
Frequently — expected failures
Rarely — usually indicates a bug
Introduced
PHP 5
PHP 7 (Error hierarchy restructured)
Key takeaways
1
The finally block runs unconditionally
even if catch re-throws. Use it for cleanup (closing connections, releasing locks) that must happen regardless of success or failure.
2
Build a custom exception hierarchy for every domain module. A PaymentException base with specific subclasses means callers can handle all payment errors uniformly or surgically handle specific failure types.
3
Always pass the original exception as $previous when wrapping it in a new exception
throw new DomainException('...', 0, $originalException). This preserves the full chain for debugging without leaking low-level details to callers.
4
In PHP 7+, Error and Exception are separate hierarchies both implementing Throwable. catch (Exception $e) won't catch a TypeError. Use catch (Throwable $e) only in global handlers
not scattered through business logic.
Common mistakes to avoid
3 patterns
×
Catching \Exception (or \Throwable) everywhere as a lazy catch-all
Symptom
Real bugs like TypeErrors and programming mistakes get silently swallowed. Logs show nothing, and the app returns incorrect data instead of failing loudly.
Fix
Catch the most specific exception type you can. Only catch \Throwable in global handlers or framework bootstrapping, and always log the full stack trace there.
×
Using throw $e; instead of bare throw; when re-throwing
Symptom
The stack trace in your logs shows the re-throw line as the origin of the exception, making it look like the error started somewhere other than where it actually did. You waste 20 minutes debugging the wrong file.
Fix
Always use bare throw; inside a catch block to re-throw the current exception with its original stack trace intact.
×
Swallowing exceptions inside finally blocks
Symptom
Cleanup code inside finally that itself throws an exception — this silently discards the original exception that triggered the finally block, making root-cause analysis nearly impossible.
Fix
Wrap cleanup code inside finally in its own try/catch, log any cleanup failures separately, and never let an exception escape from finally unless you explicitly intend to replace the original.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
What is the difference between Error and Exception in PHP 7+, and when w...
Q02SENIOR
If you catch an exception and want to re-throw it, what is the differenc...
Q03SENIOR
A junior developer puts a `return` statement inside a `finally` block. W...
Q01 of 03SENIOR
What is the difference between Error and Exception in PHP 7+, and when would you catch Throwable instead of Exception?
ANSWER
In PHP 7+, Error and Exception are separate hierarchies that both implement Throwable. Error represents engine-level problems like TypeError, ParseError, and DivisionByZeroError. A catch (Exception $e) block will not catch an Error. You need catch (Throwable $e) or a specific catch (Error $e) to catch engine errors. In production, global exception handlers should catch Throwable to ensure no failure goes unhandled. In business logic, prefer catching specific exception types or Exception to avoid masking serious engine bugs.
Q02 of 03SENIOR
If you catch an exception and want to re-throw it, what is the difference between `throw;` and `throw $e;`, and why does it matter?
ANSWER
throw; with no expression re-throws the current exception and preserves the original stack trace. throw $e; throws the same object but resets the stack trace to the current line, making debugging harder because it looks like the error originated at the re-throw point. Always use bare throw; when re-throwing the same exception. The only time you should use throw $e; is if you have modified $e (e.g., added extra context via a wrapper).
Q03 of 03SENIOR
A junior developer puts a `return` statement inside a `finally` block. What happens to an exception that was being propagated through that method, and why is this considered dangerous?
ANSWER
In PHP, a return inside finally will suppress any uncaught exception that is currently propagating. The exception is discarded and the return value is returned to the caller. This is extremely dangerous because it silently swallows errors that should have been caught or logged. The only valid use case for return in finally is when you are certain no exception will ever be thrown in the corresponding try block — and even then it's poor practice. The finally block should only contain cleanup code, never control flow.
01
What is the difference between Error and Exception in PHP 7+, and when would you catch Throwable instead of Exception?
SENIOR
02
If you catch an exception and want to re-throw it, what is the difference between `throw;` and `throw $e;`, and why does it matter?
SENIOR
03
A junior developer puts a `return` statement inside a `finally` block. What happens to an exception that was being propagated through that method, and why is this considered dangerous?
SENIOR
FAQ · 3 QUESTIONS
Frequently Asked Questions
01
What is the difference between PHP exceptions and PHP errors?
In PHP 7+, traditional PHP errors (notices, warnings, fatals) are separate from exceptions. PHP 7 introduced the Error class hierarchy — TypeError, ParseError, DivisionByZeroError etc. — which are throwable like exceptions but don't extend Exception. Both implement Throwable. A catch (Exception $e) block won't catch an Error; you need catch (Throwable $e) or a specific catch (Error $e) to handle engine-level errors.
Was this helpful?
02
Should I always catch exceptions or let them bubble up?
Catch an exception only at the layer that has enough context to do something useful with it — log it, recover from it, or convert it into a more meaningful type. If you'd catch it just to re-throw it unchanged, let it bubble naturally. Catching too eagerly hides bugs; catching too late means unhelpful error messages for users. A global exception handler (set_exception_handler) should always be your final safety net.
Was this helpful?
03
Can I use exceptions for normal control flow in PHP?
You technically can, but you shouldn't. Exceptions carry significant overhead because PHP captures a full stack trace when they're created. More importantly, using exceptions for expected conditional logic (like 'no results found') violates the principle of least surprise — exceptions should signal genuinely exceptional, unexpected conditions. For expected outcomes like empty results, return a null, empty array, or a dedicated result object instead.