mpyw/laravel-retry-on-duplicate-key
Automatically retries database operations when a duplicate key error occurs in Laravel. Wrap Eloquent firstOrCreate, updateOrCreate, or insert calls in a retryable transaction to safely handle race conditions and unique constraint conflicts under concurrent writes.
composer require mpyw/laravel-retry-on-duplicate-key. The service provider is auto-discovered; there is no config file to publish in the released version."mpyw/laravel-retry-on-duplicate-key": "1.*") and plan to vendor or fork the source rather than rely on upstream fixes for Laravel 11/12.src/ first, not the README. The whole package is a single small trait/helper. Skim it before wiring it into a model — you want to see exactly which exception codes it considers retryable and what backoff it uses.users.email) where you call firstOrCreate() / updateOrCreate() / save() inside a seeder, importer, or job queue worker, and under concurrency you intermittently get SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry '...' for key '...'.save()/create() calls go through it:use Illuminate\Database\Eloquent\Model;
use Mpyw\LaravelRetryOnDuplicateKey\RetryOnDuplicateKey; // verify namespace against src/
class User extends Model
{
use RetryOnDuplicateKey;
}
If the version you pull exposes the closure helper instead, the shape is:
RetryOnDuplicateKey::retry(function () use ($payload) {
return User::firstOrCreate(['email' => $payload['email']], $payload);
});
Pick one integration point (model trait or explicit closure wrapping) and be consistent — mixing both makes behaviour hard to reason about during an incident.
Wrap check-then-insert, not just create(). The whole point is that the read and the write are separate statements. firstOrCreate() is the canonical case; updateOrCreate(), findOrNew()->save(), and manual if (!exists) { create() } blocks are equally valid targets.
Seeders and importers. Long-running db:seed or CSV import commands are where this bites hardest — a single worker hitting a unique index repeatedly, or --parallel runners colliding. Applying the trait at the model level gives you retries without touching the seeder logic.
Queue jobs. Idempotent job patterns (firstOrCreate on an external ID) are a natural fit. The retry here is cheaper than re-dispatching the job, and it keeps the failure out of your failed_jobs table.
Keep it off hot paths. Use it for low-to-moderate contention (admin imports, webhook handlers, one-off migrations). For genuinely hot rows — counters, inventory, anything with hundreds of writes/sec — prefer a single atomic statement (Model::upsert(), raw INSERT ... ON DUPLICATE KEY UPDATE) or an explicit Cache::lock(). Retry loops on a saturated unique index just add load.
Layer with transactions carefully. If the closure touches more than one table, wrap the retried unit in DB::transaction() rather than retrying inside an outer transaction — a duplicate-key error marks the statement failed, and you want a clean transaction boundary on each attempt.
Testing. Assert the retry path explicitly by forcing the duplicate: create the row, then run the code under test in a way that skips the existence check, or mock the model to throw a duplicate-key QueryException on the first call and succeed on the second. Don't rely on --parallel to reproduce races; it will not reliably hit the window.
23000/1062 is the one it cares about.App\Support\RetryOnDuplicateKey — it's small enough to audit and own. That removes the dependency, the version pin, and the Laravel-compatibility question in one step.config/retry-on-duplicate-key.php.Log::warning('duplicate-key retry', ['model' => ..., 'attempt' => ...])). Without it, a 5× retry rate looks identical to a healthy system until it starts failing outright.DB::listen() and firing two concurrent artisan tinker processes against a scratch database. Inspect the exception's errorInfo[1] to confirm you're hitting 1062 and not something adjacent.How can I help you explore Laravel packages today?