Skip to main content

Error design — translating exceptions into HTTP responses

So far we have returned errors ad hoc.

return response()->json(['message' => 'Not enough stock'], 409);

That works, but every additional place that returns an error drifts a little in shape. One place uses message, another error, a third an errors array. The client ends up implementing three ways of reading them.

This chapter settles on one shape and gathers the conversion into one place.

Deciding the shape of an error response​

Three things need deciding.

The HTTP status code was settled in chapter 4. The client uses it first to decide the broad category.

The body shape is settled here.

The shape this series uses
{
"message": "Product 3 does not have enough stock.",
"code": "INSUFFICIENT_STOCK",
"details": {
"product_id": 3,
"requested": 5,
"available": 2
}
}

The three keys have distinct roles.

KeyReaderPurpose
messageHumansWording that can go straight on screen
codeProgramsAn identifier to branch on. Unchanged when the wording changes
detailsProgramsExtra information needed to recover

Separating out code is the point. Branch on message and the client breaks the moment you change the wording from "Product 3 does not have enough stock." to "Sorry, this item is currently out of stock." A string for display and an identifier for decisions are different things.

With details, the client can do something intelligent. Knowing there are only 2 in stock, it can offer "you can buy 2."

Validation errors stay in the default shape​

As chapter 5 showed, the response for a failed validation has the shape Laravel provides.

{
"message": "The customer email field is required. (and 1 more error)",
"errors": {
"customer_email": ["The customer email field is required."]
}
}

You will be tempted to line it up with your own shape, but we recommend not changing it. Client-side libraries often already handle Laravel's standard shape, and aligning it makes them unusable.

Since both have message, the client can write one common step: read message first. Only for a 422 does it also look at errors.

Errors Laravel returns by default​

Before writing your own, take stock of what already comes back automatically.

SituationCodeWho returns it
Route model binding finds nothing404Laravel
An undefined URL404Laravel
Wrong method (POST to a GET route)405Laravel
Validation failure422Laravel
Rate limiting (chapter 13)429Laravel
An uncaught exception500Laravel

The bootstrap/app.php setting from chapter 2 is what makes it work.

bootstrap/app.php (the initial state)
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
);
})

For a request to api/*, JSON comes back rather than an HTML error page1. Thanks to that setting, 404s come back as JSON without you writing anything.

Creating a custom exception class​

Let us create the exception chapter 6 promised StockChecker would throw.

php artisan make:exception InsufficientStockException
app/Exceptions/InsufficientStockException.php
<?php

namespace App\Exceptions;

use Exception;

class InsufficientStockException extends Exception
{
public function __construct(
public readonly int $productId,
public readonly int $requested,
public readonly int $available,
) {
parent::__construct("Product {$productId} does not have enough stock.");
}
}

The constructor takes the information needed to recover. Carrying which product is short by how much is more useful later than simply throwing "not enough stock."

The public readonly int on the arguments is PHP 8 syntax doing two things at once: declaring the property in the constructor and making that property read-only. You do not have to write $this->productId = $productId;, and the value cannot be rewritten afterwards. The information an exception carries should be settled the moment it is thrown, so this form fits.

The throwing side looks like this.

app/Services/StockChecker.php (the form written in chapter 6)
public function assertEnough(array $items): void
{
$stocks = Stock::whereIn('product_id', array_column($items, 'product_id'))
->get()
->keyBy('product_id');

foreach ($items as $item) {
$available = $stocks->get($item['product_id'])?->quantity ?? 0;

if ($available < $item['quantity']) {
throw new InsufficientStockException(
productId: $item['product_id'],
requested: $item['quantity'],
available: $available,
);
}
}
}

Writing argument names such as productId: uses PHP 8's named arguments. new InsufficientStockException(3, 5, 2) also works, but three numbers in a row tell you nothing about which is which. With names, you notice when you get the order wrong.

Why an exception​

You could express it with return false or return ['error' => '...']. There are two reasons for choosing an exception.

The caller cannot ignore it. Forget to check the return value and processing continues with insufficient stock. With an exception, processing stops unless you catch it.

It passes through intermediate layers. With three layers — StockChecker → OrderPlacementService → controller — conveying it by return value means each layer has to receive it and return it again. An exception flies all the way to the top.

Translating in withExceptions​

The exception has been thrown. Now convert it into an HTTP response.

There are two places to write the conversion: the exception class itself, and bootstrap/app.php.

Writing it in the exception class​

Define a render method on the exception class and Laravel calls it automatically1.

app/Exceptions/InsufficientStockException.php
public function render(Request $request): Response
{
return response()->json([
'message' => $this->getMessage(),
'code' => 'INSUFFICIENT_STOCK',
'details' => [
'product_id' => $this->productId,
'requested' => $this->requested,
'available' => $this->available,
],
], 409);
}

The exception and the response are in the same file, which makes the correspondence easy to see.

Writing it in bootstrap/app.php​

The other way gathers the conversions in one place.

bootstrap/app.php
use App\Exceptions\InsufficientStockException;
use Illuminate\Http\Request;

->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
);

$exceptions->render(function (InsufficientStockException $e, Request $request) {
return response()->json([
'message' => $e->getMessage(),
'code' => 'INSUFFICIENT_STOCK',
'details' => [
'product_id' => $e->productId,
'requested' => $e->requested,
'available' => $e->available,
],
], 409);
});
})

The closure's argument type decides which exception it targets1. Type-declare InsufficientStockException and only that exception arrives here.

Which to choose​

This series gathers them in bootstrap/app.php.

The reason is that it makes the response shapes easy to keep aligned. Write render on each exception class and ten exception classes scatter ten pieces of JSON-building code. Gathered in one place, you can check on one screen that the key structure has not drifted.

Writing it on the exception class suits an exception with a complex conversion of its own. When there is processing like mapping an external service's error codes to your own, keeping it on the exception class stops bootstrap/app.php from swelling.

You can change the shape of built-in exceptions too​

The message Laravel returns for a 404 is its own default wording. If you want a different message across the whole API, that too can be converted1.

bootstrap/app.php
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

$exceptions->render(function (NotFoundHttpException $e, Request $request) {
if ($request->is('api/*')) {
return response()->json([
'message' => 'The requested resource was not found.',
'code' => 'NOT_FOUND',
], 404);
}
});

What goes to the log, and what does not​

When an exception occurs, a log entry is written separately from the response. That can be controlled too.

bootstrap/app.php
$exceptions->report(function (InsufficientStockException $e) {
return false;
});

Returning false means this exception does not flow to the default logging stack1. Using ->stop() has the same effect.

We do not log insufficient stock because it is an expected event. A user trying to order more than there is in stock is normal operation. Log it as an error and the exceptions you actually need to investigate get buried.

Here is a rule of thumb for the distinction.

KindExampleLog
ExpectedInsufficient stock, duplicate order, no permissionDo not log (or log at info)
UnexpectedDatabase connection failure, null reference, type errorLog (at error level)

"Returned an error response" does not mean "something went wrong." A 409 and a 422 are both the result of working correctly.

What this chapter does not cover​

  • Designing an exception hierarchy — creating a base class for business exceptions and building an inheritance tree by kind. It earns its keep once you have dozens of exceptions. Error handling covers it
  • The Result type pattern — expressing success and failure as types rather than throwing. Same guide
  • Where logs are sent — routing to files, Sentry, CloudWatch, and so on is covered in chapter 21

What matters in production​

Never ship to production with APP_DEBUG=true​

Chapter 2 mentioned this, but it is worth restating in the context of errors.

With APP_DEBUG=true, an uncaught exception puts the stack trace in the response: absolute file paths, the code being executed, and the values that were passed as arguments. If a database password was among the arguments to the connection code, that is visible too.

Always set it to false in production. With false, a 500 response returns only "Server Error."

Do not put the cause in a 500 body​

When an unexpected exception occurs, you will want to tell the client the cause. Do not.

❌ Bad: returning the exception message as is
$exceptions->render(function (Throwable $e, Request $request) {
return response()->json(['message' => $e->getMessage()], 500);
});

Exception messages contain internal information. A message like SQLSTATE[42S02]: Base table or view not found: 1146 Table 'shop.orders_backup' doesn't exist hands over the table and database names.

Return a tracking ID instead.

bootstrap/app.php
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;

$exceptions->render(function (Throwable $e, Request $request) {
// Leave exceptions Laravel already handles to Laravel
if ($e instanceof HttpExceptionInterface // 404 / 405 and so on
|| $e instanceof ValidationException // 422
|| $e instanceof AuthenticationException // 401
|| $e instanceof AuthorizationException) { // 403
return;
}

$errorId = (string) Str::uuid();

Log::error($e->getMessage(), ['error_id' => $errorId, 'exception' => $e]);

return response()->json([
'message' => 'Something went wrong on our side.',
'code' => 'INTERNAL_ERROR',
'error_id' => $errorId,
], 500);
});

When a user gets in touch, searching the logs for error_id leads you to the exception. You keep the ability to investigate without exposing internal information. Chapter 21 develops this shape further when it covers log design.

When you catch Throwable, always return early

A closure that type-declares Throwable matches every exception. ValidationException for a failed validation and NotFoundHttpException for a 404 both arrive here.

Delete the return; in the code above and validation errors that should come back as 422 become 500s. The official documentation states that if the closure returns no value, Laravel's default conversion is used, so for exceptions you want to leave alone, return nothing1.

One interface is not enough for the exclusion list. Write only HttpExceptionInterface and authentication errors (401) and authorization errors (403) slip through. AuthenticationException and AuthorizationException extend Exception directly and do not implement that interface.

Chapter 11 adds authentication and chapter 12 adds authorization. Forget the exclusions and every path that should return 401 or 403 there becomes a 500. And the response says "Something went wrong on our side." The recipient cannot tell whether the cause is an authentication setting or a real outage.

Where you can narrow the type, catching the specific exception type rather than Throwable is safer. Use Throwable only when you really want "every unexpected exception," as in this section, and state the exclusions explicitly, checking for gaps every time you add authentication or authorization.

Do not swallow exceptions​

❌ Bad: swallowing it
try {
$this->paymentGateway->charge($order);
} catch (Throwable $e) {
// Carry on regardless
}

This code treats an order as successful even when payment failed. Nobody notices the failure, and it surfaces later as "there are orders with no payment."

If you are going to catch, do it in a form that states why you caught it.

✅ Good: catching with intent
try {
$this->analytics->track('order_placed', $order->id);
} catch (Throwable $e) {
// A failed send to the analytics platform must not affect whether the order succeeds
Log::warning('Failed to send the analytics event', ['exception' => $e]);
}

There is a judgment that "this failing must not stop the main flow," and it is logged. The difference from payment is whether anyone is harmed when it fails.

The error shape cannot change after publishing​

The message / code / details structure is a contract clients depend on. The same asymmetry from chapters 4 and 7 applies. Adding a key does not break; removing or renaming one does.

The same goes for code values. Change INSUFFICIENT_STOCK to STOCK_SHORTAGE and clients branching on that value break. Decide the values at the start and leave them alone.

Summary​

  • Split the error body into message (for humans), code (for programs), and details (for recovery)
  • Do not make clients branch on message. They break the moment you change the wording
  • Keep validation 422s in Laravel's default shape. Do not align them to your own
  • A custom exception takes the information needed to recover in its constructor
  • Gather conversions in withExceptions in bootstrap/app.php. Drift in shape is visible on one screen
  • Do not log expected errors (insufficient stock, no permission). They bury the exceptions you need to investigate
  • Do not put the cause in a 500 body; return a tracking ID

The next chapter, Testing — pinning down the API's contract with Pest, writes tests for the API built so far. It introduces how to write Pest and pins down both the happy path and the failure paths. Every chapter from 10 onward ends with a "Tests" section, so that chapter is the starting point for testing in this series.

Exercises​

The following error design leaves the client unable to branch safely. Point out the problem
$exceptions->render(function (InsufficientStockException $e) {
return response()->json([
'message' => "Product {$e->productId} does not have enough stock. {$e->available} left.",
], 409);
});

$exceptions->render(function (OrderAlreadyShippedException $e) {
return response()->json([
'message' => 'This order has already shipped and cannot be changed.',
], 409);
});

Sample answer

Both return 409 and carry only message. The client cannot tell them apart.

For insufficient stock you would want to offer "reduce the quantity and retry," but offering the same thing for an already-shipped error makes no sense. Telling them apart means inspecting the message string.

// This is what happens client-side
if (error.message.includes('does not have enough stock')) {
showQuantityAdjustment();
}

That breaks for two reasons. Change the wording and the condition stops matching. Change "does not have enough stock" to "is currently out of stock" and the branch dies. Localize and the same thing happens, since a translated message contains no such phrase.

Adding code solves it.

'code' => 'INSUFFICIENT_STOCK',
'code' => 'ORDER_ALREADY_SHIPPED',

And pass the remaining count for insufficient stock in details. Then nobody has to pull a number out of message with a regular expression.

How do you respond to a policy of "log every error"?

Sample answer

Partly agree, but it is unworkable as stated.

The concern behind the policy — wanting to avoid missing things — is valid. The problem is that logging everything buries what you actually need to investigate.

Consider logging insufficient stock at error level. On the day a popular product sells out, that error appears thousands of times. If three database connection errors happen the same day, finding them among thousands of log lines is hard. And if your monitoring has a threshold on error counts, insufficient stock alone crosses it, alerts keep firing, and eventually nobody looks at them.

The realistic middle ground is separating levels.

  • error — unexpected, needs action. Database connection failure, null reference, external API timeout
  • warning — expected, but you want to watch the frequency. A transient payment failure, processing that recovered on retry
  • info — kept as a record. Insufficient stock, permission errors

Framed as "separate where it goes" rather than "do not log it," you avoid both missing things and being unable to investigate. Wanting to know how often stock runs out is a legitimate request, and keeping it at info lets you aggregate it later.

Chapter 21 makes this routing concrete when it covers log design.


Footnotes​

  1. Source: Error Handling (Laravel documentation 13.x). On the render and report callbacks in withExceptions, the render and report methods on the exception class itself, deciding JSON responses with shouldRenderJsonWhen, and report returning false keeping it out of the default logging stack. ↩ ↩2 ↩3 ↩4 ↩5 ↩6