
How I Handle Errors and Failures in Laravel Systems
The payment went through. Stripe charged the card, the customer's bank approved it, the money moved. Then our response timed out before we wrote anything to the database. As far as our system was concerned, nothing had happened. As far as the customer's statement was concerned, they had paid. That gap, between what the outside world did and what our code recorded, is where production engineering actually lives. The card was charged. The order did not exist. Now what?
This is the twelfth post in my Laravel series. I have written about mistakes I stopped making, scalable application structure, performance in production, API design, authentication and authorization, abstractions, third party integrations, production debugging, scalable database schema design, long term maintainability, and queues and background jobs. This one is about what happens when things break, and why error handling and resilience are not the same subject.
Table of Contents
The Real Problem
Most Laravel developers think error handling is a chapter about exceptions. Wrap the risky call in a try block, catch the failure, log it, return something polite. That is the version taught in tutorials, and it is the version most codebases ship.
In production it is the wrong frame. The interesting question is almost never "what failed?" It is "what happens next?" A try block tells you a call threw. It tells you nothing about whether the user can still check out, whether the money is safe, whether the next retry will make things better or worse, or whether anyone will find out before a customer does.
Systems are written around the success path. A request comes in, every dependency answers, every row writes, a clean response goes out. That path gets the design review, the demo, and the test coverage. Then the system goes live and spends the rest of its life on the failure paths instead. The database has a slow node. Redis restarts. Stripe returns 500s for twenty minutes. A webhook arrives an hour late and out of order. None of that is exceptional. At any real scale it is Tuesday.
Teams confuse three things with resilience that are not resilience.
- Error handling is not resilience. Catching an exception only decides what your code does in the next line. It says nothing about recovery.
- Logging is not resilience. A log entry is a record that something broke. The customer is still stuck.
- Monitoring is not resilience. A dashboard turning red tells you the system is down. It does not keep it up.
Those three are necessary. None of them, alone or together, makes a system survive failure. A resilient system is one that expects failure and has already decided what happens next. The line I keep coming back to is the one I tell every engineer who joins the team. You are not building a system that does not fail. You are building a system that fails on purpose, in a way you chose.
Where Error Handling Goes Wrong
Almost every error-handling incident I have debugged traces back to one of these patterns. They look reasonable in a PR. They cause outages in production.
Catching Exception everywhere
The most common one. A broad catch (\Exception $e) wrapped around a whole method, swallowing everything from a payment decline to a fatal type error. The endpoint stays green, the bug stays hidden, and the only trace is a log line nobody reads. Catching too broadly is how a one-line coding mistake survives in production for six months.
Returning success while swallowing the error
A close cousin. The catch block logs the failure and then returns ['status' => 'ok'] anyway. The client believes the operation worked. The data says otherwise. This is the pattern behind "the UI said it saved but the record is gone" tickets, and it is almost impossible to debug because the system is actively lying about its own state.
Using try/catch instead of fixing the architecture
A call is unreliable, so it gets wrapped in a try block. Then the thing inside the try is unreliable too, so that gets its own try block. Three layers later the method is more error handling than logic, and the actual problem, a fragile synchronous dependency that should have been moved behind a queue or a boundary, is still there. A try block is a bandage. Sometimes the wound needs a different design.
Treating all failures the same
A declined card and a database outage are not the same event, but a single generic handler treats them identically. One is a normal business outcome the user needs to see and act on. The other is an infrastructure failure that should page an engineer. Collapse them together and you either page someone for declined cards or you stay silent through an outage.
Retrying permanent failures
A request fails with a 422 because the payload is malformed, and the retry logic dutifully sends the same malformed payload four more times. The input was never going to become valid. All the retry did was multiply the load and delay the real error. Retries assume the next attempt might succeed. A permanent failure breaks that assumption.
Not retrying transient failures
The opposite mistake. A momentary network blip or a database deadlock kills an operation that would have succeeded on the very next attempt, because nothing retries it. Transient failures are the one case where retrying is exactly right, and a surprising number of systems give up immediately on precisely the failures they should be patient with.
No alerting and no correlation IDs
When something does break, there is no page and no thread to pull. Logs exist, but a single user's failed request is scattered across a web log, three job logs, and an external API log with nothing tying them together. Debugging becomes archaeology. By the time the cause is found, the incident is hours old.
Leaking internals and misusing status codes
Two failures that travel together. A raw stack trace or SQL string leaks to the client, handing an attacker your schema and your file paths. And business-rule failures come back as 500s, so "your card was declined" looks identical to "our server is on fire". The client cannot tell a normal outcome from a real outage, and neither can your monitoring.
Exceptions as control flow
Throwing and catching to handle an expected branch, like "user already exists" or "cart is empty". Exceptions are expensive, they obscure the actual flow of the code, and they train the team to treat thrown errors as routine, which is exactly when a real one slips through unnoticed. Expected outcomes are return values, not exceptions.
How I Handle Errors Now
The shift that mattered for me was to stop thinking about catching errors and start thinking about classifying them. Once a failure is classified correctly, what to do next becomes obvious. Everything below follows from that one idea.
Expected vs unexpected failures
This is the single most useful distinction I draw, and almost everything else hangs off it.
Expected failures are normal outcomes of the business domain. They will happen every day, by design, no matter how perfect the code is.
- Validation errors
- Insufficient inventory
- Payment declined
- User not authorized
- Subscription expired
Unexpected failures are infrastructure or code breaking. They should be rare, and every one is a signal that something is wrong.
- Database outage
- Redis failure
- Stripe or another provider down
- Actual coding bugs and type errors
Expected failures are part of the user's flow. They get a clear message, a sensible status code, and no alert. Unexpected failures are part of the operator's flow. They get logged with full context, they page someone, and the user gets a generic apology that leaks nothing. Treating these two as the same thing is the root cause of most of the mistakes in the previous section.
Exception handling strategy
Laravel gives you one place to make the expected-versus-unexpected decision real: the exception handler. I lean on it heavily, and I back it with a small set of meaningful exception classes instead of throwing \Exception everywhere.
I split custom exceptions into two families. Domain exceptions represent expected business outcomes. Infrastructure exceptions represent dependencies failing. The names alone carry the classification.
// Domain (expected) - a normal business outcome
class PaymentDeclinedException extends DomainException {}
class InventoryUnavailableException extends DomainException {}
class SubscriptionExpiredException extends DomainException {}
// Infrastructure (unexpected) - a dependency broke
class PaymentGatewayUnavailableException extends RuntimeException {}
class SearchClusterUnavailableException extends RuntimeException {}A base domain exception can carry its own HTTP status and a safe, user-facing message, so the handler does not need to know about every subclass.
abstract class DomainException extends \Exception
{
public int $status = 422;
public function userMessage(): string
{
return $this->getMessage();
}
}
class PaymentDeclinedException extends DomainException
{
// 422, inherited. A decline is a business outcome, not "Payment Required".
public function __construct(public string $declineReason)
{
parent::__construct('Your payment was declined.');
}
} A note on that status code, because it is a common trap. The instinct is to reach for 402 Payment Required, and I shipped exactly that once. It is the wrong choice. 402 was reserved by the HTTP spec for a future digital-payment scheme that never standardized, so clients, proxies, and monitoring tools have no agreed meaning for it. A declined card is not a protocol-level "you must pay to proceed" signal. It is a well-formed request whose business precondition was not met, which is exactly what 422 Unprocessable Entity describes. 422 tells the client the request was understood and rejected on its contents, which is the truth, and your frontend already knows how to handle it. Reserve the 40x codes for what they actually mean.
Then the handler renders the two families differently. Domain exceptions become clean client responses. Everything else becomes a logged, alertable 500 with nothing leaked.
// bootstrap/app.php (Laravel 11+)
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(function (DomainException $e, $request) {
return response()->json([
'error' => class_basename($e),
'message' => $e->userMessage(),
], $e->status);
});
// Do not alert on expected outcomes.
$exceptions->dontReport(DomainException::class);
}) The payoff is that the rest of the system gets simple. A service method throws PaymentDeclinedException and stops thinking about HTTP. A controller does not need a try block for it at all. The classification happens once, in one place, and the meaning travels with the exception type instead of being re-derived at every call site.
Fail fast where it belongs
Bad input should die at the edge of the system, not three layers deep. If a request is malformed, the cheapest and safest thing is to reject it immediately, before it touches a queue, a payment provider, or a database write. The further bad data travels, the more expensive and confusing the eventual failure becomes.
This is also why retries must never be asked to fix bad input. A retry is for transient infrastructure trouble. Feeding it a permanently invalid payload just turns one fast rejection into five slow ones. Validate at the boundary, fail loudly there, and let only well-formed work travel deeper into the system.
Retries and recovery
Retrying is the most overused recovery tool, and the rule for it is one line: retry transient failures, never permanent ones. A timeout, a dropped connection, a deadlock, a 503 from an overloaded service are all worth another attempt. A 422, a validation failure, a "record not found" never will be. In a job, distinguish the two explicitly rather than letting Laravel retry everything blindly.
class SyncOrderToAccountingJob implements ShouldQueue
{
use Queueable;
public int $tries = 5;
public function backoff(): array
{
// Exponential backoff. Give the dependency room to recover.
return [10, 30, 60, 300, 900];
}
public function handle(Accounting $accounting): void
{
try {
$accounting->sync($this->orderId);
} catch (InvalidOrderPayloadException $e) {
// Permanent. Retrying will not make a bad payload valid.
$this->fail($e);
}
// Transient errors (timeouts, 503s) bubble up and retry with backoff.
}
} Exponential backoff matters because flat, immediate retries under load amplify the very problem that caused the first failure. Spacing attempts out gives the downstream system room to recover. And when retries are exhausted, the work does not vanish. It lands in Laravel's failed_jobs table, which is a dead letter queue, not an error log: work that did not complete, waiting for a human decision. Alert on its size, triage every entry, and replay or discard with a reason. Retries are one recovery tactic for a narrow class of failures, not resilience on their own.
Circuit breakers and dependency protection
If Stripe has been down for fifteen minutes, sending it more requests is not a strategy. Each retry adds load to a service that is already failing and ties up a worker waiting on a timeout. A circuit breaker handles this: after a threshold of consecutive failures you "open the circuit" and stop calling the dependency for a cooldown period. Calls during that window fail instantly and predictably instead of each one blocking on a thirty-second timeout, and after the cooldown you let a trickle through to test whether it has recovered.
class CircuitBreaker
{
public function __construct(
private string $service,
private int $threshold = 5,
private int $cooldown = 60,
) {}
public function isOpen(): bool
{
return Cache::get("circuit:{$this->service}:open", false);
}
public function recordFailure(): void
{
$failures = Cache::increment("circuit:{$this->service}:failures");
if ($failures >= $this->threshold) {
Cache::put("circuit:{$this->service}:open", true, $this->cooldown);
}
}
public function recordSuccess(): void
{
Cache::forget("circuit:{$this->service}:failures");
Cache::forget("circuit:{$this->service}:open");
}
}You do not always need a full breaker. Often plain throttling and backoff are enough: cap how many calls per minute you make to a flaky provider, and back off when it starts failing. The principle underneath all of it is the same. When a dependency is in trouble, protect it and yourself by doing less, not more. Temporary degradation beats a self-inflicted outage.
Transactions and partial failures
A database transaction is the right tool for one specific job: keeping several writes to your own database consistent. If creating an order means inserting the order, inserting its line items, and decrementing inventory, those three writes have to succeed or fail as a unit. Wrap them in a transaction and a failure halfway through rolls the whole thing back. No orders with no line items, no inventory decremented for a sale that never recorded.
DB::transaction(function () use ($cart) {
$order = Order::create($cart->toOrderAttributes());
$order->items()->createMany($cart->lineItems());
Inventory::decrementFor($order);
}); The trap is assuming that guarantee extends to anything outside the database. It does not. A transaction can only roll back what the database controls. A Stripe charge, a sent email, a webhook you fired, a file you wrote to S3: none of those are part of the transaction, and DB::rollBack() cannot undo them. The money is gone, the email is in the customer's inbox, and your database has cleanly rolled back to a state that says none of it happened.
Which is why putting an external call inside a transaction is one of the most expensive mistakes I see.
// Do not do this.
DB::transaction(function () use ($order) {
$order->update(['status' => 'paid']);
$this->stripe->charge($order); // network call inside the transaction
});Two ways this hurts. If the charge succeeds but a later statement in the transaction throws, the database rolls back and you have taken money for an order your system has no record of. And every charge now holds a database row lock open for the full round trip to Stripe, so a slow payment provider turns into database lock contention and a pile of "lock wait timeout" errors on completely unrelated requests.
The rule I follow: do the database work atomically, commit, then do the external work. Keep the transaction short and local, and move side effects to after the commit, ideally onto a queue.
$order = DB::transaction(fn () => $this->createOrder($cart));
// Transaction is committed. Now the external side effects, each retryable.
ChargeOrderJob::dispatch($order->id);Consistency across two systems is not a transaction problem, it is an idempotency and reconciliation problem. You make the external step safe to retry, record what actually happened, and have a way to reconcile when the two sides disagree. That is the same idempotency contract I covered in the queues and jobs post. Transactions protect your database. Nothing protects you from a half-finished distributed operation except designing for it.
Logging for failures
A log line is only worth writing if it helps you reconstruct what happened. This is the version I see far too often.
Log::error('Something failed');When that fires at 3 AM, it tells you nothing. Which user? Which order? Which dependency? What was the actual error? The log exists, and it is useless. Structured, contextual logging is the difference between a five-minute fix and a two-hour investigation.
Log::error('Order payment failed', [
'correlation_id' => $request->header('X-Correlation-Id'),
'user_id' => $user->id,
'order_id' => $order->id,
'provider' => 'stripe',
'decline_reason' => $e->declineReason,
'exception' => $e::class,
]);The correlation ID is the piece that changes everything. Generate one at the edge of the request, attach it to every log line, pass it into every job and every outbound call, and a single failed checkout becomes one searchable thread across the web request, the queued jobs, and the third-party calls. Without it you are correlating timestamps by hand. With it you are observing the system.
// Middleware: assign a correlation ID and bind it to the log context.
public function handle($request, Closure $next)
{
$id = $request->header('X-Correlation-Id') ?? (string) Str::uuid();
Log::shareContext(['correlation_id' => $id]);
$request->headers->set('X-Correlation-Id', $id);
return $next($request)->header('X-Correlation-Id', $id);
}The goal is not "we have logs". The goal is observability: being able to ask the system what happened to one specific request and get a straight answer.
Graceful degradation
The design question during a partial failure is which features are essential to the user's goal and which are not. The essential ones must work. The rest can degrade quietly.
- Email provider down? Complete the checkout and queue the receipt for later. A customer who paid does not care that the email is delayed by ten minutes.
- Analytics provider down? Create the order anyway. Tracking is for you, not the customer, and it must never block their purchase.
- Search cluster down? Fall back to a slower database query. Degraded search beats a blank page.
public function search(string $query): Collection
{
try {
return $this->elasticsearch->search($query);
} catch (SearchClusterUnavailableException $e) {
Log::warning('Search cluster down, falling back to database', [
'query' => $query,
]);
return Product::where('name', 'like', "%{$query}%")->limit(50)->get();
}
}The discipline is making that call ahead of time, dependency by dependency. The default when nobody decides is "the whole feature breaks", and that is almost never the behavior you actually want.
A Real Production Example
The clearest lesson I have on this came from a checkout flow that did everything inline, in order, synchronously.
public function checkout(CheckoutRequest $request)
{
$order = Order::create($request->validated());
$this->payments->charge($order); // charge the card
Mail::to($order->customer)->send(new ReceiptMail($order));
$this->crm->syncCustomer($order); // push to the CRM
$this->invoices->generate($order); // create the invoice
return response()->json(['status' => 'ok']);
}It read cleanly and it demoed perfectly. Then the CRM provider had an outage. Not our CRM, not our code, a third party we did not control. And because the CRM sync sat in the middle of the checkout method, the exception bubbled up and the entire checkout failed. Customers could not buy anything. We were losing real revenue because a marketing tool was down.
The worst part was the ordering. By the time the CRM call threw, the card had already been charged. So we had customers whose cards were charged, whose orders were half-created, and whose checkout returned a 500. The failure of the least important step was corrupting the most important one.
The fix was to ask the question I open this post with. What happens next? For each step, what is the correct behavior when it fails?
- Charging the card is essential and must succeed synchronously. If it fails, the whole checkout fails, and that is correct.
- The receipt email, CRM sync, and invoice are all things that must happen eventually but do not need to happen inside the request.
So the critical path stayed synchronous, and everything else moved behind the queue, where it could retry on its own schedule without touching the customer's experience.
public function checkout(CheckoutRequest $request)
{
$order = Order::create($request->validated());
// Essential and synchronous. If this throws, checkout fails - correctly.
$this->payments->charge($order);
$order->update(['status' => 'paid']);
// Important but not blocking. Each retries independently on failure.
SendReceiptJob::dispatch($order->id);
SyncCrmJob::dispatch($order->id);
GenerateInvoiceJob::dispatch($order->id);
return response()->json(['status' => 'ok', 'order_id' => $order->id]);
} When the CRM went down again a few months later, nothing customer-facing happened. The SyncCrmJob failed, backed off, retried, and eventually landed in failed_jobs for the ones that exhausted their attempts. We replayed those once the provider recovered. Checkout never noticed. Revenue never dipped.
The lesson was not "use queues", though that was the mechanism. The lesson was that the original code had never decided what happens when a non-critical step fails, so the default answer was "take down the whole checkout". Resilience was not a library we added. It was a decision we finally made on purpose. I wrote more about the queue mechanics of this exact pattern in the queues and jobs post.
What I Avoid Now
- Giant try/catch blocks that wrap a whole method and catch everything from a business outcome to a fatal bug.
- Swallowing exceptions, especially returning success while quietly logging a failure.
- Retrying blindly, with no distinction between transient and permanent failures.
- Logging without context. A message with no correlation ID, user ID, or resource ID is noise.
- Assuming third-party APIs are reliable. They are not, and your design has to account for their bad days.
- Treating queues as guaranteed delivery. Jobs fail, get stuck, and time out. Plan for it.
- Wrapping external calls in a database transaction, and expecting a rollback to undo a charge, an email, or a webhook.
- Exposing internal errors, stack traces, or SQL to users.
- Returning 500 for business-rule failures, so monitoring cannot tell a declined card from a real outage.
Key Principles I Follow
- Failures are normal. Design for them as a first-class part of the system, not an afterthought.
- Design recovery paths, not just error handlers. The question is always "what happens next?"
- Different failures need different responses. Expected and unexpected failures are two separate subjects.
- Retries need a strategy. Transient only, with backoff, and a dead letter destination for the rest.
- Logs are part of the system. Structured and correlated, or they are just disk usage.
- Protect the user experience first. A non-critical dependency must never take down a critical flow.
- Graceful degradation beats total failure. Decide in advance what each feature does when a dependency disappears.
Closing Thoughts
The goal was never to build a system that does not fail. That system does not exist. Networks drop, providers go down, disks fill, and code has bugs no review catches. Chasing zero failures is chasing a number you will never reach, and the pursuit makes the code more brittle, not less.
The real goal is a system that fails predictably, recovers safely, and stays understandable while it is on fire. When something breaks, you want to know within seconds, trace it to one request, see that the critical path held, and watch the non-critical work retry itself back to health. That is not the absence of failure. That is failure handled on purpose.
The sticky note version: resilience is not preventing failure, it is deciding what happens next before it happens. If you cannot say, for every dependency in your system, what the user experiences when it goes down, you do not have error handling yet. You have a list of places where the next outage will surprise you.
What failure taught you the most about building software?