All articles
PHPError HandlingBackendBest PracticesLaravel

PHP Exception Handling: What try-catch Actually Means

PKParth Kanasagra
8 min read

The most common exception handling pattern I see in code reviews looks like this:

try {
    // ... some code
} catch (\Exception $e) {
    return response()->json(['error' => 'Something went wrong'], 500);
}

This catches everything, tells you nothing about what went wrong, gives the user a useless message, and makes debugging nearly impossible. Here is what you should do instead.

PHP Exception Hierarchy

Throwable (interface)
├── Error (PHP internal — out of memory, type errors, parse errors)
│   ├── TypeError
│   ├── ValueError
│   └── ArithmeticError
└── Exception (application-level)
    ├── RuntimeException
    ├── LogicException
    │   └── InvalidArgumentException
    └── (Your custom exceptions)

When you catch \Exception, you catch application exceptions but not PHP Errors. When you catch \Throwable, you catch everything. The difference matters — silently catching a TypeError can hide a real bug.

Catch Specific, Not General

// Too broad — catches TypeErrors, database errors, everything
try {
    $user = User::findOrFail($id);
} catch (\Exception $e) {
    return response()->json(['error' => 'Not found'], 404);
}

// Correct
try {
    $user = User::findOrFail($id);
} catch (ModelNotFoundException $e) {
    return response()->json(['error' => 'User not found'], 404);
}

Custom Exceptions Are Self-Documenting

class InsufficientFundsException extends RuntimeException
{
    public function __construct(
        private readonly float $required,
        private readonly float $available,
    ) {
        parent::__construct(
            "Insufficient funds: required {$required}, available {$available}"
        );
    }

    public function getRequired(): float { return $this->required; }
    public function getAvailable(): float { return $this->available; }
}

// Usage — self-documenting at the call site
if ($wallet->balance < $amount) {
    throw new InsufficientFundsException($amount, $wallet->balance);
}

Global Exception Handling in Laravel

->withExceptions(function (Exceptions $exceptions) {

    $exceptions->report(function (InsufficientFundsException $e) {
        Log::channel('payments')->error('Insufficient funds', [
            'required'  => $e->getRequired(),
            'available' => $e->getAvailable(),
            'user_id'   => auth()->id(),
        ]);
    });

    // Clean JSON errors for API routes
    $exceptions->render(function (\Throwable $e, Request $request) {
        if ($request->expectsJson()) {
            return response()->json([
                'message' => $e instanceof HttpException
                    ? $e->getMessage()
                    : 'An unexpected error occurred.',
            ], $e instanceof HttpException ? $e->getStatusCode() : 500);
        }
    });
})

The finally Block: Often Missed

$lock = Cache::lock('payment_' . $order->id, 30);

try {
    $lock->block(10);
    $this->processPayment($order);
} catch (LockTimeoutException $e) {
    throw new PaymentConflictException('Another payment is in progress');
} finally {
    $lock->release(); // Always runs, even if exception was thrown
}

What to Do With Exceptions You Cannot Handle

  • Log with full context (via Sentry or your log aggregator)
  • Return a clean user-facing message: "Service temporarily unavailable"
  • Do NOT silently swallow: catch (\Exception $e) {} is one of the most dangerous things in PHP

Related Articles

PK

Parth Kanasagra

Certified Senior Laravel & PHP Developer · 9+ years experience · Available for hire