Laravel updateOrCreate() is an Eloquent persistence method that searches for an existing database record matching a set of attributes and updates it with new values, or inserts a new record if no match exists. It executes an initial SELECT query, evaluates the existence of the model, and then fires either an UPDATE or an INSERT statement.
Developers frequently encounter intermittent production failures with upserts. You deploy an integration importing third-party webhooks, high-frequency telemetry, or user event feeds, only to face IntegrityConstraintViolationException duplicate key crashes under concurrent traffic. The convenience of handling insert-or-update logic in a single line often obscures subtle race conditions, model lifecycle side effects, and database-level locking behaviors that impact high-throughput applications.
Understanding how updateOrCreate() behaves beneath the abstraction layer is vital for building resilient architectures. This deep dive dissects the Eloquent implementation, analyzes its performance profile against native database upserts, explores atomic concurrency controls, and establishes reliable patterns for high-scale enterprise systems.
Eloquent updateOrCreate Internal Architecture and SQL Lifecycle
At its core, updateOrCreate() is a convenience method provided by the Illuminate\Database\Eloquent\Builder class. It accepts two primary array parameters: $attributes, which represents the constraints used to locate the existing record, and $values, which contains the additional data fields that should be populated or modified.
public function updateOrCreate(array $attributes, array $values = []): Model
{
return tap($this->firstOrNew($attributes), function ($instance) use ($values) {
$instance->fill($values)->save();
});
}
Analyzing this implementation reveals an execution lifecycle split into distinct database interactions. First, Eloquent invokes firstOrNew($attributes). This helper executes a SELECT query using a LIMIT 1 clause matching the search constraints:
SELECT * FROM `orders`
WHERE `merchant_id` = 1204 AND `external_reference` = 'REF-89102'
LIMIT 1;
If a record matches, Eloquent hydrates a model instance flagged as existing (its $exists property evaluates to true). If no matching record is found, it instantiates a fresh model with $exists = false, pre-populating it with the search attributes. Next, the pipeline calls fill($values) to assign the modification payload across the model attributes subject to mass-assignment protections.
Finally, calling save() triggers Eloquent internal persistence logic. When $exists is true, Eloquent checks whether any attributes are dirty via isDirty(). If changes exist, it runs an UPDATE statement restricted by the model primary key:
UPDATE `orders`
SET `status` = 'processed', `updated_at` = '2025-05-10 14:22:01'
WHERE `id` = 45912;
If $exists is false, an INSERT statement runs instead:
INSERT INTO `orders` (`merchant_id`, `external_reference`, `status`, `created_at`, `updated_at`)
VALUES (1204, 'REF-89102', 'processed', '2025-05-10 14:22:01', '2025-05-10 14:22:01');
Understanding this multi-query sequence explains why the method cannot guarantee atomicity out of the box. You can explore broader database layering concepts in our review of modern PHP framework architectures.
Handling the Concurrency Race Condition: SELECT Then INSERT
The classic architectural vulnerability of updateOrCreate() stems directly from the temporal gap between the initial SELECT query and the subsequent INSERT. When two worker processes or incoming HTTP requests execute this method concurrently for the exact same unique constraint, both can read an empty result set simultaneously.
Consider an event pipeline processing two asynchronous webhooks for user subscription renewals arriving within 3 milliseconds of each other:
- Worker A runs
SELECTfor user ID 883. No record exists. Eloquent returns a new model with$exists = false. - Worker B runs
SELECTfor user ID 883 before Worker A finishes. No record exists. Worker B also instantiates a new model. - Worker A issues
INSERT INTO subscriptions... The row persists successfully. - Worker B issues
INSERT INTO subscriptions... The unique key constraint onuser_idtriggers a fatal database exception (SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry).
If the target table lacks a unique database index on the matched columns, the failure mode changes from an overt crash to silent data corruption, creating orphaned duplicate rows that distort analytical calculations and balance ledgers.
Resolving this concurrency problem requires explicit design choices. Developers must decide whether to handle collisions at the application level via database transactions and row locks, or delegate upsert operations entirely to the storage engine.
Database-Level Upserts: upsert() vs updateOrCreate()
To eliminate race conditions without complex transaction locks, Laravel provides the database query builder method upsert(). While updateOrCreate() runs multiple round-trips and leverages the Eloquent model lifecycle, upsert() translates directly into a single atomic native SQL statement.
For MySQL and MariaDB, Laravel compiles upsert() into an INSERT.. ON DUPLICATE KEY UPDATE statement. For PostgreSQL and SQLite, it compiles into INSERT.. ON CONFLICT (..) DO UPDATE.
// Native atomic database upsert
UserMetric:upsert(
[
['user_id' => 42, 'metric_date' => '2025-05-10', 'page_views' => 120],
['user_id' => 43, 'metric_date' => '2025-05-10', 'page_views' => 310],
],
['user_id', 'metric_date'], // Unique composite constraint columns
['page_views'] // Columns to update on conflict
);
The differences between these two patterns dictate their application contexts:
| Capability / Behavior | Eloquent updateOrCreate() | Query Builder upsert() |
|---|---|---|
| Database Round-trips | 2 queries (SELECT, then INSERT/UPDATE) | 1 single atomic query |
| Concurrency Safety | Vulnerable to race conditions without locks | Guaranteed atomic by database engine |
| Eloquent Events Fired | Yes (saving, creating, updating, saved) | No events triggered |
| Model Mutators / Casts | Fully applied via fill() | Bypassed; raw values written directly |
| Batch Ingestion Support | Single record only (N iterations required) | Multi-row batch inserts supported natively |
| Timestamp Maintenance | Automatic via Eloquent | Requires manual mapping or raw SQL expressions |
When high throughput and absolute atomicity are primary concerns, upsert() is the superior choice. If business logic relies heavily on model observers or dynamic mutators, stick with updateOrCreate() wrapped inside appropriate synchronization constructs.
Mass Assignment Guardrails: fillable vs guarded Pitfalls
A common operational bug with updateOrCreate() involves silent data omissions caused by Laravel mass assignment protection mechanisms. Because the method relies internally on $instance->fill($values), any field missing from the model $fillable array or included in $guarded is discarded without raising an exception.
Review this model configuration:
class Invoice extends Model
{
protected $fillable = [
'invoice_number',
'customer_id',
'subtotal',
];
}
Executing an upsert including non-fillable attributes results in partial persistence:
// $tax and $total are missing from $fillable
$invoice = Invoice:updateOrCreate(
['invoice_number' => 'INV-2025-001'],
[
'customer_id' => 905,
'subtotal' => 10000,
'tax' => 2000,
'total' => 12000,
]
);
In this scenario, Eloquent creates or updates the record using only invoice_number, customer_id, and subtotal. The tax and total fields are omitted. If those database columns are defined with NOT NULL constraints without defaults, the database throws an error. If defaults exist, your application silently saves zero values.
To avoid masking these defects during automated test runs, configure your application to throw explicit exceptions on unfillable attributes in local environments:
// Inside AppServiceProvider:boot()
public function boot(): void
{
Model:preventSilentlyDiscardingAttributes(! $this->app->isProduction());
}
Testing these validation boundaries thoroughly within integration suites helps catch schema drift early, a topic detailed in our guide on test automation and validation architecture.
Handling Model Events and Lifecycle Hooks Correctly
Because updateOrCreate() utilizes standard Eloquent persistence mechanisms, it executes the entire suite of model events. This behavior contrasts sharply with batch operations and raw query executions.
When an existing model is matched and modified, Eloquent triggers events in the following sequence:
savingupdatingupdatedsaved
When a record is absent and a new row is inserted, the sequence shifts:
savingcreatingcreatedsaved
The Dirty Attribute Optimization
Eloquent employs dirty checking prior to updating. If the existing record in the database already holds the exact values passed in the $values array, Eloquent detects that the model is clean ($model->isDirty() === false). Under this condition, Eloquent skips the UPDATE query entirely.
While skipping the update saves database I/O, it has a subtle implication: the updating and updated lifecycle hooks do not run, though saving and saved may still be evaluated depending on whether listeners touch other attributes. If your downstream architecture triggers asynchronous notifications based on the updated event, those jobs will not dispatch unless actual column data changes.
Safe Concurrency Patterns: Pessimistic Locking and Catch Retries
When you cannot use the atomic upsert() method because you require Eloquent event hooks and dynamic mutators, you must safeguard updateOrCreate() against concurrent execution collisions. Two primary patterns resolve this problem: pessimistic locking and transactional catch-retries.
Pattern 1: Explicit Pessimistic Locking
You can acquire an exclusive write lock on matching rows using lockForUpdate(). This stops other transactions from reading or altering the record until the current transaction commits:
use Illuminate\Support\Facades\DB;
$account = DB:transaction(function () use ($tenantId, $accountId, $balanceData) {
// Acquire an exclusive row lock on the query
$model = Account:where('tenant_id', $tenantId)
->where('account_id', $accountId)
->lockForUpdate()
->first();
if ($model) {
$model->update($balanceData);
return $model;
}
return Account:create(array_merge(
['tenant_id' => $tenantId, 'account_id' => $accountId],
$balanceData
));
});
Note that lockForUpdate() only locks existing rows. If no row matches, gap locks or next-key locks in engines like InnoDB may block adjacent ranges, but they do not eliminate duplicate key exceptions under intense concurrent inserts.
Pattern 2: Atomic Transaction Retries with Deadlock Handling
The standard pattern for high-traffic environments relies on letting the database enforce the unique constraint, catching collisions, and retrying the operation inside an atomic block:
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
function safeUpdateOrCreate(array $attributes, array $values, int $maxRetries = 3)
{
return DB:transaction(function () use ($attributes, $values) {
return OrderStatus:updateOrCreate($attributes, $values);
}, $maxRetries);
}
Passing the retry count as the second parameter of DB:transaction() instructs Laravel to automatically re-run the closure whenever a DeadlockException or transaction serialization collision occurs.
Using updateOrCreate with Eloquent Relationships
Executing updateOrCreate() through relationships maintains domain encapsulation by ensuring child records automatically inherit foreign key identifiers from the parent model instance.
Consider a User model that possesses a one-to-one relationship with a UserProfile model. You can invoke the persistence call directly through the relationship method:
$user = User:findOrFail($userId);
// user_id is automatically injected into the search and create attributes
$profile = $user->profile()->updateOrCreate(
['profile_type' => 'business'],
[
'company_name' => 'Acme Logistics',
'tax_identifier' => 'TX-990142',
'is_verified' => true,
]
);
In this call, Eloquent merges ['user_id' => $user->id] directly into the search criteria and the persistence payload. The underlying SQL query queries both the primary foreign key and the contextual criteria:
SELECT * FROM `user_profiles`
WHERE `user_profiles`.`user_id` = 84
AND `profile_type` = 'business'
LIMIT 1;
HasMany and MorphMany Nuances
When working with polymorphic or one-to-many associations, keep in mind that the relationship scope isolates the query. If you execute an upsert on a MorphMany relationship:
$post->comments()->updateOrCreate(
['author_email' => 'editor@domain.com'],
['content' => 'Reviewed and approved.']
);
Eloquent ensures that commentable_id and commentable_type are set accurately during hydration and creation, preventing misassociated records across multi-tenant data boundaries.
High-Volume Data Pipelines and Asynchronous Queue Processing
When processing massive telemetry sets, webhook ingestion streams, or third-party catalog synchronizations, running updateOrCreate() synchronously inside web requests causes latency bottlenecks. Processing records individually creates substantial network overhead between your application workers and the database server.
To scale upsert throughput, decouple record reception from persistence using background jobs. Review our architectural analysis on scaling Laravel event queues and workers to configure suitable job infrastructure.
Batching Records Before Persistence
Instead of dispatching one job per incoming record, aggregate incoming records in a Redis list or in-memory cache, then run grouped upserts inside a scheduled task or worker:
namespace App\Jobs;
use App\Models\DevicePing;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class FlushDeviceTelemetryJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function handle(): void
{
// Retrieve up to 500 queued telemetry points
$records = app('redis')->rpop('telemetry_stream', 500);
if (empty($records)) {
return;
}
$upsertData = array_map(fn($item) => json_decode($item, true), $records);
// Write batch atomically in a single network round-trip
DevicePing:upsert(
$upsertData,
['device_uuid', 'recorded_minute'],
['battery_level', 'signal_strength', 'updated_at']
);
}
}
Switching from 500 individual updateOrCreate() calls to a single multi-row upsert() call reduces database latency by more than 90%, freeing connection pools to handle incoming HTTP requests.
Performance Benchmarks: 10,000 Records Evaluated
To quantify the performance differences between Eloquent updateOrCreate(), individual transactions, and native database upserts, we benchmarked the ingestion of 10,000 records on an 8-core application server running PHP 8.3 connected over a local network to a MySQL 8.0 instance.
The benchmark scenario measured total execution time, peak memory usage, and the number of distinct SQL queries sent to the database.
| Persistence Strategy | Execution Time (s) | Peak Memory (MB) | Total SQL Queries |
|---|---|---|---|
| updateOrCreate() (Iterative) | 14.82s | 28.4 MB | 20,000 queries |
| updateOrCreate() within Single Transaction | 4.15s | 28.6 MB | 20,000 queries |
| Raw Chunked upsert() (Chunks of 1,000) | 0.31s | 12.2 MB | 10 queries |
| Raw Single Batch upsert() (All 10,000) | 0.18s | 14.5 MB | 1 query |
The metrics highlight the trade-offs of each approach:
- Round-trip overhead: Iterative
updateOrCreate()without an explicit outer transaction takes nearly 15 seconds. This is because every individual row requires a round-tripSELECT, anINSERTorUPDATE, and individual autocommit overhead on the database engine. - Transaction grouping: Wrapping individual calls inside
DB:transaction()cuts execution time by over 70% by eliminating per-statement disk syncs for autocommits. However, it still executes 20,000 queries. - Chunked bulk upserts: Using
upsert()in chunks of 1,000 records reduces total processing time to 0.31 seconds, an operational speedup of over 47x compared to the default Eloquent approach.
Optimizing database persistence reduces CPU load and operational expenses, which directly informs internal accounting as explored in our guide on software capitalization for engineering teams.
Common Anti-Patterns and Production Edge Cases
Even experienced engineers can run into edge cases when using updateOrCreate(). Understanding these anti-patterns helps prevent system regressions.
1. Omitting the Unique Index
The most dangerous anti-pattern is using updateOrCreate() on columns that lack a composite unique constraint at the database schema layer. Relying purely on application-level logic to preserve uniqueness guarantees duplicate entries under concurrent traffic. Always define explicit unique indexes in your migrations:
Schema:table('subscriptions', function (Blueprint $table) {
$table->unique(['user_id', 'plan_id']);
});
2. Query Condition Contamination
Passing dynamic user input directly into the first argument array without sanitization can inadvertently match unintended records. If an optional search attribute evaluates to null, Eloquent queries for records where that column IS NULL:
// If $externalId is null, this updates the first record where external_id IS NULL
$customer = Customer:updateOrCreate(
[
'company_id' => $companyId,
'external_id' => $externalId, // Risk if nullable
],
['contact_name' => $name]
);
Guard against this by asserting that identifying keys are present and valid before executing the persistence call.
3. The Multiple Match Paradox
If the search array in updateOrCreate() matches multiple records, Eloquent resolves this by calling first(), which applies LIMIT 1 without a deterministic ORDER BY clause. The database then returns whichever record appears first in the index scan. Subsequent updates will arbitrarily modify different rows over time, leading to unpredictable data states.
Mastering Core Laravel Mechanics
Understanding Eloquent persistence methods like updateOrCreate() is essential for building robust Laravel applications. Managing how attributes are matched, updated, and constrained at the database layer helps ensure your application remains stable as traffic scales.
For further exploration of framework architecture, Eloquent query optimization, and foundational backend patterns, browse our comprehensive resource library.
Explore our complete Laravel, Basics directory for more guides.
Frequently Asked Questions
What is the difference between firstOrCreate and updateOrCreate in Laravel?
firstOrCreate retrieves the first matching record or creates it if it does not exist, leaving existing records unchanged. In contrast, updateOrCreate searches for the matching record and updates its attributes with new values, or creates a new record if no match is found.
Does updateOrCreate trigger Eloquent model events?
Yes. It fires saving and saved events for all executions. When a record is updated, it fires updating and updated events, provided attributes were dirty. When a new row is inserted, it fires creating and created events.
How do I prevent duplicate entry errors with updateOrCreate under concurrency?
Ensure your database table has a unique index on the matching columns. Then, either wrap the updateOrCreate call in a DB:transaction with automatic retries or use the atomic Query Builder upsert method instead.
Can I use updateOrCreate on tables without created_at and updated_at columns?
Yes. If your model has public $timestamps = false, Eloquent runs updateOrCreate without referencing or updating timestamp columns.
Laravel updateOrCreate() simplifies insert-or-update workflows by combining lookup and persistence logic into a clean, expressive syntax. For standard web traffic and administrative tools, its deep integration with Eloquent mutators, mass assignment guards, and model lifecycle hooks makes it a dependable tool in your application architecture.
However, running this method under high concurrency reveals architectural trade-offs. Because it splits operations into separate SELECT and write statements, it requires defensive safeguards like database-level unique constraints, transaction retries, or pessimistic locking. For performance-critical ingestion pipelines, shifting to atomic bulk methods like upsert() minimizes database overhead and delivers predictable, scalable throughput.