mavinoo/laravel-batch
Laravel Batch provides bulk insert and update helpers for Laravel models. Use Batch::update with a key column or updateMultipleCondition for per-row conditions, making large data changes faster and cleaner without repetitive queries. Includes a facade and batch() helper.
composer update mavinoo/laravel-batch and no code changes from 3.2. That makes this a low-risk, high-return scheduling decision rather than a migration project.UPDATE ... CASE statement now holds at most 100 rows.COPY-based fast imports.upsert(): insert-or-update in one query with timestamps (created_at only on new rows), enums, and automatic splitting in one transaction. Removes the previous "read-then-decide-then-write" pattern from ETL code.deleteByKeys(): bulk deletes matched on one or more key columns; models using SoftDeletes are soft deleted unless $force is true. Bulk cleanup, retention/GDPR-style purge flows, and admin "delete selected" become configuration.updateByKeys(): update rows matched on any number of key columns — covers composite/natural keys (e.g. tenant_id + external_id).insertRows(): insert rows as column => value pairs — the shape imports actually arrive in.SELECT ... FOR UPDATE + application math:
Batch::upsert($model, $rows, ['sku'], ['qty' => ['+']]) — atomic counter adjustments. ['-'], ['max'], ['min'] also supported, on every database. This is the primitive behind inventory adjustments, wallet balance credits, leaderboard scores, quota counters, and "stock-on-hand" reconciliations that are natural upsert candidates.Batch::upsert($model, $rows, ['sku'], null, ['onlyIf' => ['updated_at' => '>']]) — conditional upserts: only overwrite an existing row when the incoming value wins. New rows always insert. This enables last-write-wins sync semantics without an external reconciler.upsertReturning() returns the affected rows (id, natural keys, other columns), removing the follow-up SELECT in sync pipelines that need to link inserted/updated rows to downstream work.insertOrIgnoreRows() returns inserted count and the actual skipped duplicate rows. Tested with 8 concurrent writers inserting overlapping keys on MySQL 8.4, MariaDB, and PostgreSQL — every row is attributed to the connection that inserted it. This makes concurrent dedup at scale a reportable, auditable operation rather than "trust the insert count."Batch::import($model, $path, ['fast' => true]) uses COPY on PostgreSQL and LOAD DATA LOCAL INFILE on MySQL/MariaDB. 1,000,000-row CSV: PostgreSQL 18.7s → 6.6s, MySQL 8.4 18.7s → 8.4s, MariaDB 12.6s → 6.4s.Batch::import($model, 'users.csv', ['rules' => ['email' => 'required|email']]) returns totalRows, skipped, and errors keyed by row number. This is the primitive behind any "upload a CSV, show row-level validation errors" UI — previously a custom job.User::where('active', true)->exportTo(storage_path('users.csv'), ['maxRows' => 100000]) writes CSV, TSV, or JSON Lines (optionally gzipped), chunked at constant memory, and splits into numbered parts via maxRows. Reporting exports, compliance dumps, and "download my data" features become a method call rather than a bespoke streaming job.User::withoutTimestamps(fn () => Batch::update(new User, $rows)) is documented and tested for every method. Removes a class of "why did updated_at move on a sync?" surprises.pretend() Enables Product-Visible Previews: See the SQL and bindings a call would run without executing it or connecting to the database. This unlocks:
HasBatch Methods: Every operation now has a static entry point (Model::batchUpdateMultipleCondition(), batchUpdateByKeys(), batchInsertRows(), batchUpsert(), batchDeleteByKeys(), batchUpdateWithTwoIndex(), and the v3.3.0 additions). This standardizes call sites, plays well with static analysis and IDE discovery, and is the direction the package is committing to.upsert() for sync/import jobs, deleteByKeys() for cleanup, and import() for CSV ingestion. Add DB::transaction() only around multi-step business workflows that must be atomic end-to-end.HasBatch methods; the instance updateMultipleCondition() is deprecated and slated for removal in 4.0.pretend() where a preview or approval step is required. Expose import(errors) and insertOrIgnoreRows(skipped) results directly in admin UIs.COPY/LOAD DATA fast imports, row validation, and streaming export are all first-class. Backed by PHPStan level 9 with Larastan in CI and a multi-database test matrix.insertRows()/insert(); use import(['fast' => true]) for CSVs up to millions of rows.import(['rules' => ...]) with row-level errors returned — a ready-made primitive for self-service CSV onboarding.upsert() (with created_at set only on new rows) and upsertReturning() when downstream steps need the resulting rows.+/-/max/min upserts in one round trip.onlyIf conditional upserts to keep the newest updated_at without a reconciler.insertOrIgnoreRows() to ingest overlapping feeds and report exactly which rows were skipped and why.deleteByKeys() with automatic soft-delete behavior; $force for hard purges.updateByKeys() for tenant/external-ID style matching.last_login < 30 days.exportTo() CSV/TSV/JSONL, gzipped, chunked, or split into numbered parts via maxRows.pretend() to show affected SQL/counts before execution.COPY-based fast imports.Adopt When:
upsert()).['+'], ['-'], ['max'], ['min']) for inventory, balances, quotas, or scores.onlyIf).upsertReturning()).SoftDeletes without writing custom logic.COPY on PostgreSQL, LOAD DATA LOCAL INFILE on MySQL/MariaDB).rules option).maxRows splitting.pretend()).COPY in v3.3.0; still validate SQL Server separately if your product depends on it.User::batchUpdate() / User::batchUpsert() / Batch::import() instead of manual foreach loops.Look Elsewhere If:
WHERE EXISTS (SELECT ...).DB::raw() in values/keys/conditions provides.LOAD DATA LOCAL INFILE (e.g. local_infile=0, hardened hosting). Standard-mode import() still works; ['fast' => true] will not.InvalidArgumentExceptions for non-array rows, non-string column names, and invalid arithmetic operators), but arbitrary runtime-supplied column names still deserve review at the call site.updateMultipleCondition(): it is deprecated and slated for removal in 4.0. Migrate to Model::batchUpdateMultipleCondition() — this is not a reason to avoid the package, but it is a reason to schedule the change now rather than at 4.0.For Executives: "Two things are true right now. First, v3.0.0 was a mandatory security and reliability upgrade: the 2.x line had a SQL-injection flaw because it built SQL by escaping values manually — exploitable on PostgreSQL and through index/JSON keys on every database. v3.0.0 fixed that with bound parameters and Laravel-native identifier quoting. If any product is still on 2.x, that remains the top priority.
*Second, every release since — including the newest v3.3.0 — is a free upgrade on top of it. It is additive; from 3.2 this is composer update, no code changes. It delivers:
Cost: 2.x → 3.0 is a short but real breaking-change migration ($raw removed, return types changed, invalid input now throws) — budget a focused task. Everything since 3.0 is a dependency bump with zero code changes. It remains MIT-licensed with no vendor lock-in."
For Engineers:
"Laravel Batch v3.3.0 is additive over 3.2 — composer update mavinoo/laravel-batch, no code changes. If you are still on 2.x, the v3.0.0 breaking changes and the SQL-injection fix still apply first.
Added across 3.1 → 3.3 (all additive):
updateByKeys() (match on any number of key columns), insertRows() (column => value pairs), upsert() (one query, created_at only on new rows, enums supported, large batches split automatically in one transaction), deleteByKeys() (respects SoftDeletes unless $force is true).pretend(): returns the SQL and bindings a call would run, without executing it or connecting to the database. Use it for dry runs, admin previews, and DB-free tests.HasBatch methods for every operation: batchUpdateMultipleCondition(), batchUpdateWithTwoIndex(), batchUpdateByKeys(), batchInsertRows(), batchUpsert(), batchDeleteByKeys().Batch::upsert($model, $rows, ['sku'], ['qty' => ['+']]) (also ['-'], ['max'], ['min'], on every database). Arithmetic happens in the upsert, atomically.Batch::upsert(..., null, ['onlyIf' => ['updated_at' => '>']]) updates an existing row only when the incoming value wins; new rows always insert. upsert() also gained an optional fifth $options argument.upsertReturning() returns affected rows; insertOrIgnoreRows() returns ['inserted' => n, 'skipped' => [...detailed rows]] and was validated with 8 concurrent writers on overlapping keys across MySQL 8.4, MariaDB and PostgreSQL. Every row is attributed to the connection that inserted it.Batch::import($model, $path, ['fast' => true]) uses COPY on PostgreSQL and LOAD DATA LOCAL INFILE on MySQL/MariaDB. 1M-row CSV: PostgreSQL 18.7s → 6.6s, MySQL 18.7s → 8.4s, MariaDB 12.6s → 6.4s. Bad values that MySQL would silently store with a warning now throw, and nothing is written.['rules' => [...]] returns totalRows, skipped, and errors keyed by row number. import() also returns an errors key in its result.Model::where(...)->exportTo($path, ['maxRows' => n]) writes CSV/TSV/JSONL (optionally gzipped), chunked at constant memory, split into numbered parts via maxRows.User::withoutTimestamps(fn () => Batch::update(new User, $rows)) is documented and tested for every method (#97).Performance (from 3.1.0): each UPDATE ... CASE statement is now capped at 100 rows — the database evaluates every row against every CASE branch, so large statements degraded badly. 50,000-row updates: MySQL 30.5s → 3.1s, MariaDB 25.3s → 2.4s, PostgreSQL 16 over 15 min → 2.5s, SQLite 3.7s → 3.4s.
Deprecated: the instance (new Model)->updateMultipleCondition() — use Model::batchUpdateMultipleCondition(). Removal in 4.0; migrate call sites now.
Also: clearer InvalidArgumentExceptions for non-array rows, non-string column names, and invalid arithmetic operators; PHPStan level 9 with Larastan in CI.
Transactions: automatically split batches run in one transaction on the model's connection, insert() rolls back on its connection, and upsert() runs its splits in one transaction. Multi-step business workflows that must be atomic end-to-end still belong inside an explicit DB::transaction().
Operational note: ['fast' => true] will not work where LOAD DATA LOCAL INFILE is disabled server-side; standard-mode import() remains a fallback."*
For PMs: "If anything is still on 2.x, upgrade now — it is a known SQL-injection fix and a breaking release that needs a scheduled engineering task, not an opportunistic dependency bump.
Then ship v3.3.0 immediately. It is additive, needs no code changes from 3.2, and gives us a large performance win (PostgreSQL large updates go from over 15 minutes to about 2.5 seconds) plus a much wider capability set we would otherwise build ourselves:
upsertReturning() when we need the resulting rows.fast, with a fallback — plus row-level validation errors returned for self-service uploads.insertOrIgnoreRows(), validated under concurrent writers — we can tell users exactly which rows were rejected and why.pretend()) we can surface in admin flows before destructive actions.Schedule one small cleanup: the old instance updateMultipleCondition() is deprecated and goes away in 4.0 — have engineering move call sites to the static methods while they are already in that code. Keep the same boundaries as before: avoid the package for complex multi-statement atomic workflows or highly specialized SQL, wrap critical paths in DB::transaction() where needed, and be aware that ['fast' => true] requires LOAD DATA LOCAL INFILE to be enabled on MySQL/MariaDB."
How can I help you explore Laravel packages today?