DEV Community

Cover image for Building Elegant Batch Jobs in Laravel with Clean Architecture
Eugeny Shigaev
Eugeny Shigaev

Posted on

Building Elegant Batch Jobs in Laravel with Clean Architecture

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:

  1. Executes one iteration (limit items starting from offset).
  2. Reschedules itself if there’s more work to do.
  3. 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;
}
Enter fullscreen mode Exit fullscreen mode

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());
    }
}
Enter fullscreen mode Exit fullscreen mode

Key points:

  1. Only primitive constructor arguments → safe for queue serialization.
  2. The handler returns how many records were processed.
  3. 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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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()));
Enter fullscreen mode Exit fullscreen mode

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)