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
throwboolean 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
PendingRequestsingleton 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 raisesRequestExceptionwith the full response attached.