artisanplaybook .dev
ARTICLES / VALIDATION

exists validated a row you already soft deleted

The exists rule does not use Eloquent. It builds a raw query against a table name, which means every global scope your model relies on — soft deletes first among them — is simply not there.

ON THIS PAGE

A task filed against a project nobody can open

A customer reports that creating a task throws a 500. You pull the trace: a null object error, deep inside a notification, on $task->project->owner->email. The task row exists. Its project_id points at project 412. Load project 412 in the admin panel and you get a 404.

Project 412 was soft deleted three weeks ago. The form request that created the task validated project_id against exists:projects,id and the rule said yes.

This is the part that feels impossible. You added SoftDeletes to the model precisely so deleted rows would stop showing up. Every query you write returns only live projects. Project::find(412) returns null. And the validator — the layer whose entire job is rejecting bad input — waved it through.

class StoreTaskRequest extends FormRequest
{
    /**
     * @return array<string, mixed>
     */
    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:255'],
            'project_id' => ['required', 'exists:projects,id'],
        ];
    }
}

That rule is wrong, and reading it will never tell you why.

The rule does not use your model

The exists rule delegates to a presence verifier. Its job is to count matching rows, and the way it does that is worth looking at, because it explains everything else:

public function getCount($collection, $column, $value, $excludeId = null, $idColumn = null, array $extra = [])
{
    $query = $this->table($collection)->where($column, '=', $value);

    if (! is_null($excludeId) && $excludeId !== 'NULL') {
        $query->where($idColumn ?: 'id', '<>', $excludeId);
    }

    return $this->addConditions($query, $extra)->count();
}

$this->table() is the query builder — DB::connection()->table('projects'). Not Project::query(). There is no Eloquent model in this code path at all.

Global scopes are an Eloquent feature. SoftDeletes works by registering a scope that appends where deleted_at is null to queries built through the model. Bypass the model and you bypass the scope. The validator is not ignoring your soft deletes; it never had access to them.

Here is the behaviour, measured against a table with one soft-deleted row:

$widget = Widget::create(['name' => 'alpha']);
$widget->delete();

Widget::find($widget->id);   // null — Eloquent correctly hides it

Validator::make(
    ['widget_id' => $widget->id],
    ['widget_id' => 'exists:widgets,id']
)->passes();                 // true

And the SQL the rule ran:

select count(*) as "aggregate" from "widgets" where "id" = ?

One condition. The trashed row counts.

Naming the model class does not fix it

The natural reaction is to reach for the rule object and pass the model, on the theory that giving the rule a real Eloquent class will make it behave like Eloquent:

'project_id' => ['required', Rule::exists(Project::class, 'id')],

This is the most dangerous line in the article, because it looks like the careful version and behaves identically to the string. The model argument is resolved by resolveTableName(), which does exactly what its name says: it instantiates the class only to call getTable() on it, prefixes the connection name, and returns a string.

Cast the rule to a string and you can watch the model disappear:

(string) Rule::exists(Project::class, 'id');
// "exists:projects,id"

That is the entire contribution of Project::class — it saved you from typing projects. The compiled rule is byte-identical to the string form, so it runs the same single-condition query and accepts the same trashed row. Verified side by side:

A  exists:widgets,id              -> PASSES  <-- trashed row accepted
B  Rule::exists(Widget::class)    -> PASSES  <-- trashed row accepted

Anything attached to the model — a global scope, a custom newQuery(), an attribute cast — is invisible here. The rule knows a table and a column.

The fix is one method, and you have to ask for it

The rule object carries a withoutTrashed() helper. It is not a default and nothing will suggest it to you:

'project_id' => ['required', Rule::exists(Project::class, 'id')->withoutTrashed()],

If your deleted-at column is named something else, pass it: withoutTrashed('archived_at'). The plain whereNull() form is equivalent and works the same way:

Rule::exists(Project::class, 'id')->whereNull('deleted_at')

Both produce the condition the model would have added for you:

select count(*) as "aggregate" from "widgets" where "id" = ? and "deleted_at" is null

Both now fail for the trashed row. There is also onlyTrashed() for the rarer case where you genuinely want to act on deleted records — a restore endpoint, say, which should accept exactly the ids every other endpoint rejects.

Worth knowing: the documentation spells out this soft-delete interaction for the unique rule, which includes trashed rows when checking uniqueness and offers the same withoutTrashed() escape hatch. The exists section does not carry the equivalent warning, which is a reasonable explanation for why so much code gets unique right and exists wrong. The mechanism is shared — both rules are built on the same DatabaseRule trait and the same presence verifier — so if you have been careful with one, apply the same care to the other.

Why this survives so long in a codebase

A soft-deleted parent is a slow failure. Nothing breaks at validation time; the request succeeds and returns a 201. The child row is written with a foreign key pointing at a row your application refuses to load. The error surfaces later and somewhere else — a null relation in a notification, a report with a blank column, a count that does not reconcile — and it surfaces in code that looks innocent, because $task->project returning null is not obviously a validation bug.

Your database will not catch it either. A foreign key constraint is satisfied: the row is still physically there. Soft deletion is an application-level concept and the constraint cannot see it. So you lose both guards at once — the validator that bypassed the scope, and the constraint that does not know the scope exists.

The blast radius is every exists rule you have ever written against a soft-deletable table. That usually means every foreign key in every form request, plus the in and exists checks in filter and search endpoints, plus anything that validates an id before dispatching a job — which is how a soft-deleted parent ends up inside a queued payload and fails on a worker at 3am instead of in the request.

What to change

Grep for it. grep -rn "exists:" app/ and grep -rn "Rule::exists" app/ give you the full list in a few seconds.

For each hit, ask one question: does that table use soft deletes? If it does, the rule needs withoutTrashed() unless you can say out loud why accepting a deleted row is correct. Do the same sweep for unique on those tables — a soft-deleted row holding an email address is why a returning customer cannot re-register.

Then write the regression test, because this is the kind of bug that comes back. Create a record, delete it, post the id, and assert a 422. That test fails today against the rule you have, which is the only proof worth having.

Related reading

ARCHIVE →