# laravel-batch: bulk update, insert and upsert without the query loop

> laravel-batch is a Laravel package that applies many rows in a few queries across MySQL, MariaDB, PostgreSQL and SQLite, covering updates with per row conditions, inserts that return their ids, upserts, deletes, table syncs, file import and export, and chunked work for large row counts. It ships as a facade and takes row arrays rather than model instances, and the README is unusually precise about where its behaviour differs per database.

**mavinoo/laravelBatch** — insert batch and update batch in laravel

- Repository: https://github.com/mavinoo/laravelBatch
- Stars: 591 · Forks: 124
- Language: PHP
- License: MIT
- Published: 2026-10-22 · Updated: 2026-10-22 · Language: en
- Canonical page: https://hysenlabs.com/projects/mavinoo-laravelbatch

## Rows in, a few queries out, and a facade called Batch

The package describes itself as bulk database operations for Laravel, covering update, insert, upsert, delete, sync, import and export of many rows in a few queries, on MySQL, MariaDB, PostgreSQL and SQLite. The repository description is narrower and more blunt: insert batch and update batch in Laravel.

Installation is a single Composer command:

```bash
composer require mavinoo/laravel-batch
```

The service provider and the Batch facade are registered automatically, so there is no configuration step and no boot file to edit. The facade is imported as Mavinoo\Batch\BatchFacade aliased to Batch, which is the only API surface most applications will touch.

The input model is a row array rather than a collection of Eloquent models, which is the design decision that makes the bulk path different from the normal one. An update looks like this:

```php
use App\Models\User;
use Mavinoo\Batch\BatchFacade as Batch;

$affected = Batch::update(new User, [
    ['id' => 1, 'status' => 'active', 'nickname' => 'Mohammad'],
    ['id' => 5, 'status' => 'deactive'],
    ['id' => 10, 'date' => now()],
], 'id');
```

Each row holds the index value and the columns to set, and rows are allowed to set different columns, so three rows with three different shapes become one statement. The third argument is the index column, and it defaults to the model's primary key.

## The affected rows count means different things per database

There is one detail in the README that deserves to be read twice, because it is the kind of difference that produces a bug report months later.

update() returns the number of affected rows, and the README says MySQL and MariaDB only count rows whose values really changed, while PostgreSQL and SQLite count every matched row. The same statement therefore returns 1 on MySQL when the row already held those values, and 1 on PostgreSQL for the same row. The number is not comparable across the four supported databases.

Two more rules about the index are documented. If the same index value appears in more than one row of a call, only the first of those rows is applied, so a duplicate key inside a single call is silently dropped rather than raising an error. And the values themselves can be strings, numbers, booleans, null, dates and enums, with backed enums stored by their value and other enums by their name.

That last point is a small trap. An enum stored by name means a rename in your PHP code changes the value written to the database, and a rename in the database is not a code change the package can see.

The timestamps rule follows the same pattern. If the model uses timestamps, updated_at is set to the current time only for rows where a value actually changes, and you can override it by passing updated_at in a row. So on MySQL and MariaDB the timestamp behaviour matches the affected rows behaviour, and on PostgreSQL and SQLite it does not, because a matched row with unchanged values will still have its updated_at rewritten.

## Increments, raw SQL and JSON paths in one row array

Three value forms are supported, and each has a specific use.

The first is arithmetic on a column, written as an operator and a number:

```php
Batch::update(new User, [
    ['id' => 1, 'balance' => ['+', 500]], // add
    ['id' => 2, 'balance' => ['-', 200]], // subtract
    ['id' => 3, 'balance' => ['*', 5]],   // multiply
    ['id' => 4, 'balance' => ['/', 2]],   // divide
    ['id' => 5, 'balance' => ['%', 2]],   // modulo
]);
```

The second is raw SQL, by wrapping a value in DB::raw():

```php
use Illuminate\Support\Facades\DB;

Batch::update(new User, [
    ['id' => 1, 'last_seen_at' => DB::raw('NOW()')],
    ['id' => 2, 'score' => DB::raw('score * 2 + bonus')],
]);
```

The README carries a warning about this one: never put user input inside DB::raw(), because every other value is sent as a bound parameter and is safe. The distinction is worth holding on to, since it is the only path in the API where a string is not parameterised.

The third is a JSON path, written with the arrow syntax Laravel uses:

```php
Batch::update(new User, [
    ['id' => 1, 'settings->theme' => 'dark', 'settings->notify->email' => false],
    ['id' => 2, 'settings->theme' => 'light'],
]);
```

The documented semantics are precise. Other keys in the document are kept, missing objects along the path are created, and a NULL column starts as an empty object. Values keep their JSON type, so false is stored as false and arrays as JSON arrays or objects, while DB::raw() values are used as given. Path keys are object keys and cannot contain quotes or backslashes, and a row cannot set a column and a path inside it in the same call. It works with every update method on all four databases.

For columns the model casts to JSON, the array, json, object, collection, AsArrayObject and AsCollection casts, arrays and collections are encoded the way the model would store them, in every method:

```php
Batch::update(new User, [['id' => 1, 'settings' => ['theme' => 'dark']]]);
```

Without such a cast, an array value is read as an increment or decrement, which is a difference you would want to know about before it shows up as a wrong number in a balance column.

## Two key columns, any number of keys, and per row conditions

Matching on more than the primary key is a first class feature rather than a workaround.

For a fixed pair of columns there is a dedicated method:

```php
Batch::updateWithTwoIndex(new Membership, [
    ['user_id' => 1, 'team_id' => 3, 'role' => 'admin'],
    ['user_id' => 2, 'team_id' => 3, 'role' => 'member'],
], 'user_id', 'team_id');
```

And for any number of keys you pass an array of column names, with every column in that array used for matching:

```php
Batch::updateByKeys(new Score, [
    ['org_id' => 1, 'year' => 2025, 'code' => 'A', 'points' => 10],
    ['org_id' => 1, 'year' => 2026, 'code' => 'B', 'points' => 20],
], ['org_id', 'year', 'code']);
```

Then there are per row conditions, where each item carries its own conditions and its own columns:

```php
Batch::updateMultipleCondition(new User, [
    [
        'conditions' => ['id' => 1, 'status' => 'active'],
        'columns'    => ['status' => 'invalid', 'nickname' => 'mohammad'],
    ],
    [
        'conditions' => ['id' => 2],
        'columns'    => ['nickname' => 'mavinoo', 'name' => 'mohammad'],
    ],
], 'id');
```

Every item's conditions must include the key column, the third argument, which is the primary key by default. A condition column can also be updated in the same call, as status is in the first item above.

One database specific failure is documented and worth knowing before you hit it. On MySQL and MariaDB, two columns that are both updated and used in each other's conditions cannot be updated in one call, because MySQL assigns columns one after another. The call throws an InvalidArgumentException, and the fix is to split it into two calls.

On PostgreSQL and SQLite that restriction does not exist, which is another of those per database differences that the README tracks carefully.

## Insert, upsert, sync, import, export and chunked work

The update path is the best documented, but the package covers a wider surface, and the feature list in the README enumerates it.

Insert takes a column list and a list of value arrays, and the linked sections cover getting the new ids back, and a variant that skips duplicates and reports which rows were skipped. Upsert is described with counters, with a condition for updating only when the new row wins, and with a variant that returns the stored rows.

Delete matches rows on several key columns. Sync reconciles part of a table with a list in one go, inserting, updating and deleting as needed. That operation is the one to look at first if your problem is reconciliation rather than a one off write, because it removes the read, diff and apply steps that people usually write by hand.

Import reads CSV, TSV and JSON Lines files, or any iterable, with constant memory, row validation and a path that uses the database's own bulk loader. Export writes a query to files and can split a large file.

And there is a section for deleting, updating or archiving millions of rows a chunk at a time, which is a different problem from a bulk update and the one place where a single statement approach would fail.

The package also offers a way to preview the SQL of any call without running it. For a library that turns arrays into generated statements, that is a genuinely useful debugging tool rather than a nicety, because the statement is where the assumptions about your schema get encoded.

## Version 3.x, a four database matrix and an upgrade guide

The requirements table is unusually explicit, and it is the first thing to check.

For the 3.x line, the supported PHP versions are 8.1 to 8.5, the supported Laravel versions are 10 to 13, and the supported databases are MySQL, MariaDB, PostgreSQL and SQLite. The 2.5.x line covers PHP 7.1 to 8.5, does not pin a Laravel version, and says it is tested with Laravel 8 and 11, and lists MySQL and MariaDB with PostgreSQL and SQLite only partly supported. There is an UPGRADE.md for people coming from 2.x.

That table is the single most useful thing in the README for a decision. It tells you that the current line requires PHP 8.1, so an application on PHP 8.0 is out, and it tells you that a Laravel 9 application has to stay on the older package line. It also makes clear that the four database support is a 3.x feature, not something the older line ever had.

The repository is small and conventional for a Laravel package: src for the code, tests, a composer.json, a phpunit.xml.dist and a phpstan.neon.dist for static analysis, a CHANGELOG.md, an UPGRADE.md, a LICENSE and a README.

On releases, the recent tags are v3.3.0 and v3.2.0 on 2026-10-05 and v3.1.0 on 2026-10-04, three minor versions inside two days, and the last push to the repository was on 2026-10-05. The 3.x line is therefore new and moving quickly, which is a double edged fact: the package is actively worked on, and the API surface described in this README may still shift under you between minor versions.

The licence is MIT, with the text in the LICENSE file, so there is no copyleft question to answer before using it commercially.

## Conclusion

Adopt laravel-batch if you have a Laravel application that writes many rows in a loop, because the package turns that into a few statements and gives you per row conditions, increments and JSON path updates that a mass update cannot express. Do not adopt it if you need the affected rows count to mean the same thing on every database, because the README is explicit that MySQL and MariaDB count only rows whose values changed while PostgreSQL and SQLite count every matched row. Verify first that your PHP and Laravel versions fall inside the 3.x support window, that your database is one of the four listed, and that the size of your batch is understood by your driver, because a single statement with very many rows is still one statement.

## FAQ

### How do I install and use laravel-batch?

Run composer require mavinoo/laravel-batch, and the service provider and the Batch facade are registered automatically. Import the facade as Mavinoo\Batch\BatchFacade as Batch, then call methods such as Batch::update(new User, $rows, 'id') with an array of rows and the index column.

### Which PHP, Laravel and database versions does laravel-batch 3.x support?

The 3.x line requires PHP 8.1 to 8.5 and Laravel 10 to 13, and supports MySQL, MariaDB, PostgreSQL and SQLite. The older 2.5.x line covers PHP 7.1 to 8.5, does not pin a Laravel version, and lists PostgreSQL and SQLite as only partly supported.

### Does the number of affected rows mean the same thing on every database?

No. The README states that MySQL and MariaDB only count rows whose values really changed, while PostgreSQL and SQLite count every matched row, so the return value of update() is not comparable across the four supported databases.

### How do I increment a column in a laravel-batch update?

Write the operator and the number as an array value, for example ['id' => 1, 'balance' => ['+', 500]] to add, and the same shape for subtract, multiply, divide and modulo. Values wrapped in DB::raw() are used as SQL, and the README warns never to put user input inside DB::raw() because every other value is a bound parameter.

## Sources

- [Issues](https://github.com/mavinoo/laravelBatch/issues)
- [License: MIT](https://github.com/mavinoo/laravelBatch/blob/master/LICENSE)
- [mavinoo/laravelBatch on GitHub](https://github.com/mavinoo/laravelBatch)
- [README](https://github.com/mavinoo/laravelBatch/blob/master/README.md)
- [Releases](https://github.com/mavinoo/laravelBatch/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/mavinoo-laravelbatch
