Good PHP error handling is the difference between a five-minute fix and an hour of guessing at 3 AM. The page fires, you open the logs, and you get "Something went wrong" with no stack trace, no request ID, no payload — just a timestamp and a shrug. That is not an error-handling problem in the moment; it is a design decision you made months ago when you wrapped half the app in catch (\Throwable $e) {} and logged $e->getMessage() with no context. The fix is structural: throw exceptions that carry domain meaning, catch them as narrowly as possible, attach context at the boundary, and ship the whole thing to a tool that records the stack trace and the request — not a lonely log line.
Why does my error log say nothing useful?
Most useless logs come from two habits. The first is swallowing: a broad catch that logs a message and continues as if nothing happened, so the failure is invisible until something downstream breaks far from the cause. The second is context starvation: you log $e->getMessage() — a string like "Undefined array key" — without the order ID, the user ID, or the third-party response that triggered it. You have the symptom and none of the inputs.
PHP also draws a line that trips people up. Errors (TypeError, ValueError, the Error hierarchy) signal programmer mistakes — a wrong type, a call to a missing method. Exceptions (the Exception hierarchy) signal conditions your code should anticipate and handle — a payment gateway timing out, a record that does not exist. Both implement \Throwable, which is exactly why catch (\Throwable $e) is so tempting and so dangerous: it scoops up the runtime bug you wanted to crash on alongside the network blip you meant to retry, and treats them identically.
How do I write exceptions that carry domain meaning?
A generic \Exception tells the next reader nothing. A custom exception class names the failure and carries the data needed to act on it. When you catch PaymentDeclinedException, you know exactly what happened and you have the gateway code and order ID on the object — no string-parsing the message to figure out what went wrong.
<?php
namespace App\Exceptions;
use Exception;
use Throwable;
class PaymentDeclinedException extends Exception
{
public function __construct(
public readonly string $gatewayCode,
public readonly int $orderId,
string $message = 'Payment was declined by the gateway',
?Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
/** Context that travels with the exception into your logs. */
public function context(): array
{
return ['gateway_code' => $this->gatewayCode, 'order_id' => $this->orderId];
}
}Two things make this pay off. The readonly typed properties carry structured data, not a flattened string. And the context() method is special in Laravel: the framework's logger calls it automatically and merges the returned array into the log record, so the order ID and gateway code land in your structured output without you repeating yourself at every catch site. Always pass $previous when you wrap a lower-level exception — that preserves the original stack trace so you can see the cURL timeout underneath your domain exception.
How narrow should my catch blocks be?
As narrow as the failure you actually know how to handle. Catch the specific type, do something meaningful, and let everything else propagate to the global handler. The anti-pattern below is the one that produces 3 AM mysteries — it catches the network failure you meant to handle and the null-reference bug you did not, logs both as the same shrug, and returns success.
// DON'T: swallow everything, lose the cause, pretend it worked
try {
$this->charge($order);
} catch (\Throwable $e) {
Log::error('checkout failed: ' . $e->getMessage());
return true; // the bug is now invisible
}
// DO: catch the failure you can act on; rethrow with context as a domain exception
try {
$this->gateway->charge($order);
} catch (ConnectionException $e) {
// transient — let the caller decide to retry (see below)
throw $e;
} catch (GatewayRejectedException $e) {
// permanent — translate to a domain exception that carries the why
throw new PaymentDeclinedException(
gatewayCode: $e->gatewayCode,
orderId: $order->id,
previous: $e,
);
}The rule of thumb: never catch \Throwable except at a true boundary (a queue job, a console command, the framework's own handler) where the job is to log-and-fail-cleanly, not to make a decision. Everywhere else, catch the exact class. If you find yourself catching broadly to keep the request alive, that is a sign the failure should be handled one layer up — push it there. Service classes are easier to reason about when each one throws cleanly and lets the boundary decide; I go deeper on that in my notes on testable service classes in Laravel.
How does Laravel's exception handler actually work in Laravel 11 and 12?
Laravel 11 moved the exception handler out of app/Exceptions/Handler.php and into a closure in bootstrap/app.php. The two jobs are still report (decide what gets logged and where) and render (decide what the HTTP response looks like). You configure both in ->withExceptions().
<?php
use App\Exceptions\PaymentDeclinedException;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__))
->withExceptions(function (Exceptions $exceptions) {
// report(): runs for logging/telemetry. Returning false stops the
// default logging; returning nothing lets it through.
$exceptions->report(function (PaymentDeclinedException $e) {
// context() is merged in automatically; add request-scoped data here
});
// Stop logging noise you do not want paging you.
$exceptions->dontReport([
\App\Exceptions\IgnorableDomainException::class,
]);
// render(): control the HTTP response per exception type.
$exceptions->render(function (PaymentDeclinedException $e, Request $request) {
return response()->json([
'message' => 'Your payment could not be processed.',
'code' => $e->gatewayCode,
], 422); // semantic status, not a blanket 500
});
})->create();Returning the right HTTP status is half of good error handling. A declined payment is a 422, a missing record is a 404, an unauthenticated request is a 401 — not a generic 500 that tells your frontend and your monitoring nothing. If you would rather keep the logic on the exception itself, give the class its own report() and render() methods; Laravel detects and calls them automatically, no registration needed. That keeps each exception self-contained, which matters once you have dozens of them.
Why does APP_DEBUG=false matter so much in production?
With APP_DEBUG=true, Laravel renders the full exception page: stack trace, file paths, environment variables, often database queries with their bindings. That is perfect locally and a disclosure incident in production — it hands an attacker your directory layout, package versions, and sometimes credentials. In production, APP_DEBUG must be false so users see a generic error page while you still capture the full detail in your logging backend. This is the single most common Laravel misconfiguration I find, and it is non-negotiable — it sits at the top of my Laravel security checklist.
An exception you swallow silently is a bug you have agreed to debug later, at the worst possible time, with the least possible information.
How do I get structured logs instead of grep-bait?
Monolog (Laravel's logging engine) takes a context array as the second argument to every log call. Use it. The message stays a stable, searchable string; the variable data goes in the array so a JSON-formatted log driver emits queryable fields instead of a sentence you have to regex.
use Illuminate\Support\Facades\Log;
try {
$this->gateway->charge($order);
} catch (PaymentDeclinedException $e) {
// Stable message + structured context. Searchable, not a sentence.
Log::channel('payments')->warning('payment.declined', [
'order_id' => $order->id,
'user_id' => $order->user_id,
'gateway_code' => $e->gatewayCode,
'amount_cents' => $order->total_cents,
]);
throw $e; // still propagate — logging is not handling
}A few habits separate logs that help from logs that bury you:
- Keep the message a fixed token (payment.declined), not an interpolated string — that way every occurrence groups together and you can alert on the count.
- Put identifiers (order_id, user_id, request_id) in the context array so you can pivot from one failing record to every related event.
- Use channels (Log::channel('payments')) to route domains to their own log streams and severities, configured in config/logging.php.
- Pick the level honestly: warning for expected-but-notable (a decline), error for a broken invariant, critical for something that needs a human now. Do not log every caught exception at error — alert fatigue is how real incidents get ignored.
- Never log secrets, full card numbers, or raw tokens into the context array. Redact before it leaves the catch block.
The thing a log line cannot give you is the full picture: the stack trace, the request headers, the user, the release that introduced it. That is what error trackers like Sentry or Laravel's own Flare do — they capture the exception with its complete environment and group recurrences so you see "this started 40 minutes ago on the v2.3.1 deploy, 230 users hit it" instead of scrolling raw log files. Wire one in and your 3 AM page comes with a link straight to the breadcrumbs.
When should I retry an error, and when should I fail fast?
The deciding question is whether the failure is transient or permanent. A connection timeout, a 503 from a rate-limited API, a deadlocked transaction — those are transient: the same call may succeed seconds later, so retry with exponential backoff. A 401, a validation rejection, a malformed-input error — those are permanent: retrying just burns your rate limit and delays the inevitable, so fail fast and surface it. The cardinal sin is doing neither: catching the error and silently moving on, so the work never happens and nobody knows.
use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
// Retry transient failures with backoff; let permanent ones throw immediately.
$response = Http::retry(
times: 3,
// Pass a closure so the wait grows. A plain int waits a FLAT 200ms
// every attempt — it does not back off. $attempt is 1-based.
sleepMilliseconds: fn (int $attempt) => 200 * (2 ** ($attempt - 1)), // 200, 400, 800
when: function (\Throwable $e, $request) {
// Retry connection-level failures and 5xx responses. Never a 4xx.
return $e instanceof ConnectionException
|| ($e instanceof RequestException && $e->response->serverError());
},
throw: true, // after the last attempt, throw so the boundary handles it
)->get('https://api.supplier.test/stock');Two practical notes. Exponential backoff (each wait roughly double the last) keeps you from hammering an already-struggling service and tripping deeper rate limits — and note that retrying on 5xx only works when you let those responses throw, which throw: true does after the final attempt. Retries belong on idempotent operations — retrying a non-idempotent charge can double-bill a customer, so make the call idempotent (an idempotency key) before you make it retryable. For background work, lean on the queue: Laravel jobs have $tries and a backoff() method, so a failing job retries on a schedule and lands in failed_jobs after exhausting attempts instead of vanishing. I cover that lifecycle in detail in my piece on Laravel queue workers in production.
Error handling is not the try/catch you sprinkle in after a bug; it is a contract you design up front. Throw exceptions that name the failure and carry its data. Catch the exact type and let everything else rise to a boundary that knows how to log and respond. Set APP_DEBUG=false, log a stable message with a structured context array, and ship it to a tracker that keeps the stack trace and request. Retry what is transient with backoff, fail fast on what is permanent, and never — ever — swallow. Do this and the 3 AM page stops being an interrogation. You open the alert, the context is already there, and you are back in bed in five minutes instead of guessing for an hour.

