Fire HTTP requests after your response with Laravel 12's batch defer

4 min read

Every time I add an outbound HTTP call inside a controller — an analytics ping, a webhook dispatch, a third-party notification — I feel the user paying for it. An extra 200ms here, 400ms there. Laravel 12.32 addressed this cleanly: Http::batch()->defer() fires your HTTP batch after the response is sent. The user doesn't wait.

Before this, the tidy options were a queued job (correct for anything non-trivial) or the defer() helper added in Laravel 11.23 (good for simple closures). For concurrent outbound HTTP calls with proper lifecycle hooks, there was nothing this direct.

The problem with synchronous HTTP calls in request handlers

Here's the pattern I kept falling into. A form submission that pings an analytics endpoint and notifies a webhook before returning:

public function store(Request $request): JsonResponse
{
    $order = Order::create($request->validated());

    // Both of these block the response
    Http::post('https://analytics.example.com/events', [
        'event'    => 'order.created',
        'order_id' => $order->id,
    ]);

    Http::post($this->webhookUrl, [
        'order' => $order->toArray(),
    ]);

    return response()->json(['id' => $order->id], 201);
}

Both Http::post() calls run synchronously. The user waits for both to complete, even though neither return value is used in the response. On a good day that's 100–200ms wasted. On a bad day, a slow third-party drags the whole request out to a second or more.

How Laravel 12's Http::batch()->defer() works

Http::batch() runs multiple HTTP requests concurrently. Chaining ->defer() delays the entire batch until after the HTTP response has been sent to the user. The requests still run in the same PHP process — this is not a queue — but the user doesn't wait for them.

use Illuminate\Http\Client\Batch;
use Illuminate\Support\Facades\Http;

Http::batch(fn (Batch $batch) => [
    $batch->post('https://analytics.example.com/events', [
        'event'    => 'order.created',
        'order_id' => $order->id,
    ]),
    $batch->post($this->webhookUrl, [
        'order' => $order->toArray(),
    ]),
])
->then(function (Batch $batch, array $results) {
    // All requests completed — log or react here
    logger('Post-response batch finished', ['failed' => $batch->failedRequests]);
})
->defer();

The full set of lifecycle hooks — before(), progress(), then(), catch(), finally() — all still work. They just execute after your user has moved on.

Practical example: deferring an HTTP batch after a webhook response

Here's the refactored controller. The 201 goes back to the user immediately; the external calls fire after:

public function store(Request $request): JsonResponse
{
    $order = Order::create($request->validated());

    Http::batch(fn (Batch $batch) => [
        $batch->post('https://analytics.example.com/events', [
            'event'    => 'order.created',
            'order_id' => $order->id,
        ]),
        $batch->post($this->webhookUrl, [
            'order' => $order->toArray(),
        ]),
    ])
    ->catch(function (Batch $batch, string|int $key, \Exception $e) {
        // You cannot surface errors to the user at this point — log them
        logger()->error('Deferred HTTP request failed', [
            'key'   => $key,
            'error' => $e->getMessage(),
        ]);
    })
    ->defer();

    return response()->json(['id' => $order->id], 201);
}

The ->catch() handler matters here. Once the response is sent, you have no way to tell the user something failed. Log it, fire an alert, or push to a dead-letter channel — but don't drop it silently.

Compared to dispatch_after_response() and the defer() helper

Laravel has two other post-response mechanisms worth knowing about, because they serve different purposes.

dispatch_after_response() is for queued Job classes. It runs the job synchronously in-process after the response. You need a full job class, and jobs process one at a time.

The global defer() helper (added in Laravel 11.23) takes any closure and defers it post-response. No job class needed, no queue infrastructure. Good for lightweight one-offs like cache invalidation or a single HTTP call.

Http::batch()->defer() is specifically for concurrent outbound HTTP requests. You get the batch hooks and concurrent execution, deferred:

// dispatch_after_response() — for job classes
ProcessExport::dispatch()->afterResponse();

// defer() helper — for lightweight closures, no infrastructure needed
defer(fn () => Cache::forget("user:{$user->id}"));

// Http::batch()->defer() — concurrent outbound HTTP, post-response
Http::batch(fn (Batch $batch) => [
    $batch->post($webhookUrl, $payload),
    $batch->post($analyticsUrl, $event),
])->defer();

Limitations and when to reach for a proper queue

This runs in the same PHP process. If the server terminates the process before the deferred batch finishes, requests get dropped. There are no retries, no visibility into what ran, and no backoff on failure.

Use Http::batch()->defer() when:

  • The calls are fire-and-forget (the response doesn't depend on the result)
  • They're fast — a handful of seconds collectively at most
  • Failure is tolerable and logged, not critical

Reach for a proper queue when:

  • You need retries with exponential backoff
  • The work is slow or resource-intensive
  • Delivery must be guaranteed
  • You want queue dashboard visibility

For lightweight third-party pings — analytics events, webhook notifications, audit logs — the deferred batch hits the right spot. For anything with real delivery requirements, queues are still the answer.

laravel
laravel-12
http-client
performance
Steven Richardson

Steven is a software engineer with a passion for building scalable web applications. He enjoys sharing his knowledge through articles and tutorials.