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.
Install/upgrade with an explicit minor constraint — 3.2 → 3.3 is additive, so no code changes are needed:
composer require "mavinoo/laravel-batch:^3.3"
Requirements: PHP 8.1+ and Laravel 10–13. If you're still on 2.x, read UPGRADE.md before touching code — the 2.x → 3.0 jump is the breaking one (SQL-injection fix, $raw removal, return types); 3.x releases since then are composer update only. The 3.1 instance-method deprecation still stands (see Gotchas #2).
Register the provider/facade only if you don't use package discovery:
'providers' => [
Mavinoo\Batch\BatchServiceProvider::class,
],
'aliases' => [
'Batch' => Mavinoo\Batch\BatchFacade::class,
],
First use case — batch update by a shared index. Values are bound parameters and identifiers are quoted by Laravel's grammar, so pass plain PHP values, not pre-escaped strings:
use App\Models\User;
$users = [
['id' => 1, 'status' => 'active'],
['id' => 2, 'status' => 'deactive'],
];
$affected = Batch::update(new User, $users, 'id'); // int, not bool
Before you run anything for real, make pretend() your first call — it returns the SQL and bindings without executing or even connecting:
$preview = Batch::pretend(fn () => Batch::update(new User, $users, 'id'));
// inspect $preview['sql'] / $preview['bindings'] (see Implementation Patterns → Preview)
On a 3.0 codebase this is the fastest way to confirm what upgrade work changed, and in review it's the fastest way to prove a batch call does what the PR claims.
Pick the right first integration — for imports use Batch::import() rather than building rows and calling insert(); for sync jobs use upsert(); for “insert new, report duplicates” use insertOrIgnoreRows(). See Implementation Patterns below.
$raw removal, return-type changes, the 3.1 deprecation, and the 3.3 import/export/upsert additions.Batch::update(), Batch::updateMultipleCondition(), Batch::updateByKeys(), Batch::insert(), Batch::insertRows(), Batch::insertOrIgnoreRows(), Batch::upsert(), Batch::upsertReturning(), Batch::import(), Batch::deleteByKeys(), Batch::pretend() — or the batch() helper.HasBatch trait for User::batchUpdate(), User::batchUpsert(), User::batchDeleteByKeys(), etc. — 3.1 added a static wrapper for every batch operation; 3.3 adds model-level export via User::where(...)->exportTo(...).DB::raw(): the only way to inject SQL into a value — worth reading the README examples for it before writing any arithmetic update.fast imports have server-side prerequisites; exports have format and maxRows part-file behavior.composer test locally; CI covers PHP 8.1–8.5, Laravel 10–13, MySQL/MariaDB/PostgreSQL/SQLite, plus PHPStan level 9 with Larastan (new in 3.1), so type-level regressions are caught before release rather than assumed away.$data = [
['id' => 1, 'status' => 'active', 'nickname' => 'Alice'],
['id' => 2, 'status' => 'deactive'],
];
$affected = Batch::update(User::class, $data, 'id');
$scope = User::where('status', 'active')->pluck('id')->all();
$data = array_values(array_filter($data, fn ($row) => in_array($row['id'], $scope, true)));
Batch::update(User::class, $data, 'id');
$conditions = [
['conditions' => ['id' => 1, 'status' => 'active'], 'columns' => ['nickname' => 'Admin']],
['conditions' => ['id' => 2], 'columns' => ['status' => 'pending']],
];
User::batchUpdateMultipleCondition($conditions, 'id');
updateMultipleCondition() with string keys, null conditions, on PostgreSQL, and when a condition column is also updated in the same call. If you had workarounds coded around those cases, delete them.WHERE.updateByKeys()) — new in 3.1$rows = [
['tenant_id' => 1, 'sku' => 'ABC', 'price' => 999],
['tenant_id' => 2, 'sku' => 'XYZ', 'price' => 1499],
];
Batch::updateByKeys(new Product, $rows, ['tenant_id', 'sku']);
User::batchUpdateByKeys($rows, ['tenant_id', 'sku']); // static trait form
updateWithTwoIndex()-style calls whose second index was really a key column; 3.0's updateWithTwoIndex() still exists and now has batchUpdateWithTwoIndex() as a static wrapper.DB::raw() inside the value:
$operations = [
['id' => 1, 'balance' => DB::raw('balance + 100')],
['id' => 2, 'quantity' => DB::raw('quantity - 5')],
];
Batch::update(new User, $operations, 'id');
InvalidArgumentException, but it still can't read your intent — wrap financial batches in a transaction and write an audit row (the package's internal transaction does not cover your audit write):
DB::transaction(function () use ($operations) {
Batch::update(new User, $operations, 'id');
AuditLog::create([...]);
});
$columns = ['name', 'email', 'status'];
$values = [
['John', '[email protected]', 'active'],
['Jane', '[email protected]', 'pending'],
];
$result = Batch::insert(new User, $columns, $values, 200); // batch size 200
// ['totalRows' => ..., 'totalChunks' => ...] — array, not bool
insertRows() takes associative rows instead, which is almost always what you actually have after a CSV import or JSON decode:
User::batchInsertRows([
['name' => 'John', 'email' => '[email protected]', 'status' => 'active'],
['name' => 'Jane', 'email' => '[email protected]', 'status' => 'pending'],
], 200);
InvalidArgumentException before writing on invalid input, but they can't know your domain rules — dedupe/validate yourself.$insertIgnore works on PostgreSQL and SQLite too, so a single code path is fine.$result['totalRows'].upsert()) and Its 3.3 VariantsProduct::batchUpsert([
['tenant_id' => 1, 'sku' => 'ABC', 'price' => 999], // new → gets created_at
['tenant_id' => 2, 'sku' => 'XYZ', 'price' => 1499], // existing → updated_at only
], ['tenant_id', 'sku']);
Batch::upsert(new Stock, $rows, ['sku'], ['qty' => ['+']]);
// qty = stored qty + new qty
['-'], ['max'] and ['min'] work the same way, on every database.Batch::upsert(new Product, $rows, ['sku'], null, ['onlyIf' => ['updated_at' => '>']]);
Updates an existing row only when the new value wins. New rows are always inserted.upsert() now takes an optional fifth $options argument (onlyIf, etc.); existing four-argument calls are unchanged.firstOrCreate/updateOrCreate loops in importers and sync jobs — the whole point is one query instead of 2N. Counter upserts replace read-modify-write stock/balance loops; verify the semantics you need for nulls and missing rows.upsertReturning()) — new in 3.3$users = Batch::upsertReturning(new User, $rows, ['email'], ['name'], ['id', 'email']);
upsert() would have discarded. For big imports prefer counting and querying later unless you genuinely need the rows.insertOrIgnoreRows()) — new in 3.3$result = Batch::insertOrIgnoreRows(new User, $rows, ['email']);
// ['inserted' => 98, 'skipped' => [['email' => '[email protected]', ...], ...]]
skipped contains the row data you passed in. That is useful for a report, but don't dump it raw into logs if it contains PII; summarize or redact.deleteByKeys()) — new in 3.1Batch::deleteByKeys(new User, [['id' => 1], ['id' => 2]], 'id');
User::batchDeleteByKeys($rows, ['tenant_id', 'sku']);
Batch::deleteByKeys(new User, $rows, 'id', force: true); // hard delete
SoftDeletes are soft deleted by default — the package checks the model, not your intent. Pass $force only when you mean it, and check it in review: a missing $force looks identical in a diff to a correct soft delete.use Mavinoo\Batch\Traits\HasBatch;
class User extends Model {
use HasBatch;
}
User::batchUpdate($data, 'id');
User::batchInsert($columns, $values, 200);
User::batchUpsert($rows, ['email']);
User::batchDeleteByKeys($rows, 'id');
batchUpdateMultipleCondition(), batchUpdateWithTwoIndex(), batchUpdateByKeys(), batchInsertRows(), batchUpsert(), batchDeleteByKeys(). Standardise on the static form — it lets Batch read $model->getConnection(), which matters for the transaction/rollback behavior.batchSize for memory-bound jobs (queues, imports).UPDATE ... CASE statement now holds at most 100 rows regardless of your batchSize. That's a performance fix, not an API change — the database evaluates every row against every CASE branch, so statement cost grew superlinearly. 50,000 rows went from 30 s → 3 s on MySQL and from >15 min → 2.5 s on PostgreSQL. If you had hand-rolled chunking with a small batchSize purely to keep statements fast, measure again — you can raise it.pretend()) — new in 3.1$preview = Batch::pretend(fn () => Batch::update(new User, $data, 'id'));
EXPLAIN, and diff bindings before/after a refactor. This is the single best answer to "did the upgrade change my queries?"Batch::import(new Order, storage_path('orders.csv'), ['fast' => true]);
Uses COPY on PostgreSQL and LOAD DATA LOCAL INFILE on MySQL and MariaDB. Release benchmarks for 1,000,000-row CSVs: PostgreSQL 18.7 s → 6.6 s, MySQL 8.4 18.7 s → 8.4 s, MariaDB 12.6 s → 6.4 s.$result = Batch::import(new User, 'users.csv', [
'rules' => ['email' => 'required|email'],
]);
// ['totalRows' => 9988, 'skipped' => 12, 'errors' => [15 => ['email' => [...]]]]
Validation uses Laravel-style rules; skipped rows don't abort the whole import. import() also returns an errors key in 3.3.fast imports, the MySQL server generally needs local_infile=1 and the client/connection must be permitted to use it; the PostgreSQL user needs COPY privileges. Bad values that MySQL would silently store with a warning now throw and nothing is written.rules so error reporting is obvious; switch to fast only after the file shape and constraints are stable, then compare counts. For very large files, run it in a queued job with a generous timeout.User::where('active', true)->exportTo(storage_path('users.csv'), ['maxRows' => 100000]);
maxRows splits the output into numbered parts.->get() + fputcsv(), which loads the whole result set. Choose the format by extension, cap the export with maxRows, and make sure the destination directory is writable before dispatching the job.User::withoutTimestamps(fn () => Batch::update(new User, $rows)) now has documentation and tests for every method (#97).created_at/updated_at must not move. It wraps the callback; make sure the batch call and any related writes that should share the behavior are inside the same callback.upsert() — so drop the ->value boilerplate:
Batch::update(new User, [
['id' => 1, 'status' => UserStatus::Active],
], 'id');
columns, conditions, key columns and insert()/insertRows() values.disableBacktick() calls and any "disable backticks for Postgres" branches — identifiers are quoted by the grammar now.int (updates/deletes) / array (insert, import, insertOrIgnore, and returning upserts). Empty input returns 0 / zero counts instead of false, so do not treat falsy as failure:
try {
$affected = Batch::update(new User, $data, 'id');
if ($affected === 0 && $data !== []) {
// nothing matched — data problem, not an exception
Log::warning('Batch update matched no rows', ['rows' => count($data)]);
}
} catch (\InvalidArgumentException $e) {
// bad input shape (non-array row, non-string column, bad arithmetic operator) — nothing was written
report($e);
}
skipped/errors arrays (row-level validation). They answer different questions.DB::listen() shows the real SQL and bindings that ran — in 2.x the logged SQL and the executed SQL were different, which is exactly what made the injection bug invisible.pretend() over log archaeology: it's deterministic, offline, and safe in a test.totalRows, skipped, errors count) rather than every row; the summary is enough to spot a bad file without flooding logs with PII.Upgrade 2.x → 3.x immediately (security). All 2.x versions build SQL by escaping values by hand, allowing SQL injection — always on PostgreSQL, and via index values and JSON keys on every database. If you can't upgrade today, audit any batch call whose index values or JSON keys touch user input. 3.0 → 3.3 is free; there is no reason to sit on an old 3.x.
The instance updateMultipleCondition() is deprecated (3.1) — fix it now, not at 4.0. (new Model)->updateMultipleCondition(...) still works but is scheduled for removal. Grep for ->updateMultipleCondition( on instances and move to the static Model::batchUpdateMultipleCondition(). Note the argument order/arity differs between the facade call (Batch::updateMultipleCondition($model, $conditions, $index)) and the static trait form (Model::batchUpdateMultipleCondition($conditions, $index)) — a mechanical find-and-replace will get one of them wrong.
$raw is gone and passing true throws. This is deliberate: a missed call must fail rather than store SQL as text. Replace:
// 2.x
Batch::update($model, [['id' => 1, 'score' => 'score * 2']], 'id', true);
// 3.x
Batch::update($model, [['id' => 1, 'score' => DB::raw('score * 2')]], 'id');
Grep for true) trailing arguments and for DB::raw used outside values — raw is supported in values, index values, and conditions, but not as a table or index-column name.
Return types changed — find every if (!Batch::...). Updates/deletes return int, insert() returns array, and 3.3's upsertReturning()/insertOrIgnoreRows()/import() return richer arrays. Old if ($result) { ... } guards silently do the wrong thing on 0; $result['totalRows'] on a bool errors. Still the most likely silent bug after the 2.x upgrade.
Empty input is not an error. 0/zero counts are returned, not false. Validate "no rows matched" separately if the UI depends on it — and remember upsert(), insertRows(), insertOrIgnoreRows() and imports report counts too, so a re-run of an import that changed nothing may legitimately report zero inserted/updated rows.
Invalid input throws InvalidArgumentException before any write — so catching it means the database is untouched. 3.1 broadened the messages (non-array rows, non-string column names, invalid arithmetic operators), which makes logged errors actionable. Don't retry these; log the offending rows. Retry only connection/constraint failures. Import row-validation failures are the exception: they are returned in errors, not thrown, unless the file/load itself fails.
upsert() depends on a real unique index — especially on MySQL, where the conflict target is whatever unique key is violated, not necessarily the columns you passed. If your "key columns" aren't backed by a unique index, you get duplicate inserts or a driver error on Postgres. Add the index. This applies equally to counter/conditional upserts and upsertReturning().
Understand conditional and counter upsert semantics before using them in money/inventory code. onlyIf affects updates to existing rows only — new rows are always inserted. Counter operations (+, -, max, min) apply on conflict and are not a substitute for a database constraint or idempotency key. Test null/negative values and concurrent writers on your real driver.
upsertReturning() can be a memory trap. Returning rows is convenient but keeps the full result set in PHP; a 500k-row sync that returns every row defeats the point of batching. Return only IDs/columns you use, and prefer plain upsert() plus a follow-up query when you only need counts.
insertOrIgnoreRows() reports skipped rows by returning your input row data. Excellent for a reconciliation report; risky for logs and support tickets if the row contains PII. Summarize, redact, or store the report behind access control. Also check memory: a feed with 250k duplicates builds a very large skipped array.
Fast imports have operational prerequisites and a different failure mode. COPY needs PostgreSQL privileges; LOAD DATA LOCAL INFILE needs MySQL/MariaDB local_infile enabled and permitted on the connection. The release notes say bad values that MySQL would otherwise store with a warning now throw and nothing is written — good for correctness, but it means a single malformed row can reject a large load, so validate a sample first.
Import validation errors are row-indexed; confirm whether that index is the CSV line or a zero-based row. The example shows 15 => ['email' => [...]]. When reporting to users, map it back to their spreadsheet row explicitly (header offset, blank lines) rather than trusting a raw array key.
upsert()/import()/exportTo() don't remove the need for your own domain checks. Uniqueness, foreign keys and allowed state transitions still belong to your schema and validation rules. The package validates shape and, for imports, the rules you supply; it doesn't know that status = 'active' requires a paid plan.
updated_at only changes for rows that actually change (3.0, every database, #120). If you relied on batch calls to touch every row (e.g. cache-busting on updated_at), you need an explicit DB::raw() assignment or a touch pass. This applies to upsert() too: a row whose values are unchanged may not bump updated_at. Use withoutTimestamps() when you explicitly don't want timestamp movement, not as a workaround for this.
deleteByKeys() soft deletes by default. If the model uses SoftDeletes, a missing $force in the call is a silent behaviour choice. Verify in tests that counts and deleted_at are what you expect, and be explicit in code review — force: true should be a deliberate, commented decision.
CREATED_AT / UPDATED_AT set to null now works (#107). Remove any custom column-name hacks added to work around this — they may now conflict with upsert() timestamp handling.
Transactions are per-connection and internal. Chunked batches — including the 100-row CASE split and upsert()/insertRows() chunking — run in one transaction on $model->getConnection(). In multi-connection apps, make sure the model instance you pass actually points at the target connection, and note insert() and deleteByKeys() roll back on the model's own connection: a wrapping DB::transaction() on the default connection won't protect a job writing to another one. The same care applies to imports/exports: they do file and bulk-load I/O, so don't assume an outer application transaction covers the file side effects.
Mavinoo\Batch\Common\Common and the global str() helper are removed. Grep for both before upgrading from 2.x; the helper's presence could shadow framework/Laravel helper behavior in unexpected places.
Three independent caps now — don't double-chunk. (a) the database's bound-parameter limit (package-handled), (b) the fixed 100-row UPDATE ... CASE split (package-handled, new in 3.1), (c) your batchSize (you choose, for memory and queue timeouts). Hand-chunking "just in case" only adds transactions and round-trips. Conversely, a big batchSize no longer makes statements slow — it only affects memory.
Re-measure after upgrading rather than trusting the benchmark table. The 3.1 50,000-row numbers and the 3.3 million-row import numbers are workload-specific. SQLite barely moved for the 3.1 update (3.7 s → 3.4 s), and import gains depend on file size, indexes and constraints. If your batches are small or narrow you'll see little change.
pretend() does not connect — that's the point and the limit. Perfect for tests, CI, SQL review and EXPLAIN; useless for catching constraint, type or driver errors. Don't let a green pretend()-only test suite stand in for one integration test per driver you actually deploy on — especially now that imports and exports touch filesystem and bulk-load paths that pretend() can't preview.
Trait conflicts: verify HasBatch alongside SoftDeletes, HasFactory, etc. in each model, and check that soft-deleted rows are excluded the way you expect from your own scoping. deleteByKeys() makes this more important, not less.
Test on your real driver, but you have less reason to: the CI matrix covers MySQL, MariaDB, PostgreSQL and SQLite, and 3.1 adds PHPStan level 9 with Larastan. If you hit a driver-specific bug, composer test against your driver is a genuinely useful reproduction path — report it with the exact driver/version. For the new import/export features, include your real MySQL/MariaDB/PostgreSQL version and relevant server settings (local_infile, COPY privileges) in the report.
How can I help you explore Laravel packages today?