lapaliv/laravel-bulk-upsert
Laravel Bulk Upsert saves collections of Eloquent models with bulk queries while still firing creating, updating, saving, deleting, and restoring events. It auto-aligns uneven fields and returns the inserted rows. Supports MySQL, PostgreSQL, and SQLite.
Install and wire up the trait. The package is a single composer dependency plus a trait on every model you want to bulk-write through it:
composer require lapaliv/laravel-bulk-upsert
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Lapaliv\BulkUpsert\Bulkable;
class User extends Model
{
use Bulkable;
}
There is no service provider configuration to publish, no config file, and no migration — it is a pure query-building layer over the model's table. That is the main reason it is worth a look: it is a drop-in replacement for Model::query()->insert()/upsert() for teams that have observers or model events doing real work (slug generation, UUID assignment, audit columns, cache invalidation, relation fan-out).
Check the runtime matrix before you commit. PHP 8.0+, Laravel 8.0+, MySQL 5.7+, PostgreSQL 9.6+, SQLite 3.32+. The SQLite floor is the one that bites CI pipelines — 3.32 is where RETURNING became broadly usable, and this package leans on returning rows. On a Laravel 10/11 app you are comfortably fine; on an older locked-down stack you may not be.
First use case: replace a foreach ($rows as $row) { Model::create($row); } loop. That loop is the thing this package is designed to delete. Take a report/import job that currently inserts 5,000 rows one at a time and rewrite the inner loop:
$users = User::query()
->bulk()
->uniqueBy('email')
->createAndReturn($rows); // Illuminate\Database\Eloquent\Collection<User>
You get the created models back — with IDs — and your existing creating/created observers still run. That is the headline feature. If you are currently doing insert() and then a hand-written WHERE email IN (...) query to recover the rows, this collapses into one call.
Where to look first. The Bulk instance is the whole API surface. Four equivalent ways to get one, pick one and be consistent so reviewers can grep for it:
User::query()->bulk(); // from a query builder
User::bulk(); // static, shortest
new Bulk(User::class);
new Bulk(new User());
For a first proof of concept, do it in a test with Event::fake() off, assert the observer fired, assert count($returned) matches, and assert the DB. Do not start by refactoring production code — start by proving your observers behave identically under both paths.
Separate your write paths by intent, not by table. The package exposes four verbs — create, update, upsert, delete/forceDelete — each with a plain, an ...AndReturn, and an accumulate variant. In practice you want a thin repository method per intent so the call site reads like the business operation:
final class UserBulkRepository
{
public function import(array $rows): Collection
{
return User::query()
->bulk()
->uniqueBy(['email'])
->upsertAndReturn($rows);
}
public function touchRoles(array $rows): void
{
User::query()->bulk()->update($rows); // rows carry 'id'
}
}
Centralising this is worth it because uniqueBy() is a correctness contract, not a hint — see the gotchas.
Prefer upsert over create/update when the input is genuinely mixed. The README's own example mixes insert rows and update rows in one payload:
$data = [
['email' => '[email protected]', 'name' => 'Jacob'], // no id → insert
['id' => 1, 'email' => '[email protected]', 'name' => 'Oscar'], // id → update
];
$users = $bulk->uniqueBy(['email'])->upsertAndReturn($data);
This is the pattern for CSV imports and sync-from-upstream jobs. update alone will silently fail to do anything useful for rows that do not yet exist; create alone will throw on conflict. upsert is the honest verb for "make the table match this payload".
Use the accumulate API for streaming sources. When rows arrive from a cursor, a paginated API, or a generator, do not buffer the whole result set in memory. Wire the chunk size once and flush explicitly:
$bulk = User::query()->bulk()
->uniqueBy(['email'])
->chunk(500);
foreach ($this->streamUpstream() as $row) {
$bulk->createOrAccumulate($row);
}
$bulk->createAccumulated(); // flush the remainder
The explicit final ...Accumulated() call is not optional — without it the tail of your stream is silently dropped. Put it in a finally block if the loop can throw.
Keep the ...AndReturn variant for cases where you actually use the models. It costs a read-back. If you are just counting or logging, use the plain variant. If you are feeding a second bulk write, use the return variant — the savedMany chain from the README is the canonical example:
class UserObserver
{
public function savedMany(Collection $users, BulkRows $bulkRows): void
{
$rawComments = [];
$bulkRows->each(function (BulkRow $bulkRow) use (&$rawComments): void {
$bulkRow->original['user_id'] = $bulkRow->model->id;
$rawComments[] = $bulkRow->original;
});
Comment::query()->bulk()->uniqueBy(['uuid'])->upsert($rawComments);
}
}
BulkRow gives you original (the array you handed in), model (the hydrated Eloquent model), and unique (the attributes used for matching). Reaching for original is how you preserve fields that are not columns — nested comments, computed values, correlation keys — through the round trip. This is the single most valuable pattern in the package and the one worth writing a team convention around.
Route existing observers rather than creating new ones. Because the package fires creating/created/updating/updated/saving/saved at the individual-model level, your existing UserObserver::creating() keeps working unchanged. Resist the urge to duplicate logic into savingMany. Use the *Many hooks only for work that is genuinely collection-scoped: relation fan-out, batch cache invalidation, aggregate recomputation, dispatching one job instead of five hundred.
Use selectAndUpdateMany for "update everything matching a filter". This is a distinct pattern from the Bulk object — it reads the matching rows, hydrates models, then updates via the query:
User::query()
->whereIn('id', [1, 2, 3, 4])
->selectAndUpdateMany(values: ['role' => null]);
Reach for it when the predicate is the source of truth rather than the payload, e.g. backfills, bulk state transitions, feature flag rollouts. It hydrates models, so it is not free — do not point it at a million rows without chunking the whereIn.
Wrap everything in a transaction at the job level. The package is chunked, so a mid-flight failure leaves earlier chunks committed. Atomicity is your responsibility:
DB::transaction(function () use ($rows) {
User::query()->bulk()->uniqueBy('email')->upsert($rows);
});
Test against the real grammar you deploy on. Insert/upsert/returning semantics differ meaningfully across MySQL, Postgres, and SQLite. If production is MySQL and CI is SQLite, a green build is not evidence. At minimum run the import path against the production engine once per release.
uniqueBy() is load-bearing for upsert and for every ...AndReturn. The docs describe it as "the unique attributes which were used for saving" — the package uses it to build the conflict target and to select the rows back. Pass the wrong columns and you get either a DB-level conflict error or, worse, a return collection that does not correspond to what you wrote. Match it exactly to a real unique index. If you have a composite unique (['tenant_id', 'email']), pass the array — and make sure the index in the database is in the same order.
Field alignment is handled, but do not mistake it for schema safety. The package aligns variable-shaped rows by padding missing fields — Laravel's own insert/upsert would throw or misbehave. That is convenience, not validation. A typo'd key in one row is still a typo; it just fails later, at the SQL layer, or lands in the wrong column if the name is close. Validate payloads upstream and keep $fillable/casts honest.
Returning false from a listener silently filters rows. Every onCreating, onUpdating, onSaving, onDeleting and their *Many equivalents is a veto. This is powerful (skip soft-deleted duplicates, skip rows failing a policy check) and dangerous, because nothing throws. If a bulk import inserts fewer rows than expected, your first debugging step is not the database — it is auditing every saving/creating callback and observer on that model for early returns, including inherited traits and global observers registered in packages.
The event ordering is unusual and worth internalising. Per the README: all *Many before hooks (onSavingMany, onCreatingMany, onDeletingMany, …) fire before the per-model after hooks (onCreated, onUpdated, onSaved). So onSavingMany → onCreatingMany → onCreated → onCreatedMany → onSavedMany. Do not assume "Many = after all singles have finished". If you have logic that depends on a model's final id inside a Many before hook, it will not be there yet. Use the after variants.
Soft deletes and restore are implicit and version-sensitive. delete() chooses soft vs. force based on whether the model uses SoftDeletes; forceDelete() always forces. Restore is triggered through the upsert path (the README notes onUpdating vetoes also block restore and delete on upsert). Deleting via uniqueBy requires the unique columns to be present in your data rows — you are matching on payload, not on model instances. Deleting 10,000 records this way means shipping their unique keys, which for many teams is a bigger payload than an UPDATE ... WHERE id IN (...). Measure before adopting for deletes specifically.
Do not use this for hot-loop single-row writes. Everything here builds a Bulk instance and a listener pipeline. For one row, $model->save() is correct and cheaper. The break-even is somewhere in the dozens-of-rows range; below that the ceremony is not repaid.
Version 1.x is a different API. The 2.x line is the fluent Bulk object and the *Many events; 1.x has separate documentation. If you inherit a codebase or copy examples from a blog post, check which line you are on. The README explicitly links to a separate 1.x doc, which is a tell that the migration is not trivial.
Treat it as a dependency you own. Fifteen stars and zero dependents means: no ecosystem of Stack Overflow answers, no third party who has already hit your edge case, and a bus factor of roughly one. That is not a reason to skip it — the code is MIT, small, and the problem it solves is real — but it is a reason to (a) pin the version exactly, (b) write your own integration tests around the specific verbs you use, and (c) wrap it behind the repository methods above so a future swap to raw upsert + manual re-select is a one-file change. Read the changelog before every bump; a two-year gap since the last release date is worth verifying against your Laravel version.
Debugging tip: drop to the query log first. Because the package batches, a failure gives you one exception for a chunk of 500. Run the failing payload with DB::listen() or DB::enableQueryLog() and a chunk size of 1 to isolate the offending row, then diagnose the field alignment or unique conflict on that single row. This shortcut turns a very unpleasant debugging session into a five-minute one.
Extension point: the listeners themselves. If you need behaviour the package does not expose, the callbacks (onCreating, onSavingMany, …) are a legitimate place to put it — they run inside the package's own pipeline with access to BulkRow, which is richer than an Eloquent event's payload. Prefer that over subclassing or forking. The README's API section is truncated in this summary; read the full README on GitHub before assuming an accessor or option does not exist.
How can I help you explore Laravel packages today?