Skip to content

Add saveOrIgnore Eloquent Model method for conflict-safe inserts#59026

Open
antonkomarev wants to merge 2 commits intolaravel:13.xfrom
antonkomarev:add-save-or-ignore-model-method
Open

Add saveOrIgnore Eloquent Model method for conflict-safe inserts#59026
antonkomarev wants to merge 2 commits intolaravel:13.xfrom
antonkomarev:add-save-or-ignore-model-method

Conversation

@antonkomarev
Copy link
Contributor

@antonkomarev antonkomarev commented Feb 27, 2026

Problem

When creating records with unique constraints, save() throws UniqueConstraintViolationException:

// Race condition: two requests register same user for same contest
$model = new ContestRegistration();
$model->user_id = $userId;
$model->contest_code = $contestCode;
$model->save(); // SQLSTATE[23505]: Unique violation

Current workarounds are verbose and suboptimal:

// Workaround 1: try/catch — error handling after the fact, not prevention;
// generates error entries in DB logs that aren't actual errors by business logic
try {
    $model->save();
} catch (UniqueConstraintViolationException) {
    // silently ignore
    // ...or perform other logic to implement idempotency
}

// Workaround 2: firstOrCreate — extra SELECT before every INSERT
ContestRegistration::firstOrCreate(
    ['user_id' => $authUserId, 'contest_code' => $code],
);

Solution: saveOrIgnore()

Uses the SQL-level INSERT ... ON CONFLICT (columns) DO NOTHING RETURNING * — no exceptions, no extra queries:

$model = new ContestRegistration();
$model->user_id = $userId;
$model->contest_code = $contestCode;
$model->saveOrIgnore(uniqueBy: ['user_id', 'contest_code']);
// true -> record inserted
// false -> conflict, nothing inserted
// exception -> something bad happened

Use Cases

1. Idempotent event handlers

// Processing webhook that may arrive multiple times
$payment = new Payment();
$payment->external_id = $webhook->paymentId;
$payment->amount = $webhook->amount;
$payment->saveOrIgnore(uniqueBy: ['external_id']);

2. Race-condition-safe registration

$subscription = new Subscription();
$subscription->user_id = $user->id;
$subscription->plan_id = $plan->id;
$subscription->saveOrIgnore(uniqueBy: ['user_id', 'plan_id']);

3. One-time actions / feature flags

$flag = new UserFeatureFlag();
$flag->user_id = $user->id;
$flag->feature = 'onboarding_completed';
$flag->saveOrIgnore(uniqueBy: ['user_id', 'feature']);

4. Conditional logic based on insert result

$like = new PostLike();
$like->user_id = $user->id;
$like->post_id = $post->id;

if ($like->saveOrIgnore(uniqueBy: ['user_id', 'post_id'])) {
    $post->increment('likes_count');
}

API Properties

  • Returns booltrue if inserted, false if conflict
  • Full model lifecycle: events (saving, creating, created, saved), timestamps, unique IDs
  • On conflict: created/saved events do NOT fire, exists stays false
  • Auto-increment ID is captured from RETURNING clause

Current Limitations

Not all databases suported

UPDATE not available

saveOrIgnore() throws LogicException if called on an existing model ($model->exists === true).

The UPDATE path is intentionally not supported because UPDATE modifies existing rows and does not create new ones, so unique constraint conflicts on the target columns can only occur if you are changing a unique column's value to one that already exists in another row. This is a fundamentally different (and rare) scenario that should be handled explicitly, not silently ignored.

AFAIK, PostgreSQL does not support it; SQLite does.

Possible methods naming

  • saveOrIgnoreOnConflict
  • createOrIgnore
  • createOrIgnoreOnConflict
  • ... your name

@antonkomarev antonkomarev marked this pull request as draft February 27, 2026 18:04
@antonkomarev antonkomarev marked this pull request as ready for review February 27, 2026 18:12
@antonkomarev
Copy link
Contributor Author

And yes, we can make it right now with query builder, but I want to have elegant API, same as default creation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant