# Externe API's aanspreken

De Http client van Laravel 13: fouten, timeouts, retries, een eigen client class en caching van responses.

Multimedia & Creatieve Technologie · 3.1 The Agency · Zelfstudie · versie 1.0.0 · 2026-09-30

Tags: laravel, php, api, performance

Slides: <https://slides.mctantwerp.be/nl/the-agency/12-api-client>

<!-- slide 2 -->

## Agenda

1. De Http client
2. Als het misloopt
3. Timeouts en retries
4. Een eigen client
5. Caching
6. Testen en debuggen

> **Toelichting:** Eerst de basis van de Http client, dan alles wat misloopt, want daar zit het meeste werk. Daarna een eigen client class die dat op één plek houdt, en tot slot caching en testen.

<!-- slide 3 -->

## Na deze les kan je

- Een externe API aanspreken met Http::get en Http::post, met een token, headers en een querystring
- Het verschil zien tussen een RequestException en een ConnectionException, en ze allebei opvangen
- Een timeout en een retry instellen die je gebruikers niet laten wachten
- Een eigen client class schrijven die de rest van je app afschermt van andermans JSON
- Responses cachen met Cache::remember en Cache::flexible
- Je code testen zonder ooit de echte API te bellen

> **Toelichting:** Gebruik de lijst als checklist voor de opdracht op het einde. De punten over fouten en timeouts zijn wat een integratie in productie overeind houdt.

<!-- slide 4 -->

## 1. De Http client

> **Toelichting:** De Http client van Laravel bestaat sinds Laravel 7. Oudere projecten gebruiken vaak Guzzle rechtstreeks, met new Client(). Dat werkt nog, maar dan mis je de fakes voor je tests en de korte methodes.

<!-- slide 5 -->

### Waar je ze tegenkomt

- **Betalingen**: Mollie of Stripe, met een officiële SDK. Zie de les over online betalingen.
- **Mail**: Postmark verstuurt je mails en meldt bounces. Zie de les over mails sturen.
- **Het systeem van de klant**: Een CRM, een ERP of een planningstool waar je klanten, orders of uren uit haalt.
- **Open data**: Treinen via [iRail](https://docs.irail.be), adressen, openingsuren, wisselkoersen.

Voor betalingen en mail gebruik je de SDK van de aanbieder, die hebben een eigen les. Deze les gaat over al de rest: API's zonder SDK, waar je de requests zelf schrijft met de Http client van Laravel.

> **Toelichting:** Een SDK is niet altijd beter. Een SDK die niet meer onderhouden wordt, of niet werkt met de PHP-versie van je project, kost meer dan een eigen client. Kijk naar de laatste release op Packagist voor je hem installeert.

<!-- slide 6 -->

**Hun API is jouw probleem.**

Stel: de voorraad-API van een leverancier antwoordt elke ochtend een kwartier niet, en je productpagina wacht erop. Dan ligt jouw webshop plat, door een server die je nooit gezien hebt. De klant belt jou, niet hen.

> **Toelichting:** Hoe je dat opvangt: een korte timeout, een fallback in de view als de data er niet is, en caching zodat je de laatste goede waarde nog hebt. Dat komt allemaal in deze les aan bod.

<!-- slide 7 -->

### Http::get

```php
use Illuminate\Support\Facades\Http;

$response = Http::acceptJson()
    ->withUserAgent('Treinbord (dev@example.com)')
    ->get('https://api.irail.be/liveboard/', [
        'station' => 'Antwerpen-Centraal',
        'format' => 'json',
    ]);

$departures = $response->collect('departures.departure');
```

1. acceptJson vraagt om JSON. iRail vraagt ook een User-Agent met een naam en een contactadres.
2. De array wordt de querystring: ?station=Antwerpen-Centraal&format=json.
3. collect() geeft een Collection van een pad in de JSON, met dot notation.

> **Toelichting:** Onder de motorkap zit Guzzle. Wat de Http client zelf niet kent, geef je mee als Guzzle-optie met withOptions().

<!-- slide 8 -->

### De response

| Methode | Geeft |
| --- | --- |
| `status()` | De statuscode, als int |
| `successful()` | true bij een 2xx |
| `failed()` | true bij een 4xx of 5xx |
| `json('data.0.name')` | Een waarde uit de JSON, met dot notation |
| `collect('data')` | Hetzelfde, als Collection |
| `header('Retry-After')` | Eén header |

`Http::get()` geeft altijd een `Response` terug, ook als de API een fout stuurt. Alle methodes staan in de [docs van de Http client](https://laravel.com/docs/13.x/http-client#making-requests).

> **Toelichting:** Response heeft ook methodes per statuscode, zoals notFound(), unauthorized() en tooManyRequests(). Die lezen vaak duidelijker dan status() === 404.

<!-- slide 9 -->

### POST en PUT

```php
Http::withToken(config('services.crm.token'))
    ->post('https://crm.example.com/api/contacts', [
        'name' => $user->name,
        'email' => $user->email,
    ]);

Http::asForm()->post($url, ['grant_type' => 'client_credentials']);

Http::attach('file', $pdf, 'offerte.pdf')->post($url);
```

1. De array gaat standaard als JSON. withToken zet het token in de Authorization-header, nooit in de url.
2. asForm() stuurt een klassiek formulier. Oudere API's en OAuth-tokenendpoints willen dat.
3. attach() stuurt een bestand mee, als multipart/form-data.

> **Toelichting:** withToken zet de header Authorization: Bearer met je token. Vraagt een API Basic auth, dan is er withBasicAuth().

<!-- slide 10 -->

### Config, niet in je code

*config/services.php*

```php
'crm' => [
    'url' => env('CRM_URL'),
    'token' => env('CRM_TOKEN'),
],
```

*.env*

```ini
CRM_URL=https://crm.example.com/api
CRM_TOKEN=sk_live_8fQ2...
```

Url en token horen in `.env`, zodat lokaal, staging en productie elk hun eigen waarden hebben. In je code lees je ze met `config('services.crm.token')`, nooit met `env()`: na `php artisan config:cache` in productie geeft `env()` buiten een config-bestand null terug.

> **Toelichting:** Hetzelfde patroon ken je van Socialite in de les over single sign-on. Zet de keys ook in .env.example, met een lege waarde, zodat een collega weet dat ze nodig zijn.

<!-- slide 11 -->

### Http::post($url, \['name' => 'Acme'\]) stuurt de data als...

- A. JSON
- B. application/x-www-form-urlencoded
- C. multipart/form-data
- D. Een querystring

**Antwoord:** A. JSON

POST, PUT en PATCH sturen standaard JSON. Een formulier (form-urlencoded) lijkt logisch omdat een HTML-formulier dat doet, maar daarvoor moet je asForm() erbij zetten. Multipart krijg je pas met attach().

> **Toelichting:** Wil je zien wat er echt vertrekt, zet dan Http::dd() voor je post. Dan zie je de headers, met Content-Type: application/json, en de body.

<!-- slide 12 -->

## 2. Als het misloopt

> **Toelichting:** Een externe API faalt op manieren die je lokaal nooit ziet: een timeout om drie uur 's nachts, een 503 tijdens hun deploy, een certificaat dat verloopt. Code die daar rekening mee houdt, is het verschil tussen een melding in je log en een klant die belt.

<!-- slide 13 -->

**Een 500 is geen exception.**

De Http client gooit niets bij een 4xx of een 5xx. Er kwam een antwoord, dus voor Laravel is de request gelukt. Zonder check werk je verder met een foutmelding als data.

> **Toelichting:** Dat is een bewuste keuze van Laravel en verschilt van Guzzle, dat standaard wel een exception gooit bij een 4xx of 5xx. Werk je in een oud project met Guzzle, hou daar dan rekening mee.

<!-- slide 14 -->

### Zonder en met throw

##### Zonder check

```php
$contacts = Http::get($url)->json('data');
```

Bij een 503 is `$contacts` gewoon `null`. De fout duikt drie classes verder op, als een TypeError in een foreach, ver van de oorzaak.

##### Met throw()

```php
$contacts = Http::get($url)->throw()->json('data');
```

Bij een 503 stopt het hier, met een `RequestException`. De response zit erin, dus je log toont meteen wat de API terugstuurde.

> **Toelichting:** Er zijn varianten: throwIf() met een voorwaarde, throwIfStatus(403) voor één statuscode en throwIfServerError() voor alleen een 5xx. Ze staan in de docs onder Throwing Exceptions.

<!-- slide 15 -->

### Twee soorten falen

|   | Wat er gebeurde | Wat je doet |
| --- | --- | --- |
| `ConnectionException` | Geen antwoord: DNS, timeout, server plat | Opnieuw proberen |
| `RequestException` | Wel een antwoord, een 4xx of een 5xx | Bij een 5xx of 429 opnieuw, bij een andere 4xx je eigen request nakijken |

Een `ConnectionException` krijg je altijd, ook zonder `throw()`: er is gewoon geen response om terug te geven. Een `RequestException` alleen als je erom vraagt, met `throw()` of `retry()`. Zie de [docs over error handling](https://laravel.com/docs/13.x/http-client#error-handling).

> **Toelichting:** Beide exceptions zitten in de namespace Illuminate\Http\Client. Een RequestException heeft een public property response, dus in een catch lees je $exception->response->status() of de body.

<!-- slide 16 -->

### Opvangen

```php
try {
    $contact = $this->crm->findContact($email);
} catch (ConnectionException) {
    return back()->with('error', 'De CRM antwoordt niet, even later nog eens.');
} catch (RequestException $exception) {
    report($exception);

    return back()->with('error', 'Er liep iets mis bij de CRM.');
}
```

1. De call naar de CRM. Hoe die findContact eruitziet, zie je in hoofdstuk 4.
2. Geen verbinding: een zin die de gebruiker snapt. Hier hoeft niets in je log, het ligt aan hen.
3. Een foutresponse: report() stuurt de exception met de response naar je log of Nightwatch.

Vang niet elke Throwable. Een bug in je eigen code moet gewoon crashen, zodat je hem ziet.

> **Toelichting:** report() stuurt de exception naar je log zonder de request te stoppen. De message van een RequestException wordt standaard ingekort tot 120 tekens. In bootstrap/app.php pas je dat aan met RequestException::truncateAt().

<!-- slide 17 -->

### De API antwoordt met een 503. Je code doet $response->json('data') zonder check. Wat gebeurt er?

- A. Laravel gooit een RequestException
- B. Je krijgt null en je code loopt verder
- C. Laravel probeert het opnieuw
- D. Je krijgt een ConnectionException

**Antwoord:** B. Je krijgt null en je code loopt verder

Er kwam een antwoord, dus voor de Http client is de request gelukt. Een RequestException lijkt logisch, maar die komt pas met throw() of retry(). Een ConnectionException is er alleen als er helemaal geen antwoord komt.

> **Toelichting:** Wil je dat elke foutresponse van een request een exception wordt, zonder na elke call throw() te schrijven, zet dan throw() voor de request vertrekt: Http::throw()->get($url).

<!-- slide 18 -->

## 3. Timeouts en retries

> **Toelichting:** Timeouts en retries bepalen het meest hoe je app zich gedraagt als een andere API traag is. De standaardwaarden zijn gekozen om niets te breken, niet om je gebruikers snel te helpen.

<!-- slide 19 -->

### 30 seconden

- Standaard wacht de Http client 30 seconden op een antwoord, en 10 om te verbinden
- Al die tijd staat een PHP-worker stil, en kijkt je gebruiker naar een spinner
- Een server heeft een vast aantal workers. Hangen ze allemaal op een trage API, dan komt er niemand meer binnen, ook niet op pagina's die de API niet nodig hebben.
- In een request zet je een timeout van een paar seconden. Trage calls horen in een job, en daar mag het langer.

> **Toelichting:** Bij PHP-FPM heet dat aantal workers pm.max_children. In de standaardconfig van PHP-FPM staat het op 5, op een echte server zet je het hoger, maar het blijft een vast aantal.

<!-- slide 20 -->

### timeout en retry

```php
$response = Http::timeout(5)
    ->connectTimeout(2)
    ->retry(3, 200, function (Throwable $exception) {
        if ($exception instanceof ConnectionException) {
            return true;
        }

        return $exception instanceof RequestException
            && ($exception->response->serverError() || $exception->response->tooManyRequests());
    })
    ->get($url);
```

1. Maximaal 5 seconden wachten op een antwoord.
2. Maximaal 2 seconden om een verbinding te maken.
3. 3 pogingen, 200 ms ertussen. De closure beslist per fout of opnieuw proberen zin heeft. Zie [retries](https://laravel.com/docs/13.x/http-client#retries).

> **Toelichting:** Een array als eerste argument, zoals retry([100, 500, 1000]), geeft per poging een andere pauze. Zo wacht je elke keer wat langer, en krijgt een overbelaste API tijd om te herstellen.

<!-- slide 21 -->

### Wat je opnieuw probeert

- Een ConnectionException: de tweede keer lukt het misschien wel
- Een 5xx of een 429: hun probleem, even wachten helpt
- Een andere 4xx nooit: een 422 blijft een 422, hoe vaak je het ook vraagt
- Een POST alleen als de API een idempotency key aanvaardt, zoals [Stripe](https://docs.stripe.com/api/idempotent_requests). Anders maak je twee orders.
- `retry(3)` is drie pogingen in totaal, niet drie extra

> **Toelichting:** Zonder idempotency key weet je na een timeout op een POST niet of de actie gelukt is. De veiligste optie is dan eerst met een GET te checken of het object al bestaat, voor je opnieuw probeert.

<!-- slide 22 -->

### Hoe lang je gebruiker wacht

Kies Hangt, en zet dan 3 pogingen en een timeout van 3 seconden. Kijk hoe de wachttijd verandert. Kies daarna 422 en wissel tussen de twee opties bij Opnieuw bij.

Een simulator voor timeout() en retry() op de Http client. Per gedrag van de andere API zie je elke poging, de pauzes ertussen, hoe lang de gebruiker wacht en hoe het afloopt. Scenario’s:

- Alles ok
- Traag, 8 s
- Hangt
- Eén keer 503
- Plat, 503
- Eén keer 429
- 422

> **Toelichting:** De simulator volgt de regels van Laravel zelf: retry() telt pogingen, en zonder callback wordt elke mislukte response opnieuw geprobeerd. In het echt komt er bij een verbinding die niet lukt nog de connectTimeout bij, die zit niet in de simulator.

<!-- slide 23 -->

### 429 van hun kant

```php
Http::retry(3, function (int $attempt, Exception $exception) {
    if ($exception instanceof RequestException) {
        if ($exception->response->tooManyRequests()) {
            return (int) $exception->response->header('Retry-After') * 1000;
        }
    }

    return $attempt * 500;
})->get($url);
```

1. De pauze mag een closure zijn. Bij een 429 wacht je zo lang als Retry-After zegt, in seconden, dus maal 1000.
2. Bij een andere fout wacht je per poging wat langer. Lange pauzes horen in een job, zie twee slides verder.

> **Toelichting:** Retry-After kan ook een datum zijn in plaats van een aantal seconden. De meeste API's sturen seconden, maar check het in hun docs: een (int) op een datum geeft 0, en dan probeer je meteen opnieuw.

<!-- slide 24 -->

### Http::retry(3, 100)->post($url, $data), zonder derde argument. De API antwoordt telkens 422. Hoeveel requests vertrekken er?

- A. 1
- B. 2
- C. 3
- D. 4

**Antwoord:** C. 3

Zonder when-callback probeert retry elke mislukte response opnieuw, ook een 422. 1 lijkt logisch, want een 422 heeft geen zin om te herhalen, maar dat moet je zelf zeggen met een callback. En 4 is fout omdat 3 het totaal aantal pogingen is, niet het aantal extra.

> **Toelichting:** De callback als derde argument krijgt de exception en de PendingRequest. Je kan in die callback de request zelfs aanpassen, bijvoorbeeld een nieuw token zetten na een 401. Zie de docs over retries.

<!-- slide 25 -->

### Drie API's tegelijk

```php
$responses = Http::pool(fn (Pool $pool) => [
    $pool->as('stock')->get("{$warehouse}/stock/{$sku}"),
    $pool->as('price')->get("{$erp}/prices/{$sku}"),
    $pool->as('reviews')->get("{$reviews}/products/{$sku}"),
]);

$stock = $responses['stock']->json('quantity');
```

1. Drie requests tegelijk in plaats van na elkaar. Duren ze elk een seconde, dan wacht je één seconde in plaats van drie.
2. Met as() geef je elke request een naam. Lukt de verbinding niet, dan zit er een ConnectionException op die plaats in plaats van een Response.

Zie [request pooling](https://laravel.com/docs/13.x/http-client#request-pooling). Heeft de pagina de data niet meteen nodig, dan hoort de call in een job.

> **Toelichting:** Er is ook Http::batch(), met callbacks voor als een request slaagt of faalt, en een defer() om de requests pas na de response te versturen. Dat staat in de docs onder Request Batching.

<!-- slide 26 -->

### Trage calls in een job

- Heeft de gebruiker het antwoord niet meteen nodig, zoals bij een contact synchroniseren of een factuur doorsturen, zet de call dan in een job
- In een job mag de timeout langer, en probeert de queue het later opnieuw, zonder dat iemand naar een spinner kijkt
- Een 429 in een job geef je terug aan de queue met release(), en het aantal seconden uit Retry-After
- Hoe jobs, retries en backoff werken, zie je in de les over queues

> **Toelichting:** In een job zet je het aantal pogingen en de pauzes op de job zelf, met de properties tries en backoff, of in Laravel 13 met de attributes #[Tries] en #[Backoff]. De queue doet het opnieuw proberen, niet de Http client.

<!-- slide 27 -->

## 4. Een eigen client

> **Toelichting:** Een Agency-project heeft na een jaar vaak drie of vier externe API's. Heeft elke API een eigen class met dezelfde vorm, dan vindt een collega meteen waar hij moet zijn als er iets verandert.

<!-- slide 28 -->

### Eén class per API

##### Http:: in elke controller

- Url, token en timeout staan op zes plaatsen
- Verandert hun API, dan zoek je overal
- Hun JSON-keys lekken tot in je Blade-views

##### Eén CrmClient

- Eén plek voor url, token, timeout en retries
- Methodes die zeggen wat ze doen: `findContact($email)`
- Je app praat met jouw class, niet met hun JSON

Maak de class de tweede keer dat je dezelfde API aanspreekt. Niet pas als er zes controllers zijn.

> **Toelichting:** Waar zet je zo'n class? Vaak in app/Services, of in een map per integratie zoals app/Integrations/Crm. Welke structuur je kiest, zie de les over applicatie-architectuur.

<!-- slide 29 -->

### Een macro als tussenstap

*AppServiceProvider::boot()*

```php
Http::macro('crm', function () {
    return Http::baseUrl(config('services.crm.url'))
        ->withToken(config('services.crm.token'))
        ->timeout(5);
});
```

```php
$response = Http::crm()->get('contacts', ['email' => $email]);
```

Een macro is lichter dan een class: url, token en timeout staan op één plek. Maar je code werkt nog altijd met hun JSON en hun urls. Voor twee of drie calls volstaat een macro, voor een echte integratie neem je een class. Zie [macros](https://laravel.com/docs/13.x/http-client#macros).

> **Toelichting:** Een macro hangt aan de facade en bestaat dus overal in je app. Je editor kent hem niet vanzelf, want hij wordt pas op runtime toegevoegd. Met een class krijg je autocompletion voor je methodes.

<!-- slide 30 -->

### CrmClient

```php
class CrmClient
{
    public function __construct(
        #[Config('services.crm.url')] private string $url,
        #[Config('services.crm.token')] private string $token,
    ) {}
}
```

1. Url en token komen via de constructor binnen. De class weet niet waar ze vandaan komen, dus in een test geef je er zelf andere waarden aan.
2. #\[Config\] is een contextual attribute: de container vult de parameter met de waarde uit config/services.php. Een binding in een service provider is niet nodig.

Zie [contextual attributes](https://laravel.com/docs/13.x/container#contextual-attributes) en de les over de service container.

> **Toelichting:** Wil je de client in een test vervangen, dan kan je een eigen instantie in de container zetten met $this->app->instance(CrmClient::class, $fake). Meestal is Http::fake eenvoudiger, zie hoofdstuk 6.

<!-- slide 31 -->

### De request opbouwen

```php
private function request(): PendingRequest
{
    return Http::baseUrl($this->url)
        ->withToken($this->token)
        ->acceptJson()
        ->timeout(5)
        ->retry(
            3,
            200,
            fn (Throwable $exception) => $exception instanceof ConnectionException,
            throw: false,
        );
}
```

1. Een PendingRequest: alles ingesteld, nog niets verstuurd.
2. throw: false, anders maakt retry van elke foutresponse een exception, ook van een 404.

> **Toelichting:** Waarom een PendingRequest teruggeven en niet meteen de response? Omdat elke publieke methode dan zelf de HTTP-methode en de url kiest, met dezelfde instellingen eronder.

<!-- slide 32 -->

### Methodes die spreken

```php
public function findContact(string $email): ?Contact
{
    $response = $this->request()->get('contacts', ['email' => $email]);

    if ($response->notFound()) {
        return null;
    }

    return Contact::fromArray($response->throw()->json('data'));
}
```

1. De url is relatief: baseUrl uit request() staat er al voor.
2. Een contact dat niet bestaat is geen fout, dat is null.
3. Al de rest wat misloopt is wel een fout: throw(). Wat terugkomt, is een Contact en geen array.

> **Toelichting:** Je kan ook een eigen exception gooien, zoals CrmUnavailableException, in plaats van de RequestException te laten doorgaan. Dan hoeft de code die findContact oproept de Http client niet te kennen.

<!-- slide 33 -->

### Een DTO in plaats van een array

```php
readonly class Contact
{
    public function __construct(
        public string $id,
        public string $name,
        public string $email,
    ) {}

    public static function fromArray(array $data): self
    {
        return new self($data['id'], $data['full_name'], $data['email']);
    }
}
```

1. Getypte, readonly properties: je editor kent $contact->email.
2. Hernoemt de CRM full_name, dan pas je één regel aan. Zie ook de les over DDD.

> **Toelichting:** Readonly classes bestaan sinds PHP 8.2. Voor een lijst van contacten geef je een Collection van Contact-objecten terug, bijvoorbeeld met collect($data)->map(Contact::fromArray(...)).

<!-- slide 34 -->

### Gebruiken

```php
public function show(User $user, CrmClient $crm): View
{
    return view('users.show', [
        'user' => $user,
        'contact' => $crm->findContact($user->email),
    ]);
}
```

De container maakt de `CrmClient` voor je, met de config erin. In een test vervang je hem door een fake of een mock.

> [!TIP]
> Is er een officiële SDK, zoals `mollie/mollie-api-php`, neem die en schrijf geen eigen client. Een grote API zonder SDK? Kijk naar [Saloon](https://docs.saloon.dev).

> **Toelichting:** Saloon schrijft een integratie als classes: een connector per API en een request-class per endpoint. Voor een API met tientallen endpoints houdt dat de code overzichtelijk, voor drie calls is het te veel.

<!-- slide 35 -->

## 5. Caching

> **Toelichting:** Caching is de goedkoopste manier om een trage of beperkte API sneller en betrouwbaarder te maken. Het moeilijke deel is kiezen hoe oud data mag zijn, en dat is een vraag voor de klant, niet voor jou alleen.

<!-- slide 36 -->

### Waarom cachen

- Hun rate limit: iRail laat 3 requests per seconde toe per IP-adres
- Snelheid: de cache antwoordt in een milliseconde, hun API in een halve seconde
- Ligt hun API even plat, dan toon je wat je nog had
- Niet cachen: wat per gebruiker verschilt, of wat je net zelf hebt aangepast

> Stel: een treinbord op tien schermen in een inkomhal ververst elke tien seconden. Zonder cache zijn dat tien requests per tien seconden. Met een cache van een minuut is het er één per minuut, voor alle schermen samen.

> **Toelichting:** Cache nooit iets met persoonlijke data onder een key die voor iedereen dezelfde is. Zet de gebruiker in de key, of cache het niet.

<!-- slide 37 -->

### Cache::remember

```php
public function departures(string $station): Collection
{
    return Cache::remember("irail.departures.{$station}", now()->addMinute(), function () use ($station) {
        return $this->request()
            ->get('liveboard/', ['station' => $station, 'format' => 'json'])
            ->throw()
            ->collect('departures.departure');
    });
}
```

1. De key bevat het station, anders krijgt Gent de treinen van Antwerpen. Na een minuut haalt de volgende bezoeker een nieuwe lijst op.
2. throw() staat binnen de closure. Gooit die een exception, dan wordt er niets bewaard. Zonder throw() cache je een foutresponse als lege lijst.

> **Toelichting:** Welke cache je gebruikt, staat in CACHE_STORE in je .env. In een nieuw project is dat database. In productie neem je liefst Redis.

<!-- slide 38 -->

### Cache::flexible

```php
$pipelines = Cache::flexible('crm.pipelines', [300, 3600], function () {
    return $this->crm->pipelines();
});
```

| Leeftijd | Wat er gebeurt |
| --- | --- |
| Tot 5 minuten | Uit de cache, geen call |
| 5 minuten tot 1 uur | Meteen de oude waarde, na de response een nieuwe ophalen |
| Ouder dan een uur | Wachten op de API, zoals bij remember |

Niemand wacht op de API, behalve na een lange stilte. Goed voor lijsten die zelden wijzigen. Zie [stale while revalidate](https://laravel.com/docs/13.x/cache#swr).

> **Toelichting:** Krijgt een bezoeker de oude waarde, dan haalt Laravel de nieuwe op nadat de response verstuurd is, met een deferred function. De tweede waarde in de array is de totale leeftijd, niet de extra tijd bovenop de eerste.

<!-- slide 39 -->

### Cache::remember rond een call die eindigt op ->collect('data'), zonder throw(). De API geeft een 503. Wat zien je gebruikers de minuut erna?

- A. De vorige lijst
- B. Een lege lijst
- C. Een foutpagina
- D. Een nieuwe poging bij elke request

**Antwoord:** B. Een lege lijst

De 503 is geen exception, collect() maakt van de foutbody een lege Collection, en die gaat de cache in. De vorige lijst lijkt logisch, maar die was net verlopen: daarom riep remember de API opnieuw aan. Met throw() komt er een exception, wordt er niets bewaard en probeert de volgende bezoeker het opnieuw.

> **Toelichting:** Wil je bij een fout de vorige waarde blijven tonen, dan heb je een tweede key nodig die langer blijft, of Cache::flexible. remember kent alleen vers of verlopen.

<!-- slide 40 -->

## 6. Testen en debuggen

> **Toelichting:** Een integratie die je niet kan testen, breekt stil. Met Http::fake test je al je foutpaden, zoals een 500, een timeout of een 429, zonder dat de echte API daarvoor moet falen.

<!-- slide 41 -->

### Nooit echt bellen in een test

```php
it('shows the contact from the crm', function () {
    Http::preventStrayRequests();

    Http::fake([
        'crm.example.com/*' => Http::response([
            'data' => ['id' => 'c_81', 'full_name' => 'Joris Peeters', 'email' => 'joris@example.com'],
        ]),
    ]);

    $user = User::factory()->create(['email' => 'joris@example.com']);

    $this->actingAs($user)->get(route('users.show', $user))->assertSee('Joris Peeters');
});
```

Een test die de echte CRM belt, is traag en faalt als hun API plat ligt. `preventStrayRequests` laat elke request zonder fake falen.

> **Toelichting:** Zet CRM_URL in phpunit.xml of in .env.testing. Anders heeft de client in je test geen baseUrl, en matcht de url niet met je fake.

<!-- slide 42 -->

### Zien wat er vertrekt

- `Http::dd()->get($url)` toont de request voor hij vertrekt, en stopt. Zo zie je meteen een verkeerde header of een ontbrekende slash in de baseUrl.
- Telescope en Nightwatch tonen elke uitgaande request met de duur en de status
- `report($exception)` bij een `RequestException` zet de response mee in je log
- Test een endpoint eerst in Postman of met curl, dan pas in je code

> **Toelichting:** Met Http::fake bootst Http::failedConnection() een ConnectionException na. Zo test je ook je catch-blok zonder op een echte storing te wachten.

<!-- slide 43 -->

### Opdracht: Een treinbord

In een nieuw Laravel 13-project, met de iRail API. Geen key nodig.

1. Een IrailClient met baseUrl https://api.irail.be, een User-Agent met je naam en je mail, en een timeout van 5 seconden
2. Een methode departures(string $station) die DTOs teruggeeft, met throw() en een retry bij een ConnectionException
3. Cache het resultaat 60 seconden, en toon de vertrekken uit Antwerpen-Centraal op een pagina
4. Een Pest-test met Http::fake en preventStrayRequests
5. Klaar als de pagina de vertrekken met hun vertraging toont, een refresh binnen de minuut geen request naar iRail doet, en de test groen is zonder internet
6. Vast? De client: slide 28 tot 34. Caching: 37 en 38. De test: 41. De velden van een vertrek staan op docs.irail.be.

> **Toelichting:** Begin in tinker: Http::withUserAgent('Treinbord (jij@example.com)')->get('https://api.irail.be/liveboard/', ['station' => 'Antwerpen-Centraal', 'format' => 'json'])->json() toont wat iRail terugstuurt. Kijk welke velden je nodig hebt voor je aan de DTO begint. Tijden komen binnen als unix timestamp, de vertraging in seconden.

## Begrippen

- **Http client**: De Http facade van Laravel, een laag rond Guzzle. Http::get() en Http::post() sturen een request en geven een Response terug. (slide 7)
- **User-Agent**: Een header die zegt welke app de request stuurt. Sommige API's, zoals iRail, vragen er een naam en een contactadres in, zodat ze je kunnen bereiken als je app ze overbelast. (slide 7)
- **RequestException**: De exception als de andere API wel antwoordt, maar met een 4xx of 5xx. Je krijgt ze pas met throw() of retry(). In $exception->response zit de response. (slide 15)
- **ConnectionException**: De exception als er geen antwoord komt: de server is onbereikbaar, de DNS faalt of de timeout is voorbij. Die komt er altijd, ook zonder throw(). (slide 15)
- **throw()**: Maakt van een 4xx- of 5xx-response een RequestException. Zonder throw() loopt je code gewoon verder met de foutresponse als data. (slide 14)
- **Timeout**: Hoe lang de Http client wacht op een antwoord, standaard 30 seconden. Al die tijd staat een PHP-worker stil. In een request zet je hem op een paar seconden. (slide 19)
- **connectTimeout**: Hoe lang de Http client wacht om een verbinding te maken, standaard 10 seconden. Komt er geen verbinding, dan volgt een ConnectionException. (slide 20)
- **retry**: Probeert een mislukte request opnieuw. retry(3, 200) is drie pogingen in totaal, met 200 ms ertussen. Zonder callback probeert hij elke fout opnieuw, ook een 422. (slide 20)
- **Retry-After**: Een header bij een 429 of 503 met het aantal seconden dat je moet wachten voor je het opnieuw probeert. (slide 23)
- **Idempotency key**: Een unieke sleutel die je meestuurt met een POST. Krijgt de API dezelfde sleutel twee keer, dan voert ze de actie maar één keer uit. Zonder zo'n sleutel is een POST opnieuw proberen riskant. (slide 21)
- **Http::pool**: Stuurt meerdere requests tegelijk in plaats van na elkaar. De totale wachttijd is die van de traagste, niet de som. (slide 25)
- **API client class**: Eén class die alles weet over één externe API: de url, het token, de timeouts en de endpoints. De rest van je app roept methodes op zoals findContact() en ziet nooit een url. (slide 28)
- **PendingRequest**: Een request die je aan het opbouwen bent, met baseUrl, withToken en timeout, maar die nog niet vertrokken is. Handig als return type van een private request()-methode. (slide 31)
- **#\[Config\]**: Een contextual attribute: de container vult een constructorparameter met een waarde uit je config, zoals services.crm.url. (slide 30)
- **DTO**: Data transfer object. Een kleine readonly class met getypte properties, zodat je code met $contact->email werkt in plaats van met $data\['email'\] uit andermans JSON. (slide 33)
- **SDK**: Een package van de aanbieder zelf om zijn API aan te spreken, zoals mollie/mollie-api-php. Bestaat er een officiële SDK, dan gebruik je die in plaats van zelf een client te schrijven. (slide 34)
- **Cache::remember**: Haalt een waarde uit de cache, of voert de closure uit en bewaart het resultaat voor de tijd die je opgeeft. Gooit de closure een exception, dan wordt er niets bewaard. (slide 37)
- **Stale-while-revalidate**: Een oude waarde tonen terwijl je op de achtergrond een nieuwe ophaalt. In Laravel met Cache::flexible: niemand wacht op de trage API, behalve na een lange stilte. (slide 38)
- **preventStrayRequests**: Laat een test falen zodra de Http client een request stuurt waarvoor geen Http::fake bestaat. Zo belt je testsuite nooit per ongeluk een echte API. (slide 41)

## Links en bronnen

### In de presentatie

- [iRail API](https://docs.irail.be) (slide 5): Liveboard, User-Agent verplicht, 3 requests per seconde
- [docs van de Http client](https://laravel.com/docs/13.x/http-client#making-requests) (slide 8)
- [docs over error handling](https://laravel.com/docs/13.x/http-client#error-handling) (slide 15)
- [retries](https://laravel.com/docs/13.x/http-client#retries) (slide 20)
- [Idempotent requests bij Stripe](https://docs.stripe.com/api/idempotent_requests) (slide 21): Een voorbeeld van een Idempotency-Key header
- [request pooling](https://laravel.com/docs/13.x/http-client#request-pooling) (slide 25)
- [macros](https://laravel.com/docs/13.x/http-client#macros) (slide 29)
- [contextual attributes](https://laravel.com/docs/13.x/container#contextual-attributes) (slide 30)
- [Saloon](https://docs.saloon.dev) (slide 34): Een package voor grote API-integraties zonder officiële SDK
- [stale while revalidate](https://laravel.com/docs/13.x/cache#swr) (slide 38)

### Bronnen

- [HTTP Client](https://laravel.com/docs/13.x/http-client): Requests, timeout, retry, throw, pool en fakes in Laravel 13
- [PendingRequest.php](https://github.com/laravel/framework/blob/13.x/src/Illuminate/Http/Client/PendingRequest.php): Hoe retry beslist: elke mislukte response zonder when-callback, en een exception op het einde
- [Cache](https://laravel.com/docs/13.x/cache): remember en flexible (stale-while-revalidate)
- [Service Container](https://laravel.com/docs/13.x/container): Contextual attributes zoals #\[Config\]
- [Retry-After](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After): MDN, de header bij een 429 of 503
