Skip to content

Stop firstOrCreate() from Creating Duplicates in Laravel

August 19, 2026 • 2 min read

If your app gives each user exactly one of something, like a subscription or a settings row, you’ve probably created it like this:

$subscription = Subscription::query()->firstOrCreate(['user_id' => $user->id]);

firstOrCreate() looks for the row and creates it if it isn’t there. Two requests at the same moment can both find nothing and both create one, so the user ends up with two subscriptions.

I sent five requests in parallel for the same user. To make the overlap easy to reproduce, I added a 200 ms pause in a creating model event, between the lookup and the insert. Without a unique index, the table ended up with five subscriptions for that user.

Let’s fix it with a unique index, and then look at the methods next to firstOrCreate() that don’t handle it the same way.

Adding a unique index

Schema::table('subscriptions', function (Blueprint $table) {
    $table->unique('user_id');
});

With the index in place, the same five requests left one row, and all five got back the same subscription.

That’s because firstOrCreate() goes through createOrFirst(), which has been the case since Laravel 10.29 following a pull request from @mpyw. createOrFirst() tries the insert, catches the UniqueConstraintViolationException from the database, and reads the row the other request created.

It reads that row through the write connection with useWritePdo(), so it doesn’t ask a read replica that hasn’t caught up yet. If you’re already inside a transaction, it runs the insert in a savepoint, so the failed insert doesn’t roll back the rest of your transaction.

updateOrInsert() still races

The query builder’s updateOrInsert() looks like it does the same job. In Laravel 13 it checks whether the row exists and then inserts:

$exists = $this->where($attributes)->exists();

// ...

if (! $exists) {
    return $this->insert(array_merge($attributes, $values));
}

Nothing catches a collision. I ran five requests in parallel with the same 200 ms pause after the existence check. The unique index kept it to one row, and four of the five requests threw a UniqueConstraintViolationException.

upsert() does it in one statement

upsert() sends a single insert ... on duplicate key update on MySQL, so the database handles the collision:

Subscription::query()->upsert(
    [['user_id' => $user->id, 'plan' => 'pro']],
    uniqueBy: ['user_id'],
    update: ['plan'],
);

The same five parallel requests left one row, and none of them threw.

So give the column a unique index first, then use firstOrCreate() or upsert(). The index is what makes both of them safe: it’s what turns the second insert into an error that firstOrCreate() can catch, and it’s how upsert() knows which row already exists.