Skip to content

Repository files navigation

Laravel Bulk Assign

Tests PHP Version Laravel License

Reassign thousands of Eloquent records in one operation — chunked, resumable, and with a before/after audit row for every single record.


The problem

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 WHERE matched 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.

Install

composer require neeraj-patel/laravel-bulk-assign
php artisan migrate

Optionally publish the config:

php artisan vendor:publish --tag=bulk-assign-config

Usage

The basic move

use 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.

Cascading writes

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 leads

Progress for long runs

BulkAssign::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.

Resuming

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);

Reading the history

$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'

How it works

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.

Tables

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)

Configuration

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',
    ],
];

Testing

composer install
composer test

Requirements

PHP 8.1+ · Laravel 10, 11 or 12

The suite runs against an in-memory SQLite database: 8 tests, 23 assertions.

License

MIT — see LICENSE.

About

Reassign thousands of Eloquent records in one operation - chunked, resumable, with a before/after audit row for every record.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages