# API development

Een JSON API bouwen in Laravel 13: REST, API Resources, statuscodes, paginatie, filters, versies en rate limiting.

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

Tags: laravel, php, api

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

<!-- slide 2 -->

## Agenda

1. Wat is een API
2. Routes en controllers
3. API Resources
4. Statuscodes en fouten
5. Paginatie en filters
6. Versies, limieten en CORS
7. Documentatie

> **Toelichting:** De volgorde volgt hoe je een API bouwt: eerst de routes, dan de vorm van de JSON, dan wat er gebeurt als het misloopt. Paginatie, versies en docs maken een API bruikbaar voor iemand anders. Authenticatie met Sanctum heeft een eigen les.

<!-- slide 3 -->

## Na deze les kan je

- Een REST-API opzetten in routes/api.php, met install:api en apiResource
- Met een API Resource bepalen welke JSON een client krijgt, los van je databasekolommen
- De juiste statuscode kiezen, en fouten en validatie als JSON teruggeven
- Lijsten pagineren, filteren en sorteren met spatie/laravel-query-builder
- Een API versioneren, rate limiten en openzetten voor een frontend op een ander domein
- Documentatie voor je API laten genereren met Scramble

> **Toelichting:** Hou deze lijst bij de hand als checklist voor de opdracht op het einde. Elk punt komt daar terug als een stap.

<!-- slide 4 -->

## 1. Wat is een API

> **Toelichting:** In een Agency-project komt de vraag naar een API vaak pas halverwege: de klant wil een app, of zijn ERP moet aan de orders. Wie dan al weet hoe een goede API eruitziet, moet achteraf niets herschrijven.

<!-- slide 5 -->

### Wie praat er met je API

- **Een mobiele app**: De klant wil naast de website ook een app. Die app praat alleen met je API.
- **Een losse frontend**: Een SPA in React of Vue op een eigen domein, of een Next.js-site.
- **De systemen van de klant**: Een ERP dat elke nacht de orders ophaalt, een kassa die de stock doorstuurt.
- **Andere developers**: Een partner die op jouw data bouwt. Die lezen je docs, niet je code.

Een API is een ingang voor code in plaats van voor mensen. Een Inertia-app met React of Vue heeft er geen nodig, die blijft in `routes/web.php`. Je bouwt een API zodra code buiten je eigen Laravel-app aan je data moet. Externe API's aanspreken is een aparte les.

> **Toelichting:** Een API voor je eigen frontend en een publieke API voor partners zijn twee verschillende dingen. De eerste mag veranderen wanneer jij de frontend aanpast, de tweede niet, want je weet niet wie hem gebruikt. Daarover gaat het hoofdstuk over versies.

<!-- slide 6 -->

### Een pagina en een API

##### routes/web.php

- Een mens met een browser
- Een sessie en een cookie onthouden wie je bent
- Elke POST moet een CSRF-token meesturen
- Een fout wordt een redirect, met de foutmeldingen in de sessie

##### routes/api.php

- Code: een app, een server, een script
- Stateless: elke request brengt zijn eigen token mee
- Geen CSRF, want er is geen formulier
- Een fout wordt een statuscode met JSON

Het is dezelfde Laravel met twee middleware groups. Daarom werkt een POST uit Postman naar een route in `web.php` niet: je krijgt een 419 Page Expired, want er is geen CSRF-token.

> **Toelichting:** De middleware groups staan in de docs over middleware. De web-groep start een sessie en checkt CSRF, de api-groep doet alleen route model binding. Wil je een SPA op hetzelfde domein laten inloggen met cookies, dan heeft Sanctum een stateful modus, zie de les over authenticatie.

<!-- slide 7 -->

### REST

| Methode | Url | Doet | Controller |
| --- | --- | --- | --- |
| `GET` | `/projects` | De lijst | `index` |
| `POST` | `/projects` | Een nieuw project | `store` |
| `GET` | `/projects/12` | Eén project | `show` |
| `PUT / PATCH` | `/projects/12` | Aanpassen | `update` |
| `DELETE` | `/projects/12` | Verwijderen | `destroy` |

De url is een zelfstandig naamwoord in het meervoud, het werkwoord zit in de methode. Dus geen `/getProjects` of `/project/12/delete`. REST is een stijl, geen standaard: de winst is dat elke developer die `/projects/12` ziet, weet wat een PUT daarop doet.

> **Toelichting:** PUT vervangt het hele project, PATCH past alleen de velden aan die je meestuurt. Laravel stuurt beide naar update(). Velden die mogen ontbreken, krijgen in de Form Request de rule sometimes.

<!-- slide 8 -->

### Veilig en idempotent

| Methode | Wijzigt data | Idempotent |
| --- | --- | --- |
| `GET` | Nee | Ja |
| `PUT` | Ja | Ja, hetzelfde project met dezelfde data |
| `DELETE` | Ja | Ja, weg is weg |
| `PATCH` | Ja | Niet gegarandeerd |
| `POST` | Ja | Nee, twee keer is twee projecten |

Idempotent betekent: twee keer versturen geeft hetzelfde resultaat als één keer. Een GET mag je dus gerust opnieuw proberen, een POST niet zomaar. En een GET die data wijzigt is gevaarlijk: browsers, crawlers en link previews in Teams doen GET-requests zonder te vragen.

> **Toelichting:** Veilig (safe) is nog iets anders dan idempotent: een veilige methode wijzigt niets, zoals GET en HEAD. DELETE is idempotent maar niet veilig. Het verschil wordt belangrijk in de les over externe API's, waar je requests opnieuw probeert.

<!-- slide 9 -->

### Goede urls

- Meervoud en kebab-case: `/projects`, `/time-entries`
- Nest één niveau diep, `/projects/12/tasks`, en niet `/clients/4/projects/12/tasks/7`
- JSON-keys in snake_case, zoals je kolommen, zodat je nooit moet nadenken welke vorm een key heeft
- Een actie die geen CRUD is, krijgt een eigen route: `POST /invoices/12/send`. Pragmatisch wint van puur.
- Datums in ISO 8601 (2026-12-18), bedragen in centen, want 0.1 + 0.2 is geen 0.3 in een float

> **Toelichting:** Of je keys in snake_case of camelCase zet, maakt minder uit dan dat je één stijl kiest. Laravel-kolommen zijn snake_case, dus dat is hier het minste werk. Een JavaScript-frontend kan de keys omzetten als het team camelCase wil.

<!-- slide 10 -->

### Welke url past bij REST om project 7 te verwijderen?

- A. GET /projects/7/delete
- B. DELETE /projects/7
- C. POST /deleteProject?id=7
- D. DELETE /project/7/remove

**Antwoord:** B. DELETE /projects/7

Het werkwoord zit in de methode, de url wijst naar de resource in het meervoud. GET /projects/7/delete lijkt handig, maar een GET mag niets wijzigen: een crawler of een link preview volgt hem zonder te vragen. DELETE /project/7/remove zet het werkwoord dubbel en gebruikt het enkelvoud.

> **Toelichting:** Er bestaan ook methodes die je zelden ziet, zoals HEAD en OPTIONS. OPTIONS kom je tegen bij CORS, in hoofdstuk 6. HEAD is een GET zonder body, handig om te checken of iets bestaat.

<!-- slide 11 -->

## 2. Routes en controllers

> **Toelichting:** Routes en controllers voor een API lijken op die van een gewone app. Het verschil zit in wat je teruggeeft: nooit een view, nooit een redirect, altijd JSON met een statuscode.

<!-- slide 12 -->

### install:api

```console
$ php artisan install:api
```

- Het maakt `routes/api.php` aan. Elke route daarin krijgt `/api` voor zijn url.
- Het installeert Sanctum, het package voor API-tokens. Hoe tokens werken, zie je in de les over authenticatie.
- De trait `HasApiTokens` zet je zelf op je `User`-model, het commando zegt het je op het einde.

Een nieuw Laravel-project heeft geen `routes/api.php` meer, sinds Laravel 11. Meer in de [docs over API routes](https://laravel.com/docs/13.x/routing#api-routes).

> **Toelichting:** Wil je een volledige OAuth2-server, bijvoorbeeld omdat andere bedrijven apps bouwen die namens jouw gebruikers inloggen, dan bestaat install:api --passport. Voor bijna elk Agency-project is Sanctum genoeg.

<!-- slide 13 -->

### bootstrap/app.php

```php
->withRouting(
    web: __DIR__.'/../routes/web.php',
    api: __DIR__.'/../routes/api.php',
    apiPrefix: 'api',
)
->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->shouldRenderJsonWhen(
        fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
    );
})
```

1. install:api zet deze regel erbij. De routes krijgen de api middleware group: geen sessie, geen CSRF.
2. apiPrefix zet /api voor elke route. api is de standaard, dus deze regel mag je ook weglaten.
3. Elke fout onder api/\* wordt JSON, ook zonder Accept-header. Dit staat al in een nieuw Laravel 13-project.

> **Toelichting:** In Laravel 10 en ouder stond dit in app/Http/Kernel.php en in RouteServiceProvider. Kom je een oudere tutorial tegen met die bestanden, dan weet je waar het nu staat. Een project dat je upgradet, heeft de regel met shouldRenderJsonWhen niet vanzelf.

<!-- slide 14 -->

### Route::apiResource

*routes/api.php*

```php
use App\Http\Controllers\Api\ProjectController;
use App\Http\Controllers\Api\TaskController;

Route::middleware('auth:sanctum')->prefix('v1')->group(function () {
    Route::apiResource('projects', ProjectController::class);
    Route::apiResource('projects.tasks', TaskController::class)->shallow();
});
```

1. Alle routes achter auth:sanctum, dus alleen met een geldig token, en onder /api/v1.
2. Vijf routes in één regel: index, store, show, update, destroy. Geen create en edit, want een API toont geen formulieren.
3. shallow(): de lijst en het aanmaken via /projects/12/tasks, de rest via /tasks/7. Een taak heeft een eigen id.

> **Toelichting:** Wil je bij een geneste route dat Laravel checkt of de taak echt bij dat project hoort, kijk dan naar scoped() in de docs over resource controllers.

<!-- slide 15 -->

### De controller genereren

```console
$ php artisan make:controller Api/ProjectController --api --model=Project --requests
$ php artisan route:list --path=api/v1/projects
 GET|HEAD    api/v1/projects ............ projects.index
 POST        api/v1/projects ............ projects.store
 GET|HEAD    api/v1/projects/{project} .. projects.show
 PUT|PATCH   api/v1/projects/{project} .. projects.update
 DELETE      api/v1/projects/{project} .. projects.destroy
```

`--api` laat create en edit weg, `--model` zet route model binding klaar en `--requests` maakt `StoreProjectRequest` en `UpdateProjectRequest`. Geeft een url een 404, kijk dan eerst in `route:list`. Zie de [docs over API resource routes](https://laravel.com/docs/13.x/controllers#api-resource-routes).

> **Toelichting:** route:list kent ook --except-vendor, om de routes van packages te verbergen. Met -v zie je ook de middleware per route, handig om te checken of auth:sanctum er echt op staat.

<!-- slide 16 -->

### Aanmaken

```php
public function store(StoreProjectRequest $request): ProjectResource
{
    $project = $request->user()->team->projects()->create($request->validated());

    return new ProjectResource($project);
}
```

1. De Form Request valideert voor de methode start. Klopt de data niet, dan komt de code hier nooit.
2. Aanmaken via de relatie van het team vult team_id zelf in. Zo zet niemand een project in een ander team.
3. Een resource rond een model dat net is aangemaakt, krijgt vanzelf status 201 Created.

> **Toelichting:** validated() geeft alleen de velden terug die in je rules staan. Stuurt de client extra velden mee, zoals team_id, dan komen die nooit in je model. Daarom is create($request->all()) een slecht idee, ook met $fillable.

<!-- slide 17 -->

### Verwijderen

```php
#[Authorize('delete', 'project')]
public function destroy(Project $project): Response
{
    $project->delete();

    return response()->noContent();
}
```

1. Nieuw in Laravel 13: de policy checkt of deze gebruiker mag verwijderen, anders een 403. Zie de les over autorisatie.
2. Route model binding zoekt het project. Bestaat het niet, dan volgt een 404.
3. Er valt niets terug te sturen, dus 204 No Content, zonder body.

> **Toelichting:** Zonder het attribute zet je dezelfde check in de methode met Gate::authorize('delete', $project). Heeft het model soft deletes, dan is delete() een soft delete: de rij blijft en krijgt een deleted_at.

<!-- slide 18 -->

## 3. API Resources

> **Toelichting:** De vorm van je JSON is het contract met iedereen die je API gebruikt. Resources maken dat contract expliciet, zodat een migratie of een nieuwe kolom het niet per ongeluk verandert.

<!-- slide 19 -->

### Geef nooit je model terug

##### `return $project`

- Elke kolom gaat mee, ook die je nooit wou tonen
- Hernoem je een kolom, dan crasht de app van de klant

##### `return new ProjectResource($project)`

- Jij kiest welke velden erin staan en hoe ze heten
- De database mag veranderen, de JSON blijft dezelfde

> [!WARNING]
> Stel: je API geeft `User::all()` terug en de tabel heeft een kolom `internal_notes`. Dan leest elke app die je API aanspreekt wat het accountteam over zijn klanten schreef. `$hidden` op het model helpt, maar dan beslist je model over je API. Een resource is het contract.

> **Toelichting:** $hidden en $visible op een model bepalen wat toArray() en toJson() tonen. Dat werkt, maar het geldt overal waar je het model serialiseert. Een resource geldt alleen voor de API, en je kan er per versie een andere van maken.

<!-- slide 20 -->

### Een resource maken

```php
class ProjectResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'status' => $this->status,
            'deadline' => $this->deadline?->toDateString(),
        ];
    }
}
```

```console
$ php artisan make:resource ProjectResource
```

1. toArray bepaalt de JSON. $this is het model, de resource geeft elke property door.
2. status is een enum cast. In de JSON wordt dat vanzelf de string, zoals open.
3. Een datum als ISO-string. Zonder toDateString krijgt de client een timestamp met tijd en tijdzone.

> **Toelichting:** Een resource gedraagt zich als het model: $this->name werkt omdat de resource elke property doorgeeft. Het model zelf vind je in $this->resource, handig als je het aan een policy of een andere resource wil geven.

<!-- slide 21 -->

### Relaties en voorwaarden

```php
return [
    'id' => $this->id,
    'client' => ClientResource::make($this->whenLoaded('client')),
    'tasks_count' => $this->whenCounted('tasks'),
    'budget' => $this->when($request->user()->isAdmin(), $this->budget),
];
```

1. whenLoaded: de client staat erin als de controller hem geladen heeft. Anders valt de key weg, zonder extra query.
2. whenCounted doet hetzelfde voor een aantal uit withCount of loadCount.
3. when: alleen een admin ziet het budget. Voor de rest verdwijnt de key helemaal, hij wordt niet null.

Alle voorwaarden staan in de [docs over conditional attributes](https://laravel.com/docs/13.x/eloquent-resources#conditional-attributes).

> **Toelichting:** Er zijn meer varianten: whenNotNull, whenHas en mergeWhen voor een groep velden onder dezelfde voorwaarde. Voor sum, avg, min en max van een relatie bestaat whenAggregated. Ze staan allemaal in de docs over conditional attributes.

<!-- slide 22 -->

### De controller kiest, de resource vormt

```php
public function show(Project $project): ProjectResource
{
    $project->load('client')->loadCount('tasks');

    return new ProjectResource($project);
}
```

De resource doet zelf geen queries. De controller beslist wat er geladen wordt, de resource toont wat er is. Zo krijg je geen N+1 bij een lijst van vijftig projecten, zie de les over Eloquent queries.

```json
{
  "data": {
    "id": 12,
    "name": "Webshop Bakkerij Peeters",
    "client": { "id": 4, "name": "Peeters" },
    "tasks_count": 31
  }
}
```

Het budget ontbreekt: deze gebruiker is geen admin.

> **Toelichting:** Laravel kan je waarschuwen als je toch lazy laadt: Model::preventLazyLoading() in AppServiceProvider gooit dan een exception in plaats van stil een extra query te doen. Meer daarover in de les over Eloquent queries.

<!-- slide 23 -->

### De data-key

*AppServiceProvider::boot()*

```php
JsonResource::withoutWrapping();
```

Laravel zet de buitenste resource in een `data`-key. Met `withoutWrapping()` zet je dat uit, behalve bij een gepagineerde lijst: daar staan `links` en `meta` naast `data`, dus die blijft.

> [!TIP]
> Laat de `data`-key staan. Dan hebben één project, een lijst en een gepagineerde lijst dezelfde vorm, en hoeft de client nooit te raden. Zie [data wrapping](https://laravel.com/docs/13.x/eloquent-resources#data-wrapping).

> **Toelichting:** Met een with()-methode op de resource, of additional() in de controller, voeg je top-level data toe, zoals een versienummer. Die komt naast data te staan, alleen als de resource de buitenste is.

<!-- slide 24 -->

### toResource

```php
return $project->toResource();

return Project::with('client')->paginate()->toResourceCollection();
```

Nieuw sinds Laravel 12. Laravel zoekt `ProjectResource` in `App\Http\Resources`, op naam. Heet hij anders, zet dan `#[UseResource(ProjectResource::class)]` op het model.

Korter, maar wie je controller leest, ziet niet meteen welke resource er gebruikt wordt. Kies één stijl per project en hou je eraan.

> **Toelichting:** Voor een collectie zoekt toResourceCollection eerst een ProjectCollection. Bestaat die niet, dan neemt Laravel ProjectResource::collection. Een eigen collection-class heb je alleen nodig voor eigen meta of links.

<!-- slide 25 -->

### De resource heeft 'client' => ClientResource::make($this->whenLoaded('client')), maar de controller laadt de client niet. Wat krijgt de app?

- A. "client": null
- B. Geen client-key in de JSON
- C. De client, met een extra query per project
- D. Een exception

**Antwoord:** B. Geen client-key in de JSON

whenLoaded laat de key weg als de relatie niet geladen is. "client": null lijkt logisch, maar dan zou de app denken dat het project geen klant heeft. En een extra query doet de resource nooit: net daarvoor bestaat whenLoaded.

> **Toelichting:** Wil je de key altijd in de JSON, laad de relatie dan altijd in de controller. Een app gaat beter om met een key die er altijd is dan met een key die soms ontbreekt.

<!-- slide 26 -->

### JSON:API

- ```json
  {
    "data": {
      "id": "12",
      "type": "projects",
      "attributes": {
        "name": "Webshop Bakkerij Peeters"
      },
      "relationships": {
        "client": {
          "data": { "id": "4", "type": "clients" }
        }
      }
    }
  }
  ```
- ```console
  $ php artisan make:resource ProjectResource --json-api
  ```

  Laravel 13 heeft `JsonApiResource` ingebouwd, volgens de spec van [jsonapi.org](https://jsonapi.org). Relaties komen er pas in met `?include=client`.

  Kies een gewone `JsonResource`, tenzij de klant of een frontend library JSON:API vraagt. Eén vorm voor de hele API. [Docs](https://laravel.com/docs/13.x/eloquent-resources#jsonapi-resources)

> **Toelichting:** JSON:API kent ook sparse fieldsets: ?fields[projects]=name,deadline stuurt alleen die velden terug. Handig voor een mobiele app op een traag netwerk.

<!-- slide 27 -->

## 4. Statuscodes en fouten

> **Toelichting:** Een statuscode is het eerste waar een client naar kijkt. Een HTTP-library, een retry-mechanisme of een monitoringtool leest de code en niet je message. Een verkeerde code betekent dat al die tools het verkeerde doen.

<!-- slide 28 -->

### Statuscodes

#### Gelukt

| Code | Wanneer |
| --- | --- |
| **200** | Alles goed, er is een body |
| **201** | Iets aangemaakt |
| **204** | Gelukt, niets terug te sturen |

Elke code met uitleg staat op [MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status).

#### Mislukt

| Code | Wanneer |
| --- | --- |
| **401** | Wie ben jij? Geen geldig token. |
| **403** | Ik weet wie je bent, maar nee |
| **404** | Bestaat niet |
| **422** | De data klopt niet |
| **429** | Te veel requests |
| **500** | Jouw bug |

> **Toelichting:** Er zijn er meer: 409 Conflict voor een botsing, zoals een e-mailadres dat al bestaat, of 503 Service Unavailable tijdens php artisan down. Hou het bij de codes uit de tabel, dan weet elke client wat hij moet doen.

<!-- slide 29 -->

### 401 of 403

#### 401 Unauthorized

Wie ben jij?

Geen token, of een token dat niet klopt. De client moet eerst inloggen. In Laravel komt die van `auth:sanctum`.

#### 403 Forbidden

Nee.

Het token klopt, maar deze gebruiker mag dit niet. Een policy of gate zegt nee. Wanneer je beter een 404 geeft, zie je in de les over autorisatie.

> **Toelichting:** De naam 401 Unauthorized is historisch slecht gekozen: het gaat over authenticatie, niet over autorisatie. Kijk dus naar wat er ontbreekt: een geldige identiteit (401) of een recht (403).

<!-- slide 30 -->

### Request en response

Klik de scenario's een voor een aan. Kijk wat er verandert in de statusregel en de headers, en zie dat de body altijd een message heeft.

Een request en de response van de API naast elkaar, per scenario:

- `GET /api/v1/projects` geeft `200 OK`
- `POST /api/v1/projects` geeft `201 Created`
- `DELETE /api/v1/projects/24` geeft `204 No Content`
- `POST /api/v1/projects` geeft `422 Unprocessable Content`
- `GET /api/v1/projects` geeft `401 Unauthorized`
- `DELETE /api/v1/projects/9` geeft `403 Forbidden`
- `GET /api/v1/projects/999` geeft `404 Not Found`
- `GET /api/v1/projects` geeft `429 Too Many Requests`

> **Toelichting:** Probeer hetzelfde in je eigen project met Postman, of met curl -i, dat toont ook de headers. Vergelijk daarna met een response van een bekende API, zoals die van GitHub, en kijk welke headers zij meesturen.

<!-- slide 31 -->

### Validatie als JSON

*StoreProjectRequest*

```php
public function rules(): array
{
    return [
        'name' => ['required', 'string', 'max:255'],
        'client_id' => ['required', Rule::exists('clients', 'id')],
        'deadline' => ['nullable', 'date', 'after:today'],
    ];
}
```

1. Rules als array, niet als string met pipes. Zo kan je er later een eigen rule-object tussen zetten.
2. Rule::exists checkt dat de client bestaat. In een echt project beperk je het tot de clients van het team.
3. Faalt een rule, dan gooit Laravel een ValidationException. In een API wordt dat een 422 met message en errors.

> **Toelichting:** Laravel 13 heeft attributes voor Form Requests, zoals #[StopOnFirstFailure]: de validatie stopt dan bij de eerste fout. Voor een API is dat zelden wat je wil, want een app toont liefst alle fouten in één keer.

<!-- slide 32 -->

### Een 302 in plaats van een 422

> Stel: je test een route uit `routes/web.php` in Postman, zonder Accept-header. De validatie faalt, en je krijgt een 302 naar `/` terug, zonder één foutmelding.

Laravel kijkt of de request JSON verwacht. Zo ja, dan een 422 met de errors. Zo nee, dan een redirect naar de vorige pagina, met de errors in de sessie, zoals bij een formulier. Onder `api/*` is het in Laravel 13 altijd JSON, door de regel in `bootstrap/app.php`. Daarbuiten beslist de header.

Zet in Postman dus altijd `Accept: application/json`. Meer in de [docs over validatiefouten](https://laravel.com/docs/13.x/validation#validation-error-response-format).

> **Toelichting:** expectsJson() kijkt naar de Accept-header. Een fetch uit JavaScript stuurt die niet vanzelf mee, dus zet hem daar ook. Axios zet standaard wel application/json in de Accept-header.

<!-- slide 33 -->

### Een 404 die te veel zegt

```json
{ "message": "No query results for model [App\\Models\\Project] 999" }
```

Dit krijgt de client als route model binding een project niet vindt, ook met `APP_DEBUG=false` in productie. Een 404 is een HTTP-exception, en daarvan toont Laravel de message altijd.

Geen ramp, maar je vertelt de wereld hoe je code in elkaar zit. De volgende slide geeft een neutrale message terug, alleen voor de API.

> **Toelichting:** Een ModelNotFoundException wordt in de exception handler omgezet naar een NotFoundHttpException, met de originele message. Daarom zie je de modelnaam, en daarom werkt de render op de volgende slide ook voor route model binding.

<!-- slide 34 -->

### De 404 herschrijven

```php
->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->render(function (NotFoundHttpException $exception, Request $request) {
        if (! $request->is('api/*')) {
            return null;
        }

        return response()->json(['message' => 'Not found.'], 404);
    });
})
```

1. In bootstrap/app.php. Door de type-hint krijgt deze closure alleen een NotFoundHttpException.
2. Buiten api/\* geef je null terug. Dan toont Laravel gewoon de 404-pagina van je website.
3. Binnen de API een neutrale message, zonder modelnaam. Zie de docs over rendering exceptions.

> **Toelichting:** Hetzelfde patroon werkt voor andere exceptions. Wil je bij een 403 een eigen message, dan render je een AccessDeniedHttpException op dezelfde manier.

<!-- slide 35 -->

### Eén vorm voor elke fout

- Elke fout heeft een `message`, een validatiefout heeft ook `errors` per veld
- De statuscode zegt wat er misliep. Nooit een 200 met `"success": false`: elke HTTP-client, monitoring en retry kijkt naar de code.
- Nooit HTML en nooit een stacktrace, dus `APP_DEBUG=false` in productie
- Kan de client het zelf oplossen, dan is het een 4xx. Jouw bug is een 5xx.
- Hoe Laravel beslist wat JSON wordt, lees je in de [docs over error handling](https://laravel.com/docs/13.x/errors#rendering-exceptions)

> **Toelichting:** Wil je nog meer structuur, kijk dan naar RFC 9457, Problem Details for HTTP APIs, met velden als type, title en detail. Voor de meeste projecten is message plus errors genoeg, zolang elke fout dezelfde vorm heeft.

<!-- slide 36 -->

### Postman stuurt POST /api/v1/projects zonder name en zonder Accept-header, naar een nieuw Laravel 13-project. Wat komt er terug?

- A. 302, een redirect naar de vorige pagina
- B. 422 met message en errors
- C. 400 Bad Request
- D. 500 Server Error

**Antwoord:** B. 422 met message en errors

De skeleton van Laravel 13 rendert elke fout onder api/\* als JSON, ook zonder Accept-header. De 302 krijg je op een route in web.php zonder die header. 400 lijkt ook logisch, maar Laravel gebruikt 422 voor een validatiefout.

> **Toelichting:** Test het zelf: maak een route in web.php en één in api.php met dezelfde Form Request, en stuur in Postman een lege POST naar beide, met en zonder Accept-header.

<!-- slide 37 -->

## 5. Paginatie en filters

> **Toelichting:** Een lijst die vandaag twintig rijen heeft, heeft er binnen een jaar twintigduizend. Paginatie toevoegen nadat een app live is, is een breaking change: de vorm van de response verandert. Doe het dus vanaf het eerste endpoint.

<!-- slide 38 -->

### Paginatie

```php
public function index(Request $request): ResourceCollection
{
    $projects = $request->user()->team->projects()
        ->with('client')
        ->latest()
        ->paginate(20);

    return ProjectResource::collection($projects);
}
```

Een paginator in `collection()` geeft `data`, `links` en `meta`. Pagineer elke lijst vanaf dag één: twaalf projecten in je seeder worden er veertigduizend bij de klant.

```json
{
  "data": [ ... ],
  "links": {
    "prev": null,
    "next": ".../projects?page=2"
  },
  "meta": {
    "current_page": 1,
    "last_page": 3,
    "total": 47
  }
}
```

> **Toelichting:** Laat je de client per_page kiezen, begrens het dan: min($request->integer('per_page', 20), 100). Anders vraagt iemand er een miljoen tegelijk.

<!-- slide 39 -->

### Drie soorten paginatie

|   | Queries | Je krijgt | Goed voor |
| --- | --- | --- | --- |
| `paginate()` | Twee, met een count | Paginanummers en total | Een tabel met pagina 1, 2, 3 |
| `simplePaginate()` | Eén | Alleen vorige en volgende | Een lijst zonder totaal |
| `cursorPaginate()` | Eén, zonder offset | Een cursor in plaats van een page | Infinite scroll, grote tabellen |

`paginate()` telt bij elke pagina de hele tabel. Een app met infinite scroll heeft geen paginanummers nodig en neemt `cursorPaginate()`, die vraagt wel een sortering op een unieke kolom. Meer in de [docs over paginatie](https://laravel.com/docs/13.x/pagination#cursor-pagination).

> **Toelichting:** Offset en cursor verschillen ook als er data bijkomt tijdens het bladeren. Met een offset kan een rij twee keer verschijnen of overgeslagen worden, met een cursor niet. Dat maakt cursors goed voor feeds die snel wijzigen.

<!-- slide 40 -->

### Filters in de querystring

```bash
GET /api/v1/projects?filter[status]=open&sort=-deadline&include=client
```

Een app wil filteren, sorteren en relaties meekrijgen. Dat zelf bouwen met `$request->input()` en een rij if's lukt, maar het gevaar zit in wat je vergeet. Laat je elke kolom toe, dan sorteert iemand op `password` of filtert hij op `budget`.

[spatie/laravel-query-builder](https://spatie.be/docs/laravel-query-builder/v7) werkt met een whitelist: alleen wat je toelaat, wordt een query.

```console
$ composer require spatie/laravel-query-builder
```

> **Toelichting:** De vorm filter[...], sort en include komt uit de JSON:API-specificatie. Een app die die spec kent, praat dus meteen met je API, ook als je responses gewone JsonResources zijn.

<!-- slide 41 -->

### Filters en sorteren

Zet filters en sorts aan en uit, en kijk hoe de SQL meegroeit. Zet daarna `sort=password` aan.

Een querystring voor `/api/v1/projects` samenstellen, met de SQL die spatie/laravel-query-builder ervan maakt. Mogelijke parameters:

- `filter[status]=open`: `status = 'open'`
- `filter[client_id]=4`: `client_id = 4`
- `filter[name]=bakkerij`: `LOWER(name) LIKE '%bakkerij%'`
- `sort=-deadline`: `deadline desc`
- `sort=name`: `name asc`
- `include=client`: `select * from clients where id in (4, 7, 11)`
- `page=2`: `20`
- `filter[budget]=5000`: niet toegelaten, 400 Bad Request
- `sort=password`: niet toegelaten, 400 Bad Request

> **Toelichting:** De SQL is vereenvoudigd: de package bindt de waarden als parameters, zodat SQL-injectie niet kan. Het principe blijft hetzelfde: alleen wat in de whitelist staat, komt in de query.

<!-- slide 42 -->

### spatie/laravel-query-builder

```php
$projects = QueryBuilder::for($request->user()->team->projects())
    ->allowedFilters(
        AllowedFilter::exact('status'),
        AllowedFilter::exact('client_id'),
        'name',
    )
    ->allowedSorts('name', 'deadline', 'created_at')
    ->allowedIncludes('client')
    ->defaultSort('-created_at')
    ->paginate(20)
    ->withQueryString();
```

1. Start vanaf de projecten van het team. Zo filtert niemand buiten zijn eigen data.
2. Alleen deze filters mogen. Versie 7 neemt losse argumenten, geen array.
3. Sorts en includes idem. withQueryString houdt de filters in de links.

> **Toelichting:** QueryBuilder::for() neemt een model-class, een Eloquent-query of een relatie. Daarna kan je er gewone Eloquent-methodes op blijven aanroepen, zoals where of with.

<!-- slide 43 -->

### Partial en exact

| In allowedFilters | Url | Query |
| --- | --- | --- |
| `'name'` | `filter[name]=peeters` | `LOWER(name) LIKE '%peeters%'` |
| `AllowedFilter::exact('status')` | `filter[status]=open` | `status = 'open'` |
| `AllowedFilter::exact('id')` | `filter[id]=1,2,3` | `id in (1, 2, 3)` |

Een string wordt een partial filter, met LIKE. Dat is goed voor namen, maar voor ids, statussen en booleans neem je `exact`: anders matcht `filter[id]=1` ook 10, 11 en 21. Zie de [docs over filteren](https://spatie.be/docs/laravel-query-builder/v7/features/filtering).

> **Toelichting:** Er zijn nog andere filters: beginsWith en endsWith gebruiken een index beter dan een partial filter, scope roept een local scope op je model aan, en met custom schrijf je een eigen filter-class. Ze staan in de docs over filteren.

<!-- slide 44 -->

### allowedFilters('name') en de url ?filter\[name\]=peeters. Welke where voert de package uit?

- A. WHERE name = 'peeters'
- B. WHERE LOWER(name) LIKE '%peeters%'
- C. Geen, name is geen exact filter
- D. 400 Bad Request

**Antwoord:** B. WHERE LOWER(name) LIKE '%peeters%'

Een string wordt een partial filter, dus LIKE met procenttekens. Een exacte vergelijking krijg je alleen met AllowedFilter::exact. En een 400 komt er pas als name niet in allowedFilters staat.

> **Toelichting:** Een partial filter met LIKE '%...%' kan geen index gebruiken, want de waarde kan overal in de kolom staan. Op een grote tabel wordt dat traag. Voor echt zoeken in grote datasets kijk je naar Laravel Scout.

<!-- slide 45 -->

## 6. Versies, limieten en CORS

> **Toelichting:** Zodra iemand anders je API gebruikt, beslis jij niet meer wanneer die code wordt aangepast. Versies, limieten en CORS gaan allemaal over die andere kant: clients die je niet kent en niet kan updaten.

<!-- slide 46 -->

### Breaking changes

##### Mag gewoon

- Een veld toevoegen aan de response
- Een nieuw endpoint
- Een optionele parameter

##### Nieuwe versie

- Een veld hernoemen of weghalen
- Het type van een veld veranderen: `"12"` wordt `12`
- Een parameter verplicht maken

Een website deploy je, en iedereen heeft meteen de nieuwe versie. Een app in de App Store niet: sommige gebruikers updaten maanden niet. Alles rechts maakt hun app stuk, wat links staat negeert een goede client gewoon.

> **Toelichting:** Een goede client negeert velden die hij niet kent. Schrijf je zelf een client voor een API, doe dat dan ook: map alleen wat je nodig hebt naar een DTO, zie de les over externe API's.

<!-- slide 47 -->

### Versie in de url

```php
use App\Http\Controllers\Api;

Route::prefix('v1')->name('v1.')->group(function () {
    Route::apiResource('projects', Api\V1\ProjectController::class);
});

Route::prefix('v2')->name('v2.')->group(function () {
    Route::apiResource('projects', Api\V2\ProjectController::class);
});
```

1. v1 blijft draaien zoals hij was, met eigen controllers en resources. Een fix in v2 breekt v1 nooit.
2. v2 krijgt de breaking change. De naam-prefix laat route('v1.projects.index') en route('v2.projects.index') naast elkaar bestaan.

> **Toelichting:** Veel code tussen v1 en v2 is gelijk. Zet de logica in een action of een service class, en laat alleen de controllers en resources per versie verschillen. Zie de les over applicatie-architectuur.

<!-- slide 48 -->

### Url of header

|   | Voorbeeld | Zichtbaar |
| --- | --- | --- |
| In de url | `/api/v2/projects` | In elke log, in de browser, in Postman |
| In een header | `Accept: application/vnd.agency.v2+json` | Alleen als je de headers bekijkt |

Kies de url. Een header is technisch netter, maar niemand ziet hem in een log of een bugreport. Begin meteen met `v1`, ook als je denkt dat er nooit een v2 komt: achteraf een prefix toevoegen is zelf een breaking change.

> [!TIP]
> Elke versie is code die je onderhoudt. Maak pas een v2 als het echt moet, en zet een einddatum op de oude.

> **Toelichting:** Laravel heeft geen ingebouwde versioning via headers. Wil je het toch, dan schrijf je zelf een middleware die de Accept-header leest.

<!-- slide 49 -->

### Rate limiting

*app/Providers/AppServiceProvider.php*

```php
public function boot(): void
{
    RateLimiter::for('api', function (Request $request) {
        return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
    });
}
```

1. Een limiter met een naam: api. Die naam gebruik je in de middleware throttle:api.
2. 60 requests per minuut, per gebruiker als er een token is, anders per IP-adres. Zo delen gasten niet één teller met je ingelogde gebruikers.

> **Toelichting:** Je kan limieten combineren door een array terug te geven, zoals Limit::perMinute(60) en Limit::perDay(1000). Geef ze dan elk een eigen by-key met een prefix, zie de docs over multiple rate limits.

<!-- slide 50 -->

### throttleApi

*bootstrap/app.php*

```php
->withMiddleware(function (Middleware $middleware): void {
    $middleware->throttleApi();
})
```

In Laravel 13 zit er standaard geen rate limiting op je API. `throttleApi()` zet `throttle:api` op elke route in `routes/api.php`. Vergeet je de limiter van de vorige slide, dan krijg je een `MissingRateLimiterException`.

Elke response krijgt dan `X-RateLimit-Limit` en `X-RateLimit-Remaining`, en boven de limiet volgt een 429 met `Retry-After`. Zie de [docs over rate limiting](https://laravel.com/docs/13.x/routing#rate-limiting).

> **Toelichting:** throttleApi() neemt ook een andere limiter-naam en een redis-vlag: throttleApi('api', redis: true) laat Redis de tellers bijhouden. Zonder throttleApi() kan je ook per route throttle:api of throttle:60,1 zetten.

<!-- slide 51 -->

### CORS in de browser

1. Browser → Laravel, **OPTIONS /api/v1/projects**: De preflight. De browser stuurt Origin: https://app.klant.be mee en vraagt of een GET met een Authorization-header mag.
2. Laravel → Browser, **Access-Control-Allow-Origin**: HandleCors antwoordt met de origins uit config/cors.php. Staat app.klant.be er niet in, dan stopt de browser hier, met een CORS-fout in de console.
3. Browser → Laravel, **GET /api/v1/projects**: Pas nu de echte request, met het token.
4. Laravel → Browser, **200 en JSON**: Met de CORS-headers erbij. Zonder die headers geeft de browser de response niet aan je JavaScript.

> **Toelichting:** Een GET zonder speciale headers is een simple request. Daar slaat de browser de preflight over, maar hij houdt het antwoord nog altijd tegen als Access-Control-Allow-Origin ontbreekt.

<!-- slide 52 -->

### config/cors.php

- CORS is een regel van de browser. Postman en andere servers checken het niet.
- De origin is scheme, host en poort: localhost:5173 en localhost:8000 zijn er twee
- `HandleCors` doet het voor `api/*`, en standaard mag elke origin. Zet er het domein van de frontend in.
- Een CORS-fout zie je in de browserconsole, niet in je Laravel-log. Meer op [MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS).

```console
$ php artisan config:publish cors
```

```php
'paths' => ['api/*', 'sanctum/csrf-cookie'],

'allowed_origins' => [env('FRONTEND_URL')],

'supports_credentials' => false,
```

> **Toelichting:** allowed_origins_patterns neemt reguliere expressies, handig voor preview-omgevingen met een wisselend subdomein. Zet nooit * samen met supports_credentials op true: browsers weigeren die combinatie.

<!-- slide 53 -->

### Je React-app op app.klant.be krijgt een CORS-fout bij elke request naar de API. In Postman werkt alles. Waar los je het op?

- A. In de React-app, met een extra header
- B. In config/cors.php van de Laravel-API
- C. In de rate limiter
- D. Met JsonResource::withoutWrapping()

**Antwoord:** B. In config/cors.php van de Laravel-API

De API moet de browser vertellen dat app.klant.be mag, met de headers uit config/cors.php. Een header in de React-app helpt niet: de browser beslist op basis van wat de API terugstuurt. Postman is geen browser en checkt CORS niet, daarom werkt het daar wel.

> **Toelichting:** Een CORS-fout debug je in de Network-tab van de browser. Kijk naar de OPTIONS-request en de headers van het antwoord. Ontbreekt Access-Control-Allow-Origin, of staat er een andere origin, dan ligt het aan config/cors.php.

<!-- slide 54 -->

## 7. Documentatie

> **Toelichting:** Een API zonder docs wordt niet gebruikt, of verkeerd. Gegenereerde docs verouderen niet, omdat ze bij elke deploy opnieuw uit je code komen.

<!-- slide 55 -->

### Scramble en Scribe

- **[Scramble](https://scramble.dedoc.co)**: Leest je code: Form Requests, resources, return types. OpenAPI-docs op `/docs/api`, zonder annotaties.
- **[Scribe](https://scribe.knuckles.wtf/laravel)**: Genereert een statische site, een Postman collection en OpenAPI. Meer controle met docblocks en attributes.

Neem Scramble voor de meeste projecten: installeren en je hebt docs, en ze blijven kloppen omdat ze uit je code komen. Neem Scribe als de klant een Postman collection of statische docs op een eigen domein wil.

> **Toelichting:** Beide maken een OpenAPI-document. Dat importeer je in Postman of Bruno, zodat de developer van de klant meteen alle requests klaar heeft staan.

<!-- slide 56 -->

### Scramble installeren

```console
$ composer require dedoc/scramble
```

*AppServiceProvider::boot()*

```php
Gate::define('viewApiDocs', function (User $user) {
    return in_array($user->email, ['dev@agency.test']);
});
```

Na de installatie staan je docs op `/docs/api`, standaard alleen lokaal. In productie zet je ze open met de gate `viewApiDocs`, hier voor één e-mailadres. Zie de [installatie van Scramble](https://scramble.dedoc.co/installation).

> **Toelichting:** Scramble haalt veel uit je types. Een controller-methode met return type ProjectResource geeft een beter schema dan een methode zonder return type.

<!-- slide 57 -->

### Wat erin moet

- Hoe de developer van de klant aan een token komt
- Per endpoint een voorbeeld van de request en de response
- De vorm van een fout, en welke statuscodes je gebruikt
- De rate limits, en hoe de paginatie werkt
- Een changelog per versie, zodat een breaking change nooit als verrassing komt

> [!NOTE]
> De docs zijn voor iemand die jouw code nooit ziet. Ontbreekt er iets, dan komen er mails en calls met vragen, en die zitten niet in het budget.

> **Toelichting:** Scramble toont wat het uit je code kan halen. Wat er niet in je code staat, zoals hoe je aan een token komt, schrijf je zelf, in de config van Scramble of in een README voor de klant.

<!-- slide 58 -->

### Opdracht: Een API voor projecten

In je Agency-project, of in een nieuw Laravel 13-project met projects en clients.

1. install:api, en een apiResource voor projects onder /api/v1 achter auth:sanctum
2. Een ProjectResource met de client via whenLoaded, en Form Requests voor store en update
3. Filteren op status, sorteren op deadline en include=client met spatie/laravel-query-builder
4. Rate limiting op 60 requests per minuut, en Scramble voor de docs
5. Klaar als ?filter\[status\]=open&sort=deadline&include=client een gepagineerde lijst geeft, een POST zonder name een 422 in JSON, en /docs/api alle endpoints toont
6. Vast? Routes en controller: slide 12 tot 17. Resources: 19 tot 22. Query builder: 40 tot 43. Een token maak je in tinker met $user->createToken('postman')->plainTextToken.

> **Toelichting:** Begin met de routes en een resource zonder filters, en test elk endpoint in Postman voor je verdergaat. Pas als index, store en show werken, komt de query builder erbij. Een Postman-collectie met alle requests is een handige extra om mee in te leveren.

## Begrippen

- **API**: Een ingang van je applicatie voor code in plaats van voor mensen. Een app, een SPA of de server van een partner stuurt requests en krijgt JSON terug. (slide 5)
- **REST**: Een manier om een API op te bouwen rond resources. De url wijst naar een ding in het meervoud (/projects/12), de HTTP-methode zegt wat je ermee doet. (slide 7)
- **Endpoint**: Eén combinatie van methode en url in je API, zoals POST /api/v1/projects. (slide 7)
- **Idempotent**: Twee keer versturen geeft hetzelfde resultaat als één keer. GET, PUT en DELETE zijn dat, een POST niet: twee keer POST is twee projecten. (slide 8)
- **Stateless**: De server onthoudt niets tussen twee requests. Een API-request brengt zijn token zelf mee, er is geen sessie zoals in routes/web.php. (slide 6)
- **install:api**: Het artisan-commando dat routes/api.php aanmaakt, Sanctum installeert en de api-routes aanzet in bootstrap/app.php, met /api als prefix. (slide 12)
- **apiResource**: Registreert de vijf routes van een REST-resource (index, store, show, update, destroy), zonder create en edit, want een API toont geen formulieren. (slide 14)
- **API Resource**: Een class die een model omzet naar de JSON die de client krijgt. Jij kiest de velden en hun namen, los van je databasekolommen. (slide 20)
- **whenLoaded**: Zet een relatie alleen in de JSON als de controller ze al geladen heeft. Zo doet een resource nooit zelf een query en vermijd je N+1. (slide 21)
- **Data wrapping**: Laravel zet de buitenste resource in een data-key. JsonResource::withoutWrapping() zet dat uit, behalve bij paginatie, want daar staan links en meta naast. (slide 23)
- **JSON:API**: Een specificatie voor de vorm van een JSON-response, met type, id, attributes en relationships. Laravel 13 heeft er JsonApiResource voor. (slide 26)
- **201 Created**: De statuscode na een geslaagde POST die iets aanmaakte. Een resource rond een model dat net is aangemaakt, krijgt die vanzelf. (slide 28)
- **204 No Content**: Gelukt, maar er is niets terug te sturen. Typisch na een DELETE, met response()->noContent(). (slide 17)
- **401 en 403**: 401: ik weet niet wie je bent, er is geen geldig token. 403: ik weet wie je bent, maar je mag dit niet. (slide 29)
- **422 Unprocessable Content**: De statuscode bij een validatiefout. De body heeft een message en een errors-object met per veld de foutmeldingen. (slide 31)
- **shouldRenderJsonWhen**: Bepaalt wanneer Laravel een fout als JSON rendert. In de skeleton van Laravel 13 staat het aan voor alles onder api/\*, of als de request JSON verwacht. (slide 13)
- **Paginatie**: Een lijst in stukken teruggeven, met links naar de volgende pagina en meta zoals total. In Laravel geef je een paginator aan ProjectResource::collection(). (slide 38)
- **Cursor paginatie**: Paginatie met een cursor in plaats van een paginanummer. De query start vanaf de laatste rij (where id > 15) in plaats van met een offset, dus snel en stabiel bij grote tabellen. (slide 39)
- **spatie/laravel-query-builder**: Een package dat filter, sort en include uit de querystring omzet naar een Eloquent-query. Alleen wat je expliciet toelaat, wordt uitgevoerd, de rest geeft een 400. (slide 42)
- **Breaking change**: Een wijziging waardoor een bestaande client stukgaat: een veld hernoemen of weghalen, een type veranderen, een parameter verplicht maken. (slide 46)
- **API versioning**: Een nieuwe versie van je API naast de oude laten draaien, meestal als /api/v1 en /api/v2, zodat apps die nog niet geüpdatet zijn blijven werken. (slide 47)
- **Rate limiting**: Een maximum aantal requests per tijdseenheid, per gebruiker of IP. Daarboven antwoordt Laravel met 429 Too Many Requests en een Retry-After-header. (slide 49)
- **CORS**: Cross-Origin Resource Sharing. De browser blokkeert JavaScript dat een API op een andere origin aanspreekt, tenzij de API met headers zegt dat het mag. Servers en Postman trekken zich er niets van aan. (slide 51)
- **Preflight**: Een OPTIONS-request die de browser vooraf stuurt om te vragen of een request van een andere origin mag. Pas als de API ja zegt, volgt de echte request. (slide 51)
- **OpenAPI**: Een standaardformaat (JSON of YAML) dat je API beschrijft: endpoints, parameters, responses. Scramble en Scribe genereren het, tools als Postman lezen het in. (slide 55)

## Links en bronnen

### In de presentatie

- [docs over API routes](https://laravel.com/docs/13.x/routing#api-routes) (slide 12)
- [docs over API resource routes](https://laravel.com/docs/13.x/controllers#api-resource-routes) (slide 15)
- [docs over conditional attributes](https://laravel.com/docs/13.x/eloquent-resources#conditional-attributes) (slide 21)
- [data wrapping](https://laravel.com/docs/13.x/eloquent-resources#data-wrapping) (slide 23)
- [JSON:API](https://jsonapi.org) (slide 26): De specificatie achter JsonApiResource
- [Docs](https://laravel.com/docs/13.x/eloquent-resources#jsonapi-resources) (slide 26)
- [HTTP response status codes](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status) (slide 28): MDN, elke statuscode met uitleg
- [docs over validatiefouten](https://laravel.com/docs/13.x/validation#validation-error-response-format) (slide 32)
- [docs over error handling](https://laravel.com/docs/13.x/errors#rendering-exceptions) (slide 35)
- [docs over paginatie](https://laravel.com/docs/13.x/pagination#cursor-pagination) (slide 39)
- [Laravel Query Builder v7](https://spatie.be/docs/laravel-query-builder/v7) (slide 40): allowedFilters, allowedSorts, allowedIncludes en de 400 bij een onbekende parameter
- [docs over filteren](https://spatie.be/docs/laravel-query-builder/v7/features/filtering) (slide 43)
- [docs over rate limiting](https://laravel.com/docs/13.x/routing#rate-limiting) (slide 50)
- [Cross-Origin Resource Sharing](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) (slide 52): MDN, wat de browser blokkeert en waarom
- [Scramble](https://scramble.dedoc.co) (slide 55): OpenAPI-docs uit je code, op /docs/api
- [Scribe](https://scribe.knuckles.wtf/laravel) (slide 55): Statische docs, Postman collection en OpenAPI
- [installatie van Scramble](https://scramble.dedoc.co/installation) (slide 56)

### Bronnen

- [Eloquent: API Resources](https://laravel.com/docs/13.x/eloquent-resources): JsonResource, whenLoaded, toResource en JSON:API resources in Laravel 13
- [Routing](https://laravel.com/docs/13.x/routing): install:api, rate limiting en CORS
- [Controllers](https://laravel.com/docs/13.x/controllers): apiResource, make:controller --api, shallow nesting en #\[Authorize\]
- [Middleware](https://laravel.com/docs/13.x/middleware): De api middleware group, zonder throttle
- [Error Handling](https://laravel.com/docs/13.x/errors): shouldRenderJsonWhen en render voor NotFoundHttpException
- [Validation](https://laravel.com/docs/13.x/validation): Het 422-formaat met message en errors
- [Pagination](https://laravel.com/docs/13.x/pagination): paginate, simplePaginate en cursorPaginate
- [bootstrap/app.php in laravel/laravel](https://github.com/laravel/laravel/blob/13.x/bootstrap/app.php): De skeleton rendert fouten onder api/\* standaard als JSON
