artisanplaybook .dev
ARTICLES / VALIDATION

validated() handed create() the keys you never validated

The protection you think validated() gives you depends on four conditions holding at once. Miss any of them and the raw payload flows through to your model untouched.

ON THIS PAGE

A customer who made themselves the owner

You find it in an audit query: an account where a member row carries role = 'owner' and nobody on your team granted it. The endpoint that created it accepts a nested payload and it does the thing everyone agrees you should do — it never touches $request->all().

public function store(StoreMemberRequest $request): JsonResponse
{
    $member = Member::create($request->validated()['member']);

    return response()->json($member, 201);
}

And the rules:

/**
 * @return array<string, mixed>
 */
public function rules(): array
{
    return [
        'member' => ['required', 'array'],
    ];
}

There is the hole. validated() returned the entire member array, every key of it, because nothing in those rules told the validator which keys were allowed.

$payload = ['items' => ['name' => 'Widget', 'price' => 10, 'is_admin' => true, 'role' => 'owner']];

Validator::make($payload, ['items' => 'array'])->validated();

What comes back:

array (
  'items' =>
  array (
    'name' => 'Widget',
    'price' => 10,
    'is_admin' => true,
    'role' => 'owner',
  ),
)

is_admin and role were never validated. They were never mentioned. They came back anyway, and create() took them.

validated() filters keys, not values

The mental model most of us carry is that validated() returns the data that passed validation. It is closer to say it returns the values at the keys that appear in your rules array.

foreach ($this->getRules() as $key => $rules) {
    $value = data_get($this->getData(), $key, $missingValue);

    if ($this->excludeUnvalidatedArrayKeys &&
        (in_array('array', $rules) || in_array('list', $rules)) &&
        $value !== null &&
        ! empty(preg_grep('/^'.preg_quote($key, '/').'\.+/', array_keys($this->getRules())))) {
        continue;
    }

    if ($value !== $missingValue) {
        Arr::set($results, $key, $value);
    }
}

For a scalar key this is exactly what you want. 'email' => 'required|email' puts email in the output and an unmentioned role is dropped on the floor, because there is no role key in the rules to iterate over.

An array key breaks the symmetry. The rule 'member' => 'array' names the key member, so data_get() pulls the whole array and Arr::set() writes the whole array. Every nested key rides along inside the one value you asked for. The continue in the middle is the guard against this, and it only fires when four things are true at once:

  1. excludeUnvalidatedArrayKeys is enabled.
  2. The key's rules contain array or list.
  3. The value is not null.
  4. At least one rule key matches member.* — a nested rule actually exists.

Condition 4 is the one that fails in real code. Declaring a field as an array without then validating anything inside it is a completely natural thing to write, and it is precisely the shape that leaks.

Add one nested rule and the guard engages:

Validator::make($payload, ['items' => 'array', 'items.name' => 'string'])->validated();

The filtered output:

array (
  'items' =>
  array (
    'name' => 'Widget',
  ),
)

Same payload, same array rule, one extra line — and is_admin and role are gone. The difference between a leak and no leak is whether you happened to validate a child key.

Two different defaults for the same flag

There is a second way to lose this protection, and it is nastier because the rules are correct when it happens.

The flag has a different default depending on which class you ask:

Factory::$excludeUnvalidatedArrayKeys   default = true
Validator::$excludeUnvalidatedArrayKeys default = false

The Validator class itself defaults to off. The safe default lives on the Factory, which copies it onto each instance inside make(). So protection is a property of going through the factory — which is what the Validator facade, $request->validate() and form requests all do.

Construct a validator directly and you opt out without writing anything that looks like opting out:

$direct = new Illuminate\Validation\Validator(
    app('translator'),
    $payload,
    ['items' => 'array', 'items.name' => 'string']
);

$direct->validated();

And the result:

array (
  'items' =>
  array (
    'name' => 'Widget',
    'price' => 10,
    'is_admin' => true,
    'role' => 'owner',
  ),
)

Those are the rules that were safe one section ago. The nested rule is present, condition 4 holds, and the data leaks regardless — because condition 1 now fails. If you have a service class, a custom rule pipeline or a test helper that news up a Validator, it is running without the guard.

The global escape hatch is Validator::includeUnvalidatedArrayKeys(). It produces the same leak for every validator in the application, and it exists for backwards compatibility. If you find it in a service provider, treat it as a finding.

Write the allow-list

The fix is to stop declaring a bare array and start declaring which keys are permitted. The array rule takes them as parameters:

'member' => ['required', 'array:name,email'],
'member.name' => ['required', 'string', 'max:255'],
'member.email' => ['required', 'email'],

Now an unexpected key does not get filtered out quietly — the request fails:

Validator::make($payload, ['items' => 'array:name,price'])->passes(); // false

Failing is better than filtering. A silently dropped field is a bug report about data not saving; a 422 tells the client exactly what it got wrong.

One wrinkle to budget for, because it will cost you ten minutes otherwise. The error message does not mention keys at all:

The items field must be an array.

It is an array. It was always an array. The message is reused for the type check and the key check, so when an allow-list rejects a payload you get a sentence that reads like a type error. Override it with a custom message if clients consume these responses, or you will field the confusion yourself.

Two more things worth knowing before you roll this out. The allow-list permits absent keys — array:name,price passes for a payload containing only price, because it constrains which keys may appear, not which must. Use required_array_keys or per-key required rules for that. And for lists of rows, the allow-list goes on the element, not the collection:

'rows' => ['array'],
'rows.*' => ['array:name'],
'rows.*.name' => ['required', 'string'],

What to change

Grep your rules for array declarations: grep -rn "'array'" app/Http/Requests/. Every hit is a candidate. For each one, check whether nested rules exist for that key, and then add the allow-list anyway — relying on condition 4 means your protection disappears the day someone deletes the last nested rule during a refactor.

Then grep for new Validator and for includeUnvalidatedArrayKeys. The first tells you where the guard is off by construction; the second tells you whether someone turned it off everywhere.

Do not let validated() be the only thing standing between a payload and your database. Keep $fillable or $guarded honest on the model, so that a rule you forgot is a second mistake rather than the only one — and know that $fillable has its own gaps, which the mass-assignment piece on this site covers. Two imperfect guards that fail in different places are worth more than one you trusted completely.

Related reading

ARCHIVE →