Dan Newns.
Back to Articles

Laravel's HTTP::batch(): Parallel Requests Done Right (Finally!)

5th November 2025

This post was originally published on the Jump24 blog, where I serve as Technical Director. I've syndicated it here because I wrote it, I'm proud of the work, and I want my personal site to reflect the full scope of what I'm building - both professionally and personally. If you're here for Laravel insights, you'll find them alongside golf breakdowns and life updates. If you found this helpful, you might enjoy Jump24's other content too.

Let's set the scene. You know that feeling when you need to fetch data from multiple APIs, and you're sitting there watching your application make request... after request... after request... while your users are staring at a loading spinner that's been going for so long it's practically become a screensaver?

We've all been there, we've all built systems that have had to work like this in PHP. And honestly, it's been a bit rubbish.

Sure, Laravel gave us Http::pool() back in version 8, and that helped. But if you wanted to track progress, handle failures gracefully, or know when everything was done? Well, you were back to writing boilerplate code that looked like it crawled out of 2015.

Enter Laravel Http::batch() (Huge thanks to Wendell Adriel for the work on this).

The Problem

Picture this scenario. You're building a dashboard that needs to talk to multiple services on a page load for instance:

  • Fetch user data from your auth service

  • Pull analytics from Google

  • Grab sales figures from Stripe

  • Get support tickets from Zendesk

  • Retrieve deployment status from your CI/CD pipeline

With traditional sequential requests, here's what that horror show looked like:

php
1<?php
2// The old way - AKA "Why is the dashboard taking 8 seconds to load?"
3public function getDashboardData()
4{
5 $startTime = microtime(true);
6 
7 // Request 1: 800ms
8 $userData = Http::withToken($this->authToken)
9 ->get('https://auth.example.com/api/user');
10 
11 // Request 2: 1200ms
12 $analytics = Http::withToken($this->googleToken)
13 ->get('https://analytics.googleapis.com/v4/reports');
14 
15 // Request 3: 600ms
16 $sales = Http::withBasicAuth($this->stripeKey, '')
17 ->get('https://api.stripe.com/v1/charges');
18 
19 // Request 4: 900ms
20 $tickets = Http::withToken($this->zendeskToken)
21 ->get('https://example.zendesk.com/api/v2/tickets');
22 
23 // Request 5: 700ms
24 $deployments = Http::withHeaders(['Authorization' => $this->ciToken])
25 ->get('https://api.github.com/repos/org/app/deployments');
26 
27 // Total time: 4.2 seconds (ouch!)
28 Log::info('Dashboard load time: ' . (microtime(true) - $startTime));
29 
30 return [
31 'user' => $userData->json(),
32 'analytics' => $analytics->json(),
33 'sales' => $sales->json(),
34 'tickets' => $tickets->json(),
35 'deployments' => $deployments->json(),
36 ];
37}

Four seconds. FOUR. SECONDS. That's an eternity in web time. Your users have already opened Twitter in another tab.

Important note here, this is just some example code and you could have handled this scenario in different ways without HTTP batch such as pre-caching.

Enter Http::pool()

Laravel 8 introduced Http::pool(), which was a massive improvement and allowed concurrent requests to happen meaning our code became a lot neater and also our load time became faster:

php
1<?php
2// Better, but still missing something
3public function getDashboardDataWithPool()
4{
5 $responses = Http::pool(fn (Pool $pool) => [
6 $pool->as('user')->withToken($this->authToken)
7 ->get('https://auth.example.com/api/user'),
8 $pool->as('analytics')->withToken($this->googleToken)
9 ->get('https://analytics.googleapis.com/v4/reports'),
10 $pool->as('sales')->withBasicAuth($this->stripeKey, '')
11 ->get('https://api.stripe.com/v1/charges'),
12 $pool->as('tickets')->withToken($this->zendeskToken)
13 ->get('https://example.zendesk.com/api/v2/tickets'),
14 $pool->as('deployments')->withHeaders(['Authorization' => $this->ciToken])
15 ->get('https://api.github.com/repos/org/app/deployments'),
16 ]);
17 
18 // Now it only takes as long as the slowest request (1.2 seconds)
19 
20 return [
21 'user' => $responses['user']->json(),
22 'analytics' => $responses['analytics']->json(),
23 'sales' => $responses['sales']->json(),
24 'tickets' => $responses['tickets']->json(),
25 'deployments' => $responses['deployments']->json(),
26 ];
27}

As you can see in the example above we've moved our code into a pool and used the as method to give all our calls a corresponding name allowing us to access them using that name. This new code cut our load time from 4.2 seconds to 1.2 seconds. Brilliant! But here's where it fell short:

  • No way to show progress to users

  • No hooks for handling failures

  • No lifecycle callbacks

  • All or nothing - if you wanted to process responses as they came in, tough luck

HTTP::batch()

Laravel 12's Http::batch() is like Http::pool() went to university, got a PhD, and came back ready to solve real-world problems.

Here's the same dashboard with the new batch method:

php
1<?php
2 
3public function getDashboardDataWithBatch()
4{
5 $progress = [];
6 
7 $responses = Http::batch(fn (Batch $batch) => [
8 $batch->as('user')->withToken($this->authToken)
9 ->get('https://auth.example.com/api/user'),
10 $batch->as('analytics')->withToken($this->googleToken)
11 ->get('https://analytics.googleapis.com/v4/reports'),
12 $batch->as('sales')->withBasicAuth($this->stripeKey, '')
13 ->get('https://api.stripe.com/v1/charges'),
14 $batch->as('tickets')->withToken($this->zendeskToken)
15 ->get('https://example.zendesk.com/api/v2/tickets'),
16 $batch->as('deployments')->withHeaders(['Authorization' => $this->ciToken])
17 ->get('https://api.github.com/repos/org/app/deployments'),
18 ])
19 ->before(function (Batch $batch) {
20 // Fire off a websocket event to show loading started
21 broadcast(new DashboardLoadingStarted(auth()->id()));
22 Log::info('Starting dashboard batch requests', [
23 'total_requests' => $batch->totalRequests,
24 ]);
25 })
26 ->progress(function (Batch $batch, int|string $key, Response $response) use (&$progress) {
27 // Update progress for each successful request
28 $progress[$key] = 'completed';
29 
30 broadcast(new DashboardProgress(
31 auth()->id(),
32 count($progress),
33 $batch->totalRequests
34 ));
35 
36 Log::info("Request {$name} completed successfully");
37 })
38 ->catch(function (Batch $batch, string $key, $exception) {
39 // Handle individual failures gracefully
40 Log::error("Request {$key} failed", [
41 'error' => $exception->getMessage()
42 ]);
43 
44 // Maybe use fallback data or cached results
45 Cache::get("dashboard.{$key}.fallback");
46 })
47 ->then(function (Batch $batch, array $results) {
48 // All successful! Maybe cache the results
49 foreach ($results as $key => $response) {
50 Cache::put("dashboard.{$key}", $response->json(), now()->addMinutes(5));
51 }
52 
53 broadcast(new DashboardLoadingCompleted(auth()->id()));
54 })
55 ->finally(function (Batch $batch, array $results) {
56 // Clean up, log metrics, whatever you need
57 Log::info('Dashboard batch completed', [
58 'total_time' => $batch->elapsedTime(),
59 'success_count' => count(array_filter($results, fn($r) => $r?->successful())),
60 ]);
61 })
62 ->send();
63 
64 return $responses;
65}

Look at that beauty! We've got progress tracking, error handling, caching fallbacks, and we can push updates out through WebSocket broadcasts for real-time updates. Your loading spinner can now show actual progress instead of just spinning aimlessly into the void.

Example: E-commerce Order Processing

Let's look at a quick example of using batch within an e-commerce application. When a customer completes an order, we need to:

  1. Charge their payment method

  2. Update inventory across multiple warehouses

  3. Send confirmation emails

  4. Notify our fulfilment partner

  5. Log analytics events

Here's how it could be done with Http::batch():

php
1<?php
2 
3class OrderProcessor
4{
5 public function processOrder(Order $order)
6 {
7 $criticalFailures = [];
8 
9 return Http::batch(fn (Batch $batch) => [
10 // Critical - must succeed
11 $batch->as('payment')
12 ->withBasicAuth(config('services.stripe.key'), '')
13 ->post('https://api.stripe.com/v1/charges', [
14 'amount' => $order->total_cents,
15 'currency' => 'gbp',
16 'source' => $order->payment_token,
17 ]),
18 
19 // Important but not critical
20 $batch->as('inventory_main')
21 ->withToken(config('services.warehouse.token'))
22 ->patch("https://warehouse1.api/inventory/bulk", [
23 'items' => $order->items->toArray()
24 ]),
25 
26 $batch->as('inventory_backup')
27 ->withToken(config('services.warehouse.token'))
28 ->patch("https://warehouse2.api/inventory/bulk", [
29 'items' => $order->items->toArray()
30 ]),
31 
32 // Nice to have
33 $batch->as('email')
34 ->withToken(config('services.sendgrid.key'))
35 ->post('https://api.sendgrid.com/v3/mail/send', [
36 'personalizations' => [['to' => [['email' => $order->email]]]],
37 'from' => ['email' => 'orders@jump24.co.uk'],
38 'subject' => 'Order Confirmation',
39 'content' => [['type' => 'text/html', 'value' => $this->getEmailHtml($order)]]
40 ]),
41 
42 $batch->as('fulfillment')
43 ->withHeaders(['X-API-Key' => config('services.fulfillment.key')])
44 ->post('https://partner.fulfillment.com/api/orders', [
45 'order_id' => $order->id,
46 'items' => $order->items,
47 'shipping' => $order->shipping_address,
48 ]),
49 
50 $batch->as('analytics')
51 ->post('https://analytics.internal/events', [
52 'event' => 'purchase',
53 'properties' => $order->toArray(),
54 ]),
55 ])
56 ->catch(function (Batch $batch, int|string $key, $exception) use (&$criticalFailures, $order) {
57 // Payment failures are critical
58 if ($key === 'payment') {
59 $criticalFailures[] = 'payment';
60 $order->markAsFailed('Payment processing failed');
61 
62 // Don't continue if payment failed
63 $batch->cancel();
64 return;
65 }
66 
67 // Log other failures but continue
68 Log::warning("Non-critical service failed for order {$order->id}", [
69 'service' => $key,
70 'error' => $exception->getMessage(),
71 ]);
72 
73 // Queue for retry later
74 ProcessFailedOrderService::dispatch($order, $key)->delay(now()->addMinutes(5));
75 })
76 ->then(function (Batch $batch, array $results) use ($order) {
77 // Only runs if payment succeeded and batch wasn't cancelled
78 $order->markAsProcessed();
79 
80 event(new OrderSuccessfullyProcessed($order));
81 })
82 ->finally(function (Batch $batch, array $results) use ($order, $criticalFailures) {
83 // Always runs unless batch was cancelled
84 activity()
85 ->performedOn($order)
86 ->withProperties([
87 'batch_id' => $batch->id,
88 'total_requests' => $batch->totalRequests,
89 'failed_services' => $criticalFailures,
90 'processing_time' => $batch->elapsedTime(),
91 ])
92 ->log('Order processing completed');
93 })
94 ->send();
95 }
96}

So there are a few things here to look at. Firstly notice now thanks to the catch method we can cancel the entire batch if payment fails by checking the key? Then in our finally method we can do our activity logging that we require or anything else that might be required as part of a clean up task. You have to love the simplicity in all of this its great.

The Technical Bits (For the Nerds Among Us)

Under the hood, Http::batch() is using Guzzle's promise-based concurrency, but Laravel wraps it in a much friendlier API. Here's what's actually happening:

php
1<?php
2 
3public function send(): array
4 {
5 $this->inProgress = true;
6 
7 if ($this->beforeCallback !== null) {
8 call_user_func($this->beforeCallback, $this);
9 }
10 
11 $results = [];
12 $promises = [];
13 
14 foreach ($this->requests as $key => $item) {
15 $promise = match (true) {
16 $item instanceof PendingRequest => $item->getPromise(),
17 default => $item,
18 };
19 
20 $promises[$key] = $promise;
21 }
22 
23 if (! empty($promises)) {
24 (new EachPromise($promises, [
25 'fulfilled' => function ($result, $key) use (&$results) {
26 $results[$key] = $result;
27 
28 $this->decrementPendingRequests();
29 
30 if ($result instanceof Response && $result->successful()) {
31 if ($this->progressCallback !== null) {
32 call_user_func($this->progressCallback, $this, $key, $result);
33 }
34 
35 return $result;
36 }
37 
38 if (($result instanceof Response && $result->failed()) ||
39 $result instanceof RequestException) {
40 $this->incrementFailedRequests();
41 
42 if ($this->catchCallback !== null) {
43 call_user_func($this->catchCallback, $this, $key, $result);
44 }
45 }
46 
47 return $result;
48 },
49 'rejected' => function ($reason, $key) use ($catchCallback) {
50 $this->decrementPendingRequests();
51 
52 if ($reason instanceof RequestException) {
53 $this->incrementFailedRequests();
54 
55 if ($this->catchCallback !== null) {
56 call_user_func($this->catchCallback, $this, $key, $reason);
57 }
58 }
59 
60 return $reason;
61 },
62 ]))->promise()->wait();
63 }
64 
65 if (! $this->hasFailures() && $this->thenCallback !== null) {
66 call_user_func($this->thenCallback, $this, $results);
67 }
68 
69 if ($this->finallyCallback !== null) {
70 call_user_func($this->finallyCallback, $this, $results);
71 }
72 
73 $this->finishedAt = new CarbonImmutable;
74 $this->inProgress = false;
75 
76 return $results;
77 }

But you don't need to worry about any of that Promise nonsense. Laravel handles it all.

Controlling Concurrency

By default, Http::batch() fires off all requests at once. But sometimes you need to be a bit more polite:

php
1<?php
2 
3Http::batch(fn (Batch $batch) => [
4 // ... your requests
5])
6->concurrency(3) // Only 3 requests at a time
7->send();

This is particularly useful when hitting rate-limited APIs or when you're worried about overwhelming a service by setting the number here to 3 the batch will send off a maximum of 3 HTTP requests that are concurrently in-flight.

How to inspect a Batch

As part of the Batch instance you're provided with a number of properties and methods that allow you to inspect and interact with a batch request. Such as being able to see the total number of requests that the batch in question has been assigned.

php
1<?php
2 
3// The number of requests assigned to the batch...
4$batch->totalRequests;
5 
6// The number of requests that have not been processed yet...
7$batch->pendingRequests;
8 
9// The number of requests that have failed...
10$batch->failedRequests;
11 
12// The number of requests that have been processed thus far...
13$batch->processedRequests();
14 
15// Indicates if the batch has finished executing...
16$batch->finished();
17 
18// Indicates if the batch has request failures...
19$batch->hasFailures();

Testing This Beast

One thing we love about the new batch method is how testable it is. Here's how we're testing our order processor could look:

php
1<?php
2 
3/** @test */
4it('processes orders with all services successfully', function () {
5 Http::fake([
6 'api.stripe.com/*' => Http::response(['id' => 'ch_test123'], 200),
7 'warehouse*.api/*' => Http::response(['status' => 'updated'], 200),
8 'api.sendgrid.com/*' => Http::response(['message' => 'Queued'], 202),
9 'partner.fulfillment.com/*' => Http::response(['tracking' => 'ABC123'], 201),
10 'analytics.internal/*' => Http::response(null, 204),
11 ]);
12 
13 $order = Order::factory()->create();
14 
15 $processor = new OrderProcessor();
16 $responses = $processor->processOrder($order);
17 
18 expect($responses['payment']->successful())->toBeTrue();
19 expect($order->fresh()->status)->toBe('processed');
20 
21 Http::assertSentCount(6); // All requests were sent
22});
23 
24/** @test */
25it('cancels batch when payment fails', function () {
26 Http::fake([
27 'api.stripe.com/*' => Http::response(['error' => 'Card declined'], 402),
28 '*' => Http::response(['status' => 'ok'], 200),
29 ]);
30 
31 $order = Order::factory()->create();
32 
33 $processor = new OrderProcessor();
34 $responses = $processor->processOrder($order);
35 
36 expect($order->fresh()->status)->toBe('failed');
37 
38 // Should only have attempted payment before cancelling
39 Http::assertSentCount(1);
40});

As you can see were able to test our happy path but also the unhappy path of a failed order for instance the stripe card is declined so we no longer proceed with the order and cancel.

Gotchas and Reality Checks

Now, let's be honest about the limitations because we don't do fluff here at Jump24:

1. Can't Add Requests After Starting

Once you call send(), that's it:

php
1<?php
2 
3$batch = Http::batch(fn (Batch $batch) => [
4 $batch->get('https://api.example.com/1'),
5])->send();
6 
7// This will throw BatchInProgressException
8$batch->add($batch->get('https://api.example.com/2')); // Nope!

2. Memory Considerations

If you're fetching massive responses, they're all held in memory so take this into consideration as you use them:

php
1<?php
2 
3// This might eat your RAM
4Http::batch(fn (Batch $batch) =>
5 collect(range(1, 1000))->map(fn ($i) =>
6 $batch->get("https://api.example.com/huge-response/{$i}")
7 )->toArray()
8)->send();

For huge batches, consider chunking:

php
1<?php
2 
3collect($urls)->chunk(50)->each(function ($chunk) {
4 Http::batch(fn (Batch $batch) =>
5 $chunk->map(fn ($url) => $batch->get($url))->toArray()
6 )->send();
7});

3. Error Handling Complexity

With great power comes great responsibility. You need to think about partial failures:

php
1<?php
2 
3->catch(function (Batch $batch, string $name, $exception) {
4 // What if 3 out of 5 requests fail?
5 // What if they fail in a specific order?
6 // What if a critical request fails but non-critical ones succeed?
7 // You need to handle these scenarios!
8})

Should You Use This?

Honestly? If you're making more than one HTTP request in a single process and you're on Laravel 12.32+, there's almost no reason not to use Http::batch().

The only exceptions we can think of:

  • Single requests (obviously)

  • When order matters and request B depends on the response from request A

  • When you're already using a dedicated queue system for this

For everything else? Batch it up.

The Bottom Line

Http::batch() is a great addition to the framework and makes you wonder how you lived without it. It's not revolutionary - other languages and frameworks have had similar features for years. But it's implemented in that distinctly Laravel way: powerful yet approachable, flexible yet opinionated, complex internally but dead simple to use.

Your Turn

Got a controller method that's making multiple HTTP requests sequentially? Go refactor it with Http::batch() right now. Seriously, we'll wait.

Share your before and after times with us - we're keeping a leaderboard of the biggest performance improvements. Current record is a 12-second page load down to 1.8 seconds. Beat that and we'll buy you a coffee (or a beer, your choice).

Building Laravel applications that need to juggle multiple API integrations? We've been wrestling with parallel HTTP requests since 2014, and we'd love to help you modernise your external service integrations. Whether it's payment providers, warehouse systems, or that legacy SOAP API that nobody wants to touch - we've probably dealt with something similar. Get in touch and let's make your application faster together.