Laravel Tutorial · Lessons

Foundation

Basic

Medium

Advanced

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.

Browser /api/demo/http-client Laravel app Http::timeout(5) ->retry(2, 200)->get(...) External API payments, CRM, weather… retry on timeouts and 5xx, never on 4xx
Your server is the client here. Every outgoing call needs a timeout, a retry policy and a plan for failure.

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');
Repeated settings belong in a macro or a dedicated client class: 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: false hands 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 a Response with status(), successful(), json() and collect(). 4xx and 5xx don't throw unless you call throw().
  • Always set timeout() (and often connectTimeout()). Retry with retry() only for connection errors and 5xx, with a delay between attempts.
  • Catch ConnectionException and RequestException, 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() and Http::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).