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.
Supersedes the v3.1.0 assessment. This release is v3.3.0; we skipped v3.2.0 and have not reviewed its notes. The release states 3.2 → 3.3 is additive with no code changes, which implies the 3.1 → 3.3 hop is also no-code-change — but the 3.2.0 changelog is an unreviewed gap and should be read before approval.
Strengths:
Performance-Centric, and now materially faster (unchanged from 3.1.0): The UPDATE ... CASE statement cap of 100 rows removed the super-linear degradation large statements suffered. The 50,000-row benchmark remains the headline:
Bulk import is now a first-class capability (new): Batch::import($model, $path, ['fast' => true]) uses COPY on PostgreSQL and LOAD DATA LOCAL INFILE on MySQL/MariaDB. The published 1,000,000-row CSV numbers:
| 1M-row CSV | import | fast |
|---|---|---|
| PostgreSQL | 18.7s | 6.6s |
| MySQL 8.4 | 18.7s | 8.4s |
| MariaDB | 12.6s | 6.4s |
This is an order-of-magnitude change in the shape of what the package is for. Previously it was "batch writes from PHP arrays". Now it is a credible ingestion path for file-based feeds — a workload we currently solve with bespoke scripts, \copy invocations, or ETL tooling. That is the most strategically significant item in this release, and also the one with the most operational prerequisites.
Import validation with per-row error reporting (new): ['rules' => [...]] returns ['totalRows' => n, 'skipped' => n, 'errors' => [rowIndex => [field => messages]]]. This is the first time the package offers row-level diagnostics — a gap flagged in every prior assessment. It applies to imports only, not to update()/upsert(), but it materially reduces the "which row failed?" support burden for the ingestion use case.
Fast-import data fidelity (new, needs verification): The release claims bad values that MySQL would otherwise store with a warning now throw and write nothing. If it holds under our MySQL strict-mode settings, it closes the biggest objection to LOAD DATA LOCAL INFILE — silent truncation. Verify before relying on it.
Counter upserts (new): ['qty' => ['+']], plus ['-'], ['max'], ['min'], on every database. Replaces hand-rolled ON DUPLICATE KEY UPDATE qty = qty + VALUES(qty) raw SQL — a common source of per-driver divergence. Directly applicable to inventory, counters, quotas and usage-metering tables.
Conditional upserts (new): ['onlyIf' => ['updated_at' => '>']] updates only when the new value wins, while new rows always insert. This is last-writer-wins-with-guard semantics — the primitive most hand-rolled sync jobs implement badly. Note it guards updates only; it does not prevent an insert.
upsertReturning() (new): Returns the affected rows, giving us IDs and generated values without a follow-up query. Closes a real workflow gap (upsert-then-need-the-PK). Implementation is almost certainly native RETURNING on PostgreSQL and an emulation elsewhere — see risk notes; treat cross-driver semantics as unverified until we test.
insertOrIgnoreRows() with skipped-duplicate reporting (new): Returns ['inserted' => 98, 'skipped' => [...]], reporting which rows were skipped. Release notes state this was validated with 8 concurrent writers on MySQL 8.4, MariaDB and PostgreSQL, with correct per-connection attribution. If that claim holds, it turns "insert and hope" into a reconcilable operation.
Export (new): User::where(...)->exportTo($path, ['maxRows' => 100000]) writes CSV/TSV/JSON Lines, optionally gzipped, chunked at constant memory, splitting into numbered parts. New capability, but it is a data egress surface first and a feature second — see governance questions.
withoutTimestamps() documented and tested for every method (new): Closes a long-standing documentation hole. Timestamp behaviour on upsert() was previously implicit; it is now a documented, testable contract.
Upsert / composite keys / row-shaped inserts / pretend() (from 3.1.0, still central): updateByKeys()/deleteByKeys() for composite and tenant-scoped keys, insertRows() for heterogeneous rows, and pretend() — which returns SQL and bindings without executing or connecting. pretend() remains the single highest-leverage operational feature in the package: SQL review in code review, CI assertions, and pre-flight validation of destructive batches.
Eloquent Integration: Unchanged — native Eloquent models, facade and helper patterns, plus static HasBatch methods for every operation. Discoverability is now very high, which is a double-edged sword (see Gaps).
Security Posture (3.x baseline): v3.0.0 eliminated the SQL-injection class present in all 2.x releases by binding all values and delegating quoting to Laravel's grammar. This release does not weaken that for the parameterised paths — but the fast import path is a different mechanism: file contents are parsed by the database server, not bound as parameters. Column names, table names, file paths and options are still composed into SQL and are the remaining interpolation surface. Review the wrapper's handling of those inputs.
Correctness Signals: CI matrix across MySQL, MariaDB, PostgreSQL and SQLite, plus PHPStan level 9 with Larastan. The concurrency testing described for insertOrIgnoreRows() is a genuine maturity marker.
Database Agnostic (partially eroded): The core CRUD paths remain CI-verified on four drivers. The new features are increasingly driver-specific in implementation (COPY, LOAD DATA, RETURNING), so "database agnostic" is now true of the API shape but less true of the execution path.
Gaps (post-v3.3.0 reassessment):
upsert(), upsertReturning(), insertOrIgnoreRows(), counter/conditional upserts and import() all bypass observers, validation hooks and audit hooks. deleteByKeys() soft deletes correctly but fires no deleting/deleted. This remains the single largest "surprise" risk and the surface it covers has roughly doubled.update(), updateByKeys() and upsert() do not filter deleted_at. Callers must exclude soft-deleted rows.upsert(), updateByKeys() and insertRows() still bypass model validation entirely. The asymmetry is a trap: developers who learn "the package validates rows" from import() will assume it applies everywhere.import(), fast mode, exportTo(), upsertReturning(), insertOrIgnoreRows(), counter upserts, conditional upserts, withoutTimestamps() coverage). All tested, but with very little production mileage. The package is now growing faster than our ability to validate it.DB::transaction(). import() raises the stakes: a large import inside one transaction means a large rollback log and long-held locks — verify the transaction strategy for big files.updateMultipleCondition() remains deprecated and removed in 4.0. Unchanged by this release; still a guaranteed future fatal.local_infile must be enabled client- and server-side; the CSV file must exist on the filesystem of whichever process runs the job (worker, not web node); SQLite has neither COPY nor LOAD DATA. None of this is discoverable from the method signature.fast does when local_infile is off, what onlyIf does with NULLs, what insertOrIgnoreRows() does with a non-duplicate error) are the likely thin spots.fast import adds infrastructure configuration: a MySQL/MariaDB parameter-group change (local_infile=1) and a filesystem-placement constraint for the CSV on queue workers. This is the first release where adoption has a non-PHP prerequisite.composer update — additive. The 3.0 → 3.1 hop was likewise additive. The 2.x → 3.x pass remains unchanged and still required wherever 2.x remains. The instance updateMultipleCondition() → static migration task is still outstanding and still cheaper now than later.^3.3, or remove the package.int/array/0 on empty instead of false), $raw = true now throws, and typed BatchInterface signatures that fatal in any in-house decorator or mock. Silent business-logic failure for falsy-return guards.COPY and LOAD DATA LOCAL INFILE hand the file to the database server. Values are no longer bound parameters; the safety of the operation depends on correct CSV escaping, correct file handling, and the server's own parsing. This is not a regression in the injection class — but it is a different execution model and deserves its own review, not an assumption of inherited safety. Combined with server-side file reading, local_infile is a known data-exfiltration vector if the client ever connects to an untrusted or spoofed MySQL server. Enforce TLS verification and restrict the setting to application connections pointing at our own DB.upsertReturning() (how is it emulated off PostgreSQL?), insertOrIgnoreRows() (how is skip attribution computed?), conditional upserts (NULL and type-coercion comparisons), and counter upserts (unsigned underflow, overflow, decimal vs. integer coercion). Mitigation: pilot on non-critical tables, assert generated SQL with pretend(), and write integration tests per driver we actually run.insertOrIgnoreRows() Error Conflation (new): On MySQL, INSERT IGNORE downgrades more than duplicate-key errors — data truncation, FK violations and NOT NULL issues become warnings. If the implementation derives "skipped" from the ignore path, a row rejected for a FK violation could be reported as a duplicate. The release's concurrency claim addresses attribution, not classification. Verify explicitly with non-duplicate failure cases.upsertReturning() Read-After-Write (new): Unless it is native RETURNING, the returned rows are likely a follow-up read. Under concurrency that read can see rows written by another transaction, or miss rows changed in between. Ask whether the operation is a single statement per driver and whether it is transaction-wrapped.['onlyIf' => ['updated_at' => '>']] is evaluated against the stored value — correct only if NULL handling, timezone normalisation and type coercion match the column's semantics. New rows always insert, so this is not an idempotency guard. Test with NULLs, equal values and out-of-order timestamps.exportTo() writes PII to arbitrary paths, chunked, gzipped, multi-part. There is no access control, no audit trail and no retention policy in the API. This is a compliance review item before any customer-data export ships.fast has no SQLite equivalent and likely none on SQL Server; RETURNING is native only on PostgreSQL. Code that runs on CI's SQLite will not exercise the production path for these features. Test on the production driver or don't ship the feature.DB::transaction().upsert() where observers matter) rises faster than documentation can compensate.upsert() gains an optional fifth $options argument; import() gains an errors key. Both additive at the end of the argument list and in the return array. Only in-house wrappers that override these methods need touching.updateMultipleCondition()).mavinoo/laravel-batch at 2.x? Is any exposed to user-controlled index values or JSON keys?^3.3 this cycle? If not, what is the compensating control and who accepts the risk?$raw = true, on false returns for empty input, on Common\Common / global str(), or on BatchInterface implementations/mocks?updateMultipleCondition()? (Mandatory before 4.0.)upsert()/import() in any wrapper? (Signature changes are additive but overrides are not free.)import()? What is the current cost of those jobs?local_infile permitted by our security baseline and enabled on our managed MySQL/MariaDB instances? Who owns that change, and does it require a maintenance window?fast fall back silently on unsupported drivers (SQLite, SQL Server), or throw? Silent fallback would make CI and production behave differently.rules (row validation) and fast be combined? If validation forces the PHP path, what is the real-world throughput penalty and which do we choose per job?upsertReturning() a single statement per driver (native RETURNING on PostgreSQL) or an emulated follow-up read? Is it transaction-wrapped?insertOrIgnoreRows() classify a row as "skipped" — duplicate only, or any ignored error? Test with a FK violation and a truncation.['max']/['min'] defined for strings and dates, or numbers only?onlyIf and an arithmetic operator are combined?exportTo(), and does any of it touch customer PII? What access control, audit trail and retention apply?import() in scope, the answer is now more likely "core" — which justifies the wrapper investment.deleting/deleted now that deleteByKeys() exists?pretend() output be captured into our audit trail for destructive batches?fast import via LOAD DATA LOCAL INFILE — requires local_infile=1 on client and server.fast import via COPY. Most likely to have native RETURNING for upsertReturning().fast import path — confirm it throws rather than degrading.pretend() helps review generated SQL but not execution semantics.local_infile; worker-local or shared filesystem access for import files; disk headroom for exports and multi-part output; DB privileges unchanged for client-side COPY FROM STDIN and LOAD DATA LOCAL INFILE (neither requires FILE/superuser, which is a point in favour).batch:import / batch:export commands wrapping the new APIs, with the wrapper enforcing path allowlists and capability checks.pretend() to assert generated SQL for destructive paths. Additionally, now test on the production driver — SQLite cannot exercise COPY, LOAD DATA or RETURNING, so CI green is not evidence that import/upsertReturning work.^3.3 where Laravel 10+/PHP 8.1+ allows. Where not, upgrade the framework or replace the package. Do not leave 2.x in production.$raw/true, === false/!$result on batch returns, Common\Common, global str(). Migrate instance updateMultipleCondition() to the static form. Update BatchInterface implementations and mocks.import().import() opportunity honestly.BatchService wrapper to abstract the package — centralise transaction wrapping, soft-delete filtering, observer-parity hooks, path allowlisting, capability detection for fast, and pretend()-based audit capture.pretend() first, before any new destructive path ships.insert() → insertRows() → update() → batchUpdateMultipleCondition() → updateByKeys() → deleteByKeys().withoutTimestamps() (documentation only) → insertOrIgnoreRows() → counter upserts → conditional upserts → upsertReturning() → import() → exportTo().import() goes last of the write paths because it carries infrastructure prerequisites (local_infile, worker filesystem), not just semantic ones. exportTo() is gated on a data-governance review, not engineering.deleteByKeys() with and without $force.fast attempted on SQLite (confirm it throws); import() with rules returning correct row-indexed errors; insertOrIgnoreRows() against duplicate and non-duplicate failure rows; upsertReturning() under a concurrent writer; counter upserts at zero, at column max, and on unsigned columns; conditional upserts with NULL, equal and out-of-order values; export of >maxRows records with part-file naming and cleanup.updateMultipleCondition(); local_infile is enabled in our DB parameter group and who owns reverting it; exportTo() requires a data-handling approval before customer data is exported.fast import is verified only on MySQL/MariaDB/PostgreSQL; SQL Server unverified for everything and near-certainly unsupported for fast and RETURNING.upsert() $options argument and the import() errors key.$raw, removed Common/str()) still affect consuming code.updateMultipleCondition() (fatal at 4.0).upsert() or import() need their signatures updated.local_infile: must be enabled on both client and server; may be blocked by our security baseline; requires a parameter-group change on managed services.COPY FROM STDIN works in transaction pooling, but verify against our specific pooler mode before assuming the fast path is available in production.^3.3 — highest priority; remediates the 2.x injection class. 3.1 → 3.3 is a drop-in update; take it in the same hop. Blocks all else on affected services.$raw call sites, falsy return checks, BatchInterface implementations, Common/str() usages, deprecated instance updateMultipleCondition(), and any overrides of upsert()/import().Batch::insert(), insertRows(), then pilot insertOrIgnoreRows() on a deduplicating feed and reconcile the skipped list against source data.WHERE id IN (...) loops; adopt DB::raw() arithmetic; adopt withoutTimestamps() where we currently fight timestamp updates; re-baseline performance against the 100-row cap.batchUpdateMultipleCondition(); updateByKeys() for composite/tenant-scoped tables.upsert() (validate created_at-only-on-new-rows and enum handling) → counter upserts (inventory/metering tables) → conditional upserts (sync jobs with stale-write protection) → upsertReturning() (workflows needing the PK back). Gate each behind pretend()-based SQL assertions plus driver-specific integration tests.deleteByKeys() on a SoftDeletes model first; $force only behind wrapper-level prohibition and dual review.local_infile in a non-production parameter group; prove the fast path in staging on the production driver with malformed input; then migrate one real ingestion job. Layer rules validation on a job that needs row-level error reporting. Keep the old path behind a feature flag for one cycle.maxRows part-splitting, gzip, and cleanup on failure.BatchService with mandatory DB::transaction() for multi-call workflows, soft-delete filtering for update/upsert paths, observer-parity hooks, an allowlist of approved methods (default-deny for anything not yet piloted), path allowlisting for import/export, fast capability detection, and pretend()-based audit logging for destructive batches.fast import adds a second owner in infrastructure/DB ops — the local_infile parameter is now part of our production configuration and needs an owner and a documented rationale.^3.3 and track the changelog. Single-maintainer bus factor remains a register item — and it matters more now that the package sits on ingestion paths.import(), exportTo(), upsertReturning(), insertOrIgnoreRows() and the counter/conditional upsert options until each has a named pilot and test coverage. This is the practical answer to "the API grew by eight methods in one release".updateMultipleCondition() still has a hard 4.0 deadline. Scheduled task, not backlog item.BatchService is the durable asset; the package is a swappable implementation detail behind it. With import()/exportTo() in scope, the wrapper must also own file handling, so swapping the package out doesn't mean rewriting ingestion.fast prerequisite failures, onlyIf NULL semantics, insertOrIgnoreRows() classification of non-duplicate errors, and upsertReturning() behaviour per driver.upsert, deleteByKeys, insertOrIgnoreRows and import; (b) update/upsert paths do not filter soft deletes even though delete paths do; (c) return contract is int/array/0 for core methods, plus new structured array shapes for import/insertOrIgnoreRows/upsertReturning; (d) pretend() is the safe way to inspect a batch; (e) row validation applies to imports only — it is not a package-wide guarantee; (f) fast needs local_infile, a local file, and the production driver — it will not work in SQLite CI.pretend() for SQL review; import errors output for feed triage; the skipped list from insertOrIgnoreRows() for dedupe reconciliation. Together these turn three previously opaque failure classes into reviewable artefacts — capture all three in the wrapper and attach to deploy/job records.fast import moves 1M rows from ~19s to 6–8s, which changes ingestion from "schedule it" to "run it inline or in a short job" for moderate files.import() streams from disk, but the file must exist complete, and rules validation will hold per-row state — expect rules to be the throughput limiter, not the DB. Revisit timeout budgets downward for fast jobs and keep conservative values for validated imports.pretend() plus SQLite still make core-SQL regressions cheap to catch. That advantage does not extend to import, fast or upsertReturning — those require the production driver, which raises the cost of a proper test suite for the new features. Budget for it explicitly.^3.3.local_infile Exfiltration Vector (new): Enabling client-side file loading means a malicious or spoofed MySQL server could request arbitrary client files. Severity depends on our TLS and endpoint trust posture. Mitigation: enable only on connections to our own verified DB endpoints, enforce TLS verification, and document the setting as a security-relevant configuration.import(), insertOrIgnoreRows(), upsertReturning() and the counter/conditional upsert paths all bypass hooks. Mitigation: explicit post-batch hooks in the wrapper; treat "did anything downstream need to know?" as a mandatory review question for every new batch call site.local_infile is off, the file isn't on the worker, or the driver is SQLite, the fast path fails — hopefully loudly. Symptom: ingestion jobs fail in production only, because CI runs SQLite. Mitigation: capability check at boot/health-check, and a staging run on the production driver before enabling.LOAD DATA stores out-of-range and malformed values with warnings. The release claims this now throws and writes nothing. Mitigation: prove it with truncated integers, over-length strings, invalid dates and bad encodings before trusting any fast import.insertOrIgnoreRows() reports non-duplicate rejections as duplicates, we silently lose rows while the report says "deduplicated". Symptom: missing records that nobody investigates because the report looked clean. Mitigation: test with FK violations and truncation, not just duplicate keys.upsertReturning() Race (new): If emulated as a follow-up read, returned rows may not match what this call wrote. Symptom: wrong IDs propagated into downstream work. Mitigation: confirm single-statement semantics per driver, or wrap in a transaction and validate against a known key.pretend() to inspect the generated predicate.deleteByKeys() soft deletes by default; $force = true is a physical delete with no observer interception. Mitigation: forbid $force outside the wrapper, require pretend() evidence and second review.deleted_at filtering. Symptom: mutated or resurrected deleted records. Mitigation: filter in the wrapper.DB::transaction() in the wrapper — and be careful not to accidentally wrap a 1M-row import in a business transaction.$raw = true throws by design; deprecated updateMultipleCondition() becomes fatal at 4.0.SELECT ... FOR UPDATE or version columns on contended paths.InvalidArgumentException and import validation, but update/upsert paths still bypass model validation. Validate upstream.Batch::update(). No longer low for the package as a whole: the highest-value new feature (fast import) needs infrastructure work before it can be used at all.composer update, no code changes. Only in-house overrides of upsert()/import() need attention. No reason to defer.withoutTimestamps(), counter upserts on a non-critical table.insertOrIgnoreRows(), conditional upserts, upsertReturning() — all need per-driver tests beyond duplicate/timestamp cases.fast import — DB parameter change, worker filesystem, malformed-input validation, capability detection, rollback behaviour. It's an infrastructure project with a PHP API attached.exportTo() — technically simple, but blocked on data-handling approval.$force, pretend() assertions) now gains two more: an approved-method allowlist and production-driver test coverage for anything not exercised by SQLite.pretend() remains the best teaching tool for (1); a short staging walkthrough of a failing fast import is the best teaching tool for (2).^3.3. Treat the 2.x → 3.x upgrade as security remediation with a deadline; take 3.1 → 3.3 immediately as a no-risk, no-code-change win; read the skipped 3.2.0 changelog first. Then sequence deliberately: core writes and withoutTimestamps() now; insertOrIgnoreRows() and the upsert family as named pilots with per-driver tests; fast import as a separate infrastructure-enabled workstream with DB ops; exportTo() gated on data-governance approval. Keep instance updateMultipleCondition() migration on the calendar for 4.0.How can I help you explore Laravel packages today?