Introduction
When you need to process millions of records, the naive approach—looping through everything in a single job—quickly fails. A better way is to break processing into small, repeatable batches.
In this article, we’ll build a generic batch job pattern for Laravel that’s:
- safe to serialize in the queue,
- dependency-injection friendly,
- clean and extensible.
No reflection tricks, no magic container calls—just solid code.
The Core Idea
Each batch job:
- Executes one iteration (limit items starting from offset).
- Reschedules itself if there’s more work to do.
- Delegates business logic to a handler implementing a shared interface.
Step 1 — The Handler Interface
namespace App\Foundation\Jobs\Batch;
use Illuminate\Support\Carbon;
interface BatchJobHandler
{
/**
* Process one batch.
* Must return the number of processed items.
*/
public function handle(Carbon $momentAt, int $limit, int $offset): int;
}
Every batch operation implements this interface.
It’s explicit and easy to test.
Step 2 — The Base Class
namespace App\Foundation\Jobs\Batch;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Carbon;
use function dispatch;
use function now;
abstract readonly class BatchJob implements ShouldQueue
{
public function __construct(
public string $momentAt,
public int $limit = 5000,
public int $offset = 0,
) {}
protected function handleAndDispatchNext(BatchJobHandler $handler): void
{
$handledCount = $handler->handle(
Carbon::parse($this->momentAt),
$this->limit,
$this->offset,
);
if ($handledCount < $this->limit) {
return; // no more data
}
dispatch(new static(
$this->momentAt,
$this->limit,
$this->offset + $this->limit
))->delay(now()->addSecond());
}
}
Key points:
- Only primitive constructor arguments → safe for queue serialization.
- The handler returns how many records were processed.
- If the handler processes a full batch, the job automatically requeues the next iteration.
Step 3 — The Handler
namespace App\Modules\Orders\Commands;
use App\Foundation\Jobs\Batch\BatchJobHandler;
use Illuminate\Support\Carbon;
use App\Modules\Orders\Domain\Contracts\OrderRepository;
final readonly class CloseExpiredOrdersBatch implements BatchJobHandler
{
public function __construct(
private OrderRepository $orders,
) {}
public function handle(Carbon $momentAt, int $limit, int $offset): int
{
$expired = $this->orders->findExpired($momentAt, $limit, $offset);
foreach ($expired as $order) {
// either dispatch a job or process directly:
// dispatch(new CloseOrderJob($order->id, $momentAt));
$order->close($momentAt);
$this->orders->update($order);
}
return $expired->count();
}
}
Each handler focuses solely on domain logic for its batch slice.
The framework handles pagination and chaining automatically.
Step 4 — A Concrete Job
namespace App\Modules\Orders\Jobs;
use App\Foundation\Jobs\Batch\BatchJob;
use App\Modules\Orders\Commands\CloseExpiredOrdersBatch;
final readonly class CloseExpiredOrdersBatchJob extends BatchJob
{
public function handle(CloseExpiredOrdersBatch $command): void
{
$this->handleAndDispatchNext($command);
}
}
Laravel’s container automatically injects CloseExpiredOrdersBatch into handle().
The job itself doesn’t contain any business logic—only orchestration.
Step 5 — Kicking It Off
You start it like any other Laravel job:
dispatch(new CloseExpiredOrdersBatchJob(now()->toIso8601String()));
The system runs the first batch, counts processed items,
and queues the next one until no more work remains.
Why This Pattern Works
✅ Serializable: No objects in constructor.
✅ DI-friendly: Laravel injects handlers naturally.
✅ Reusable: The base class fits any batchable use case.
✅ Transparent: No container reflection magic—everything is explicit.
Conclusion
This pattern is a small, composable framework for scalable background processing in Laravel.
It keeps orchestration separate from business logic, uses native Laravel features, and remains fully testable.
If your queues ever need to handle thousands—or millions—of records, this approach will keep your jobs clean, safe, and elegant.
Top comments (0)