firstOrCreate() is not atomic, and the unique index is the fix
The safety in firstOrCreate() lives in your schema, not in your code. Without a unique index it is a check-then-insert race with a friendly name.
ON THIS PAGE
Two rows, same email, same millisecond
The signup form double-submitted. Or a webhook was delivered twice, 4ms apart. Or a queue worker picked up two jobs for the same customer because an upstream service retried. However it happened, you are now looking at this:
id | email | created_at
---+----------------------+---------------------
81 | sally@example.com | 2026-09-23 11:04:17
82 | sally@example.com | 2026-09-23 11:04:17
And the code that produced it looks like it could not possibly have done so:
$user = User::firstOrCreate(
['email' => $request->email],
['name' => $request->name],
);
The name reads like a single decision — find it, or make it. It is two decisions, and another process can slip between them.
What the method actually runs
firstOrCreate() is three lines of code. A SELECT, and then a second method:
// Illuminate\Database\Eloquent\Builder
public function firstOrCreate(array $attributes = [], Closure|array $values = [])
{
if (! is_null($instance = (clone $this)->where($attributes)->first())) {
return $instance;
}
return $this->createOrFirst($attributes, $values);
}
Two statements, two round trips, and nothing holding a lock between them. Two workers both run the SELECT, both get nothing back, and both go on to insert. That window is measured in whatever your database round-trip latency is — a millisecond or two on a good day, which is plenty. This is the classic check-then-act race, and no amount of rearranging the PHP closes it, because the PHP is not where the decision can be made atomically.
createOrFirst() is the interesting half:
public function createOrFirst(array $attributes = [], Closure|array $values = [])
{
try {
return $this->withSavepointIfNeeded(fn () => $this->create(array_merge($attributes, value($values))));
} catch (UniqueConstraintViolationException $e) {
return $this->useWritePdo()->where($attributes)->first() ?? throw $e;
}
}
Read that catch carefully. The recovery from a lost race is entirely conditional on the database raising a unique constraint violation. If nothing raises one, there is nothing to catch, the second insert succeeds, and you get row 82.
The fix is in the migration
Here is the same race run twice against this application's test database — once against a table with a plain string column, once with ->unique() on it. In both cases a competing process inserts the row after the SELECT has already returned nothing:
no unique index: rows matching 'laravel' => 2
with unique index: rows matching 'laravel' => 1, wasRecentlyCreated => false
That is the whole article in two lines. The method is correct, and it is correct because of the index. Take the index away and it degrades silently into a race.
So the fix is not in your controller:
Schema::table('users', function (Blueprint $table) {
$table->unique('email');
});
If the uniqueness is compound — one row per customer per day, one row per user per provider — the index has to be compound too, and it has to cover exactly the columns you pass as the first argument to firstOrCreate():
$table->unique(['user_id', 'provider']);
Mismatch those and you get the worst outcome of the three. Say the index is on (user_id, provider) but you call firstOrCreate(['provider' => $provider]). The insert violates the index, the exception is caught, and then where(['provider' => $provider])->first() searches on the wrong column set. If it finds nothing, look at the end of that line: ?? throw $e. The exception comes straight back out at you from a method whose name suggests it handles this.
One practical note for when you are debugging this: the failed insert does not appear in the query log. Connection::runQueryCallback() logs a query after the PDO call returns, and a constraint violation throws before it gets there. Enable the query log around a recovered createOrFirst() and you see the recovery SELECT with no insert in front of it.
Catch the exception where you need to know
The class is Illuminate\Database\UniqueConstraintViolationException, and it extends QueryException — so a broad catch (QueryException) already swallows it, which is usually not what you want. Catch the narrow one:
use Illuminate\Database\UniqueConstraintViolationException;
try {
$invite = Invite::create([
'team_id' => $team->id,
'email' => $email,
]);
} catch (UniqueConstraintViolationException $e) {
report($e);
return back()->withErrors(['email' => 'That person has already been invited.']);
}
In the current release the exception carries two properties that tell you which constraint tripped, which matters as soon as a table has more than one unique index:
} catch (UniqueConstraintViolationException $e) {
$e->index; // 'invites_team_id_email_unique' on MySQL
$e->columns; // ['email'] on SQLite
}
They are populated per driver, and the drivers do not agree on which one they can give you. MySqlConnection parses the index name out of Duplicate entry '…' for key '…' and leaves columns empty. SQLiteConnection parses the column list out of UNIQUE constraint failed: table.column and leaves index null. Detection itself is per driver too: MySQL matches on Integrity constraint violation: 1062, Postgres on SQLSTATE 23505, SQLite on the message text. So if you branch on $e->index, test that branch against the database you actually deploy on — not the SQLite file your test suite uses.
Inside a transaction, the insert needs a savepoint
withSavepointIfNeeded() in that snippet earlier is not decoration:
return $this->getQuery()->getConnection()->transactionLevel() > 0
? $this->getQuery()->getConnection()->transaction($scope)
: $scope();
When you are already inside a transaction, the attempted insert is wrapped in a nested transaction — a savepoint — so that the failure can be rolled back without taking your outer transaction with it. On Postgres this is the difference between working code and a dead connection: a statement error inside a transaction puts that transaction into an aborted state where every subsequent statement fails with current transaction is aborted. The savepoint is what lets the recovery SELECT run at all.
That has a consequence worth knowing. The recovery only finds a row that is visible to your connection. A competing row committed by another process is visible; a row inserted earlier inside the same rolled-back savepoint is not. And the re-query calls useWritePdo() deliberately — on a read-replica setup, the row is milliseconds old and asking a replica about it is exactly how you get ?? throw $e on a row that definitely exists.
The same hole in the methods next door
updateOrCreate() is firstOrCreate() plus a save:
return tap($this->firstOrCreate($attributes, $values), function ($instance) use ($values) {
if (! $instance->wasRecentlyCreated) {
$instance->fill(value($values))->save();
}
});
Same select, same race, same dependence on the index. It also means that under concurrency two callers can both end up in the fill()->save() branch — last write wins, which for a counter or a running total is a lost update the index cannot help with. If the new value depends on the old one, do it in the database: increment(), or an incrementOrCreate() call, or an explicit lockForUpdate() inside a transaction.
firstOrNew() is a different shape and offers nothing at all here. It returns an unsaved model; the race happens later, whenever you get around to calling save(), and there is no catch anywhere in the path. firstOr() and findOr() run a callback of your choosing and are subject to whatever you put in it.
What to change
Go through your codebase for firstOrCreate, createOrFirst and updateOrCreate — there are usually more than you remember, often in webhook handlers and queued jobs, which are precisely the concurrent paths. For each one, open the migration for that table and answer one question: is there a unique index on exactly the columns in the first argument?
Where there is not, add one. Do it in this order, because a plain CREATE UNIQUE INDEX on a table that already has duplicates will fail:
php artisan tinker --execute 'print_r(App\Models\User::selectRaw("email, count(*) as n")->groupBy("email")->havingRaw("count(*) > 1")->pluck("n", "email")->all());'
Merge or delete what that prints, then ship the index. On a large MySQL table, add it with ALGORITHM=INPLACE, LOCK=NONE rather than letting the default lock your writes for the duration.
Then decide, per call site, whether a lost race is something the user should hear about. A deduplicating webhook handler wants the silent recovery createOrFirst() gives it. A signup form probably wants to catch UniqueConstraintViolationException and say so. Either answer is fine. Relying on the method name is not.