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
Source: Laravel — Making Requests and Error Handling
Two defaults differ from Guzzle and from older code:
-
The body is JSON unless asked otherwise.
postsends JSON. Many older APIs expect forms and needasForm(). -
A 4xx or 5xx response is not an exception. Guzzle throws by default, but
Httpreturns the response. Code that never checksfailed()or callsthrow()treats an error page as success.throw()returns the response when it succeeded, soHttp::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]);
Source: Laravel — Timeout
Illuminate\Http\Client\ConnectionException after 1003 ms: cURL error 28: Operation timed out after 1001 milliseconds with 0 bytes received
Source: Laravel — Timeout
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]);
Source: Laravel — Retries
retry: 200 attempt 3 in 409 ms
Source: Laravel — Retries
The run found two traps with retries:
-
A plain
retryretries 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. Awhencallback limits the retries to connection errors and 5xx responses. -
retrythrows when it gives up, and whenwhensays no. With awhencallback, the 404 was sent once, but it surfaced as aRequestException, not as a response, becauseretryturns on throwing.throw: falsebrings the normal behaviour back: the last response is returned and the caller decides. AConnectionExceptionis 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
Source: Laravel — Retries
Retry-After honoured: 429 after 1022 ms
Source: Laravel — Retries
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));
Source: Laravel — Macros
<?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
}
}
Source: Laravel — URI Templates and Throwing 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)
Source: Laravel — Headers and Bearer Tokens
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
Source: Laravel — Request Pooling
sequential 3 x 500 ms: 1505 ms
pool: 503 ms; a=200 c=Illuminate\Http\Client\ConnectionException
Source: Laravel — Concurrent Requests
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()]));
Source: Laravel — Global Middleware and Events
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/
Source: Laravel — Events
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. TheHttpfacade (Laravel 7) wraps the same Guzzle, addsHttp::fake, and a macro or a service class holds the configuration. -
file_get_contents($url)withallow_url_fopen(Powers ch. 7) has no timeout by default (it falls back todefault_socket_timeout, 60 seconds), no status handling and no test seam. -
Mocking Guzzle with a
MockHandlerstack is replaced byHttp::fake,Http::sequenceand theassertSent*family. -
A hand-written retry loop with
sleep()blocks for whole seconds and is not testable.retrywith an array of milliseconds andSleep::fake()replaces it.