PHP Exception Handling: What try-catch Actually Means
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