Laravel Contextual HTTP Clients: Per-Service Config, Retries, and Middleware Stacks
#laravel #http-client #service-container #testing

Laravel Contextual HTTP Clients: Per-Service Config, Retries, and Middleware Stacks

4 min read Mohamed Said Mohamed Said

The Problem With Ad-Hoc HTTP Clients

Most Laravel codebases scatter HTTP configuration everywhere. One service sets a base URL, another configures a timeout, a third adds an auth header — and none of it is testable in isolation. Laravel's Http facade is powerful, but without structure it becomes a maintenance liability.

The solution is to register named, fully-configured HTTP client instances through the service container, so every collaborator receives a ready-to-use client with the correct base URL, headers, retry policy, and middleware already applied.

Registering a Named Client in a Service Provider

Http::buildClient() returns a PendingRequest, which is itself a fluent builder. You can bind one per external service:

// app/Providers/HttpClientsServiceProvider.php

use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Http;

class HttpClientsServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton('http.stripe', function () {
            return Http::baseUrl(config('services.stripe.base_url'))
                ->withToken(config('services.stripe.secret'))
                ->timeout(15)
                ->retry(3, 200, function (\Exception $e) {
                    return $e instanceof \Illuminate\Http\Client\ConnectionException;
                })
                ->withMiddleware($this->idempotencyMiddleware());
        });

        $this->app->singleton('http.sendgrid', function () {
            return Http::baseUrl('https://api.sendgrid.com/v3')
                ->withHeader('Authorization', 'Bearer ' . config('services.sendgrid.key'))
                ->acceptJson()
                ->timeout(10)
                ->retry(2, 500);
        });
    }

    private function idempotencyMiddleware(): callable
    {
        return function (callable $handler) {
            return function ($request, array $options) use ($handler) {
                $request = $request->withHeader(
                    'Idempotency-Key',
                    (string) str()->uuid()
                );
                return $handler($request, $options);
            };
        };
    }
}

The middleware closure follows the Guzzle handler stack contract — Laravel's PendingRequest::withMiddleware() accepts any PSR-compatible Guzzle middleware.

Injecting Named Clients Into Services

Use contextual binding or a typed wrapper to keep service classes clean:

// app/Services/StripeClient.php

use Illuminate\Http\Client\PendingRequest;

final class StripeClient
{
    public function __construct(
        private readonly PendingRequest $client
    ) {}

    public function createPaymentIntent(int $amountCents, string $currency): array
    {
        return $this->client
            ->post('/payment_intents', [
                'amount'   => $amountCents,
                'currency' => $currency,
            ])
            ->throw()
            ->json();
    }
}

Bind it contextually so the container knows which PendingRequest to inject:

// Inside HttpClientsServiceProvider::register()

$this->app->when(StripeClient::class)
    ->needs(PendingRequest::class)
    ->give(fn ($app) => $app->make('http.stripe'));

Now StripeClient has zero configuration knowledge. It just calls endpoints.

Testing Without Hitting the Network

Laravel's Http::fake() intercepts all outgoing requests regardless of how the client was built, so your named instances are fully fakeable:

// tests/Feature/StripeClientTest.php

use Illuminate\Support\Facades\Http;
use App\Services\StripeClient;

it('creates a payment intent', function () {
    Http::fake([
        '*/payment_intents' => Http::response([
            'id'     => 'pi_test_123',
            'status' => 'requires_payment_method',
        ], 201),
    ]);

    $result = app(StripeClient::class)->createPaymentIntent(5000, 'usd');

    expect($result['id'])->toBe('pi_test_123');

    Http::assertSent(fn ($request) =>
        $request->url() === config('services.stripe.base_url') . '/payment_intents'
        && $request['amount'] === 5000
    );
});

Because the PendingRequest is resolved fresh per singleton scope, Http::fake() intercepts correctly. If you need per-test isolation, swap the singleton for a bind or reset the fake between tests.

Retry Policies Worth Knowing

  • retry(3, 200) — three attempts, 200 ms fixed delay.
  • Pass a throw boolean as the fourth argument (false) to suppress the final exception and return the last response instead.
  • The callback form lets you retry only on specific exception types or response status codes, avoiding retries on 4xx client errors.

Takeaways

  • Register one named PendingRequest singleton per external service in a dedicated service provider.
  • Use contextual binding to inject the correct client into each service class without string-keyed app() calls.
  • Guzzle middleware added via withMiddleware() is the right place for cross-cutting concerns like idempotency keys, request signing, or structured logging.
  • Http::fake() works transparently with named instances — no extra test infrastructure needed.
  • Prefer ->throw() on the response chain over manual status checks; it raises RequestException with the full response attached.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Does Http::fake() intercept requests made through a named PendingRequest singleton?
Yes. Laravel's fake layer patches the underlying Guzzle handler factory globally, so any PendingRequest instance — regardless of how it was constructed — will be intercepted when Http::fake() is active in a test.
Q02 Should I use a singleton or a transient binding for named HTTP clients?
Singleton is fine for stateless configuration (base URL, headers, timeout). If your middleware mutates per-request state — such as generating a unique idempotency key — ensure the mutation happens inside the middleware closure at request time, not at binding time, so the singleton remains safe.
Q03 Can I add structured logging to every outgoing request without touching each service class?
Yes. Add a Guzzle middleware via withMiddleware() in the service provider. The middleware receives the request before it is sent and the response after, giving you a single place to log URL, duration, and status code for every call made through that named client.

Continue reading

More Articles

View all