> ## 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 en tests voor packages

> Ontwerp met de gedeelde HTTP Factory van Laravel 13 een API-client met gescheiden status per request, serviceregistratie en verificatie via Http::fake().

Wanneer je vanuit een package een externe API wilt gebruiken, combineer je twee dingen: **de configuratie van de door Laravel beheerde HTTP-client overnemen** en **geen instellingen van de ene aanroep naar de volgende meenemen**. Als je zelf een Factory aanmaakt of een nog niet verzonden request lange tijd vasthoudt, kan dat ertoe leiden dat de fakes van de gebruikende kant niet worden toegepast.

Deze pagina is gecontroleerd aan de hand van de implementatie van Laravel Framework `v13.39.0`. Zie [HTTP-client](https://kawax.biz/nl/http-client.md) voor de basisbediening en [Packages testen](https://kawax.biz/nl/advanced/package-testing.md) voor het opzetten van de testomgeving.

## Deel de Factory en maak elke keer een nieuwe PendingRequest

De HTTP-client van Laravel bestaat uit objecten met verschillende verantwoordelijkheden.

| Object | Rol | Gebruik in een package |
| - | - | - |
| `Illuminate\Http\Client\Factory` | Beheert fakes, gedeelde opties, gedeelde middleware en dergelijke | Ontvang je uit de container van Laravel |
| `Illuminate\Http\Client\PendingRequest` | Bevat de instellingen van het te verzenden request, zoals bestemming, headers en timeouts | Maak je bij elke aanroep van een API-methode opnieuw aan |
| `Illuminate\Http\Client\Response` | Inspecteert de status en de body | Behandel je als resultaat van de communicatie en zet je zo nodig om naar een exception of returnwaarde |

`FoundationServiceProvider` registreert de Factory als singleton, en ook de `Http`-facade resolvet uit de container via dezelfde klassenaam. Daarom kun je in een gewone Laravel-applicatie **ook de communicatie via een Factory die je uit de container ontvangt, verifiëren met `Http::fake()`**. Een ontwerp dat de facade direct gebruikt is ook mogelijk, maar op deze pagina injecteren we de Factory om de afhankelijkheden expliciet te maken.

Gewone methoden voor requestinstellingen op de Factory, zoals `baseUrl()`, worden via `__call()` doorgestuurd naar een nieuwe PendingRequest. `createPendingRequest()` geeft de fake-callbacks en de beperking op stray requests mee zoals die op het moment van aanmaken gelden, en `newPendingRequest()` past de gedeelde middleware en gedeelde opties toe.

```mermaid theme={null}
flowchart LR
    A["Container van Laravel"] --> B["Gedeelde Factory"]
    C["Responses instellen met Http::fake()"] --> B
    B --> D["Per API-aanroep<br>een nieuwe PendingRequest"]
    D --> E["Instellingen specifiek voor de aanroep"]
    E --> F["Verzenden en response inspecteren"]
```

> [!WARNING]
>
> Als je binnen een package `new Factory()` of een client van een andere HTTP-bibliotheek aanmaakt, worden de fakes en `Http::preventStrayRequests()` van de gedeelde Factory niet overgenomen. Deze functies blokkeren niet alle communicatie van het hele proces. Gebruik voor clients die voor Laravel bedoeld zijn de gedeelde Factory, en zorg voor een eigen testmethode als je een onafhankelijke client aanbiedt.

## API-bewerkingen bundelen in een kleine client

Als voorbeeld nemen we het package `Acme\Catalog`, dat producten ophaalt uit een fictieve catalogus-API. `src/CatalogClient.php` houdt alleen de Factory en de basis-URL vast, en het request wordt pas aangemaakt tijdens de uitvoering van `product()`.

```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);
    }
}
```

Met dit ontwerp blijft de `Accept-Language` die bij de vorige aanroep is toegevoegd niet hangen, ook als je met dezelfde client eerst `product(42, 'ja')` en daarna `product(42)` aanroept. De gedeelde instellingen die de gebruikende applicatie op de Factory heeft ingesteld, worden nog steeds op elk request toegepast.

* Pas packagespecifieke instellingen toe op de nieuw aangemaakte PendingRequest. Vermijd het instellen van `Http::globalOptions()` tijdens de initialisatie van het package, omdat dat ook ongerelateerde communicatie beïnvloedt.
* Verzend niets in de constructor. Ontwerp het zo dat het resolven van de service, het starten van Artisan of het publiceren van configuratiebestanden op zichzelf geen verbinding met de externe API maakt.
* `throw()` maakt van 4xx- en 5xx-responses een `RequestException`. Onderscheid dit van een `ConnectionException` bij verbindingsfouten of timeouts, en vervang een fout niet door een succeswaarde zoals een lege array.
* `json(flags: JSON_THROW_ON_ERROR)` maakt van ongeldige JSON een `JsonException`. In het voorbeeld wordt alleen gecontroleerd of het een array is, dus valideer bij een echte API ook de verplichte velden en datatypen.

> [!TIP]
>
> Als je alleen de basis-URL of headers wilt delen, zijn de HTTP-macro's uit de officiële documentatie ook een optie. Als je als package ook API-bewerkingen, de controle van returnwaarden en het contract voor exceptions aanbiedt, is het makkelijker te onderhouden als je dit in een aparte klasse zoals deze onderbrengt. Zie [De Macroable-trait](https://kawax.biz/nl/advanced/macroable.md) voor hoe macro's werken.

## Registreren in de service provider

Zet de standaardinstellingen klaar in `config/catalog.php` van het package. Sla hier geen HTTP-clients of closures op, maar alleen waarden die in de configuratiecache kunnen worden opgeslagen.

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

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

`src/CatalogServiceProvider.php` merget de configuratie en geeft de gedeelde Factory mee wanneer de client wordt geresolved. De binding van de Factory zelf wordt niet overschreven.

```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');
    }
}
```

De provider wordt geladen via bijvoorbeeld [Package discovery](https://kawax.biz/nl/advanced/package-discovery.md). De gebruiker publiceert de configuratie en wijzigt de basis-URL in `config/acme-catalog.php` naar de echte API. De `catalog.example.test` in het voorbeeld is een fictieve host voor de uitleg en voor fakes.

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

Aan de gebruikende kant resolve je de client via constructor-injectie of de container. Pas bij deze aanroep wordt het request voor het ophalen van het product verzonden.

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

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

Voor de registratie wordt `bind()` gebruikt, maar als de kant die de client vasthoudt een singleton is, wordt die referentie niet elke keer opnieuw aangemaakt. Het is belangrijk om de taal, de authenticatiegegevens en het requestobject die specifiek zijn voor een aanroep niet in properties op te slaan. Zie [Levenscyclusontwerp van packageservices](https://kawax.biz/nl/advanced/package-service-lifetimes.md) voor details.

Het overschrijven van de configuratie en het vernieuwen van de cache bij een deploy worden behandeld in [Configuratie van packages mergen en cachen](https://kawax.biz/nl/advanced/package-config-merging.md). Beheer de basis-URL als betrouwbare applicatieconfiguratie en laat de bestemming niet vrij bepalen door de invoer van een request.

## De echte client verifiëren met fakes

Als je de client zelf mockt, kun je niet controleren of de gedeelde Factory wordt gebruikt en of URL's en headers correct worden opgebouwd. In de tests van het package **resolve je de echte `CatalogClient` uit de container en fake je alleen de HTTP-responses**.

Het volgende voorbeeld draait in de [testomgeving van Orchestra Testbench](https://kawax.biz/nl/advanced/package-testing.md). In `setUp()` van elke test wordt `preventStrayRequests()` ingeschakeld. In tests die communiceren stel je alleen voor de verwachte URL een response in en laat je een `'*'`-fallback weg, zodat ook fouten in de URL worden gedetecteerd.

```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);
    }
}
```

In de tweede test wordt **de fake pas ingesteld nadat de client is geresolved**. Omdat de client de Factory vasthoudt en de PendingRequest pas bij de daadwerkelijke aanroep aanmaakt, wordt de fake ook in deze volgorde toegepast. Als je het ontwerp wijzigt zodat de constructor een PendingRequest aanmaakt en vasthoudt, detecteert deze test dat fakes die daarna zijn toegevoegd niet worden toegepast.

`assertSent()` verifieert dat er minstens één overeenkomend request is. Combineer dit met `assertSentCount()` om onbedoelde extra verzendingen niet te missen. `assertSentInOrder()` verifieert het aantal en de volgorde, dus je kunt het ook gebruiken om de scheiding van status te controleren wanneer je dezelfde client meerdere keren achter elkaar gebruikt.

De guard wordt ingesteld na `parent::setUp()`. Deze test alleen kan dus geen communicatie tijdens het opstarten van de provider verifiëren, dus houd vast aan een ontwerp waarin registratie en opstarten geen communicatie veroorzaken.

> [!NOTE]
>
> Met fakes kun je de requests controleren die de client opbouwt, en de verwerking van de responses die je hebt klaargezet. Ze garanderen niets over de authenticatie van de echte service, TLS-verbindingen, werkelijke timeoutduur of wijzigingen in de specificatie van de externe API. Voer verificaties met externe verbindingen apart uit in een expliciet daarvoor ingerichte testomgeving.

## Extra verificaties voor langdurig onderhoud

| Wijziging of foutscenario | Wat je controleert |
| - | - |
| Overschrijven van de basis-URL | De applicatieconfiguratie wordt toegepast en er wordt naar het bedoelde pad verzonden |
| Verbindingsfout | Met `Http::failedConnection()` wordt een `ConnectionException` doorgegeven aan de gebruikende kant |
| Ongeldige JSON of onverwacht type | Ongeldige JSON leidt tot een `JsonException`, een waarde die geen array is tot de `UnexpectedValueException` uit het voorbeeld |
| Retries toevoegen | Verifieer het aantal pogingen, de betreffende statussen en de verzonden inhoud, en houd bij API's met bijwerkingen rekening met dubbele uitvoering |
| Authenticatie of taal toevoegen | Ook als je dezelfde client meerdere keren gebruikt, worden waarden van de vorige aanroep niet meegenomen |
| Laravel-updates | Blijf voor de ondersteunde versies controleren hoe de Factory wordt geresolved, hoe fakes worden toegepast en wat het contract voor exceptions is |

Als je de verantwoordelijkheden van de gedeelde Factory en elke PendingRequest scheidt, kan de gebruikende kant de gewone testmethoden van Laravel ongewijzigd gebruiken. Zorg eerst voor een implementatie die via de gedeelde Factory loopt en voor tests die dat controleren, in plaats van eerst een eigen fake-mechanisme te bouwen.

## Geraadpleegde primaire bronnen

* [Officiële Laravel-documentatie: HTTP Client](https://github.com/laravel/docs/blob/13.x/http-client.md)
* [Officiële Laravel-documentatie: Packageontwikkeling](https://github.com/laravel/docs/blob/13.x/packages.md)
* [Framework v13.39.0: fake, createPendingRequest en \_\_call van Factory](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Http/Client/Factory.php)
* [Framework v13.39.0: instellingen en stubverwerking van PendingRequest](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Http/Client/PendingRequest.php)
* [Framework v13.39.0: throw en JSON-decodering van Response](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Http/Client/Response.php)
* [Framework v13.39.0: resolvedoel en fake van de Http-facade](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Support/Facades/Http.php)
* [Framework v13.39.0: singletonregistratie in FoundationServiceProvider](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Foundation/Providers/FoundationServiceProvider.php)
* [Officiële Laravel-tests: HttpClientTest](https://github.com/laravel/framework/blob/v13.39.0/tests/Http/HttpClientTest.php)


## Related topics

- [HTTP-client](https://kawax.biz/nl/http-client.md)
- [Geavanceerde onderwerpen](https://kawax.biz/nl/advanced/index.md)
- [Ontwikkelgids voor apps met de engine-API - VOICEVOX for Laravel](https://kawax.biz/nl/packages/laravel-voicevox/app-guide.md)
- [Praktische technieken voor Laravel Telescope](https://kawax.biz/nl/blog/telescope-introduction.md)
- [BlueskyManager en HasShortHand](https://kawax.biz/nl/packages/laravel-bluesky/bluesky-manager.md)


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