Reassign thousands of Eloquent records in one operation — chunked, resumable, and with a before/after audit row for every single record.
Every CRM, ticketing system and job board eventually needs this: a sales rep leaves, a region is reshuffled, a territory is split. Ten thousand records have to move to somebody else, today.
The obvious answer is one statement:
Lead::where('owner_id', 41)->update(['owner_id' => 64]);It runs in a second, and then the questions start.
- Which ten thousand moved? The
WHEREmatched a moving target. - What were they before? That information is gone.
- The quotes attached to those leads still point at the old owner. Who fixes those?
- It timed out at record 6,000. Which 6,000?
- Six months later: "why is this lead with Priyanka?" — nobody can answer.
This package answers all five.
composer require neeraj-patel/laravel-bulk-assign
php artisan migrateOptionally publish the config:
php artisan vendor:publish --tag=bulk-assign-configuse NeerajPatel\BulkAssign\Facades\BulkAssign;
$batch = BulkAssign::assign(
Lead::where('owner_id', 41)->where('region_id', 3),
['owner_id' => 64, 'manager_id' => 12],
['reason' => 'North region reshuffle — Q3'], // stored on the batch
);
$batch->succeeded; // 9_847
$batch->failed; // 3
$batch->failures; // [['id' => 5501, 'reason' => '...'], ...]Only the columns you name are touched. Everything else on the record is left alone.
An assignment is rarely one table. The cascade closure runs for each record
inside the same transaction, so the record and its dependants move together or
not at all:
$batch = BulkAssign::cascade(function (Lead $lead, array $before) {
return Quotation::where('lead_id', $lead->id)
->where('status', 'open')
->update(['owner_id' => 64]); // returned count lands in $batch->cascaded
})
->assign(Lead::where('owner_id', 41), ['owner_id' => 64]);
$batch->cascaded; // 1_204 quotations followed their leadsBulkAssign::chunkSize(500)
->onProgress(fn (int $done, int $total) => Cache::put("assign.progress", $done / $total))
->assign($query, $changes);Pair it with a queued job and poll the cache, or drive it straight from the browser in chunks — either way the work is already split at a transaction boundary.
A deploy, a killed worker, a max_execution_time — pick the batch back up and it
skips everything already logged:
$batch = AssignmentBatch::where('status', 'running')->first();
BulkAssign::resume($batch);$log = AssignmentLog::where('record_type', Lead::class)
->where('record_id', 18961)
->latest()
->first();
$log->diff();
// ['owner_id' => ['from' => 41, 'to' => 64]]
$log->batch->context['reason'];
// 'North region reshuffle — Q3'assign(query, changes)
│
├─ open batch ── total, changes, context, who ran it
│
├─ chunkById(200) ─────────────────────────────────┐
│ │ │
│ └─ per record, in its own transaction: │ repeat until
│ 1. snapshot the tracked columns │ the set is
│ 2. apply the changes │ exhausted
│ 3. write the before/after log row │
│ 4. run the cascade closure │
│ │
├─ after each chunk: counters, event, progress ────┘
│
└─ close batch ── status, completed_at, BatchCompleted event
Three decisions worth calling out:
Per-record transactions, not per-chunk. A chunk-wide transaction would hold locks across 200 rows and roll all of them back for one bad record. Per record, a failure costs exactly one record and the batch keeps going.
chunkById, not chunk. The assignment usually changes the very column being
filtered on, which makes OFFSET pagination skip rows as the result set shifts under
it. Keyset pagination doesn't have that problem.
The log row is written inside the transaction. If the update lands, the audit row lands. There is no window where a record has moved and nothing recorded it.
| Table | What it holds |
|---|---|
assignment_batches |
one row per run — target, changes, context, counts, status, timings |
assignment_logs |
one row per record — before, after, indexed on (record_type, record_id) |
return [
'chunk_size' => 200, // records per transaction
'max_recorded_failures' => 50, // failure reasons kept on the batch row
'tables' => [
'batches' => 'assignment_batches',
'logs' => 'assignment_logs',
],
];composer install
composer testPHP 8.1+ · Laravel 10, 11 or 12
The suite runs against an in-memory SQLite database: 8 tests, 23 assertions.
MIT — see LICENSE.