The unique rule ignored nothing and the row failed itself
ignore() compiles down to a string. A falsy id becomes the literal text NULL, the exclusion disappears from the query, and the rule validates the row against itself.
ON THIS PAGE
Saving the form without changing anything fails
A user opens their profile, changes nothing, and presses save. The response is a 422: the email address has already been taken. It has — by them. The record they are editing is the record the rule is complaining about.
You wrote the rule the way the documentation shows it:
'email' => ['required', 'email', Rule::unique('users')->ignore($user)],
That line is correct. The problem is that $user was not what you thought it was at the moment the rule was built — and when the id behind it is falsy, the rule does not warn you, fail loudly, or behave differently in any visible way. It silently stops ignoring anything.
The rule is a string, and that is where it breaks
Rule::unique() looks like an object that carries state into the query. It is really a builder for a rule string, and the compilation step is where the information is lost:
return rtrim(sprintf('unique:%s,%s,%s,%s',
$this->table,
$this->column,
$this->ignore ? '"'.addslashes($this->ignore).'"' : 'NULL',
$this->idColumn
), ',');
Look at the third argument. It is a ternary on the truthiness of $this->ignore. Anything falsy — null, 0, '0', an empty string — takes the else branch and becomes the four characters NULL.
Cast a few rules to strings and the behaviour is plain:
$widget = Widget::create(['name' => 'alpha']);
$unsaved = new Widget(['name' => 'alpha']);
(string) Rule::unique('widgets', 'name')->ignore($widget);
(string) Rule::unique('widgets', 'name')->ignore($widget->id);
(string) Rule::unique('widgets', 'name')->ignore(null);
(string) Rule::unique('widgets', 'name')->ignore(0);
(string) Rule::unique('widgets', 'name')->ignore('');
(string) Rule::unique('widgets', 'name')->ignore($unsaved);
Each of those, in order:
unique:widgets,name,"1",id
unique:widgets,name,"1",id
unique:widgets,name,NULL,id
unique:widgets,name,NULL,id
unique:widgets,name,NULL,id
unique:widgets,name,NULL,id
The last four are the same string. ignore(null) and ignore($unsavedModel) compile to something indistinguishable from never having called ignore() at all.
Then the presence verifier treats that text as a sentinel meaning "no exclusion":
if (! is_null($excludeId) && $excludeId !== 'NULL') {
$query->where($idColumn ?: 'id', '<>', $excludeId);
}
So the clause is not merely ineffective. It is absent. Here is the query for each case, measured against a table holding one row named alpha:
ignore($w) saved -> passes
select count(*) from "widgets" where "name" = ? and "id" <> ? bindings=["alpha","1"]
ignore(unsaved model) -> FAILS: The name has already been taken.
select count(*) from "widgets" where "name" = ? bindings=["alpha"]
ignore(null) -> FAILS: The name has already been taken.
select count(*) from "widgets" where "name" = ? bindings=["alpha"]
no ignore at all -> FAILS: The name has already been taken.
select count(*) from "widgets" where "name" = ? bindings=["alpha"]
Three different pieces of code, one query. The row is compared against every row including itself, finds itself, and reports a collision.
How a falsy id gets in there
Nobody writes ignore(null) deliberately. It arrives through ordinary paths:
An unsaved model. A form object, a factory make() instead of create(), a model built in a service and validated before save() — getKey() returns null and the exclusion vanishes. This is the common one, and it is invisible because the model is a perfectly real User instance with the right email on it.
Route binding that did not bind. If the parameter name in the route and the method signature disagree, you get a fresh empty model rather than an error. $this->route('user') returning null in a form request, because the segment is actually called profile, has the same effect.
A rule built before the model is resolved. rules() on a form request runs per request, but a rule assembled in a constructor, a static property or a cached array may capture a model that was empty at the time.
And the genuinely nasty one: an id of 0. If a table has a row with id 0 — a sentinel "system" or "unassigned" record, or a legacy import that started its sequence at zero — then that row can never pass its own uniqueness check:
DB::table('widgets')->insert(['id' => 0, 'name' => 'zero']);
Validator::make(
['name' => 'zero'],
['name' => [Rule::unique('widgets', 'name')->ignore(0)]]
)->passes();
That returns false. The id is correct, the model is saved, the code is right, and the row still fails against itself — because 0 is falsy and the ternary cannot tell a legitimate zero from an absent value.
Pass the model, not the key
There is a second reason to hand ignore() a model rather than a key, and it shows up the moment a table stops using id as its primary key.
Passing a model routes through ignoreModel(), which asks the model what its key is called:
public function ignoreModel($model, $idColumn = null)
{
$this->idColumn = $idColumn ?? $model->getKeyName();
$this->ignore = $model->{$this->idColumn};
return $this;
}
Passing a value does not. It sets the ignored value and leaves $idColumn at its default of id, because a bare string carries no information about which column it came from.
For a model keyed on uuid, the two calls compile differently:
(string) Rule::unique('docs', 'title')->ignore($doc);
(string) Rule::unique('docs', 'title')->ignore($doc->uuid);
The last segment is the column the exclusion will be applied to:
unique:docs,title,"11111111-1111-1111-1111-111111111111",uuid
unique:docs,title,"11111111-1111-1111-1111-111111111111",id
The second one excludes on a column the table does not have. The value is right, the record is right, and the exclusion still does not find the row — the same 422 as before, arriving through a different mistake. Passing the model gets this right for free; if you only have the key, name the column yourself with the second argument, which also compiles to uuid and passes:
Rule::unique('docs', 'title')->ignore($doc->uuid, 'uuid')
Worth noting that a UUID or ULID key sidesteps the falsy-id trap entirely, since a non-empty string is always truthy. Tables keyed on a UUID fail here through the wrong column instead, and tables keyed on an auto-incrementing integer fail through the falsy check. Both end at the same error message.
What to do about it
Pass a saved model or an explicit key, and assert that you have one. The cheapest version is to stop relying on a truthiness test you do not control:
public function rules(): array
{
$user = $this->route('user');
abort_if($user === null || $user->getKey() === null, 500);
return [
'email' => ['required', 'email', Rule::unique('users')->ignore($user->getKey())],
];
}
A 500 is the right outcome here. A rule that cannot identify the record it is updating is a programming error, not a user error, and failing loudly beats returning a 422 that blames the user for their own email address.
If your table genuinely has an id of 0, do not fight the ternary — write the exclusion yourself with a clause that gets no special treatment:
Rule::unique('widgets', 'name')->whereNot('id', $widget->getKey())
While you are in this code, two related points. The documentation carries an explicit warning never to pass user-controlled request input into ignore(), and it is not theoretical: the value is interpolated into the rule string with addslashes(), which is escaping for a string literal and not a parameter binding. Pass $request->input('id') and you are handing an attacker a say in a rule your database executes. Use a system-generated key from a model instance.
And remember that unique includes soft-deleted rows when it counts. A deleted user still holds their email address, so a returning customer cannot re-register — the same withoutTrashed() helper that fixes the exists rule applies here. The exists piece on this site goes through why both rules share this blind spot.
What to change
Grep for ->ignore( across the application. For each hit, answer two questions: can the value be falsy, and is it user input? The first is a silent 422 that users will report as "the form will not save". The second is a security bug.
Then write the test that nobody writes — the one that saves a record without changing the unique field and asserts a 2xx. An update endpoint that cannot accept its own current values is broken in a way no create-path test will ever reveal.