Bus::bulk() is not a fancy alias for dispatch() in a loop, and it is definitely not a lighter Bus::batch(). In Laravel 13, it is a lower-level dispatch optimization for many independent jobs where you care about enqueue efficiency more than workflow semantics. That can be a real win. It can also make a queue system harder to reason about if you adopt it for the wrong workload.
My recommendation is simple: use Bus::bulk() when the jobs are independent, idempotent, and high-volume, and when faster enqueueing actually matters. Do not use it because the method name sounds more scalable. Bulk dispatch changes the shape of failure, observability, queue pressure, and recovery. If your team ignores those tradeoffs, the performance win is usually fake.
Laravel introduced Bus::bulk() in Laravel 13.13, and the official queue documentation is careful about its scope: it is for cases where you do not need batch tracking or callbacks. That line matters more than most people think.
Bus::bulk() optimizes transport, not workflow
The first mental model to fix is this: Bus::bulk() is about how jobs are pushed, not about how a larger business operation is managed.
When you call dispatch() repeatedly, Laravel resolves and pushes each job individually. With Bus::bulk(), Laravel groups jobs by their configured connection and queue name, then calls the queue driver's bulk() implementation for each group. That means the behavior is partly framework-level and partly driver-specific.
That distinction matters because not every queue backend gets the same benefit.
The upside depends on the driver
On Redis, bulk dispatch can reduce round-trips and make large fan-out workloads cheaper to enqueue. On the database driver, it can collapse many inserts into a single write operation, which can dramatically reduce application-side dispatch overhead.
But on SQS, the improvement is much less exciting. Laravel's queue driver does not currently convert Bus::bulk() into a single AWS SendMessageBatch operation. It still iterates through jobs internally. So if you expected one bulk network call instead of thousands, that assumption does not hold.
That is the first place teams overstate the benefit. Bus::bulk() is not one universal optimization. It is a facade over different backend strategies.
It is intentionally weaker than batching
The second mental model to fix is that Bus::bulk() is not a workflow primitive. It gives you no built-in batch ID, no progress tracking, no completion callbacks, no cancellation semantics, and no batch-level failure handling.
If your use case needs to answer questions like these, Bus::bulk() is probably the wrong tool:
- Has the whole import finished?
- Which jobs from this run failed?
- When should the next phase start?
- Can we cancel the remaining work?
- Can the UI show progress for this operation?
Those are Bus::batch() questions, not Bus::bulk() questions.
That difference is why the method can be faster. Laravel is doing less coordination for you.
Where bulk dispatch is genuinely useful
There are real workloads where Bus::bulk() is the right call. The best ones share a few traits: the jobs are independent, there are a lot of them, their payloads are small, and the producer is spending non-trivial time enqueueing work.
Good fit: wide fan-out, independent work
A classic example is search indexing, thumbnail generation, export assembly, cache warming, or syncing records to a third-party system where each item can succeed or fail on its own.
If the jobs do not depend on each other, and there is no single coordinated "all done" step that needs framework support, bulk dispatch is a reasonable optimization.
use App\Jobs\SyncProductToSearch;
use Illuminate\Support\Facades\Bus;
$jobs = Product::query()
->where('is_active', true)
->select('id')
->cursor()
->map(fn ($product) => new SyncProductToSearch($product->id));
Bus::bulk($jobs);
The key detail is not the method call. The key detail is that each job only needs a product ID and can run without coordination with the rest.
Best fit: Redis-backed queues
Redis is where Bus::bulk() is easiest to justify. Redis handles high-throughput enqueueing well, and Laravel's implementation can reduce the chatter required to push a large number of jobs.
If you have a command or scheduled task that creates tens of thousands of small jobs, the wall-clock difference between a plain loop and bulk dispatch can be noticeable. This is especially true when the enqueue path itself is part of a latency-sensitive operation, such as an admin action or a scheduled window with a hard runtime budget.
Conditional fit: database queues
The database queue can also benefit because its bulk path can insert many job rows in a single operation. That sounds attractive, and sometimes it is. But this is where teams need discipline.
Database queues are usually fine for modest workloads, but they are not the backend I would choose for sustained high-volume fan-out. If you need Bus::bulk() because you are generating a huge amount of work regularly, that is often a signal to revisit the backend first.
A faster insert path does not remove the downstream cost of a relational database being your queue broker. It just lets you feed that broker harder.
Weak fit: SQS when you expect dramatic throughput gains
SQS remains a good queue backend overall, but Bus::bulk() is not a transport miracle there. If your main reason to adopt it is dispatch-time performance, benchmark first. You may still like the cleaner API, but the performance story is weaker than on Redis or the database driver.
That matters because engineering teams tend to cargo-cult queue APIs. One developer sees a new method, assumes it is categorically faster, and starts replacing loops everywhere. That is not engineering. That is styling.
The hidden costs show up after dispatch, not during it
The biggest trap with Bus::bulk() is that it looks like a producer-side optimization, so people evaluate it only by enqueue timing. In practice, the harder problems show up later.
Queue pressure gets worse faster
Bulk dispatch makes it easier to create a backlog spike than to drain one. That is the trade.
If your code can now enqueue 50,000 or 200,000 jobs in a burst, you have changed the operational shape of the system even if each job is unchanged. Redis memory can jump. The jobs table can absorb a large write burst. Horizon dashboards can flatten into one huge queue mountain. More importantly, shared workers can become busy with bulk work while user-facing jobs wait.
That means queue topology matters more once you adopt bulk dispatch.
If you bulk-dispatch low-priority work onto the same queue as password emails, checkout webhooks, or billing callbacks, you are asking for user-visible regressions. The producer got faster. The system got less fair.
A safer design usually means at least one of these:
- a dedicated queue for the bulk workload
- dedicated worker pools for that queue
- capped concurrency if the jobs hit a fragile dependency
- chunked dispatch instead of one giant burst
Observability becomes something you have to rebuild
With plain bulk dispatch, Laravel knows about individual jobs. It does not know much about the logical run that produced them unless you encode that yourself.
That becomes painful the first time someone asks, "Which product reindex run caused these failures?" or "Did yesterday's CRM sync complete?"
Bus::batch() gives you a first-class unit of work. Bus::bulk() gives you raw job fan-out. If you want run-level visibility, you need to create it.
The practical fix is to stamp correlation metadata into every job and log around that metadata consistently.
use App\Jobs\PushInvoiceToCrm;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Str;
$runId = (string) Str::uuid();
$jobs = $invoices->map(
fn ($invoice) => new PushInvoiceToCrm(
invoiceId: $invoice->id,
runId: $runId,
triggeredBy: auth()->id(),
)
);
Bus::bulk($jobs);
That runId should then flow through logs, failed job records, metrics, and any operational dashboards you care about. Without that, bulk workloads are much harder to debug than teams expect.
Failure becomes fragmented by design
With Bus::bulk(), partial success is not an edge case. It is normal.
A queue worker can crash after processing some jobs. A deployment can restart workers mid-run. Downstream APIs can start rate limiting halfway through. A subset of jobs can serialize or deserialize differently due to stale assumptions in the payload. Some jobs can succeed while others fail and retry repeatedly.
That is not a flaw in Bus::bulk(). It is the consequence of choosing an independent-job model. But your application code needs to be honest about it.
If the business operation actually requires coordinated all-or-nothing behavior, bulk dispatch is already the wrong abstraction.
The real production requirement is idempotency
If you adopt Bus::bulk() without strong idempotency, you are building a queue system that only works on good days.
This is the point most teams underinvest in. They focus on the dispatch API and ignore the job contract.
What idempotency means here
An idempotent job can run more than once without causing broken state, duplicate external effects, or corrupted accounting. That does not mean the code is literally side-effect free. It means the side effects are safe to repeat or safely de-duplicated.
With bulk dispatch, idempotency matters even more because large bursts multiply the odds that you will eventually see duplicates, retries, replay scenarios, or ambiguous outcomes.
A bad bulk job
A bad bulk job assumes:
- it will only run once
- it will run after all related records are fully committed
- no other worker will race it
- no retry will hit the same side effect twice
- the external API will always accept a duplicate write cleanly
That job may appear fine in staging. It will not stay fine in production.
A better bulk job pattern
A safer job keeps its payload small, reads fresh state inside handle(), and uses an idempotent persistence pattern around the side effect.
use App\Models\Invoice;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
class PushInvoiceToCrm implements ShouldQueue
{
use Queueable;
public function __construct(
public int $invoiceId,
public string $runId,
public ?int $triggeredBy = null,
) {}
public function handle(CrmClient $crm): void
{
$invoice = Invoice::query()->findOrFail($this->invoiceId);
DB::transaction(function () use ($invoice, $crm) {
$existing = DB::table('crm_sync_log')
->where('invoice_id', $invoice->id)
->where('run_id', $this->runId)
->first();
if ($existing && $existing->status === 'synced') {
return;
}
$remoteId = $crm->upsertInvoice(
idempotencyKey: "invoice:{$invoice->id}:run:{$this->runId}",
payload: [
'number' => $invoice->number,
'total' => $invoice->total,
'currency' => $invoice->currency,
],
);
DB::table('crm_sync_log')->updateOrInsert(
[
'invoice_id' => $invoice->id,
'run_id' => $this->runId,
],
[
'remote_id' => $remoteId,
'status' => 'synced',
'triggered_by' => $this->triggeredBy,
'updated_at' => now(),
'created_at' => now(),
]
);
});
}
}
This job is not fancy. That is a good sign. It loads current state, uses a stable idempotency key, and records whether the logical unit of work already completed for the given run.
That pattern is much more valuable than shaving a few milliseconds off dispatch.
Payload discipline matters too
Bulk-dispatched jobs should usually carry IDs and metadata, not fat object graphs. Passing hydrated models is convenient, but it makes queue payloads heavier and makes serialization behavior more brittle.
This matters more at scale. A queue system that handles 500 small payloads may behave very differently when asked to store 100,000 large serialized jobs. Bulk dispatch makes that mistake more expensive, not less.
How to keep bulk dispatch from overwhelming the system
The safest pattern with Bus::bulk() is usually not "collect everything and fire once." It is chunked bulk dispatch with queue isolation.
Prefer chunked fan-out over one giant submission
Chunking gives you three concrete benefits:
- It bounds memory usage in the producer.
- It reduces the blast radius of a dispatch-time failure.
- It lets the workers start draining while the producer is still generating more work.
That is often a much healthier operating pattern than one massive enqueue burst.
use App\Jobs\GenerateStatementPdf;
use App\Models\Customer;
use Illuminate\Support\Facades\Bus;
Customer::query()
->where('needs_statement', true)
->select('id')
->chunkById(1000, function ($customers) {
$jobs = $customers->map(
fn ($customer) => (new GenerateStatementPdf($customer->id))
->onQueue('statements')
);
Bus::bulk($jobs);
});
This pattern is operationally boring, which is what you want. It avoids building one enormous in-memory collection, and it gives your workers a steadier stream of work instead of a queue cliff.
Isolate bulk queues from user-facing queues
If the workload is high-volume, do not share a queue with latency-sensitive jobs unless you have a very good reason.
This is one of the easiest queue design mistakes to make in Laravel because the framework makes adding jobs so convenient. A bulk reindex run should not be able to delay password reset mail, checkout events, or subscription webhooks.
Separate queue names are cheap. Worker isolation is cheaper than production incidents.
Align concurrency to the dependency, not the CPU
A common failure mode is scaling workers based on server capacity while ignoring what the jobs actually hit.
If the jobs call an external API with rate limits, increasing workers may only turn a manageable queue into a retry storm. If the jobs write to the same hot tables, more workers may just increase lock contention. If the jobs hit S3 or image processing, the bottleneck may sit in network bandwidth or disk I/O instead of PHP execution.
Bulk dispatch forces this conversation because it can feed workers much faster. That is useful only when the rest of the pipeline is ready.
Watch transaction boundaries
Another subtle risk is dispatch timing relative to database commits. If you bulk-create jobs based on records that are still being written inside a transaction, workers may begin processing before the expected state is visible.
That is not unique to Bus::bulk(), but bulk dispatch makes the failure mode wider because many jobs can be enqueued at once.
The practical rule is the same one good queue systems always follow: dispatch jobs after the state they depend on is durably committed, or use after-commit semantics where appropriate.
What to benchmark before adopting it broadly
A lot of queue articles stop at "bulk is faster." That is not enough. The only benchmark that matters is one that measures both enqueue cost and downstream impact.
At minimum, compare a loop of dispatch() calls against Bus::bulk() using the same workload and the same backend. Measure:
- time spent enqueueing
- time until the first job starts
- total drain time for the workload
- queue depth peak during the run
- failed jobs and retry volume
- Redis memory or database pressure during the spike
- whether other queues got slower while the bulk run was active
If you use Horizon, watch the whole system, not just the bulk queue. It is very common to cut producer time in half and quietly make unrelated queues worse.
Also benchmark with realistic payloads. Small fake jobs can hide the real cost of serialization, storage, and downstream calls.
When to use Bus::bulk(), Bus::batch(), or plain dispatch
The cleanest decision rule looks like this:
Use Bus::bulk() when
- jobs are independent
- you have a lot of them
- dispatch overhead is measurable
- you do not need framework-level progress or callbacks
- each job is idempotent and safely retryable
- you are prepared to add your own correlation and observability
Use Bus::batch() when
- the workload is one logical operation
- you need progress tracking or completion callbacks
- you need a first-class batch ID
- cancellation and failure coordination matter
- the UI or ops team needs batch-level visibility
Use plain dispatch() in a loop when
- the number of jobs is modest
- clarity beats micro-optimization
- the enqueue path is not a bottleneck
- you do not want driver-specific bulk behavior to shape the design
That last point is worth underlining. A lot of teams should stay with plain dispatch() longer than they think. Simpler code is often the better production choice when queue volume is still moderate.
Bus::bulk() earns its place when the producer is demonstrably expensive, not when the method name sounds more scalable.
The practical takeaway
Bus::bulk() is useful, but it is a sharp tool. It helps most when you already have a disciplined queue design: small payloads, idempotent jobs, isolated queues, realistic worker concurrency, and decent observability.
If you do not have those things, bulk dispatch mostly gives you the ability to create bigger problems faster.
So the production rule is this: bulk-dispatch only independent jobs, bulk-dispatch them in chunks, and never treat enqueue speed as the only success metric. If you need coordinated workflow semantics, use Bus::batch(). If you just need raw fan-out efficiency and the jobs are built correctly, Bus::bulk() is the right tool.
Read the full post on QCode: https://qcode.in/laravel-bus-bulk-when-bulk-dispatch-helps-hurts/
Top comments (0)