Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 099 / 100

Laravel HTTP client: a PHP & Laravel guide for developers

Call external APIs cleanly.

Expona Admin8 min read

Introduction

Calling external APIs is a core part of modern PHP and Laravel applications. Whether you're integrating payment processors, fetching data from third-party services, or syncing with partner platforms, you need a clean, reliable way to make HTTP requests. The Laravel HTTP client: a PHP & Laravel guide for developers covers the tools and patterns that turn messy cURL calls into maintainable, testable code.

Laravel's HTTP client abstracts away the complexity of HTTP communication while giving you powerful features like token management, retry logic, and request timeouts out of the box. In this guide, we'll walk through the most common patterns your team will use in production, along with the mistakes that catch even experienced developers.

Using Http::withToken()->get() for authenticated requests

Most external APIs require authentication. The Laravel HTTP client makes token-based authentication straightforward with the withToken() method, which automatically adds the Authorization: Bearer header.

php
use Illuminate\Support\Facades\Http;

$response = Http::withToken('your-api-token')
    ->get('https://api.example.com/users');

$data = $response->json();

This is cleaner than building headers manually. The method defaults to Bearer tokens, which works for most modern APIs. If you need a different scheme, you can pass headers directly:

php
$response = Http::withHeaders([
    'Authorization' => 'ApiKey ' . config('services.api_key'),
])
    ->get('https://api.example.com/data');

In production applications, never hardcode tokens. Store them in your .env file or a secure configuration service. For long-lived integrations, consider storing tokens in the database with an expiration timestamp so you can refresh them programmatically.

A common pattern in our teams is wrapping API calls in a dedicated service class. This keeps your controllers clean and makes it easy to manage authentication, error handling, and logging in one place:

php
namespace App\Services;

use Illuminate\Support\Facades\Http;

class PaymentGatewayService
{
    protected string $apiToken;
    protected string $baseUrl;

    public function __construct()
    {
        $this->apiToken = config('services.payment.token');
        $this->baseUrl = config('services.payment.url');
    }

    public function chargeCard(array $cardData, float $amount): array
    {
        $response = Http::withToken($this->apiToken)
            ->post("{$this->baseUrl}/charge", [
                'amount' => $amount * 100, // cents
                'card' => $cardData,
            ]);

        return $response->json();
    }
}

This approach also makes it trivial to swap implementations or mock the service during testing.

Implementing retry() with backoff for resilience

Network calls fail. Servers go down. Rate limits kick in. The retry() method lets you handle transient failures gracefully without writing try-catch boilerplate.

php
$response = Http::retry(times: 3, sleep: 100)
    ->get('https://api.example.com/data');

This retries the request up to 3 times, waiting 100 milliseconds between attempts. However, a fixed delay doesn't work well in the real world. Exponential backoff—where each retry waits longer than the last—gives overloaded services time to recover and reduces thundering herd problems:

php
$response = Http::retry(
    times: 4,
    sleep: fn ($attempt) => $attempt * 1000, // 1s, 2s, 3s, 4s
)
    ->get('https://api.example.com/data');

But exponential backoff isn't always enough. Some APIs return specific HTTP status codes that signal you should retry (like 429 for rate limit or 503 for service unavailable), while others don't. The retry() method by default retries on connection errors and 5xx responses, but you can customize this with a callback:

php
$response = Http::retry(
    times: 5,
    sleep: fn ($attempt) => min(1000 * (2 ** ($attempt - 1)), 16000),
    throw: false,
)
    ->get('https://api.example.com/data');

if ($response->failed()) {
    // Handle the failure after exhausting retries
    Log::error('API call failed', [
        'status' => $response->status(),
        'body' => $response->body(),
    ]);
}

Setting throw: false prevents the HTTP client from throwing an exception on failure, letting you handle the response yourself. This is useful when you want to log the error or return a fallback value rather than crashing.

A real-world example: integrating with a ride-hailing platform's API. If the service is temporarily overloaded and returns 503, you want to retry. If it returns 4xx (client error), there's no point retrying because the problem is with your request. Use a callback to conditionally retry:

php
$response = Http::retry(
    times: 3,
    sleep: fn ($attempt) => $attempt * 500,
    throw: false,
)
    ->withQueryParameters(['api_version' => 'v2'])
    ->post('https://api.ridehailing.com/trips', $tripData);

if ($response->status() === 429) {
    // Rate limited—queue for later
    dispatch(new RetryRideHailingSync($tripData))->delay(now()->addMinutes(5));
}

Setting timeout() on every call

A timeout prevents your application from hanging indefinitely if an external API stops responding. Yet it's often overlooked. Without a timeout, a single slow request can exhaust your worker pool and cascade into application failure.

php
$response = Http::timeout(seconds: 10)
    ->get('https://api.example.com/data');

Ten seconds is a reasonable default for most APIs. However, the right timeout depends on your use case. A payment authorization might need 30 seconds, while a weather lookup should complete in 5. Set timeouts based on your SLA with the external service:

php
// Quick metadata lookup
$response = Http::timeout(5)
    ->get('https://api.example.com/metadata');

// Payment processing—needs more time
$response = Http::timeout(30)
    ->post('https://payment.example.com/authorize', $paymentData);

// File upload—could take minutes
$response = Http::timeout(300)
    ->post('https://storage.example.com/upload', $largeFile);

The connectTimeout() method lets you separately control how long to wait for the initial connection before giving up on DNS lookup and TCP handshake:

php
$response = Http::connectTimeout(5)
    ->timeout(30)
    ->get('https://api.example.com/data');

In practice, we wrap this in a base service class so every outbound call has a timeout by default:

php
namespace App\Services;

use Illuminate\Support\Facades\Http;

abstract class BaseApiService
{
    protected const CONNECT_TIMEOUT = 5;
    protected const REQUEST_TIMEOUT = 30;

    protected function client()
    {
        return Http::connectTimeout(self::CONNECT_TIMEOUT)
            ->timeout(self::REQUEST_TIMEOUT);
    }
}

class SlackNotificationService extends BaseApiService
{
    public function sendMessage(string $channel, string $text): bool
    {
        $response = $this->client()
            ->withToken(config('services.slack.token'))
            ->post('https://slack.com/api/chat.postMessage', [
                'channel' => $channel,
                'text' => $text,
            ]);

        return $response->successful();
    }
}

Using Http::fake() in tests

Testing code that makes HTTP calls can be tricky. You don't want real requests hitting external APIs during tests. The Http::fake() method intercepts all HTTP calls and returns canned responses.

php
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

class PaymentProcessingTest extends TestCase
{
    public function test_successful_charge()
    {
        Http::fake([
            'https://api.payment.com/*' => Http::response([
                'id' => 'charge_123',
                'status' => 'succeeded',
                'amount' => 5000,
            ], 200),
        ]);

        // Your code that calls the payment API
        $result = (new PaymentService())->charge(50.00);

        $this->assertEquals('charge_123', $result['id']);
        $this->assertTrue($result['status'] === 'succeeded');
    }
}

The pattern uses a URL wildcard (*) to match multiple endpoints. You can also fake specific URLs with different responses:

php
Http::fake([
    'https://api.example.com/users' => Http::response(['id' => 1, 'name' => 'John']),
    'https://api.example.com/posts' => Http::response(['id' => 1, 'title' => 'First post']),
    'https://api.example.com/error' => Http::response([], 500),
]);

For complex scenarios, use a callback to conditionally return different responses based on the request:

php
Http::fake(function ($request) {
    if ($request->url() === 'https://api.example.com/auth' && 
        $request->header('Authorization') === 'Bearer invalid') {
        return Http::response(['error' => 'Unauthorized'], 401);
    }

    return Http::response(['success' => true], 200);
});

After making a faked request, you can verify it was called correctly using Http::assertSent():

php
public function test_calls_payment_api_with_correct_payload()
{
    Http::fake([
        'https://api.payment.com/charge' => Http::response(['id' => 'ch_123']),
    ]);

    $service = new PaymentService();
    $service->charge(amount: 100, currency: 'USD');

    Http::assertSent(function ($request) {
        return $request->url() === 'https://api.payment.com/charge' &&
               $request['amount'] === 100 &&
               $request['currency'] === 'USD';
    });
}

This ensures your code sends the exact payload you expect, catching bugs where you accidentally pass the wrong parameter or forget to encode data correctly.

Common mistakes

  • Forgetting timeout on every call. A single hung request can cascade into service degradation. Make timeout a habit, not an afterthought.
  • Retrying on all errors. Not every failure is transient. A 401 Unauthorized or 404 Not Found won't succeed no matter how many times you retry. Use conditional retry logic based on status codes.
  • Hardcoding base URLs and tokens. Keep configuration in your .env and config files. This makes it easy to switch between environments (dev, staging, production) without touching code.
  • Testing with real API calls. Tests should be fast and deterministic. Always mock external calls with Http::fake(). If you need to test against a real API, use a separate integration test suite.
  • Ignoring response status codes. Always check whether the response was successful before using its data. Use $response->successful(), $response->failed(), or check the status code explicitly.
  • Logging too little or too much. Log the request method, URL, status code, and error details for debugging. But don't log sensitive data like API keys or passwords. Use Laravel's rate limiting on logging to avoid filling up your logs during API storms.

Key takeaways

  • Use Http::withToken() for Bearer token authentication and store tokens securely in configuration.
  • Always set timeout() on every HTTP request to prevent hangs and worker exhaustion.
  • Implement retry() with exponential backoff for resilience against transient failures, but be selective about which errors you retry.
  • Wrap HTTP calls in dedicated service classes for easier testing, error handling, and token management.
  • Use Http::fake() and Http::assertSent() in tests to verify your code calls external APIs with the correct payloads.
  • Log request details for debugging but exclude sensitive data like tokens and credentials.
  • Test status codes and handle failures explicitly rather than assuming requests always succeed.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles