skip to content

For a nightly Eloquent product-feed import, why use Product::upsert() instead of updateOrCreate() per row, and what does upsert() skip?

level: seniorimportance: should knowfreq 42%

answer

  1. one statement per batch, not per row
  2. values, uniqueBy, update columns
  3. unique index on uniqueBy columns
  4. timestamps set, events and casts skipped
  5. Laravel 13: empty uniqueBy throws

basics

~20 s

upsert() inserts or updates a whole batch in one statement, where updateOrCreate() costs a select plus a write per row. It sets timestamps but skips model events, casts, mutators and mass-assignment filtering, and needs a unique index on uniqueBy.

solid answer

~40 s

`updateOrCreate()` runs a select and then an insert or update for every row, so an 80,000-row feed means well over 80,000 queries and hydrated models. `Product::upsert($rows, uniqueBy: ['sku'], update: ['name', 'price_cents', 'stock'])` sends one insert-or-update statement per batch. Eloquent's version adds `created_at` and `updated_at`, keeps `created_at` for existing rows, and fills `HasUuids` or `HasUlids` keys, but it builds no models: no events or observers, no casts or mutators, no `$fillable` check and no per-row created-or-updated signal. The `uniqueBy` columns need a primary or unique index except on SQL Server, MySQL and MariaDB ignore `uniqueBy` and use the table's own unique indexes, and in Laravel 13 an empty `uniqueBy` throws `InvalidArgumentException`. I batch the rows so each statement stays under driver parameter limits.

go deeper

for a junior

Recall that upsert() inserts new rows and updates existing ones in bulk, identified by the uniqueBy columns.

for a middle

Explain the three arguments, the index requirement and what Eloquent adds, timestamps and unique ids, over the query builder.

for a senior

Trade per-row helpers against batched upserts, and plan how skipped events, casts and change reporting are handled after each batch.

for a principal

Decide how bulk imports keep derived systems, search, caches, audit, consistent when the write path bypasses model events.

## Per-row helpers versus one statement An electronics store's nightly feed carries 80,000 products. The obvious loop is one `updateOrCreate()` per row. Each call selects the row, then inserts it or saves the changed columns, so the import costs well over 80,000 round trips and hydrates 80,000 models. `upsert()` replaces the loop with one **insert-or-update statement per batch**: the database inserts rows whose unique key is new and updates the listed columns for rows that already exist. ```php foreach (array_chunk($feedRows, 1000) as $batch) { Product::upsert( $batch, uniqueBy: ['sku'], update: ['name', 'price_cents', 'stock'], ); } ``` ## The three arguments 1. **`$values`** — an array of rows, each an associative array of column => value. A single row may also be passed. 2. **`$uniqueBy`** — the column or columns that identify an existing row. Except on SQL Server, these must be covered by a primary or unique index. 3. **`$update`** — the columns to overwrite when the row exists. Omit it and every column of the first row is updated. On the query builder an empty array turns the call into a plain insert, but Eloquent appends `updated_at` to the list on a model with timestamps, so existing rows still get their `updated_at` bumped. Eloquent adds on top of the query builder's version: - it sets `created_at` and `updated_at` on every row when the model uses timestamps, and adds `updated_at` to the update list, so an existing row keeps its original `created_at`; - for models using `HasUuids` or `HasUlids`, it generates the unique id for rows that lack one. The call returns the number of affected rows as reported by the driver. ## What upsert skips Because the rows go straight to the query builder, no models are built: - **no model events**, so observers never see the created or updated products; - **no casts or mutators**, so a JSON column needs an already-encoded string and a money value must be in its stored form; - **no mass-assignment filtering**, since `$fillable` only applies to `fill()`; - no `wasRecentlyCreated`, no dirty tracking and no report of which rows were inserted versus updated. These are the price of speed. When a side effect depends on the change, such as reindexing search for changed prices, it has to be handled explicitly after the batch. ## Database behaviour to know | Point | Detail | |---|---| | Index requirement | `uniqueBy` columns need a primary or unique index on every database except SQL Server | | MySQL and MariaDB | ignore `uniqueBy` and match on the table's primary and unique indexes | | Laravel 13 validation | an empty `uniqueBy` now throws `InvalidArgumentException` instead of producing invalid SQL | | Batch size | each row adds bindings, so batches of a few hundred to a few thousand rows keep statements under driver parameter limits | The MySQL row is a common surprise: a table with a unique `sku` **and** a unique `ean` can match on `ean` even though the call said `uniqueBy: ['sku']`. ## Choosing - **`updateOrCreate()`** for small volumes, or when observers, casts or per-row decisions must run. - **`upsert()`** for bulk imports where the data is already in storable form and side effects can run separately. - **`insertOrIgnore()` or `saveOrIgnore()`** when existing rows must be left alone entirely. A pragmatic import often combines them: batch `upsert()` for the catalogue fields, then a targeted query for the rows whose price changed to trigger downstream work. ## Preparing the rows Since casts do not run, each row must already be in the form the database stores: - JSON columns as encoded strings, for example `json_encode($row['specs'])`; - enum-backed columns as their backing value, not the enum case; - money in the stored unit, such as integer cents, not a formatted string; - every row with the **same set of keys**, since the statement is compiled from the first row's columns. Validating and normalising the feed before the batch is cheap compared with discovering a malformed column halfway through a million-row import.

  • On MySQL, why can `upsert(..., uniqueBy: ['sku'])` update a row whose SKU differs from the incoming one?
    MySQL and MariaDB ignore the `uniqueBy` argument and resolve conflicts against every primary and unique index on the table. If `products` also has a unique `ean`, an incoming row with a new SKU but an existing EAN collides on `ean`, and that row is updated. Keep one unique business key per table you upsert into, or check for conflicting indexes first.
  • What happens to `created_at` on existing rows during an Eloquent `upsert()`?
    It is kept. Eloquent adds both timestamps to the inserted values but only appends `updated_at` to the update list, so a row that already exists gets a new `updated_at` and keeps its original `created_at`, unless you list `created_at` in the update columns yourself.

saying these in an interview costs you the question

  • upsert() fires created and updated events for each row
  • upsert() applies the model's casts to JSON columns
  • uniqueBy works without any unique index on those columns
  • MySQL uses only the uniqueBy columns to detect conflicts
  • upsert() overwrites created_at on existing rows by default