artisanplaybook .dev
ARTICLES / VALIDATION

exclude_unless dropped the field and required never fired

exclude_if checks whether the condition field is even there. exclude_unless does not, and the difference is a single Arr::has call. The field disappears from validated() and no rule on it gets a say.

ON THIS PAGE

The price change that did not change the price

Support forwards a complaint: a customer edited their plan, set the rate to 12.50, saw a success banner, and got billed the old amount. You check the audit log. The request arrived with rate=12.5. The response was a 200. The rate column never moved.

The endpoint is four lines and none of them look capable of this:

public function update(UpdatePlanRequest $request, Plan $plan): RedirectResponse
{
    $plan->update($request->validated());

    return back()->with('status', 'Plan updated.');
}

The form request is the kind of thing you would approve in review without stopping:

public function rules(): array
{
    return [
        'name' => ['sometimes', 'string', 'max:255'],
        'mode' => ['sometimes', 'in:flat,tiered'],
        'rate' => ['exclude_unless:mode,tiered', 'required', 'numeric'],
    ];
}

The intent is readable: a rate only matters on tiered plans, so drop it when the plan is flat. And it works, right up until a client sends rate without sending mode. Here is that payload going through the rules above:

$rules = [
    'name' => ['sometimes', 'string', 'max:255'],
    'mode' => ['sometimes', 'in:flat,tiered'],
    'rate' => ['exclude_unless:mode,tiered', 'required', 'numeric'],
];

$validator = Validator::make(['name' => 'Pro', 'rate' => '12.5'], $rules);

What comes back from passes() and validated():

bool(true)
Array
(
    [name] => Pro
)

Validation passed. rate is gone. update() was handed an array with one key in it and did exactly what it was told. There is no error, no log line, and no 422 — the only evidence is a column that did not change.

exclude_if looks for the field; exclude_unless assumes it

The two rules read like mirror images, and they are not. This is validateExcludeIf:

public function validateExcludeIf($attribute, $value, $parameters)
{
    $this->requireParameterCount(2, $parameters, 'exclude_if');

    if (! Arr::has($this->data, $parameters[0])) {
        return true;
    }

    [$values, $other] = $this->parseDependentRuleParameters($parameters);

    return ! in_array($other, $values, is_bool($other) || is_null($other));
}

Note the guard. If the condition field is not present in the payload at all, the rule returns true — which here means "do not exclude" — and your field survives. Now validateExcludeUnless:

public function validateExcludeUnless($attribute, $value, $parameters)
{
    $this->requireParameterCount(2, $parameters, 'exclude_unless');

    [$values, $other] = $this->parseDependentRuleParameters($parameters);

    return in_array($other, $values, is_bool($other) || is_null($other));
}

No guard. A missing mode is fetched by Arr::get() as null, null is not in ['tiered'], so the rule returns false and the attribute is excluded. The asymmetry is three lines of code and it decides whether your data arrives.

Run the identical payload through both and the divergence is plain. With exclude_unless:mode,tiered and no mode, validated() is ['name' => 'Pro']. Switch one rule to exclude_if:mode,flat — logically equivalent for a two-value enum — and send the same thing:

bool(true)
Array
(
    [name] => Pro
    [rate] => 12.5
)

Same request, same intent, opposite outcome. Which rule name you reached for is now a load-bearing decision, and nothing in either name tells you that.

The required rule never got a say

Look again at that rule array. rate is marked required. In the failing case it is absent from validated() and validation still passed. That is not a conflict the validator resolves in favour of required — exclusion happens first and ends the chain for that attribute.

Which means every other rule you attached is also skipped. Send a rate that is not a number at all, with a mode that triggers exclusion:

$validator = Validator::make(
    ['mode' => 'flat', 'rate' => 'not-a-number'],
    ['mode' => ['required'], 'rate' => ['exclude_if:mode,flat', 'numeric']]
);

The errors and the validated data:

bool(true)
Array
(
)
Array
(
    [mode] => flat
)

numeric never ran. Garbage in the payload is not rejected, it is discarded, and those two outcomes look the same from the controller and completely different from the client's point of view. A client that sent a malformed value gets a 200 and the quiet assumption that you stored it.

Reaching for the other accessor does not help either. safe() is a view over the same filtered array:

$validator->safe()->all();      // ['name' => 'Pro']
$validator->safe()->has('rate') // false

There is no method that hands you back what the exclusion removed. By the time you have a validator result, the key is simply not part of it.

The rest of the family removes keys the same silent way, so check each one you use against the same question — can the thing it depends on be absent? Plain exclude is unconditional; validateExclude() returns false and nothing else is consulted. exclude_without:plan drops the attribute whenever plan is missing, which is the trap in this article promoted to the rule's entire purpose. And Rule::excludeIf() takes a boolean or a closure, so the decision can be any expression you like. All of them remove the key before the rest of the chain runs, and none of them report that they did.

The same rule, two answers, depending on your client

The comparison at the end of both methods has one more surprise in it. parseDependentRuleParameters runs the rule's parameters through convertValuesToBoolean, which maps only the literal strings true and false. A parameter of 1 stays the string "1". Then in_array is called with its strict flag set to is_bool($other).

So when the condition field holds a real boolean, the comparison becomes strict, and true === "1" is false. That makes the outcome depend on how the value reached you. A JSON API client sends {"metered": true} and gets a boolean; an HTML form sends metered=1 and gets the string "1". Same rule, same field, four combinations:

json true  + ,1      exclude_if:metered,1         KEPT
form "1"   + ,1      exclude_if:metered,1         DROPPED
json true  + ,true   exclude_if:metered,true      DROPPED
form "1"   + ,true   exclude_if:metered,true      KEPT

Perfectly inverted. One form request serving both a JSON endpoint and a browser form will keep the field for one caller and drop it for the other, and whichever one you tested is the one that works.

There is a fix, and it is not to pick a spelling and hope. convertValuesToBoolean also runs when the condition field carries the boolean rule, because shouldConvertToBoolean() checks for it by name. Declare it and both spellings agree:

'metered' => ['boolean'],
'rate' => ['exclude_if:metered,true', 'numeric'],

With boolean declared, metered=1 and metered: true both exclude, as written.

What to change

Grep for the family — grep -rn "exclude_unless\|exclude_if\|exclude_with" app/ covers it in one pass. For every hit, ask whether the condition field can be absent from the request. On a store endpoint it usually cannot. On an update, a PATCH, a partial save or anything built from a JavaScript client that sends only dirty fields, it absolutely can, and exclude_unless is wrong there.

Two changes make it safe. Mark the condition field required so it can never be missing, which turns a silent drop into an honest error:

'mode' => ['required', 'in:flat,tiered'],
'rate' => ['exclude_unless:mode,tiered', 'required', 'numeric'],

That payload now fails with The mode field is required. — a 422 the client can act on, instead of a 200 that lies. And where the condition field is genuinely optional, prefer exclude_if over exclude_unless, because its Arr::has guard fails in the safe direction: it keeps your data and lets the remaining rules run.

Then test the absence case specifically. Not the happy path, not the excluded path — the request that omits the condition field entirely and still sends the dependent one. Assert on the stored value, not the status code, because the status code was never the problem.

Related reading

ARCHIVE →