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.
Architecture fit. The package is a thin augmentation of Eloquent rather than a parallel ORM: a Bulkable trait on the model, a Bulk facade-ish object constructed via Model::bulk() or new Bulk(User::class), and a fluent builder that terminates in create/update/upsert/delete/forceDelete plus ...AndReturn and ...OrAccumulate variants. That is architecturally idiomatic for Laravel — no service provider ceremony, no repository abstraction to adopt, no schema changes. For a team already on Eloquent models with observers, the mental model is "the same foreach ($models as $model) { $model->save(); } loop, batched into a few queries per chunk."
The genuine differentiator is not bulk insert throughput — Laravel's Model::upsert already does that — it is event fidelity. Native Model::query()->insert() bypasses creating/created/saving/saved and observers, which is the single most common reason teams revert to per-row saves and eat the N+1 cost. This package closes that gap and adds collection-scoped *Many events plus a BulkRows payload that carries original, model, and unique — useful for the "parent saved, now fan out children" pattern shown in the README.
Integration feasibility. Low friction. composer require lapaliv/laravel-bulk-upsert, one trait per model, replace a loop. No infra, no queue, no config file evidenced. The ...OrAccumulate + chunk($n) API is a good fit for streaming imports and queue workers where you want to bound batch size without rewriting the caller.
Technical risk. Material, and concentrated in three areas:
$fillable/$guarded, HasUuids/HasUniqueStringIds, soft-delete scopes, and updated_at semantics behave identically to per-model saves. Divergence here produces silent data corruption, not errors....AndReturn necessarily re-selects after the write. Under concurrency that read-back is not atomic with the write; on replicas it is worse. The correctness of uniqueBy mapping to ON DUPLICATE KEY (MySQL) vs ON CONFLICT (Postgres) vs SQLite's constraint handling is driver-dependent and needs verification per driver.on* callbacks return false to suppress a row. In a chunked batch this implies partial application — atomicity guarantees are undocumented. If a chunk half-applies under an exception, the caller's retry is non-idempotent unless uniqueBy is correct.Key questions for a spike (not marketing questions — verification questions):
saving observer that mutates the model actually affect the persisted row, or is the write path pre-computed from raw input?uniqueBy accept composite keys consistently?false from onCreatingMany abort the chunk or skip models — and is the remainder still written?Stack fit. Requires PHP 8.0+, Laravel 8.0+, and MySQL 5.7+ / PostgreSQL 9.6+ / SQLite 3.32+. Confirm the installed Laravel major is explicitly supported by the latest tag rather than inferred from "8.0+". Verify the DB driver matches the primary target — teams running MySQL in prod and SQLite in test can get divergent conflict-resolution behavior, so the test matrix must include the prod driver.
Migration path. Do not do a repository-wide swap. Sequence:
foreach + save(). Instrument query count before/after and diff observer side effects (emails, audit rows, cache busts) against the legacy path for an identical dataset.Model::upsert — the package adds read-back and event overhead you don't need.Compatibility and sequencing. Add the dependency, then add a single integration test asserting observer parity for create/update/upsert/delete on your prod driver. Gate broader rollout on that test staying green through the next framework upgrade. Watch for interplay with SoftDeletes — delete() silently means soft or hard depending on the trait, and forceDelete bypasses the restore path.
Maintenance. Expect to own it. The package is small enough to fork and maintain (a handful of classes around the Eloquent builder), which is the mitigant for the bus-factor risk. Pin the version explicitly; do not float on ^2. Track the fork in your dependency-review process alongside first-party code.
Support. No vendor, no SLA, community-only. Internal expertise will sit with whoever wrote the spike — document the event-ordering contract and the halting semantics in-repo, because they are non-obvious and the README lists order but not failure behavior.
Scaling. Two levers: chunk size and connection concurrency. Larger chunks reduce round-trips but increase lock-hold duration and deadlock surface on MySQL ON DUPLICATE KEY under parallel workers — serializing upserts per uniqueness key, or using partitioned batches, is usually necessary. The ...OrAccumulate path holds full model instances in memory, so chunk size is a memory bound as well as a throughput knob. ...AndReturn adds a read-back per chunk; route it to the write connection, never a replica.
Failure modes to instrument:
uniqueBy.false-returning callbacks — emit a counter; a misbehaving observer can drop writes with no error.uniqueBy vs actual DB constraint.id — in bulk, created fires after the batch, and ID availability depends on the return path....AndReturn.Ramp-up. API surface is small and the README is well-structured with the event-ordering table and a realistic parent/child example — a mid-level Laravel developer is productive in under a day. The real ramp-up cost is not the API; it is building the team's intuition for where bulk-write semantics diverge from per-model saves. That knowledge should live in a short internal ADR alongside the fork, not in tribal memory.
Recommendation. Conditional adopt. The problem it solves is real and the API is well-matched to Laravel. The risk is not technical — it is that a 15-star, single-maintainer package becomes load-bearing in your write path. Adopt only if (a) the parity spike passes on your production driver, (b) you are willing to fork and pin, and (c) you scope it to paths that genuinely need Eloquent events in bulk. For everything else, native Model::upsert remains the lower-risk default.
How can I help you explore Laravel packages today?