For a nightly Eloquent product-feed import, why use Product::upsert() instead of updateOrCreate() per row, and what does upsert() skip?
answer
- one statement per batch, not per row
- values, uniqueBy, update columns
- unique index on uniqueBy columns
- timestamps set, events and casts skipped
- Laravel 13: empty uniqueBy throws
basics
~20 supsert() 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
Recall that upsert() inserts new rows and updates existing ones in bulk, identified by the uniqueBy columns.
Explain the three arguments, the index requirement and what Eloquent adds, timestamps and unique ids, over the query builder.
Trade per-row helpers against batched upserts, and plan how skipped events, casts and change reporting are handled after each batch.
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