Calling External APIs
The HTTP client: requests, retries, timeouts, faking responses in tests.
Most applications talk to other services: payment providers, shipping rates, weather, a CRM, your own microservices. Laravel's HTTP client (the Http facade) wraps Guzzle in a short, readable API with timeouts, retries, concurrent requests and, best of all, a built-in way to fake responses in tests. This lesson covers the patterns you'll use for every integration, demonstrated by a real endpoint that makes outgoing requests from this site's server.
Try it: outgoing requests from the server
GET /api/demo/http-client?scenario=… makes real outgoing requests with the Http facade. To keep things safe the target URLs are fixed on the server (this site's own demo API, and a port that refuses connections), so you choose a scenario, not a URL.
The attempts field counts every try, retries included. A 404 is attempted once, because retrying a request that's simply wrong is pointless; an unreachable host is tried three times before giving up.
Making requests
use Illuminate\Support\Facades\Http;
$response = Http::get('https://api.example.com/rates', ['currency' => 'GBP']); // query string
$response = Http::post('https://api.example.com/orders', ['sku' => 'A1', 'qty' => 2]); // JSON body
$response = Http::asForm()->post($url, ['grant_type' => 'client_credentials']); // form body
$response = Http::attach('file', $contents, 'invoice.pdf')->post($url); // multipart
$response = Http::withToken($apiKey)->acceptJson()->delete("$url/orders/42");
The Response object has everything you need to inspect the answer:
$response->status(); // 200
$response->successful(); // 2xx?
$response->failed(); // 4xx or 5xx?
$response->clientError(); // 4xx? ->serverError() for 5xx
$response->json(); // decoded array
$response->json('data.0.name'); // dot-notation into the JSON
$response->collect('data'); // as a Collection
$response->header('X-RateLimit-Remaining');
Http::macro('github', fn () => Http::withToken(config('services.github.token'))->baseUrl('https://api.github.com')), then Http::github()->get('/user').
Timeouts and retries
The client's default timeout is 30 seconds. A slow third party can therefore tie up a PHP worker for half a minute and, under load, take your whole site down with it. Always set a timeout, and retry only failures that might succeed next time.
This is the real code behind the ok and not-found scenarios:
app/Http/Controllers/Api/HttpClientDemoController.php
/**
* @return array<string, mixed>
*/
private function fetch(string $path): array
{
$url = $this->baseUrl().$path;
$attempts = 0;
try {
$response = Http::acceptJson()
->timeout(5)
->retry(2, 200, function (Throwable $exception): bool {
// Retry only connection problems and 5xx answers, never 4xx
return $exception instanceof ConnectionException
|| ($exception instanceof RequestException && $exception->response->serverError());
}, throw: false)
->withMiddleware($this->countAttempts($attempts))
->get($url);
} catch (ConnectionException $exception) {
return ['url' => $url, 'attempts' => $attempts, 'ok' => false, 'error' => class_basename($exception)];
}
return [
'url' => $url,
'attempts' => $attempts,
'status' => $response->status(),
'ok' => $response->successful(),
'body' => $response->successful()
? ['projects' => collect($response->json('data'))->pluck('name')]
: ['message' => $response->json('message')],
];
}
timeout(5)caps the whole request;connectTimeout(1)caps just the connection.retry(2, 200, …)makes up to two attempts with 200 ms between them. Pass an array such as[100, 500, 2000]for back-off that grows.- The callback decides which failures to retry: connection problems and 5xx, never a 4xx.
throw: falsehands back the final failed response instead of throwing, so the code can read its status.withMiddleware(...)plugs a Guzzle middleware into the request. Here it only counts attempts for the demo; real uses include signing requests or adding tracing headers.
Handling failure
Decide up front what happens when the other service is down. Either let the exception bubble up (it becomes a 500 and is logged), or catch it and degrade gracefully:
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
try {
$rates = Http::timeout(3)->retry(2, 100)
->get('https://api.example.com/rates')
->throw() // turn 4xx/5xx into a RequestException
->json('rates');
} catch (ConnectionException|RequestException $e) {
report($e); // still logged, but the page keeps working
$rates = Cache::get('rates.last_known', []);
}
throw() is the important call: by default the client doesn't throw on 4xx or 5xx responses, it just returns them. Use throwIf() / throwUnless() for conditional versions, or ->onError(fn ($response) => ...) to run a callback.
Concurrent requests with pool
Three independent calls one after another take the sum of their times. Http::pool() sends them at the same time, so the total is roughly the slowest one:
/**
* @return array<string, mixed>
*/
private function fetchConcurrently(): array
{
$base = $this->baseUrl();
$responses = Http::pool(fn (Pool $pool) => [
$pool->as('projects')->acceptJson()->timeout(5)->get("{$base}/api/demo/projects"),
$pool->as('high_priority')->acceptJson()->timeout(5)->get("{$base}/api/demo/tasks", ['priority' => 'high']),
$pool->as('stats')->acceptJson()->timeout(5)->get("{$base}/api/demo/stats"),
]);
return [
'ok' => collect($responses)->every(fn ($response) => $response instanceof Response && $response->successful()),
'responses' => collect($responses)->map(fn ($response) => $response instanceof Response
? ['status' => $response->status(), 'took_ms' => round(($response->transferStats?->getTransferTime() ?? 0) * 1000, 1)]
: ['error' => class_basename($response)]),
];
}
Run the pool scenario in the demo above and compare the overall took_ms with the three individual timings.
Faking responses in tests
Tests must never call real external APIs: they'd be slow, flaky and might charge money. Http::fake() intercepts every outgoing request and returns the responses you define. These are real tests in this app:
tests/Feature/Demo/QueryErrorsHttpDemoTest.php
public function test_http_client_retries_server_errors_then_succeeds(): void
{
config(['services.demo_api.base_url' => 'https://demo.test']);
Http::fake([
'demo.test/*' => Http::sequence()
->push(['message' => 'Server Error'], 500)
->push(['data' => [['name' => 'Mobile App API']]]),
]);
$this->getJson(route('api.demo.http-client'))
->assertOk()
->assertJsonPath('status', 200)
->assertJsonPath('attempts', 2);
}
public function test_http_client_pool_runs_three_requests(): void
{
config(['services.demo_api.base_url' => 'https://demo.test']);
Http::fake(['demo.test/*' => Http::response(['data' => []])]);
$this->getJson(route('api.demo.http-client', ['scenario' => 'pool']))
->assertOk()
->assertJsonPath('ok', true)
->assertJsonPath('responses.projects.status', 200)
->assertJsonPath('responses.high_priority.status', 200)
->assertJsonPath('responses.stats.status', 200);
Http::assertSentCount(3);
}
Other useful fakes and assertions:
Http::fake(['github.com/*' => Http::response(['login' => 'matt'], 200)]);
Http::fake(fn ($request) => Http::response('Server Error', 500)); // every request fails
Http::preventStrayRequests(); // any un-faked request throws
Http::assertSent(fn ($request) => $request->hasHeader('Authorization') && $request['qty'] === 2);
Http::assertNotSent(fn ($request) => str_contains($request->url(), 'refund'));
Http::assertNothingSent();
Security: never fetch a URL the user chose
If users can make your server fetch any URL, they can reach things only your server can see: internal admin panels, cloud metadata endpoints, the database port. This attack is called server-side request forgery (SSRF). The demo endpoint avoids it by letting callers pick only a named scenario, validated with Rule::in(self::SCENARIOS). Sending ?scenario=https://evil.example returns a 422.
When a feature genuinely needs user-supplied URLs (link previews, webhooks), validate the scheme and host against an allow-list and resolve the IP to reject private ranges before making the call.
Key takeaways
Http::get/post/...returns aResponsewithstatus(),successful(),json()andcollect(). 4xx and 5xx don't throw unless you callthrow().- Always set
timeout()(and oftenconnectTimeout()). Retry withretry()only for connection errors and 5xx, with a delay between attempts. - Catch
ConnectionExceptionandRequestException,report()them, and fall back to cached or default data when the feature can survive without the call. Http::pool()runs independent requests concurrently.- In tests,
Http::fake(),Http::sequence()andHttp::assertSent()make integrations fast and deterministic;preventStrayRequests()catches anything you forgot to fake. - Never let users choose the URL your server fetches without strict validation (SSRF).