HTTP Client

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Laravel 13.x (13.34.0) on PHP 8.3 to 8.5, checked on 2026-10-05. Laravel’s Http facade wraps Guzzle (Laravel 13 accepts Guzzle 7.8+ or 8; these runs used 8.2, and the suite also passes on 7.15, which Reverb’s guzzlehttp/psr7 ^2.6 requirement forced later, see Broadcasting and Reverb) in a fluent API with retries, concurrent requests and a test fake. The plain-PHP options (cURL, stream contexts, PSR-18 clients) are on HTTP Clients, APIs and Email. Bookshelf looks books up in an external catalogue service by ISBN. To make the failures reproducible, the examples call a small local stand-in on port 8030 that can be slow, fail a set number of times, or answer 429.

Making requests

<?php
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;

$base = 'http://127.0.0.1:8030';

$response = Http::get("$base/isbn/9780807083055");
$response->status();          // 200
$response->json('title');     // "Kindred", dot notation into the decoded body
$response['pages'];           // 264, array access works too

$missing = Http::get("$base/isbn/0000000000000");
$missing->failed();           // true, and no exception
$missing->clientError();      // true
$missing->throw();            // now RequestException: HTTP request returned status code 404: {"error":"unknown isbn"}

Http::post("$base/echo", ['title' => 'Kindred']);                 // JSON body, Content-Type: application/json
Http::asForm()->post("$base/echo", ['title' => 'Kindred']);       // title=Kindred, application/x-www-form-urlencoded
Http::get("$base/echo", ['q' => 'butler']);                       // ?q=butler
Http::withUrlParameters(['endpoint' => $base, 'isbn' => '9780807083055'])
    ->get('{+endpoint}/isbn/{isbn}');                             // RFC 6570 template, values are encoded

Two defaults differ from Guzzle and from older code:

  • The body is JSON unless asked otherwise. post sends JSON. Many older APIs expect forms and need asForm().

  • A 4xx or 5xx response is not an exception. Guzzle throws by default, but Http returns the response. Code that never checks failed() or calls throw() treats an error page as success. throw() returns the response when it succeeded, so Http::get(...)->throw()->json() is the usual one-liner.

Timeouts

The client’s defaults are a 30-second timeout and a 10-second connect timeout. A web request that calls a slow service then holds a PHP-FPM worker for that long. timeout limits the whole request, and connectTimeout limits the TCP and TLS setup:

<?php
Http::timeout(1)->get("$base/slow", ['ms' => 2000]);
Illuminate\Http\Client\ConnectionException after 1003 ms: cURL error 28: Operation timed out after 1001 milliseconds with 0 bytes received

A timeout is a ConnectionException, not a RequestException. There is no response to inspect.

Retries

retry repeats a failed attempt. An array gives the wait before each retry, so [100, 300] means three attempts in total. The mock service fails twice with 503 and then succeeds:

<?php
$response = Http::retry([100, 300])->get("$base/flaky", ['fail' => 2]);
retry: 200 attempt 3 in 409 ms

The run found two traps with retries:

  • A plain retry retries every failure, including 4xx. Http::retry(3, 100) sent the unknown ISBN three times, and the server counted three requests. A 404 or 422 will not change on a retry. Retrying it only adds load. A when callback limits the retries to connection errors and 5xx responses.

  • retry throws when it gives up, and when when says no. With a when callback, the 404 was sent once, but it surfaced as a RequestException, not as a response, because retry turns on throwing. throw: false brings the normal behaviour back: the last response is returned and the caller decides. A ConnectionException is still thrown, because there is no response to return.

For a 429, the wait should come from the server’s Retry-After header. The second argument can be a callback that reads it:

<?php
Http::retry(2, fn (int $attempt, Throwable $e) => $e instanceof RequestException && $e->response->status() === 429
    ? (int) $e->response->header('Retry-After') * 1000
    : 100)->get("$base/limited");                 // the mock answers 429 with Retry-After: 1
Retry-After honoured: 429 after 1022 ms

The retries happen inside the web request, so the user waits through every sleep. Long backoff belongs in a queued job with its own backoff (Queues and Jobs). The general patterns, such as jitter, retry budgets and circuit breakers, are on Retry and Circuit Breaker.

One configured client

Base URL, token, timeouts and retry policy belong in one place. A macro on the Http factory gives the whole application a named client. The URL and token come from config/services.php, which reads them from .env:

<?php
// app/Providers/AppServiceProvider.php, in boot()
Http::macro('catalogue', fn () => Http::baseUrl(config('services.catalogue.url'))
    ->withToken((string) config('services.catalogue.token'))
    ->acceptJson()
    ->withUserAgent('Bookshelf/1.0')
    ->connectTimeout(1)
    ->timeout(3)
    ->retry([100, 300], when: fn (Throwable $e) => $e instanceof ConnectionException
        || ($e instanceof RequestException && $e->response->serverError()), throw: false));
<?php
// app/Services/CatalogueClient.php
namespace App\Services;

use Illuminate\Support\Facades\Http;

class CatalogueClient
{
    /** @return array{isbn: string, title: string, author: string, pages: int}|null */
    public function lookup(string $isbn): ?array
    {
        $response = Http::catalogue()->withUrlParameters(['isbn' => $isbn])->get('/isbn/{isbn}');

        if ($response->notFound()) {
            return null;
        }

        return $response->throw()->json();     // other 4xx, and 5xx after the retries, become exceptions
    }
}

The mock service’s /echo endpoint shows what the macro sends, and how it handles the failures:

GET /echo?q=butler: {"method":"GET","query":{"q":"butler"},"authorization":"Bearer demo-…","user_agent":"Bookshelf/1.0","x_request_id":"req-42","accept":"application/json"}
404 with throw: false: 404, requests: 1
503 x5 with throw: false: 503 attempt 3, requests: 3, 509 ms
connection refused: Illuminate\Http\Client\ConnectionException (thrown even with throw: false)

When a RequestException is logged or reported, its message (status plus body) is cut to 120 characters, so a large error page cannot fill the log. RequestException::truncateAt() and dontTruncate() in bootstrap/app.php change that.

Concurrent requests

Http::pool sends requests at the same time through Guzzle’s curl multi handler. Three calls to an endpoint that takes 500 ms:

<?php
use Illuminate\Http\Client\Pool;

$responses = Http::pool(fn (Pool $pool) => [
    $pool->as('a')->get("$base/slow", ['ms' => 500]),
    $pool->as('b')->get("$base/slow", ['ms' => 500]),
    $pool->as('c')->timeout(0.2)->get("$base/slow", ['ms' => 500]),
], concurrency: 5);

$responses['a']->status();      // 200
$responses['c'];                // a ConnectionException object, returned and not thrown
sequential 3 x 500 ms: 1505 ms
pool: 503 ms; a=200 c=Illuminate\Http\Client\ConnectionException

A pooled request that fails to connect comes back as the exception object in the result array, so every entry needs an instanceof Throwable check before ->status(). The pool cannot be chained after withHeaders or a macro, so each $pool->... call sets its own options. Http::batch takes the same requests and adds then, catch and finally callbacks, plus defer() to send the batch after the response has gone to the user.

Middleware and events

Guzzle middleware can change every request and response. globalRequestMiddleware applies to every request the application sends, which suits a correlation ID that the other service can log:

<?php
// AppServiceProvider::boot()
Http::globalRequestMiddleware(fn ($request) => $request->withHeader('X-Request-Id', Context::get('request_id')));

// listeners, for logging or metrics
Event::listen(ResponseReceived::class, fn (ResponseReceived $e) => Log::debug('http', [
    'url' => $e->request->url(),
    'status' => $e->response->status(),
    'ms' => $e->response->handlerStats()['total_time'] * 1000,
]));
Event::listen(ConnectionFailed::class, fn (ConnectionFailed $e) => Log::warning('http down', ['url' => $e->request->url()]));
event: GET http://127.0.0.1:8030/echo -> 200 in 0.6 ms
global middleware header: bookshelf-fe01ce2a
event: connection failed for http://127.0.0.1:8031/

Context carries the request ID into queued jobs too (Processes, Concurrency and Context). Telescope and Nightwatch record outgoing requests from the same events (Monitoring and Debugging).

A URL that comes from a user (a cover image link, a webhook target) can point at 127.0.0.1, a cloud metadata address or an internal service. Http will fetch any of them. The checks that prevent server-side request forgery are on Web Security Fundamentals.

Testing with Http::fake

Http::fake replaces Guzzle’s handler, so no request leaves the process. URL patterns map to responses, sequences return a different response on each call, and failedConnection() simulates a dead service. preventStrayRequests() makes any request without a fake throw. Sleep::fake() skips the waits between retries and records them:

<?php
// tests/Feature/CatalogueClientTest.php
use App\Services\CatalogueClient;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Request;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Sleep;

beforeEach(function () {
    config(['services.catalogue.url' => 'https://catalogue.test', 'services.catalogue.token' => 'test-token']);
    Http::preventStrayRequests();
    Sleep::fake();                                         // retry() waits through Sleep
});

test('looks a book up by isbn', function () {
    Http::fake(['catalogue.test/isbn/*' => Http::response(['isbn' => '9780807083055', 'title' => 'Kindred', 'author' => 'Octavia E. Butler', 'pages' => 264])]);

    expect(app(CatalogueClient::class)->lookup('9780807083055'))->toMatchArray(['title' => 'Kindred']);

    Http::assertSent(fn (Request $request) => $request->url() === 'https://catalogue.test/isbn/9780807083055'
        && $request->hasHeader('Authorization', 'Bearer test-token')
        && $request->hasHeader('Accept', 'application/json'));
});

test('returns null for an unknown isbn without retrying', function () {
    Http::fake(['catalogue.test/*' => Http::response(['error' => 'unknown isbn'], 404)]);

    expect(app(CatalogueClient::class)->lookup('0000000000000'))->toBeNull();
    Http::assertSentCount(1);
});

test('retries server errors and then succeeds', function () {
    Http::fake(['catalogue.test/*' => Http::sequence()
        ->push(['error' => 'busy'], 503)
        ->push(['error' => 'busy'], 503)
        ->push(['isbn' => '9780807083055', 'title' => 'Kindred', 'author' => 'Octavia E. Butler', 'pages' => 264])]);

    expect(app(CatalogueClient::class)->lookup('9780807083055')['title'])->toBe('Kindred');
    Http::assertSentCount(3);
    Sleep::assertSequence([Sleep::for(100)->milliseconds(), Sleep::for(300)->milliseconds()]);
});

test('gives up after three server errors', function () {
    Http::fake(['catalogue.test/*' => Http::response(['error' => 'busy'], 503)]);

    expect(fn () => app(CatalogueClient::class)->lookup('9780807083055'))->toThrow(RequestException::class);
    Http::assertSentCount(3);
});

test('a dead service is a connection exception', function () {
    Http::fake(['catalogue.test/*' => Http::failedConnection()]);

    expect(fn () => app(CatalogueClient::class)->lookup('9780807083055'))->toThrow(ConnectionException::class);
});

test('stray requests are refused', function () {
    expect(fn () => Http::get('https://openlibrary.org/isbn/9780807083055.json'))
        ->toThrow(RuntimeException::class, 'Attempted request to [https://openlibrary.org/isbn/9780807083055.json] without a matching fake.');
});
$ php artisan test tests/Feature/CatalogueClientTest.php

   PASS  Tests\Feature\CatalogueClientTest
  ✓ looks a book up by isbn                                              0.14s
  ✓ returns null for an unknown isbn without retrying                    0.01s
  ✓ retries server errors and then succeeds                              0.01s
  ✓ gives up after three server errors                                   0.01s
  ✓ a dead service is a connection exception                             0.01s
  ✓ stray requests are refused                                           0.01s

  Tests:    6 passed (15 assertions)

The second and fourth tests pin down the retry policy, so a later change to a plain retry(3) fails the suite instead of tripling the traffic to the catalogue. preventStrayRequests() can go in tests/Pest.php for the whole suite. A forgotten fake then fails the test instead of calling a real service from CI.

Where older books and tutorials are outdated

  • new GuzzleHttp\Client() in the controller, common in Laravel 5 tutorials, has no shared configuration and is hard to fake. The Http facade (Laravel 7) wraps the same Guzzle, adds Http::fake, and a macro or a service class holds the configuration.

  • file_get_contents($url) with allow_url_fopen (Powers ch. 7) has no timeout by default (it falls back to default_socket_timeout, 60 seconds), no status handling and no test seam.

  • Mocking Guzzle with a MockHandler stack is replaced by Http::fake, Http::sequence and the assertSent* family.

  • A hand-written retry loop with sleep() blocks for whole seconds and is not testable. retry with an array of milliseconds and Sleep::fake() replaces it.