Two fields and three rules are enough for a license. Real requests get more complicated, and FormRequests have a place for each kind of complication. Knowing them is what keeps validation from creeping back into the controller.
Clean the Input First
Consumers send " Example.COM ". You want example.com, and you want the rules to judge the cleaned value. Laravel has done half of that before your code runs: its TrimStrings middleware trims every string in every request. The lowercasing is yours:
// app/Http/Requests/StoreLicenseRequest.php
protected function prepareForValidation(): void
{
if (is_string($this->domain)) {
$this->merge([
'domain' => Str::lower($this->domain),
]);
}
}
prepareForValidation() runs before the rules. Whatever it merges is what gets validated, and what validated() returns afterward. The is_string check matters: a consumer can send an array where you expected text, and the rules, not a type error, should be what rejects it. Normalizing here, once, means the controller, the driver, and the cache key all see the same string.
Arrays and Nested Fields
Chapter 18 adds an endpoint that creates several licenses at once. The rules reach into each element with *:
// app/Http/Requests/StoreLicenseBatchRequest.php
return [
'licenses' => ['required', 'array', 'min:1', 'max:100'],
'licenses.*.name' => ['required', 'string', 'max:100'],
'licenses.*.domain' => [
'required', 'string', 'max:100', 'distinct',
],
];
distinct rejects a request that lists the same domain twice. An error for the third element comes back keyed as licenses.2.domain, so the consumer knows exactly which item to fix. And the max:100 on the array itself is not optional: an array without a ceiling is an input whose cost the caller decides.
A Rule of Your Own
When a check isn’t in Laravel’s list, don’t write it inline. Give it a name:
php artisan make:rule RegistrableDomain
// app/Rules/RegistrableDomain.php
class RegistrableDomain implements ValidationRule
{
private const PATTERN = '/^([a-z0-9-]+\.)+[a-z]{2,}$/';
public function validate(
string $attribute,
mixed $value,
Closure $fail,
): void {
$valid = is_string($value)
&& preg_match(self::PATTERN, $value) === 1;
if (! $valid) {
$fail('validation.domain')->translate();
}
}
}
A rule object is reusable across requests, testable alone, and its message goes through the same language files as every built-in rule. Use it as new RegistrableDomain in the rules array. (Laravel documentation: Validation › Custom Validation Rules.)
Rules That Depend on Other Fields
Some fields are required only because of another. In Chapter 18 a consumer can be given a webhook, and a consumer with a webhook must also have a contact address, so that someone can be told when deliveries fail:
// app/Http/Requests/UpdateConsumerRequest.php, in rules()
'webhook_url' => ['nullable', 'url:https'],
'contact_email' => ['required_with:webhook_url', 'email'],
Chapter 18 adds two more rules to the first of those lines: a length limit, and a check that the address is public.
Laravel has a family of these: required_with, required_if, prohibited_unless, exclude_if, and Rule::when() for anything conditional. Reach for them before writing code. When a check really does need code, a FormRequest’s after() method returns closures that run after the field rules, whether or not those passed, and add their errors to the same 422. (Laravel documentation: Validation › Conditionally Adding Rules and Validation › Performing Additional Validation.)
Keep both for checks that are about the request being acceptable. If a check is really a business rule, one that can fail for reasons the consumer can’t fix by editing the request, throw an exception from the code that does the work, as Chapter 7 does. The test is: could the consumer correct it by sending something different? Then it is validation.
Stop at the First Failure, Sometimes
By default Laravel runs every rule and reports every failure, which is what a consumer wants: fix everything in one round. When a later rule is expensive (a database lookup, for example), put bail first on that field, and the rest are skipped once one fails. Chapter 5 gives consumers names that must be unique, with this rule:
// app/Http/Requests/StoreConsumerRequest.php, in rules()
'name' => [
'bail', 'required', 'string', 'max:100',
Rule::unique('consumers', 'name'),
],
The uniqueness query now runs only for values that were at least present, a string, and short enough.