Laravel Validation: The Rules Almost Everybody Gets Wrong
Laravel Validation: The Rules Almost Everybody Gets Wrong

A support ticket arrives: a time entry in the database with a duration of minus 3,600 seconds, and a member’s total for the week showing as negative. The endpoint has validation. The validation passed. The rule said integer, and minus 3,600 is an integer.
Almost every validation bug we have shipped falls into one of four buckets: a rule that checks the type but not the range, a field that skipped validation entirely and reached the database through mass assignment, the wrong choice between required, nullable and sometimes, or validation quietly being asked to do authorisation’s job. Here is each of them.

Three questions, not one
Before any code, it is worth separating things that get squashed together in a controller.
- Validation asks whether the input is well-formed. Is
durationa positive integer under 24 hours? Isemailshaped like an email? This is about the request in isolation. - Authorisation asks whether this user may do this. Does project 41 belong to their organisation? Are they allowed to log time for somebody else? This needs the actor and the database.
- Business rules ask whether the action makes sense given current state. Is the sprint closed? Has the invoice already been paid? Is the member on approved leave for that date?
They fail differently and should be answered differently: 422 with field errors, 403 with nothing useful, and 409 or a domain error respectively. Mixing them produces the two bugs that matter — a permission check that leaks information through a validation message, and an authorisation hole hiding behind a rule that looks like a check.
Form Requests versus inline validation
Inline validation is not a code smell. For a small, single-purpose endpoint it is the right answer.
<?php
public function search(Request $request)
{
$data = $request->validate([
'q' => ['required', 'string', 'min:2', 'max:100'],
'page' => ['sometimes', 'integer', 'min:1'],
]);
return $this->search->run($data['q'], $data['page'] ?? 1);
}
Two fields, one call site, no authorisation to do, nothing worth testing separately. A Form Request here is ceremony.
It stops being the right answer at a predictable point, and the point is not the number of fields. It is when any of these become true.
- The same rules are needed in two places — store and update, or a web controller and an API controller.
- You need to transform input before validating it, such as stripping spaces from a phone number or parsing a date in the organisation’s timezone.
- There is authorisation attached to the action, and you want it decided before any rule runs.
- The rules depend on each other or on the database, so they need real methods rather than a long array.
- You want to test the rules without going through the controller.
<?php
class StoreTimeEntryRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', TimeEntry::class);
}
protected function prepareForValidation(): void
{
$this->merge([
'note' => trim((string) $this->input('note')),
]);
}
public function rules(): array
{
return [
'project_id' => ['required', 'integer', Rule::exists('projects', 'id')
->where('organization_id', $this->user()->organization_id)],
'started_at' => ['required', 'date', 'before_or_equal:now'],
'duration' => ['required', 'integer', 'min:60', 'max:86400'],
'note' => ['nullable', 'string', 'max:500'],
];
}
public function attributes(): array
{
return ['project_id' => 'project'];
}
}
Note the duration rule. integer alone is what let minus 3,600 through. min:60 and max:86400 are what actually express the intent: at least a minute, at most a day. Almost every numeric field in an application has a real range, and almost none of them have it written down.
A useful review question for any numeric or string rule: what is the largest and smallest value this field can legitimately hold? If the rule does not say, somebody will eventually find out for you.
The hooks worth knowing
prepareForValidation()— normalise before the rules see the data. Trimming, casing, turning an empty string into null.passedValidation()— derive values after the rules pass, such as computingended_atfrom a start and a duration.after()— cross-field checks that need every field validated first, added to the validator rather than fought into a rule string.attributes()andmessages()— the difference between “The project id field is required” and “Choose a project.”
validated() is the only array you should pass on
This is the single highest-value habit in the article, and it is one word.
<?php
// dangerous, no matter how good the rules are
TimeEntry::create($request->all());
// safe: only keys that had rules and passed them
TimeEntry::create($request->validated());
Validation checks the fields you named. It does not remove the ones you did not. $request->all() hands the model every key the client sent, including the ones you never thought about, and mass assignment does the rest.
<?php
// the request the client actually sent
{
"project_id": 41,
"duration": 3600,
"organization_id": 7, // not in your rules
"approved_at": "2026-09-16T10:00:00Z",
"billable_rate": 0
}
Every rule passes, because the three extra fields were never examined. With $request->all() and a permissive $fillable, a member has just written a time entry into somebody else’s organisation, pre-approved, at a rate of zero. This is not a theoretical attack; it is the most common way a well-validated Laravel application is exploited.
Two defences, and you want both, because each covers a failure of the other.
- Pass only
validated(). It returns exactly the keys that had rules and passed. A field with no rule is simply not in the array — that is the feature, not a limitation. - Keep
$fillablehonest. List the columns a user may set. Never putorganization_id,user_id,role,statusor anything money-related in it; set those in code where the value comes from the session, not the request.
<?php
$entry = TimeEntry::create([
...$request->safe()->only(['project_id', 'started_at', 'duration', 'note']),
'organization_id' => $request->user()->organization_id, // never from input
'user_id' => $request->user()->id,
]);
safe() gives you the validated data with only(), except() and merge() on top, which is handy when one Form Request serves an endpoint that writes to two tables. The tenant key and the actor come from the authenticated session, always. If a tenant id can arrive in a request body anywhere in your application, that is the bug to fix this week.

required, nullable and sometimes
These three are constantly mixed up, and the confusion produces a specific bug: a PATCH request that wipes fields the user did not send.
required— the key must be present and not empty. Missing is an error, null is an error, an empty string is an error.nullable— the key may hold null. It says nothing about presence. Without it, the other rules run against null and a rule likestringordatefails.sometimes— if the key is absent, skip every other rule for this field. If it is present, apply them all.
Put plainly: required is about presence, nullable is about the value, sometimes is about whether we look at all. They are not alternatives; they combine.
<?php
// Create: the client must supply a project, may omit a note
'project_id' => ['required', 'integer'],
'note' => ['nullable', 'string', 'max:500'],
// Update (PATCH): only touch what was sent
'project_id' => ['sometimes', 'required', 'integer'],
'note' => ['sometimes', 'nullable', 'string', 'max:500'],
sometimes|required reads like a contradiction and is exactly right for a partial update: if you send it, it must be valid and non-empty; if you do not send it, leave the existing value alone. Without sometimes, either the field is required on every partial update or, worse, nullable alone lets an absent field arrive as null and blank the column.
One more trap. An empty HTML form field arrives as an empty string, not null. Laravel’s ConvertEmptyStringsToNull middleware handles that for you by default, which is also why a rule set that works for a JSON API can behave differently for a form post. If you removed that middleware, add nullable everywhere it applies.

Nested arrays and files
Real payloads are not flat. Laravel validates nested structures with dot notation, and the wildcard does most of the work.
<?php
public function rules(): array
{
return [
'entries' => ['required', 'array', 'min:1', 'max:200'],
'entries.*.project_id' => ['required', 'integer', 'distinct'],
'entries.*.duration' => ['required', 'integer', 'min:60', 'max:86400'],
'entries.*.tags' => ['sometimes', 'array', 'max:10'],
'entries.*.tags.*' => ['string', 'max:30'],
'settings' => ['required', 'array:timezone,week_start'],
'settings.timezone' => ['required', 'timezone'],
];
}
Three details there are worth pointing at. The max:200 on the array itself is a denial-of-service control — without it a client can post fifty thousand entries and you will loop over all of them. array:timezone,week_start rejects any key you did not list, which is the array equivalent of validated(). And error keys come back as entries.3.duration, so a frontend can highlight the right row if you keep the order stable.
Files
<?php
'avatar' => ['required', 'file', 'image', 'mimes:jpg,jpeg,png,webp',
'max:2048', 'dimensions:max_width=4000,max_height=4000'],
'attachment' => ['required', 'file', 'mimetypes:application/pdf', 'max:10240'],
max on a file is in kilobytes, not bytes, and it is the mistake everyone makes once. mimes checks the type PHP guesses from the file contents, not the extension in the name, which is why it is worth more than it looks. It is still not a security boundary on its own — storage location, the filename you save under and how the file is served matter more, and we went through that in securing file uploads in PHP. Also remember that upload_max_filesize and post_max_size are enforced before your rules run; exceed post_max_size and the request arrives empty, so required fires with a message that explains nothing.
Custom rules, and when a closure is enough
A closure rule is right when the logic is used once and reads clearly inline.
<?php
'started_at' => ['required', 'date', function ($attribute, $value, $fail) {
if (Carbon::parse($value)->isFuture()) {
$fail('You cannot log time that has not happened yet.');
}
}],
Promote it to a rule object when any of three things is true: it is needed in more than one Form Request, it needs dependencies injected, or it deserves its own unit test. An OverlappingTimeEntry rule that checks whether a member already has time logged across that window is a good example of all three — the logic is fiddly, it is used by create and update, and getting it wrong double-bills a client.
Scoping unique and exists to the tenant
In a multi-tenant application the stock rules are subtly wrong by default.
<?php
// wrong: project names must be unique across every customer
'name' => ['required', 'string', Rule::unique('projects', 'name')],
// right: unique within this organisation, ignoring this record on update
'name' => ['required', 'string',
Rule::unique('projects', 'name')
->where('organization_id', $this->user()->organization_id)
->ignore($this->route('project'))],
The ignore() is what stops an update from failing against itself, and forgetting it produces the classic “I only changed the description and it says the name is taken” bug. The where() is what stops one customer from learning that another customer already uses a project name — a genuine information leak through a validation message.
Rules worth knowing exist
A handful of built-in rules replace code people routinely write by hand, and a few defaults are not what the name suggests.
emailalone is deliberately permissive.email:rfc,dnsalso checks that the domain has a record, which stops a typedgmial.comat the form instead of at the bounce.decimal:0,2is what you want for a rupee amount.numericaccepts1e5and0x1Ais not the only surprise it holds.Rule::enum(Status::class)beats a hand-writtenin:draft,sent,paidthat drifts out of step with the enum a month later.date_format:Y-m-dis stricter thandate, which happily accepts “next tuesday” and then stores something nobody expected.confirmedpairs a field withfield_confirmationautomatically;Password::min(10)->uncompromised()checks a password against the breach list without sending it anywhere.prohibited_ifandrequired_without_allexpress “one of these, not both” without anafter()closure.bailat the front of a rule list stops at the first failure for that field, which matters when a later rule is a database query.
Test the rules, not the controller
A Form Request is a class, so its rules can be tested without touching HTTP. In practice the higher-value test is still a feature test that posts a bad payload and asserts the field errors, because it also proves the route, the middleware and the response shape. Two or three of those per endpoint cover the rules that matter, and they are the tests that fail when somebody deletes a rule during a refactor.
<?php
$this->actingAs($member)
->postJson('/api/time-entries', ['project_id' => 41, 'duration' => -3600])
->assertStatus(422)
->assertJsonValidationErrors(['duration', 'started_at']);
Error messages a person can act on
A 422 is a conversation with a user, not a log line. Default messages are fine for developers and poor for everybody else.
- Name the field the way the screen does.
attributes()turns “The project id field is required” into “The project field is required”. Five lines, and it removes the most obvious sign that nobody read the form. - Say what to do, not what failed. “Duration must be between 1 minute and 24 hours” beats “The duration must be at least 60.” The user has no idea your field is in seconds.
- Keep the field key machine-readable. The frontend needs
errors.durationto mark the right input red. Never flatten the response to a single string. - Do not echo the value back into the page. An error message that prints what the user typed is a cross-site scripting hole waiting for a template that forgets to escape.
- Translate once, in language files. Messages scattered across twenty Form Requests cannot be changed consistently later.

Validation is not authorisation
This is the section worth re-reading, because the failure is invisible in testing and severe in production.
<?php
// This is a validation rule. It is not a permission check.
'project_id' => ['required', 'exists:projects,id'],
That rule confirms that a project with that id exists somewhere. It says nothing about whose project it is. A member of organisation 7 posting project_id: 812 passes validation and writes a time entry against a competitor’s project. This is the insecure direct object reference, and it usually arrives dressed as a validation rule that looked sufficient.
The scoped Rule::exists shown earlier fixes the immediate hole and is worth having, because it produces a clean 422 instead of an exception. It is still not the authorisation layer. Keep the two separate.
- Validation answers: is this input usable? It runs on the request, returns 422, and its messages are meant to be read.
- Authorisation answers: may this actor do this? It runs in a policy or gate, returns 403, and its messages should say nothing at all.
- The tenant boundary belongs in neither by accident. A global scope on the model, applied for every query, is the only version of this that survives a developer forgetting.
There is a second reason not to let validation carry authorisation: the two produce different information. A 422 saying “the selected project is invalid” and a 403 saying nothing are both correct answers, but only one of them tells an attacker whether id 812 exists. Decide deliberately which one an endpoint gives.
What validation cannot do at all
Some checks look like validation and cannot be done there safely, because the answer can change between the check and the write.
- Uniqueness under concurrency. Two simultaneous signups both pass
unique:users,emailand both insert. Only a unique index on the column actually prevents it; the rule exists to produce a nice message, not to guarantee anything. - State transitions. Whether an invoice may move from paid to draft is a domain rule that needs the current record, ideally inside the same transaction as the update.
- Quota and plan limits. “This plan allows five members” depends on a count that another request may change a millisecond later. Check it where you write, with a lock.
- Anything involving money precision. Validate the shape, then work in integer paise. A rule of
numericon a rupee amount accepts1e5, and floats will find you eventually.
On Monday morning
- Grep for
$request->all()and$request->input()passed intocreate,updateorfill. Replace each withvalidated()orsafe()->only([...]). This is an afternoon and it closes real holes. - Read your
$fillablearrays out loud. Any tenant key, foreign key to a user, role, status or price in there is a question worth answering today. - Check every numeric rule for a range.
integerwith nominis how a negative duration gets in. - Search for
exists:without a tenant scope. Each one is a potential cross-customer write. - Fix your PATCH endpoints. If a partial update can blank a field the client did not send, you need
sometimes. - Write one test that posts an unexpected field —
organization_idof another tenant — and assert it was ignored. It takes ten minutes and it never stops being true.
Good validation is not a long rules array. It is a short one that states the real range of every field, a controller that passes on only what was validated, and an authorisation layer sitting beside it doing its own job. Those three together are what keeps minus 3,600 out of the database.

