# Kirby CMS: verder

Controllers, page models, plugins, rollen, meertalige sites, Kirby als headless backend en deployen.

Multimedia & Creatieve Technologie · 3.1 The Agency · Zelfstudie · versie 1.0.0 · 2026-10-01

Tags: kirby, php, cms, api

Slides: <https://slides.mctantwerp.be/nl/the-agency/21-kirby-advanced>

<!-- slide 2 -->

## Agenda

1. Van url naar HTML
2. Controllers en models
3. Hergebruik
4. Plugins
5. Rollen
6. Meertalig
7. Headless
8. Deployen

> **Toelichting:** De eerste vier hoofdstukken gaan over je code: waar logica hoort, en hoe je ze één keer schrijft. Rollen en meertaligheid gaan over je klant. Headless en deployen gaan over wat er rond je site gebeurt: een andere app die de content leest, en de server waar alles op komt.

<!-- slide 3 -->

## Na deze les kan je

- Queries uit je templates halen met controllers, page models en collections
- Blueprints opsplitsen in herbruikbare stukken, en data ophalen met de query language
- Een plugin schrijven met een field method, een hook en een route
- Een rol maken voor je klant, en een site in twee talen opzetten
- Je content als JSON aanbieden aan een andere app, en een site veilig online zetten

> **Toelichting:** Elk punt komt terug in de opdracht op het einde. Lukt een punt niet, ga dan terug naar dat hoofdstuk voor je aan de opdracht begint.

<!-- slide 4 -->

## 1. Van url naar HTML

> **Toelichting:** Weet je welke bestanden Kirby in welke volgorde gebruikt, dan weet je ook waar je een stuk code moet zetten. En als iets niet werkt, weet je waar je moet zoeken.

<!-- slide 5 -->

### Een request

Van url naar HTML: Router (config.php): Een eigen route wint. Anders zoekt Kirby de map. Pagina (content/…): De map die bij de url hoort, met haar .txt. Model (site/models): Een eigen class voor die pagina, als die er is. Controller (site/controllers): Haalt data op en geeft ze door aan de template. Template (site/templates): Zet alles in HTML, met snippets.

Enkel de template is verplicht, de rest voeg je toe als je het nodig hebt. Model en controller zijn de eerste twee hoofdstukken van deze les, routes zitten bij de plugins.

> **Toelichting:** Routes worden altijd eerst gecontroleerd. Een route die hetzelfde pattern heeft als een pagina, wint dus. Dat is handig om een pagina te vervangen, en een bron van verwarring als je het vergeet. Het model wordt pas geladen als de pagina gevonden is, en geldt ook als je die pagina ergens anders ophaalt, bijvoorbeeld in een lijst op de homepage.

<!-- slide 6 -->

## 2. Controllers en models

> **Toelichting:** In de vorige les stond alles in de template, ook de queries. Voor een kleine site werkt dat. Zodra een pagina filtert, pagineert of dezelfde logica op drie plaatsen nodig heeft, wordt je template onleesbaar. Dan haal je die logica eruit.

<!-- slide 7 -->

### Logica uit je template

*site/controllers/projects.php*

```php
<?php

return function ($page) {
    $projects = $page->children()->listed()
        ->sortBy('date', 'desc');

    if ($tag = get('tag')) {
        $projects = $projects->filterBy('tags', $tag, ',');
    }

    return [
        'projects' => $projects->paginate(9),
    ];
};
```

1. Zelfde naam als de template. Kirby geeft `$page` door op naam, net als `$site`, `$kirby` en `$pages`.
2. De gepubliceerde projecten, nieuwste eerst.
3. Staat er `?tag=web` in de url, dan houden we enkel die projecten over.
4. Elke sleutel in de array wordt een variabele in de template.

> **Toelichting:** get('tag') leest ?tag= uit de url, zoals request()->query() in Laravel. paginate(9) geeft negen projecten per pagina, en leest de huidige pagina zelf uit de url (?page=2). Een controller voor een JSON-representatie heet project.json.php, daarover meer in het hoofdstuk Headless.

<!-- slide 8 -->

### In de template

*site/templates/projects.php*

```php
<?php foreach ($projects as $project): ?>
  <?php snippet('project-card', ['project' => $project]) ?>
<?php endforeach ?>

<?php snippet('pagination', ['pagination' => $projects->pagination()]) ?>
```

Geen query meer, enkel HTML en een foreach. `$projects` komt uit de controller. Wil je weten welke data een pagina gebruikt, dan kijk je in één bestand.

Meer in de docs: [Controllers](https://getkirby.com/docs/guide/templates/controllers)

> **Toelichting:** De pagination heeft methodes als hasPrevPage(), prevPageUrl(), nextPageUrl() en pages() voor een rij nummers. Schrijf die snippet één keer en gebruik hem overal. De Starterkit heeft er een die je kan kopiëren.

<!-- slide 9 -->

### De site controller

*site/controllers/site.php*

```php
<?php

return function ($site) {
    return [
        'menu' => $site->children()->listed(),
        'contact' => $site->find('contact'),
    ];
};
```

Nieuw in Kirby 5. De data uit `site.php` komt in elke template, zodat je `$menu` en `$contact` niet in elke controller opnieuw ophaalt. Goed voor wat elke pagina nodig heeft: het menu, de footer, de contactgegevens.

> **Toelichting:** Vóór Kirby 5 moest je dit in elke controller herhalen, of in een snippet zelf ophalen. Een controller per template vult de data van site.php aan. Geeft hij een sleutel met dezelfde naam terug, dan wint die van de template.

<!-- slide 10 -->

### Page models

*site/models/project.php*

```php
class ProjectPage extends Page
{
    public function coverImage(): ?File
    {
        return $this->cover()->toFile()
            ?? $this->images()->first();
    }

    public function isRecent(): bool
    {
        return $this->date()->toDate()
            > strtotime('-1 year');
    }
}
```

Een eigen class voor alle pagina's met de template project. Ze erft alles van Page, en krijgt er je eigen methodes bij.

`coverImage()` kiest het beeld uit het veld cover, en anders het eerste beeld in de map.

> **Toelichting:** Het type ?File betekent: een File of null. ?? geeft het rechterdeel als het linkerdeel null is. Kirby kent Page en File zonder use, omdat het aliassen voor die classes heeft. In een editor met autocomplete schrijf je toch beter use Kirby\Cms\Page bovenaan.

<!-- slide 11 -->

### Een model gebruiken

```php
<img src="<?= $project->coverImage()?->url() ?>" alt="">

$recent = $projects->filter(fn ($p) => $p->isRecent());
```

Elk project heeft nu `coverImage()` en `isRecent()`, op de homepage, in het overzicht en op de projectpagina zelf. `?->` stopt als er geen beeld is, in plaats van een fout te geven.

Meer in de docs: [Page models](https://getkirby.com/docs/guide/templates/page-models)

> **Toelichting:** Het model werkt overal waar Kirby een pagina met die template maakt: in een template, een snippet, een controller, een collection, een query in een blueprint. Dat is het grote verschil met een controller, die enkel bij één template hoort.

<!-- slide 12 -->

### Model of controller

- Gaat het over één pagina, overal waar ze opduikt? Dan hoort het in een **model**.
- Heeft één template data nodig om te renderen? Dan hoort het in een **controller**.
- De class van een model heet zoals de template, met `Page` erachter: `project` wordt `ProjectPage`.
- Een methode in je model wint van een veld met dezelfde naam. Een veld `cover` en een methode `cover()` botsen.

> **Toelichting:** Daarom heet de methode coverImage() en niet cover(). Met een methode cover() krijg je in je template plots een File waar je een Field verwachtte, en werkt $page->cover()->toFile() niet meer. Kies voor methodes in een model namen die geen veld kunnen zijn.

<!-- slide 13 -->

### Op de homepage, het overzicht en elke projectpagina toon je de cover van een project. Waar hoort de code die de cover kiest?

- A. In de controller van elke template
- B. In een page model ProjectPage
- C. In config.php
- D. In de blueprint project.yml

**Antwoord:** B. In een page model ProjectPage

Het gaat over één project, op drie plaatsen. Een model geeft elk project die methode, waar je het ook ophaalt. Met drie controllers schrijf je dezelfde code drie keer, en pas je ze later op één plek aan en op twee niet. config.php is voor instellingen, en een blueprint bepaalt het Panel, niet wat je template toont.

> **Toelichting:** Een controller die coverImage() gebruikt, is wel prima. De vuistregel: de controller beslist welke pagina's er getoond worden, het model weet hoe één pagina zich gedraagt.

<!-- slide 14 -->

## 3. Hergebruik

> **Toelichting:** Een klantsite groeit. Na een paar maanden staat dezelfde query op vijf plaatsen en heeft elke blueprint zijn eigen kopie van de SEO-velden. Dit hoofdstuk gaat over hoe je dat vermijdt, in je PHP en in je YAML.

<!-- slide 15 -->

### Collections

*site/collections/projects.php*

```php
<?php

return function ($site) {
    return $site->find('projects')
        ->children()
        ->listed()
        ->sortBy('date', 'desc');
};
```

*Overal*

```php
collection('projects')
  ->limit(3);

collection('projects')
  ->filterBy('featured', true);
```

Een benoemde query die je overal ophaalt met `collection()`. Verandert de regel, zoals enkel projecten met een cover tonen, dan pas je één bestand aan.

Meer in de docs: [Collections](https://getkirby.com/docs/guide/templates/collections)

> **Toelichting:** Een collection mag ook argumenten van Kirby krijgen, zoals $site, $pages en $kirby, net als een controller. Je kan ze verder ketenen, want collection() geeft een gewone Pages-collectie terug. In een blueprint gebruik je ze in een query: query: kirby.collection('projects').

<!-- slide 16 -->

### Blueprints hergebruiken

*site/blueprints/tabs/seo.yml*

```yaml
label: SEO
icon: search
fields:
  metaTitle:
    label: Titel in Google
    type: text
  metaDescription:
    label: Beschrijving
    type: textarea
    maxlength: 160
```

*project.yml*

```yaml
title: Project

tabs:
  content:
    label: Inhoud
    fields:
      text:
        type: blocks
  seo: tabs/seo
```

`seo: tabs/seo` laadt de tab uit een eigen bestand. Elke blueprint die de tab gebruikt, krijgt dezelfde velden.

> **Toelichting:** Hetzelfde werkt voor fields en sections: site/blueprints/fields/cover.yml en dan cover: fields/cover in je blueprint. Wil je één optie anders, gebruik dan extends: fields/cover en zet de optie die verschilt eronder, zoals een ander label.

<!-- slide 17 -->

### Queries in blueprints

```yaml
related:
  type: pages
  query: site.find('projects').children.listed

client:
  type: select
  options:
    type: query
    query: site.find('clients').children

projects:
  type: pages
  info: "{{ page.client }}, {{ page.date.toDate('Y') }}"
  image:
    query: page.coverImage
```

1. Een pages field dat enkel gepubliceerde projecten laat kiezen.
2. Een select met als opties de klanten, die zelf pagina's zijn. Een nieuwe klant staat er meteen bij.
3. Een pages section met de klant en het jaar onder elk project, en de cover uit het model als thumbnail.

Meer in de docs: [Query language](https://getkirby.com/docs/guide/blueprints/query-language)

> **Toelichting:** De query language werkt ook in een info-tekst of een help-tekst, tussen dubbele accolades. Krijg je een lege lijst, test de query dan eerst in PHP in een template: site()->find('projects')->children()->listed(). Werkt hij daar, dan zit de fout in de schrijfwijze.

<!-- slide 18 -->

## 4. Plugins

> **Toelichting:** Een plugin klinkt als iets om te publiceren. In Kirby is het ook gewoon de plaats waar je eigen uitbreidingen voor één site staan: een field method, een hook, een route. Elke serieuze Kirby-site heeft er minstens één.

<!-- slide 19 -->

### Een plugin

*site/plugins/agency/index.php*

```php
<?php

Kirby::plugin('agency/helpers', [
    'fieldMethods' => [ /* ... */ ],
    'hooks' => [ /* ... */ ],
    'routes' => [ /* ... */ ],
]);
```

Een map in `site/plugins` met een `index.php`. Kirby laadt ze vanzelf. In de array registreer je wat de plugin toevoegt: hier field methods, hooks en routes, maar ook snippets, blueprints, blocks en uitbreidingen van het Panel.

Meer in de docs: [Plugin basics](https://getkirby.com/docs/guide/plugins/plugin-basics)

> **Toelichting:** De naam is vendor/naam, zoals bij Composer. Eén plugin per project met je eigen helpers is prima. Iets dat je in meerdere projecten gebruikt, maak je een aparte plugin met een eigen repo, die je met Composer installeert.

<!-- slide 20 -->

### Een eigen field method

*site/plugins/agency/index.php*

```php
'fieldMethods' => [
    'readingTime' => function ($field) {
        $text = strip_tags($field->kt());
        $words = str_word_count($text);

        return max(1, (int) ceil($words / 200));
    },
],
```

*project.php*

```php
<p>
  <?= $page->text()->readingTime() ?>
  min lezen
</p>
```

Het eerste argument is altijd het veld zelf. Daarna werkt de methode op elk veld, net als `kt()` of `esc()`. Hier: tweehonderd woorden per minuut, en minstens één minuut.

> **Toelichting:** Je kan ook een veld teruggeven in plaats van een waarde: return $field->value(...). Dan kan je verder ketenen, zoals bij esc(). Field methods die je overal gebruikt bij een klant, zoals een prijs formatteren of een telefoonnummer als link, horen hier.

<!-- slide 21 -->

### Hooks

*site/plugins/agency/index.php*

```php
'page.changeStatus:after' => function ($newPage, $oldPage) {
    if ($oldPage->isDraft() && $newPage->isListed()) {
        kirby()->email([
            'from' => 'site@agency.test',
            'to' => 'team@agency.test',
            'subject' => $newPage->title() . ' staat online',
            'body' => $newPage->url(),
        ]);
    }
},
```

1. Draait nadat een pagina van status veranderde.
2. Enkel als een draft gepubliceerd wordt.
3. Een mail naar het team, met de link.

> **Toelichting:** Kirby geeft de argumenten op naam door. Noem je ze $new en $old, dan krijg je niets. Welke hooks er zijn en hoe hun argumenten heten, staat in de reference. Hou hooks kort: ze draaien terwijl de redacteur op Save wacht. Iets dat lang duurt, zoals een export of een grote mail, start je beter vanuit een cronjob.

<!-- slide 22 -->

### Routes

*site/plugins/agency/index.php*

```php
[
    'pattern' => 'work/(:any)',
    'action' => fn (string $slug) => go('projects/' . $slug, 301),
],
[
    'pattern' => 'api/projects',
    'action' => fn () => collection('projects')->toArray(fn ($p) => [
        'title' => $p->title()->value(),
        'url' => $p->url(),
    ]),
],
```

1. Oude urls na een herstructurering. `(:any)` vangt één stuk van de url, en komt binnen als argument.
2. Een eigen JSON-endpoint met de projecten. Een array wordt vanzelf JSON.

> **Toelichting:** go() stuurt door, met 301 voor een blijvende verhuis zodat Google de nieuwe url overneemt. Een array als antwoord wordt JSON. Een route kan ook een pagina teruggeven, of een Response met een eigen content type. Routes in config.php werken net zo, een plugin houdt ze bij de rest van je code.

<!-- slide 23 -->

### Plugins van anderen

```console
$ composer require getkirby/kql
```

- Op [plugins.getkirby.com](https://plugins.getkirby.com) staan honderden plugins, voor SEO, formulieren, zoeken en het Panel.
- Installeer ze met Composer. Ze landen vanzelf in site/plugins, en een update gaat met composer update.
- Controleer of een plugin Kirby 5 ondersteunt en nog onderhouden wordt, voor je hem in een klantproject zet.

> **Toelichting:** Sommige plugins zijn betalend, net als Kirby zelf. Reken ze dan mee in de offerte. Een plugin die al twee jaar geen release had, blokkeert je volgende update van Kirby: kijk op GitHub of er recente commits en antwoorden op issues zijn.

<!-- slide 24 -->

## 5. Rollen

> **Toelichting:** Bij de oplevering krijgt je klant een account. Met welke rechten, beslis jij. Een klant die per ongeluk de homepage verwijdert of je eigen account wist, is geen grap, en het gebeurt vaker dan je denkt.

<!-- slide 25 -->

### Een rol voor je klant

*site/blueprints/users/editor.yml*

```yaml
title: Redacteur

permissions:
  access:
    system: false
    users: false
  pages:
    changeTemplate: false
    delete: false
  user:
    changeRole: false
```

- Een rol is een blueprint in site/blueprints/users. Wat je niet vermeldt, mag.
- Deze redacteur kan pagina's bewerken, maar niet verwijderen, en ziet System en Users niet.
- Jullie team blijft `admin`. Een admin kan elke andere user verwijderen, ook die van het bureau.

Meer in de docs: [Permissions](https://getkirby.com/docs/guide/users/permissions)

> **Toelichting:** Bij het aanmaken van een user in het Panel kies je de rol. Zijn er naast admin geen rollen, dan krijgt iedereen admin. Een rol kan ook beperkt worden tot bepaalde pagina's of templates, met options in de blueprint van een pagina. Dat zie je in de docs bij Permissions.

<!-- slide 26 -->

## 6. Meertalig

> **Toelichting:** In België vraagt bijna elke klant vroeg of laat een tweede taal. Kirby doet dat zonder plugin, maar je beslist het best bij de start van een project.

<!-- slide 27 -->

### Talen aanzetten

- Zet `'languages' => true` in config.php. Daarna maak je de talen in het Panel, en Kirby bewaart ze in `site/languages`.
- Elke pagina krijgt een tekstbestand per taal: `project.nl.txt` en `project.en.txt`.
- Met `translate: false` blijft een veld in elke taal hetzelfde, zoals een datum of een coverbeeld.
- Beslis het bij de start. Achteraf moet elk tekstbestand een taalcode krijgen, en dat is een migratie van al je content.

> **Toelichting:** Zegt de klant misschien later Engels, zet het dan meteen aan met één taal. Dat kost niets, en een tweede taal toevoegen is dan een klik. Een url met taal ziet er standaard uit als /en/projects, de standaardtaal kan zonder prefix met url: / in het taalbestand.

<!-- slide 28 -->

### Een taalkeuze

*site/snippets/languages.php*

```php
<?php foreach ($kirby->languages() as $lang): ?>
  <a href="<?= $page->url($lang->code()) ?>" hreflang="<?= $lang->code() ?>">
    <?= $lang->code() ?>
  </a>
<?php endforeach ?>
```

`$kirby->languages()` geeft alle talen. `url()` met een taalcode geeft de url van dezelfde pagina in die taal, dus je blijft op de pagina waar je was.

Meer in de docs: [Languages](https://getkirby.com/docs/guide/languages)

> **Toelichting:** hreflang vertelt Google welke versie van een pagina in welke taal is, zodat een Engelse bezoeker de Engelse pagina in de zoekresultaten krijgt. Bestaat een pagina niet in een taal, dan toont Kirby de inhoud van de standaardtaal.

<!-- slide 29 -->

### Vaste teksten

*site/languages/nl.php*

```php
return [
    'code' => 'nl',
    'default' => true,
    'name' => 'Nederlands',
    'url' => '/',
    'translations' => [
        'read-more' => 'Lees meer',
    ],
];
```

*project-card.php*

```php
<a href="<?= $project->url() ?>">
  <?= t('read-more') ?>
</a>
```

Een knop of een label in je template komt niet uit de content. Zet die teksten in de translations van elke taal, en haal ze op met `t()`.

> **Toelichting:** Translations kan je ook in het Panel aanpassen, bij de taal zelf. Zo kan je klant de tekst van een knop veranderen zonder jou. Geef je keys een naam die zegt waar ze staan, zoals project.back of footer.newsletter, anders vind je ze na een tijd niet meer terug.

<!-- slide 30 -->

## 7. Headless

> **Toelichting:** In The Agency werken 3D en Web samen. Een Unity-app op een beurs of een configurator in Three.js heeft ook teksten en beelden nodig, en die wil de klant zelf aanpassen. Dan wordt Kirby de backend, en leest de app de content als JSON.

<!-- slide 31 -->

**Kirby zonder templates.**

De klant werkt in het Panel, en de content gaat als JSON naar een Nuxt-site, een app of een Unity-installatie op een beurs.

> **Toelichting:** Headless betekent: een CMS zonder eigen frontend. Kirby kan beide tegelijk. Je site draait gewoon, en dezelfde content gaat ook naar een andere app. Zo hoeft de klant niet in twee systemen te werken.

<!-- slide 32 -->

### JSON per pagina

*site/templates/project.json.php*

```php
<?php

echo json_encode([
    'title' => $page->title()->value(),
    'client' => $page->client()->value(),
    'cover' => $page->coverImage()?->url(),
    'text' => $page->text()->toBlocks()->toHtml(),
]);
```

*/projects/herfstcampagne.json*

```json
{
  "title": "Herfstcampagne",
  "client": "Stad Antwerpen",
  "cover": "https://…/cover.jpg",
  "text": "<h2>De opdracht</h2>…"
}
```

Een tweede template voor dezelfde pagina, met `.json` in de naam. De url krijgt `.json` erachter, en Kirby stuurt de juiste content type mee.

Meer in de docs: [Content representations](https://getkirby.com/docs/guide/templates/content-representations)

> **Toelichting:** json_encode zet slashes in urls om naar \/. Wil je dat niet, geef dan JSON_UNESCAPED_SLASHES mee als tweede argument. Een eigen controller voor de representatie heet project.json.php, in site/controllers. Zonder valt Kirby terug op de gewone controller.

<!-- slide 33 -->

### KQL

*POST /api/query*

```json
{
  "query": "page('projects').children.listed",
  "select": {
    "title": true,
    "client": true,
    "images": {
      "query": "page.images",
      "select": { "url": true }
    }
  },
  "pagination": { "limit": 10 }
}
```

- KQL is een plugin: `getkirby/kql`.
- De frontend kiest zelf welke velden hij krijgt, in één request.
- Standaard werkt de endpoint enkel met een login.
- `'kql' => ['auth' => false]` maakt alles publiek wat een query kan bereiken.

Meer in de docs: [getkirby/kql](https://github.com/getkirby/kql)

> **Toelichting:** KQL lijkt op GraphQL: de frontend stuurt mee welke velden hij wil, en krijgt niets meer. Handig als 3D en Web elk iets anders nodig hebben uit dezelfde content. De auth staat standaard aan. Een frontend die in de browser draait, zou dan een wachtwoord moeten meesturen, en dat is dan zichtbaar voor iedereen.

<!-- slide 34 -->

### De Unity-app van 3D heeft de titel en de cover van elk project nodig. Wat is de kleinste oplossing?

- A. Een aparte Laravel API
- B. Een route die een array teruggeeft, of projects.json.php
- C. KQL met auth uit
- D. De .txt-bestanden mee in de build van de app

**Antwoord:** B. Een route die een array teruggeeft, of projects.json.php

Een route of een representation geeft precies die velden terug, publiek, zonder extra plugin. Een Laravel API is een tweede project om te onderhouden. KQL met auth uit maakt veel meer publiek dan twee velden, en loont pas als de frontend zelf wil kiezen. Met de .txt-bestanden in de build moet 3D een nieuwe versie maken voor elke tekstwijziging.

> **Toelichting:** In Unity haal je de JSON op met UnityWebRequest, zoals in de les over single sign-on. Spreek met 3D af welke velden ze nodig hebben, en geef ze een voorbeeld van de JSON voor je begint, dan kunnen ze al bouwen terwijl jij de route schrijft.

<!-- slide 35 -->

## 8. Deployen

> **Toelichting:** Een Kirby-site online zetten is een map kopiëren. Daarom gaat het ook makkelijk mis: één verkeerde kopie, en de wijzigingen van je klant zijn weg. Dit hoofdstuk gaat over welke map van wie is.

<!-- slide 36 -->

### Wat in git hoort

| In git | Niet in git |
| --- | --- |
| `site/` | `media/` |
| `assets/` | `site/accounts/` |
| `composer.json` | `site/sessions/ en site/cache/` |
| `content/`, tot de livegang | `site/config/.license` |

`accounts` en `sessions` bevatten wachtwoordhashes en sessies, de licentie hoort bij één domein. `media` maakt Kirby zelf opnieuw.

> **Toelichting:** De .gitignore van de Plainkit zet dit al goed, kijk ze eens na. kirby zelf staat niet in die lijst, maar als je met Composer werkt, installeer je het op de server met composer install en hoeft het niet in je repo.

<!-- slide 37 -->

### Wie is de baas over de content

- **Voor de livegang**: Jullie vullen de content. Ze staat in git en gaat mee bij elke deploy.
- **Na de livegang**: De klant werkt in het Panel. De server is de bron, je deploy laat `content/` met rust.

```console
$ rsync -avz klant@server:www/content/ content/
```

Wil je de echte content lokaal, haal ze dan van de server naar je laptop. Nooit omgekeerd.

> **Toelichting:** Het klassieke ongeluk: de klant werkt een week in het Panel, jij deployt met de content uit je repo, en zijn wijzigingen zijn weg. Zet daarom content/ uit je deploy zodra de site live staat. Een backup van content/ op de server is dan ook geen luxe: de meeste hosts kunnen dat elke nacht.

<!-- slide 38 -->

### Config per omgeving

*site/config/config.php*

```php
<?php

return [
    'debug' => false,
    'cache' => [
        'pages' => ['active' => true],
    ],
];
```

*site/config/config.localhost.php*

```php
<?php

return [
    'debug' => true,
    'cache' => [
        'pages' => ['active' => false],
    ],
];
```

Kirby laadt eerst `config.php`, en daarna het bestand met de host in de naam. Dat laatste wint. Zo staat debug lokaal aan en kan je het online niet vergeten.

> **Toelichting:** Kirby kijkt naar de host van de request, dus voor een staging-site maak je config.staging.klant.be.php. Zet in die bestanden enkel wat verschilt, de rest staat in config.php. Wachtwoorden en API-keys horen niet in git: lees ze uit een .env, of zet ze in een bestand dat in de .gitignore staat.

<!-- slide 39 -->

### Voor de livegang

- `debug` staat uit en de page cache aan.
- De licentie is geregistreerd in het Panel, op naam van de klant.
- De webserver blokkeert `content`, `site` en `kirby`. Apache doet dat met de .htaccess van Kirby, nginx niet vanzelf.
- De klant heeft een account met een eigen rol, en weet hoe een wachtwoord resetten werkt.
- Er is elke nacht een backup van content/ op de server.

> **Toelichting:** Test het derde punt echt: surf op de live site naar /content/home/home.txt. Zie je de tekst, dan staat de server verkeerd en kan iedereen je content en accounts lezen. Bij nginx zet je de regels uit de Kirby docs zelf in je config, zoek in de docs op nginx.

<!-- slide 40 -->

### Opdracht: Je portfolio, verder

Bouw verder op de site uit de opdracht van Kirby CMS.

1. Een controller voor projects met filteren op tag en paginatie
2. Een model ProjectPage met coverImage(), en een collection projects die de homepage gebruikt
3. Een plugin met readingTime() en een hook die iets doet bij publiceren
4. Een rol voor een redacteur die geen pagina's kan verwijderen, en een account met die rol
5. Klaar als /projects.json de titel en de cover van elk project geeft, en de redacteur niets kan wissen

> **Toelichting:** Begin bij de collection en het model: de controller en de JSON gebruiken ze allebei. Vast bij de hook? Kijk of je argumenten exact $newPage en $oldPage heten, en zet debug aan. Vast bij de rol? Log in als die user in een privévenster, dan zie je meteen wat hij kan.

## Begrippen

- **Controller**: Een bestand in site/controllers met dezelfde naam als de template. Het geeft een array terug, en elke sleutel is een variabele in de template. (slide 7)
- **Site controller**: site/controllers/site.php, nieuw in Kirby 5. Zijn data komt in elke template, en een controller per template vult ze aan of overschrijft ze. (slide 9)
- **Page model**: Een class in site/models die Page uitbreidt, voor alle pagina's met een bepaalde template. Goed voor methodes als cover() of isRecent() die je overal gebruikt. (slide 10)
- **Collection**: Een benoemde query in site/collections, die je overal ophaalt met collection('naam'). Zo staat de definitie van "de projecten" op één plek. (slide 15)
- **Kirby Query Language**: De korte syntax waarmee een blueprint data ophaalt, zoals site.find('projects').children.listed. Ook {{ page.client }} in een info-tekst is query language. (slide 17)
- **Plugin**: Een map in site/plugins met een index.php die Kirby::plugin() aanroept. Daarin registreer je field methods, hooks, routes, blueprints en meer. (slide 19)
- **Field method**: Een methode op een veld, zoals kt() of toDate(). Met een plugin voeg je er zelf toe, zoals readingTime(). (slide 20)
- **Hook**: Code die Kirby uitvoert bij een gebeurtenis, zoals page.changeStatus:after. De argumenten worden op naam doorgegeven, dus $newPage en $oldPage moeten exact zo heten. (slide 21)
- **Route**: Een eigen url met een pattern en een action, in config.php of in een plugin. Een array als antwoord wordt JSON, go() stuurt door. (slide 22)
- **Rol**: Een blueprint in site/blueprints/users met permissions. Bepaalt wat een user in het Panel mag. De klant krijgt een eigen rol, admin blijft voor het team. (slide 25)
- **Content representation**: Een tweede template voor dezelfde pagina, met een extensie in de naam: project.json.php. De url krijgt .json erachter en Kirby zet de juiste content type. (slide 32)
- **KQL**: Een plugin die de Kirby Query Language als API aanbiedt op /api/query. Een frontend vraagt in één request precies de velden die hij nodig heeft. (slide 33)
- **Headless CMS**: Een CMS dat alleen de content levert, als JSON, en de weergave aan een andere app laat: een Nuxt-site, een Unity-game, een app. (slide 30)

## Links en bronnen

### In de presentatie

- [Controllers](https://getkirby.com/docs/guide/templates/controllers) (slide 8): Ook de site controller van Kirby 5
- [Page models](https://getkirby.com/docs/guide/templates/page-models) (slide 11)
- [Collections](https://getkirby.com/docs/guide/templates/collections) (slide 15)
- [Kirby Query Language](https://getkirby.com/docs/guide/blueprints/query-language) (slide 17)
- [Plugin basics](https://getkirby.com/docs/guide/plugins/plugin-basics) (slide 19)
- [plugins.getkirby.com](https://plugins.getkirby.com) (slide 23)
- [Permissions](https://getkirby.com/docs/guide/users/permissions) (slide 25)
- [Languages](https://getkirby.com/docs/guide/languages) (slide 28)
- [Content representations](https://getkirby.com/docs/guide/templates/content-representations) (slide 32)
- [Kirby KQL](https://github.com/getkirby/kql) (slide 33): De endpoint /api/query en auth

### Bronnen

- [Reusing blueprint parts](https://getkirby.com/docs/guide/blueprints/extending-blueprints)
- [Hooks](https://getkirby.com/docs/reference/plugins/hooks): Namen van de argumenten per hook
- [Routing](https://getkirby.com/docs/guide/routing)
- [Multi-environment setup](https://getkirby.com/docs/guide/configuration#multi-environment-setup)
- [Plainkit .gitignore](https://github.com/getkirby/plainkit/blob/main/.gitignore)
