Senior 6 min · March 06, 2026

PHP Exception Handling — Uncaught TypeErrors Corrupt Data

An uncaught TypeError in PHP 7+ silently corrupts databases with partial commits.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • MySQL turns on exception handling via PHP's built-in SPL classes
  • Try wraps risky code; catch handles specific exception types; finally cleans up unconditionally
  • Custom exception hierarchies give domain-specific context for each failure
  • Performance overhead: idle try/catch adds ~0 overhead; throwing an exception costs ~1-5 µs
  • 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.
 */
function fetchUserById(int $userId, bool $simulateFailure = false): array
{
    // Pretend we opened a database connection here
    echo "[DB] Opening connection...\n";

    try {
        if ($simulateFailure) {
            // This is what happens when the DB is unreachable or the query fails
            throw new RuntimeException(
                "Could not fetch user #$userId: connection timed out after 30s"
            );
        }

        // Happy path: query succeeds
        echo "[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 them
        echo "[ERROR] Database error caught: " . $exception->getMessage() . "\n";

        // Return a safe default so the caller doesn't receive null unexpectedly
        return [];

    } finally {
        // This block ALWAYS runs — connection is always cleaned up
        // Even if catch() threw another exception, this still executes
        echo "[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
// ─────────────────────────────────────────────

/**
 * Base class for ALL payment-related errors in this system.
 * Catching this type handles any payment failure — useful at the controller layer.
 */
class PaymentException extends RuntimeException {}

/**
 * Thrown when the card processor declines the charge.
 * Carries extra context beyond a plain message.
 */
class PaymentDeclinedException extends PaymentException
{
    public function __construct(
        private readonly string $declineReason,
        private readonly 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);
    }

    public function getDeclineReason(): string { return $this->declineReason; }
    public function getCardLastFour(): string  { return $this->cardLastFour; }
}

/**
 * Thrown when we can't even reach the payment gateway — not a card problem.
 */
class PaymentGatewayUnavailableException extends PaymentException {}

// ─────────────────────────────────────────────
// SERVICE LAYER
// ─────────────────────────────────────────────

class PaymentService
{
    /**
     * Attempts to charge a card. Throws typed exceptions instead of returning
     * ambiguous booleans or null values that callers might forget to check.
     */
    public function chargeCard(string $cardLastFour, float $amountGbp): void
    {
        echo "[PaymentService] Attempting to charge £$amountGbp to card ending $cardLastFour\n";

        // Simulate: card ending 0000 always gets declined
        if ($cardLastFour === '0000') {
            throw new PaymentDeclinedException(
                declineReason: 'Insufficient funds',
                cardLastFour: $cardLastFour,
                processorCode: 1051
            );
        }

        // Simulate: card ending 9999 means gateway is down
        if ($cardLastFour === '9999') {
            throw new PaymentGatewayUnavailableException(
                "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
// ─────────────────────────────────────────────

function handleCheckout(string $cardLastFour, float $amount): void
{
    $paymentService = new PaymentService();

    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 feedback
        echo "[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 instead
        echo "[Checkout] Gateway down. Queuing payment for retry: " . $e->getMessage() . "\n";

    } catch (PaymentException $e) {
        // Fallback: catches any PaymentException subclass we didn't anticipate
        echo "[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] Charge successful. Reference: TXN-3F8A1C2D
[Checkout] Payment complete. Sending confirmation email.
=== Test 2: Declined card ===
[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.
Key Takeaway
Custom exceptions carry domain context — card last four, processor codes, retry strategies.
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 level
    if (!(error_reporting() & $severity)) {
        return false; // Let PHP handle it normally
    }
    throw new ErrorException($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 failed
    exit(1);
});

// ─────────────────────────────────────────────
// STEP 3: Demonstrate exception chaining in action
// ─────────────────────────────────────────────

function loadConfigFile(string $filePath): array
{
    // In real code this would be: file_get_contents(), json_decode(), etc.
    // We simulate the low-level failure:
    throw new RuntimeException("File not found: $filePath");
}

function bootApplication(): 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.
        throw new RuntimeException(
            "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 it
puts_("--- Triggering uncaught exception via exception chain ---\n");

function puts_(string $text): void { echo $text; } // helper for clarity

bootApplication(); // 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
Stack trace:
#0 GlobalExceptionHandler.php(78): bootApplication()
#1 {main}
[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

class UserNotFoundException extends RuntimeException {}
class UserSuspendedException extends RuntimeException {
    public function __construct(string $username, private readonly \DateTimeImmutable $suspendedUntil) {
        parent::__construct("User '$username' is suspended until " . $suspendedUntil->format('Y-m-d'));
    }
    public function getSuspendedUntil(): \DateTimeImmutable { return $this->suspendedUntil; }
}
class DatabaseUnavailableException extends RuntimeException {}
class InvalidCredentialsException extends RuntimeException {}

/**
 * Simulates an authentication service with multiple failure modes.
 */
function authenticateUser(string $username, string $password): string
{
    // Simulate different failure scenarios based on username
    return match($username) {
        'ghost'     => throw new UserNotFoundException("No account found for '$username'"),
        'banned'    => throw new UserSuspendedException('banned', new \DateTimeImmutable('+7 days')),
        'wrongpass' => throw new InvalidCredentialsException("Password incorrect for '$username'"),
        'dbdown'    => throw new DatabaseUnavailableException("Auth DB unreachable"),
        default     => "session_token_" . md5($username . time())
    };
}

/**
 * Handles login — catches what it can recover from, re-throws what it can't.
 */
function handleLoginRequest(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 page
        echo "[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 separately
echo "\n--- Login attempt: dbdown ---\n";
try {
    handleLoginRequest('dbdown', 'any');
} catch (\Throwable $e) {
    echo "[FRAMEWORK] Unhandled error logged and 503 returned: " . $e->getMessage() . "\n";
}
Output
--- Login attempt: alice ---
[Auth] Login successful. Token: session_token_3f8a1d2c4b7e9a5f
--- Login attempt: ghost ---
[Auth] Invalid username or password.
--- Login attempt: banned ---
[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

interface ConnectionInterface {
    public function beginTransaction(): void;
    public function commit(): void;
    public function rollback(): void;
    public function close(): void;
}

class DatabaseConnection implements ConnectionInterface {
    public function beginTransaction(): void {
        echo "[DB] Begin transaction\n";
    }
    public function commit(): void {
        echo "[DB] Commit\n";
    }
    public function rollback(): void {
        echo "[DB] Rollback\n";
    }
    public function close(): void {
        echo "[DB] Close connection\n";
    }
}

function transferFunds(ConnectionInterface $db, int $from, int $to, float $amount): void
{
    $db->beginTransaction();
    try {
        // simulate debit/credit operations
        if ($amount <= 0) {
            throw new \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 exception
        throw new \RuntimeException("Transfer failed: " . $e->getMessage(), 0, $e);
    } finally {
        $db->close();  // Always close
    }
}

$db = new DatabaseConnection();
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.
Commands
tail -f /var/log/php_errors.log | grep -i 'uncaught'
php -r 'set_error_handler(function($s,$m){throw new ErrorException($m,0,$s);}); include "path/to/bootstrap.php";'
Fix now
Register a global exception handler that logs full trace and returns a 500 error page immediately.
Catch block not firing+
Immediate action
Check the exception type — is it an Exception, Error, or Throwable? Verify catch signature matches.
Commands
get_class($e) inside catch prints the actual class. If not caught, add a catch(\Throwable $e) at top level.
php -r 'try { throw new \RuntimeException("test"); } catch (\Exception $e) { echo "caught Exception"; } catch (\Throwable $e) { echo "caught Throwable"; }'
Fix now
Change catch to a broader type (but not too broad) — e.g., catch (\RuntimeException) or catch specific subtype.
finally block code crashes and masks original exception+
Immediate action
Wrap finally block content in own try/catch. Log any finally exception separately.
Commands
error_log('Finally block exception: ' . $e->getMessage());
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+
AspectException (extends Exception)Error (extends Error)
Hierarchy rootException implements ThrowableError implements Throwable
Typical sourceApplication/library logicPHP engine / type system
Common examplesRuntimeException, InvalidArgumentExceptionTypeError, DivisionByZeroError, ParseError
Caught by catch(Exception $e)YesNo — will bubble past
Caught by catch(Throwable $e)YesYes
When to catch in business codeFrequently — expected failuresRarely — usually indicates a bug
IntroducedPHP 5PHP 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.
FAQ · 3 QUESTIONS

Frequently Asked Questions

01
What is the difference between PHP exceptions and PHP errors?
02
Should I always catch exceptions or let them bubble up?
03
Can I use exceptions for normal control flow in PHP?
🔥

That's Advanced PHP. Mark it forged?

6 min read · try the examples if you haven't

Previous
Composer and Autoloading in PHP
2 / 13 · Advanced PHP
Next
PHP Design Patterns