Exception design and validation — Form Requests and custom exceptions
The OrderController in chapter 10 wrote request validation inline with $request->validate([...]) and expressed insufficient stock as a RuntimeException.
This chapter designs both a little more carefully.
We look at how to gather validation rules into a Form Request class, and how to write custom exception classes that make intent clear.
Gathering validation into a Form Request
The controller in chapter 10 wrote validation directly inside the method.
public function store(Request $request)
{
$validated = $request->validate([
'items' => 'required|array',
'customer_email' => 'required|email',
]);
// ...
}
Extract those rules into a dedicated class called a Form Request.
class StoreOrderRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'items' => ['required', 'array'],
'items.*.name' => ['required', 'string'],
'items.*.price' => ['required', 'integer', 'min:0'],
'items.*.quantity' => ['required', 'integer', 'min:1'],
'customer_email' => ['required', 'email'],
];
}
}
In the controller, all you do is type-declare this class instead of Request.
public function __construct(
private readonly OrderPlacementService $orderPlacementService,
) {
}
public function store(StoreOrderRequest $request)
{
$order = $this->orderPlacementService->placeOrder(
$request->validated()['items'],
$request->validated()['customer_email'],
);
return response()->json($order, 201);
}
Type-declaring StoreOrderRequest makes Laravel run the validation automatically before the controller method executes.
If a check fails, a response with a 422 status and the error details is returned automatically1.
What rules() returns are checks about the shape of the request: required, type, range, existence.
Making intent clear with custom exception classes
The OrderPlacementService in chapter 10 threw a RuntimeException when stock was insufficient.
if (! $this->stockChecker->hasEnoughStock($items)) {
throw new RuntimeException('Not enough stock');
}
RuntimeException is a general-purpose exception class, and whoever catches it cannot tell what happened without reading the message string.
Define a dedicated exception class and the type itself carries the intent.
class InsufficientStockException extends RuntimeException
{
}
if (! $this->stockChecker->hasEnoughStock($items)) {
throw new InsufficientStockException('Not enough stock');
}
The caller can distinguish the kind of exception by type, as in catch (InsufficientStockException $e).
Throwing immediately once stock is known to be insufficient, and not going on to create the order or send mail, is a design called fail-fast2.
Cutting processing short the moment a condition is known to fail prevents work from continuing in an invalid state.
Converting exceptions into HTTP responses
The chapter 10 controller caught RuntimeException with try-catch and converted it into an HTTP response.
try {
$order = $this->orderPlacementService->placeOrder(/* ... */);
} catch (RuntimeException $e) {
return response()->json(['error' => $e->getMessage()], 422);
}
Writing the same conversion in every controller repeats the same code.
From Laravel 11 onward, you can register how exceptions are converted in one place, in withExceptions() in bootstrap/app.php3.
// bootstrap/app.php
return Application::configure(basePath: dirname(__DIR__))
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(function (InsufficientStockException $e) {
return response()->json(['error' => $e->getMessage()], 422);
});
})->create();
Insufficient stock is a business rule violation and differs in nature from a request shape check (the Form Request), but here we use 422 for both. The section "What this chapter does not cover" below says more about telling them apart.
With this registered, the controller no longer needs try-catch.
public function store(StoreOrderRequest $request)
{
$order = $this->orderPlacementService->placeOrder(
$request->validated()['items'],
$request->validated()['customer_email'],
);
return response()->json($order, 201);
}
What this chapter does not cover — where business rule validation belongs
A constraint like min:1 on items.*.quantity can be decided in a Form Request, but a constraint that looks at the current state of the database, such as a stock check, is a different matter.
That difference is why chapter 10 did the stock check inside OrderPlacementService rather than in a Form Request.
Detailed design guidance on where validation of business rules that require domain knowledge, such as a stock check, should live is beyond this guide's scope. Designing the presentation layer in A Practical Guide to Laravel × DDD × Clean Architecture covers the division of roles between Form Requests and the domain layer in detail, so see that if you want to design this properly.
Note that this chapter uses 422 for both shape checks and business rule violations, but there are designs that distinguish the two with different HTTP status codes (400 for business rule violations, for instance). That distinction is covered at the same reference.
Summary
- Gather validation rules into a Form Request class. Changing the type declaration in the controller is enough to get automatic checking before execution
- Defining a custom exception class instead of a general-purpose exception makes the type itself carry the intent
- Throwing as soon as a condition is known to fail is a design called fail-fast
- Conversion from exception to HTTP response can be gathered in one place, in
withExceptions()inbootstrap/app.php - Validating business rules that require domain knowledge is outside this guide's scope
What to read next
The next chapter puts every principle so far to work and refactors a Fat Controller and a Fat Model.
Exercises
For each of the following two checks, say whether it belongs in a Form Request or a custom exception, and why
- You want an error when the order has no order lines (
items) at all - You want an error when the order total exceeds the customer's daily purchase limit (a value stored in the database)
Sample answer
- Form Request: whether
itemsis an array with at least one entry is a shape check, expressible as'items' => ['required', 'array', 'min:1'] - Custom exception: a per-customer purchase limit is a business rule that cannot be decided without looking at the current state of the database. Checking it inside the service class and throwing a dedicated exception (say,
PurchaseLimitExceededException) is the right fit