> ## Documentation Index
> Fetch the complete documentation index at: https://kawax.biz/llms.txt
> Use this file to discover all available pages before exploring further.

# HTTP clients and testing in packages

> Use Laravel 13's shared HTTP Factory to design API clients with per-request state isolation, service registration, and verification through Http::fake().

When a package needs to call an external API, it should do two things at once: **inherit the HTTP client configuration managed by Laravel** and **avoid carrying configuration over between calls**. Creating your own Factory or holding on to an unsent request for a long time can prevent the consuming application's fakes from being applied.

This page is based on the implementation of Laravel Framework `v13.39.0`. For basic usage, see [HTTP Client](https://kawax.biz/en/http-client.md). For preparing a test environment, see [Package testing](https://kawax.biz/en/advanced/package-testing.md).

## Share the Factory, create a PendingRequest every time

Laravel's HTTP client consists of objects with different responsibilities.

| Object | Role | How a package should treat it |
| - | - | - |
| `Illuminate\Http\Client\Factory` | Manages fakes, shared options, shared middleware, and more | Receive it from Laravel's container |
| `Illuminate\Http\Client\PendingRequest` | Holds the configuration of the request to send, such as the destination, headers, and timeouts | Create a new one each time an API method is called |
| `Illuminate\Http\Client\Response` | Inspects the status and body | Treat it as the result of the call and convert it to an exception or return value as needed |

`FoundationServiceProvider` registers the Factory as a singleton, and the `Http` facade resolves the same class name from the container. As a result, in a normal Laravel application, **requests made through a Factory received from the container can also be verified with `Http::fake()`**. Using the facade directly is a valid design, but this page injects the Factory to make the dependency explicit.

Regular request configuration methods called on the Factory, such as `baseUrl()`, are forwarded through `__call()` to a new PendingRequest. `createPendingRequest()` passes along the fake callbacks and stray request restrictions in effect at creation time, and `newPendingRequest()` applies the shared middleware and shared options.

```mermaid theme={null}
flowchart LR
    A["Laravel container"] --> B["Shared Factory"]
    C["Configure responses with Http::fake()"] --> B
    B --> D["New PendingRequest<br>per API call"]
    D --> E["Call-specific configuration"]
    E --> F["Send and inspect response"]
```

> [!WARNING]
>
> If your package creates `new Factory()` or a client from another HTTP library, the shared Factory's fakes and `Http::preventStrayRequests()` are not inherited. These features do not block network traffic for the whole process. Use the shared Factory for Laravel-oriented clients, and if you provide an independent client, supply a testing approach dedicated to that client.

## Group API operations into a small client

As an example, consider an `Acme\Catalog` package that fetches products from a fictional catalog API. `src/CatalogClient.php` holds only the Factory and the base URL, and builds the request while `product()` is running.

```php theme={null}
<?php

namespace Acme\Catalog;

use Illuminate\Http\Client\Factory;
use Illuminate\Http\Client\PendingRequest;
use UnexpectedValueException;

final class CatalogClient
{
    public function __construct(
        private readonly Factory $http,
        private readonly string $baseUrl,
    ) {
    }

    public function product(int $id, ?string $language = null): array
    {
        $request = $this->request();

        if ($language !== null) {
            $request->withHeaders(['Accept-Language' => $language]);
        }

        $data = $request->get('/products/'.$id)
            ->throw()
            ->json(flags: JSON_THROW_ON_ERROR);

        if (! is_array($data)) {
            throw new UnexpectedValueException('Catalog response must be an array.');
        }

        return $data;
    }

    private function request(): PendingRequest
    {
        return $this->http->baseUrl($this->baseUrl)
            ->acceptJson()
            ->connectTimeout(3)
            ->timeout(10);
    }
}
```

With this design, calling `product(42)` after `product(42, 'ja')` on the same client does not keep the `Accept-Language` header added by the previous call. Shared configuration that the consuming application set on the Factory continues to apply to every request.

* Apply package-specific configuration to the newly created PendingRequest. Avoid setting `Http::globalOptions()` during package initialization, because it affects unrelated requests as well.
* Do not send requests in the constructor. Design the package so that resolving the service, starting Artisan, or publishing configuration files never connects to the external API.
* `throw()` turns 4xx and 5xx responses into a `RequestException`. Keep it distinct from the `ConnectionException` raised for connection failures and timeouts, and do not replace failures with success values such as an empty array.
* `json(flags: JSON_THROW_ON_ERROR)` turns invalid JSON into a `JsonException`. The example only checks whether the result is an array, so with a real API you should also validate required fields and data types.

> [!TIP]
>
> If you only need to share a base URL or headers, HTTP macros from the official documentation are also an option. When your package provides API operations, return value validation, and an exception contract, splitting them into a dedicated class like this is easier to maintain. For how macros work, see [The Macroable trait](https://kawax.biz/en/advanced/macroable.md).

## Register it in a service provider

Provide default settings in the package's `config/catalog.php`. Do not store HTTP clients or closures there; only put values that can be stored in the configuration cache.

```php theme={null}
<?php

return [
    'base_url' => 'https://catalog.example.test/api',
];
```

`src/CatalogServiceProvider.php` merges the configuration and passes the shared Factory when the client is resolved. It does not override the binding for the Factory itself.

```php theme={null}
<?php

namespace Acme\Catalog;

use Illuminate\Contracts\Foundation\Application;
use Illuminate\Http\Client\Factory;
use Illuminate\Support\ServiceProvider;

class CatalogServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->mergeConfigFrom(__DIR__.'/../config/catalog.php', 'acme-catalog');

        $this->app->bind(CatalogClient::class, function (Application $app): CatalogClient {
            return new CatalogClient(
                $app->make(Factory::class),
                $app['config']->get('acme-catalog.base_url'),
            );
        });
    }

    public function boot(): void
    {
        $this->publishes([
            __DIR__.'/../config/catalog.php' => config_path('acme-catalog.php'),
        ], 'acme-catalog-config');
    }
}
```

Load the provider through mechanisms such as [package discovery](https://kawax.biz/en/advanced/package-discovery.md). Users publish the configuration and change the base URL in `config/acme-catalog.php` to the real API. The `catalog.example.test` host in the example is fictional and used only for explanation and fakes.

```shell theme={null}
php artisan vendor:publish --tag=acme-catalog-config
```

The consuming application resolves the client through constructor injection or the container. The request to fetch the product is sent only when this call is made.

```php theme={null}
use Acme\Catalog\CatalogClient;

$product = app(CatalogClient::class)->product(42, 'ja');
```

The registration uses `bind()`, but if the object holding the client is a singleton, that reference is not recreated every time. What matters is not storing call-specific languages, credentials, or request objects in properties. For details, see [Designing package service lifetimes](https://kawax.biz/en/advanced/package-service-lifetimes.md).

Overriding configuration and refreshing the cache at deploy time are covered in [Merging and caching package configuration](https://kawax.biz/en/advanced/package-config-merging.md). Manage the base URL as trusted application configuration, and do not let request input freely choose the destination.

## Verify the real client with fakes

If you mock the client itself, you cannot confirm whether it uses the shared Factory or builds URLs and headers correctly. In package tests, **resolve the real `CatalogClient` from the container and fake only the HTTP responses**.

The following example runs in an [Orchestra Testbench test environment](https://kawax.biz/en/advanced/package-testing.md). Enable `preventStrayRequests()` in each test's `setUp()`. In tests that make requests, configure responses only for the expected URLs and do not add a `'*'` fallback, so that incorrect URLs are also detected.

```php theme={null}
<?php

namespace Acme\Catalog\Tests;

use Acme\Catalog\CatalogClient;
use Acme\Catalog\CatalogServiceProvider;
use Illuminate\Http\Client\Request;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Client\StrayRequestException;
use Illuminate\Support\Facades\Http;
use Orchestra\Testbench\TestCase;

class CatalogClientTest extends TestCase
{
    protected function getPackageProviders($app): array
    {
        return [CatalogServiceProvider::class];
    }

    protected function setUp(): void
    {
        parent::setUp();

        Http::preventStrayRequests();
    }

    public function test_resolving_the_client_does_not_send_a_request(): void
    {
        Http::fake();

        $this->assertInstanceOf(CatalogClient::class, $this->app->make(CatalogClient::class));

        Http::assertNothingSent();
    }

    public function test_product_request_uses_the_shared_factory(): void
    {
        $client = $this->app->make(CatalogClient::class);

        Http::fake([
            'https://catalog.example.test/api/products/42' => Http::response([
                'id' => 42,
                'name' => 'Notebook',
            ]),
        ]);

        $this->assertSame(['id' => 42, 'name' => 'Notebook'], $client->product(42, 'ja'));

        Http::assertSent(static fn (Request $request): bool =>
            $request->method() === 'GET'
            && $request->url() === 'https://catalog.example.test/api/products/42'
            && $request->hasHeader('Accept', 'application/json')
            && $request->hasHeader('Accept-Language', 'ja')
        );
        Http::assertSentCount(1);
    }

    public function test_language_is_not_carried_over_to_the_next_request(): void
    {
        Http::fake([
            'https://catalog.example.test/api/products/42' => Http::response(['id' => 42]),
        ]);

        $client = $this->app->make(CatalogClient::class);
        $client->product(42, 'ja');
        $client->product(42);

        Http::assertSentInOrder([
            static fn (Request $request): bool => $request->hasHeader('Accept-Language', 'ja'),
            static fn (Request $request): bool => ! $request->hasHeader('Accept-Language'),
        ]);
    }

    public function test_server_errors_are_not_returned_as_product_data(): void
    {
        Http::fake([
            'https://catalog.example.test/api/products/42' => Http::response(['error' => 'unavailable'], 503),
        ]);

        $this->expectException(RequestException::class);

        $this->app->make(CatalogClient::class)->product(42);
    }

    public function test_a_request_without_a_matching_fake_is_rejected(): void
    {
        Http::fake([]);

        $this->expectException(StrayRequestException::class);

        $this->app->make(CatalogClient::class)->product(42);
    }
}
```

The second test **configures the fake after resolving the client**. Because the client holds the Factory and creates a PendingRequest at call time, the fake still takes effect in this order. If the design is changed to create and hold a PendingRequest in the constructor, this test detects that fakes added afterward are not applied.

`assertSent()` verifies that at least one matching request was sent. Combine it with `assertSentCount()` so that unintended extra requests are not missed. `assertSentInOrder()` verifies both count and order, so it also works for checking state isolation when the same client is used repeatedly.

The guard is configured after `parent::setUp()`. This test alone cannot verify requests made while providers boot, so keep the design that registration and booting never make requests.

> [!NOTE]
>
> Fakes confirm the requests built by the client and how it handles the responses you prepared. They do not guarantee authentication with the real service, TLS connections, actual timeout durations, or changes to the external API's specification. Run verification involving external connections separately, in an explicitly prepared test environment.

## Additional checks for long-term maintenance

| Change or failure case | What to verify |
| - | - |
| Overriding the base URL | Application configuration is applied and requests go to the intended path |
| Connection failure | With `Http::failedConnection()`, a `ConnectionException` propagates to the consuming application |
| Invalid JSON or unexpected types | Invalid JSON raises `JsonException`, and non-array values raise the example's `UnexpectedValueException` |
| Adding retries | Verify the number of attempts, target statuses, and request contents, and account for duplicate execution with APIs that have side effects |
| Adding authentication or languages | Values from a previous call are not carried over when the same client is used multiple times |
| Laravel upgrades | Keep checking the Factory resolution path, fake application, and exception contract on supported versions |

Separating the responsibilities of the shared Factory and each PendingRequest lets consuming applications use Laravel's normal testing approach as is. Rather than building your own fake mechanism first, start with an implementation that goes through the shared Factory and tests that confirm it.

## Primary sources

* [Laravel documentation: HTTP Client](https://github.com/laravel/docs/blob/13.x/http-client.md)
* [Laravel documentation: Package Development](https://github.com/laravel/docs/blob/13.x/packages.md)
* [Framework v13.39.0: Factory fake, createPendingRequest, and \_\_call](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Http/Client/Factory.php)
* [Framework v13.39.0: PendingRequest configuration and stub handling](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Http/Client/PendingRequest.php)
* [Framework v13.39.0: Response throw and JSON decoding](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Http/Client/Response.php)
* [Framework v13.39.0: Http facade resolution target and fake](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Support/Facades/Http.php)
* [Framework v13.39.0: FoundationServiceProvider singleton registration](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Foundation/Providers/FoundationServiceProvider.php)
* [Laravel framework tests: HttpClientTest](https://github.com/laravel/framework/blob/v13.39.0/tests/Http/HttpClientTest.php)


## Related topics

- [HTTP client](https://kawax.biz/en/http-client.md)
- [Advanced Topics](https://kawax.biz/en/advanced/index.md)
- [Testing](https://kawax.biz/en/packages/laravel-bluesky/testing.md)
- [HTTP Tests](https://kawax.biz/en/http-tests.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.