API Reference

Auto-gegenereerd via Reflection — blijft altijd in sync met src/.

Filter: alleAppCacheCmsDbDebugDynamicEventsFilesFormHtmlHttpImageLogMediaSecuritySessionStdlibSupportView
Inhoud: Container · ContainerException · EnvLoader · Kernel · Layout · NotFoundException · Route · RouteResult · Router · ArrayCache · FileCache · InvalidCacheKey · Domain · Website · Adapter · Operator · ProfilingAdapter · Query · Sql · TableRepository · Debug · ErrorHandler · DynamicCacheBuilder · DynamicCacheWarmer · DynamicItem · DynamicQuery · DynamicQueryEav · DynamicQueryInterface · DynamicStructure · DynamicType · DynamicTypeField · DynamicWriter · EavStrategy · AfterDeleteEvent · AfterInsertEvent · AfterUpdateEvent · BeforeDeleteEvent · BeforeInsertEvent · BeforeUpdateEvent · EventDispatcher · ListenerProvider · AltTagger · GroqAltTagger · FileQuery · FileRecord · FileRepository · FileStorage · FileType · Files · FilesConfig · FolderRecord · FolderRepository · JsonFileRepository · JsonFolderRepository · JsonStore · LocalFileStorage · CompositeRule · ConditionalRule · RuleEvaluator · RuleOperator · FormSchemaException · AbstractField · AddressField · CheckboxField · CompositeFieldInterface · Countries · CreditCardField · DateField · DateRangeField · EmailField · MultiCheckboxField · PersonalNameField · PhoneNumberField · RadioField · ReCaptchaField · SelectField · TextField · TextareaField · ZipcodeField · Form · FormElementInterface · FormFactory · FormRenderer · FormResult · FieldRow · ConditionalValidator · EmailValidator · MaxLengthValidator · MinLengthValidator · ReCaptchaValidator · RegexValidator · RequiredValidator · ValidatorInterface · A · ClassList · El · ElCollection · Img · NodeInterface · StyleDeclaration · CachingTransport · Client · CurlTransport · Headers · HttpException · Method · Request · Response · ServerRequest · TransportInterface · Url · GdBackend · ImageBackendException · ImageBackendInterface · ImagickBackend · VariantCache · FitMode · Format · HmacSigner · ImageHandler · Image · FileStorageSource · RemoteFetchException · RemoteUrlSource · SourceNotFoundException · SourceResolverInterface · SourceResult · SpecCodec · GifFrameExtractor · PassthroughExtractor · StillFrameException · StillFrameExtractor · StillFrameRegistry · VideoFrameExtractor · TransformBounds · TransformSpec · Transformer · InvalidUrlException · ParsedUrl · UrlBuilder · UrlParser · FileLogger · AbstractSource · Breakpoints · Decoder · DisplayMode · Media · PosterMode · RenderOptions · Renderer · Image · Video · Vimeo · YouTube · Url · Variant · Csrf · Encryption · ReCaptchaMode · ReCaptchaResult · ReCaptchaVerifier · ArraySessionStore · FileSessionStore · Flash · PdoSessionStore · Session · SessionConfig · SessionRecord · SessionStoreInterface · Map · ParamType · Parameters · Address · Color · Currency · DateRange · EmailAddress · Format · Iban · Inflect · Money · PhoneNumber · TimeRange · TemplateNotFoundException · View · ViewException

Container

Framework\App\Container
final class

Minimale DI-container, PSR-11 compatible.

- `bind()` voor transient services (factory wordt elke get() opnieuw aangeroepen)
- `singleton()` voor services die maar één keer geconstrueerd worden
- `instance()` voor reeds gemaakte objecten

Geen auto-wiring: explicit is better than implicit voor een klein framework.

Gooit:
- {@see NotFoundException} (PSR-11 NotFoundExceptionInterface) als id onbekend is
- {@see ContainerException} (PSR-11 ContainerExceptionInterface) als factory faalt

6 public methods
bind(string $id, Closure|string $factory): void
get(string $id): ?mixed
has(string $id): bool
instance(string $id, ?mixed $instance): void
make(string $id): object

Typed resolver — return-type matcht de meegegeven class-string.
Gooit ContainerException als de service van het verkeerde type is.

singleton(string $id, Closure|string $factory): void

ContainerException

Framework\App\ContainerException
class

Generieke container-fout (factory crash, dubbele binding, etc.).
Voor "service niet gevonden" → {@see NotFoundException}.

__construct(string $message = '', int $code = 0, ?Throwable $previous = NULL)

EnvLoader

Framework\App\EnvLoader
final class

Minimale .env-parser — geen Composer-dependency nodig.

Ondersteund formaat:
DB_HOST=127.0.0.1
DB_NAME="mijn_db" # aanhalingstekens worden gestript
DB_PASS='geheim'
# dit is een commentaarregel
LEGE_WAARDE=

Laadt waarden in $_ENV en $_SERVER, en via putenv().
Bestaande waarden (bijv. gezet door de webserver/Docker) worden
NIET overschreven — de omgeving wint altijd.

Gebruik:
EnvLoader::load('/pad/naar/.env');
$host = EnvLoader::get('DB_HOST', '127.0.0.1');

3 public methods
static get(string $key, ?string $default = NULL): ?string

Lees een omgevingsvariabele — zoekt in $_ENV, $_SERVER en getenv().
Geeft $default terug als de variabele niet bestaat.

static isLoaded(): bool

Geeft true als EnvLoader::load() al is aangeroepen.

static load(string $path): void

Laad een .env-bestand. Idempotent: tweede aanroep met hetzelfde
bestand heeft geen effect.

Kernel

Framework\App\Kernel
final class

Kernel — framework-kernel, CMS-vrij.

Eén instance per request. Kern-services hangen als readonly virtual properties
(PHP 8.4 property hooks) zodat ze kort opvraagbaar zijn maar nog steeds via
de container resolven — testen kan via Kernel::swap() of $kernel->container.

$kernel->router->get('/path', $handler);
$kernel->router->post('/path', $handler);
$kernel->layout->wrap(...)
$kernel->get(SomeService::class)

8 public methods
bind(string $id, Closure|string $factory, bool $singleton = true): void
static boot(string $appRoot, bool $debug = false, bool $registerErrorHandler = true): self

Boot een Kernel met de standaard kern-services geregistreerd.
CMS-bootstrap (Website, etc.) gebeurt daarna door de aanroeper.

static current(): self
get(string $id): ?mixed
handle(\ServerRequest $request): \Response

Verwerk het verzoek en geef de gerenderde {@see Response} terug. Pure
functie (geen `header()`/`echo`-side-effects) — testbaar via PHPUnit.

Trailing-slash redirects, dispatch, 404 en 405 worden hier afgehandeld.

instance(string $id, ?mixed $value): void
run(): void

Verwerk het huidige HTTP-verzoek en stuur de response (status, headers,
body) naar de client. Wrappert {@see handle()} met de IO-laag.

static swap(?self $kernel): void

Swap voor tests. Geef null om te resetten.

Layout

Framework\App\Layout
final class

Paginawrapper met sidebar-layout.

Sidebar-data staat in `config/sidebar.php` zodat de structuur op één plek
beheerd wordt en de live-search dezelfde lijst kan gebruiken.

__construct(string $appRoot)
2 public methods
notFound(string $requestedPath = ''): string

Render een 404-pagina, gewikkeld in de standaard layout.

wrap(string $title, string $content): string

Wikkel content in de volledige HTML-pagina.

NotFoundException

Framework\App\NotFoundException
final class

Gegooid door {@see Container::get()} als de gevraagde id niet geregistreerd is.

__construct(string $message = '', int $code = 0, ?Throwable $previous = NULL)

Route

Framework\App\Route
final class

Eén geregistreerde route. Bevat:
- methods : welke HTTP-methods matchen (lege list = alle)
- pattern : regex-pattern voor `$request->url->path`
- action : callable(ServerRequest): string|\Stringable
- middleware: per-route stack (na de globale Router-middleware)

`$route->middleware()` is chainable — lift-en-shift maakt 'm mutable maar
de constructor-properties blijven readonly.

__construct(array $methods, string $pattern, ?mixed $action)
3 public methods
getMiddleware(): array
matchesMethod(string $method): bool

Is `$method` toegestaan voor deze route? Lege methods-list = altijd ja.

middleware(callable ...$middleware = ?): self

Voeg één of meer middleware-callables toe. Een middleware krijgt
`(ServerRequest $req, callable $next)` en moet `$next($req)` aanroepen
(of een eigen response teruggeven om de chain te short-circuiten).

RouteResult

Framework\App\RouteResult
final class

Resultaat van `Router::dispatch()`.

Drie outcomes:
- **Matched** → URL én HTTP-method matchen; `response` draagt status,
headers en body
- **Method not allowed** → URL matched, method niet; `allowedMethods` lijst
gaat in de `Allow:`-header van een 405-response
- **Not found** → geen URL match; consumer rendert een 404

Pure value-object; geen state, alleen tagging.

6 public methods
isMatched(): bool
isMethodNotAllowed(): bool
isNotFound(): bool
static matched(\Response $response): self
static methodNotAllowed(array $allowed): self
static notFound(): self

Router

Framework\App\Router
final class

Router — regex-based, method-aware, met middleware-pipe.

Method-shortcuts:

$router->get ('/users', $handler);
$router->post ('/users', $handler);
$router->put ('/users/(\d+)',$handler);
$router->patch ('/users/(\d+)',$handler);
$router->delete('/users/(\d+)',$handler);
$router->any ('/healthz', $handler); // alle methods
$router->match (['GET','POST'],'/contact', $handler); // custom set

Capture-groups in het pattern komen door als positionele route-params:

$router->get('/articles/([a-z0-9-]+)',
fn(ServerRequest $req) => 'Slug: ' . $req->param(0));

Middleware (PSR-15-stijl, maar simpler):

$router->use(fn($req, $next) => $next($req)); // globaal
$router->get('/admin', $h)->middleware($authMw); // per-route

Een middleware krijgt `(ServerRequest, callable $next)` en MOET `$next($req)`
aanroepen om door te gaan, of een eigen response (`Response`/string/\Stringable)
returnen om te short-circuiten (bv. een redirect of 401).

`dispatch()` returnt een {@see RouteResult} met drie outcomes: matched,
methodNotAllowed (405), notFound (404).

9 public methods
any(string $pattern, callable $action): \Route

Match alle HTTP-methods op dit pattern.

delete(string $pattern, callable $action): \Route
dispatch(\ServerRequest $request): \RouteResult
get(string $pattern, callable $action): \Route
match(array $methods, string $pattern, callable $action): \Route

Match een expliciete set methods.

patch(string $pattern, callable $action): \Route
post(string $pattern, callable $action): \Route
put(string $pattern, callable $action): \Route
use(callable $middleware): self

Registreer globale middleware (loopt vóór de per-route middleware).

ArrayCache

Framework\Cache\ArrayCache
final class

In-memory PSR-16 cache. Bedoeld voor tests en korte-levensduur scoping
binnen een enkel request.

8 public methods
clear(): bool
delete(string $key): bool
deleteMultiple(iterable $keys): bool
get(string $key, ?mixed $default = NULL): ?mixed
getMultiple(iterable $keys, ?mixed $default = NULL): iterable
has(string $key): bool
set(string $key, ?mixed $value, DateInterval|int|null $ttl = NULL): bool
setMultiple(iterable $values, DateInterval|int|null $ttl = NULL): bool

FileCache

Framework\Cache\FileCache
final class

File-based PSR-16 cache. Eén bestand per item, met TTL als header.

Layout:
<directory>/<sha1(key)>.cache → "<expiresAt>\n<serialized>"

expiresAt = unix timestamp; 0 = nooit verlopen.

__construct(string $directory)
8 public methods
clear(): bool
delete(string $key): bool
deleteMultiple(iterable $keys): bool
get(string $key, ?mixed $default = NULL): ?mixed
getMultiple(iterable $keys, ?mixed $default = NULL): iterable
has(string $key): bool
set(string $key, ?mixed $value, DateInterval|int|null $ttl = NULL): bool
setMultiple(iterable $values, DateInterval|int|null $ttl = NULL): bool

InvalidCacheKey

Framework\Cache\InvalidCacheKey
final class
__construct(string $message = '', int $code = 0, ?Throwable $previous = NULL)

Domain

Framework\Cms\Domain
final class

CMS-domein-DTO. Eén entry uit `config/settings.php`'s `sites[0]` lijst,
maar dan typed.

$d = new Domain(id: 1, host: 'multiminded.nl', locale: 'nl', root: '/');
$d->matches('multiminded.nl'); // true
$d->matches('www.multiminded.nl'); // true mits in aliases

__construct(int $id, string $host, string $locale = 'nl', string $root = '/', array $aliases = array ( ))
3 public methods
equals(self $other): bool
static fromSettings(object $entry): self

Hydrateer uit een settings.php-entry (object met domain/id/language_iso/...).

matches(string $host): bool

Website

Framework\Cms\Website
final class

Website — CMS-context voor het huidige request.

Leest config/settings.php en bepaalt op basis van de host welk domein
(en daarmee welke locale/root) actief is. Bestaat alleen in CMS-context;
een CLI-worker zonder site bind 'm gewoon niet.

$site = Website::current();
$site->siteId; $site->domain->language_iso; $site->isLive;

3 public methods
static current(): self
static loadFrom(string $appRoot, ?string $host = NULL): self

Laad settings.php en bepaal het huidige domein op basis van de host.

setting(string $key, ?mixed $default = NULL): ?mixed

Lees een waarde uit settings (top-level keys).

Adapter

Framework\Db\Adapter
class

Dunne wrapper rond een PDO-connectie.

Doel: één plek voor de PDO-instance (kernel-service `$kernel->db`),
een transactional helper, en een `into:`-parameter zodat queries direct
een specifieke klasse of callable als rij-prototype kunnen krijgen
(zoals legacy `Db\Adapter\PDO::query(..., ['prototype' => ...])`).

`query()` returnt een `PDOStatement` zodat alle native PDO-features
(`fetchAll`, `fetch`, foreach-iteratie, rowCount, errorInfo, …) gewoon
werken zonder extra abstractie-laag.

$db = new Adapter($pdo);

// stdClass per rij (PDO default na FETCH_OBJ in Bootstrap)
$rows = $db->query('SELECT * FROM users WHERE active = ?', [1])->fetchAll();

// Hydrate naar een klasse — PDO::FETCH_CLASS
$rows = $db->query($sql, $params, into: User::class)->fetchAll();

// Custom builder — PDO::FETCH_FUNC, perfect voor readonly objects
$rows = $db->query($sql, $params,
into: fn($id, $name) => new User(id: $id, name: $name)
)->fetchAll();

// Transactional block
$id = $db->transactional(fn() => $repo->insert([...]));

Voor query-timing/logging: wrap in `ProfilingAdapter` (zelfde interface).

__construct(PDO $pdo)
4 public methods
lastInsertId(): int
pdo(): PDO

Escape-hatch voor code die direct PDO nodig heeft.

query(string $sql, array $params = array ( ), callable|string|null $into = NULL): PDOStatement

Voer een query uit met optionele bind-params en optioneel rij-prototype.

transactional(callable $fn): ?mixed

Voer `$fn` uit binnen een transactie. Bij exception: rollback en re-throw.
Genest aanroepen wordt als no-op behandeld (de outer transactie wint).

Operator

Framework\Db\Operator
enum

Vergelijkings-operators voor {@see Query}.

Gevangen in een enum zodat de SQL-compiler een gesloten set kan
accepteren — geen string-injectie via een onbekende operator.

4 public methods
static cases(): array
static from(string|int $value): static
static from2(string $op): self

Resolve een loose string naar een case (case-insensitive, '<>' alias).

static tryFrom(string|int $value): ?static
Cases: Eq, NotEq, Lt, Lte, Gt, Gte, Like, NotLike, In, NotIn, IsNull, IsNotNull, Between

ProfilingAdapter

Framework\Db\ProfilingAdapter
final class

Adapter-decorator die elke query timed en via een PSR-3 logger logt.

$db = new ProfilingAdapter($pdo, $logger);
$db->query('SELECT 1');
// Log-line: "DB query (1.2ms) SELECT 1" met context [duration_ms, params]

Vervangt legacy `Db\Profiler` — die schreef naar eigen output, deze gaat
door dezelfde PSR-3 logger als de rest van het framework. Niveau is per
default DEBUG; te overriden via constructor.

Profiling stats (totaal-aantal queries, totale tijd) leven op het object;
`stats()` geeft 'm terug. Geen autoflush — caller bepaalt wanneer.

__construct(PDO $pdo, \LoggerInterface $logger, string $level = 'debug')
3 public methods
query(string $sql, array $params = array ( ), callable|string|null $into = NULL): PDOStatement
resetStats(): void
stats(): array

Query

Framework\Db\Query
final class

Fluent, immutable query-builder boven {@see TableRepository}.

Twee groeperings-stijlen:

// closure
->whereGroup(fn(Query $q) => $q->where('age', '>=', 18)->orWhere('parent', 1))

// nest()/unnest() — geleend van legacy/library/Db/Predicate
->nest()->where('age', '>=', 18)->orWhere('parent', 1)->unnest()

Beide produceren `(...)`-groeperingen in de SQL.

__construct(PDO $pdo, string $table, string $pk = 'id')
26 public methods
count(): int
delete(): int

Massa-delete binnen het filter. Gooit exceptie zonder filter; gebruik
`->deleteAll()` voor truncate-achtige actie.

deleteAll(): int
exists(): bool
first(): ?array
get(): array
limit(int $limit, int $offset = 0): static
nest(string $combiner = 'AND'): static

Open een nieuwe group; returned de inner Query. Sluiten met `unnest()`.

orNest(): static
orWhere(string $column, ?mixed $opOrValue, ?mixed $value = NULL): static
orWhereGroup(callable $build): static
orderBy(string $column, string $direction = 'asc'): static
paginate(int $page = 1, int $perPage = 20): array

Pagineer: returnt items + meta (page, perPage, total, totalPages, hasMore).

toSql(): array
unnest(): static

Sluit een nest af en returneer de parent met de child-children gekopieerd.

update(array $data): int

Massa-update binnen het filter. Gooit een exceptie wanneer er geen filter is —
gebruik `->updateAll($data)` om dat bewust te accepteren.

updateAll(array $data): int
where(string $column, ?mixed $opOrValue, ?mixed $value = NULL): static

where(col, value) → '='
where(col, op, value)

whereBetween(string $column, ?mixed $from, ?mixed $till): static
whereGroup(callable $build, string $combiner = 'AND'): static

Group via closure. De callback krijgt een verse Query en moet de
gebouwde Query returnen (omdat de builder immutable is).

->whereGroup(fn($q) => $q->where('age', '>=', 18)->orWhere('parent', 1))

whereIn(string $column, array $values): static
whereLike(string $column, string $pattern): static
whereNotIn(string $column, array $values): static
whereNotLike(string $column, string $pattern): static
whereNotNull(string $column): static
whereNull(string $column): static

Sql

Framework\Db\Sql
final class

Pure SQL-compiler. Krijgt geserialiseerde where-nodes binnen,
spuugt SQL-fragments uit met PDO-positional-params.

Geen DB-toegang; volledig testbaar.

6 public methods
static compileAssignments(array $data): array

SET-clausule voor UPDATE/INSERT.

static compileLimit(?int $limit, int $offset = 0): string
static compileOrderBy(array $orders): string

Bouw ORDER BY clausule. Accepteert ['col' => 'asc'|'desc'] map.

static compileWhere(array $nodes): array

Compileer een where-tree naar SQL + params.

Tree-structuur (gemaakt door Query):
['type' => 'leaf'|'group', ...]

leaf: ['type' => 'leaf', 'combiner' => 'AND'|'OR', 'column' => string,
'op' => Operator, 'value' => mixed]

group: ['type' => 'group', 'combiner' => 'AND'|'OR', 'children' => list<node>]

static quoteColumn(string $name): string
static quoteTable(string $name): string

TableRepository

Framework\Db\TableRepository
final class

Generieke repository voor "normale" tabellen — geen DynamicItems.

$members = new TableRepository($pdo, 'members');

$me = $members->find(42);
$id = $members->insert(['email' => 'jan@x.nl', 'name' => 'Jan']);
$members->update(42, ['name' => 'Jan Jansen']);
$members->delete(42);

$rows = $members->query()
->where('active', 1)
->whereIn('country', ['nl', 'be'])
->orderBy('name')
->limit(20)
->get();

$page = $members->query()->where('active', 1)->paginate(page: 2, perPage: 20);

Veldnamen worden gevalideerd via {@see Sql::COLUMN_REGEX} (anti-injection).
Optioneel `->columns([...])` om verder strikt te whitelisten.

Events (alleen wanneer `EventDispatcherInterface` is geïnjecteerd):
- BeforeInsertEvent / AfterInsertEvent
- BeforeUpdateEvent / AfterUpdateEvent
- BeforeDeleteEvent / AfterDeleteEvent

__construct(PDO $pdo, string $table, string $pk = 'id', ?\EventDispatcherInterface $events = NULL)
11 public methods
columns(array $columns): self

Schakel een strikte kolom-whitelist in.

countBy(array $criteria = array ( )): int
delete(string|int $id): bool
find(string|int $id): ?array
findBy(array $criteria, ?int $limit = NULL): array
findOneBy(array $criteria): ?array
insert(array $data): string|int
query(): \Query
transactional(callable $fn): ?mixed

Roep $fn aan binnen één PDO-transactie. Bij exception → rollback.

update(string|int $id, array $data): bool
upsert(array $data, array $on): string|int

Debug

Framework\Debug\Debug
final class

Ontwikkelaarshulpmiddelen — altijd expliciet, nooit stilletjes in productie.

Globale aliassen (geladen via Bootstrap):
pr($var, ...) — dump één of meer variabelen, geef control terug
prd($var, ...) — dump en exit()

Direct via de klasse:
Debug::dump($var) — geeft de geformatteerde HTML-string terug
Debug::dd($var) — dump en exit()
Debug::trace() — huidige call-stack als string

4 public methods
static dd(?mixed ...$vars = ?): never

Dump en stop uitvoering (exit).

static dump(?mixed ...$vars = ?): void

Dump één of meer variabelen naar de output.
Toont bestandsnaam + regelnummer van de aanroepende plek.

static render(?mixed $var): string

Geeft de geformatteerde dump terug als string — handig om in El te embedden.

$el->raw(Debug::render($data));

static trace(): string

Huidige call-stack als leesbare string.

ErrorHandler

Framework\Debug\ErrorHandler
final class

Development error- en exception-handler.

Toont exceptions en PHP-errors als leesbare HTML-pagina met:
- Exception-type en bericht
- Bestandsnaam + regelnummer
- Gefilterde stack trace (vendor-frames ingeklapt)
- Previous exceptions

Gebruik in Bootstrap (alleen in development):
ErrorHandler::register();

In productie: NIET registreren, gebruik een logger + generieke foutpagina.

3 public methods
static handleException(Throwable $e): void
static isDebug(): bool

Detecteer of debug-modus actief is.

Volgorde (eerste match wint):
1. Constante APP_DEBUG === true (gezet via Bootstrap)
2. HTTP-header X-Debug: 1 (handig via browser-extensie / Postman)
3. CLI met --debug vlag (php script.php --debug)

In productie: APP_DEBUG = false, geen header → altijd false.

static register(): void

Registreer als PHP error- en exception-handler.
Altijd actief — isDebug() bepaalt of de fout getoond of gelogd wordt.
Veilig om meerdere keren aan te roepen — registreert slechts één keer.

DynamicCacheBuilder

Framework\Dynamic\Cache\DynamicCacheBuilder
final class

Pure cache-bouwer voor `DynamicItems.metadata.cache.values.{lang}.*`.

Geen DB, geen state — alleen input → output. Zo kunnen verschillende
IO-paden (warmer, post-save-hook, easyhandling's table-class) dezelfde
regels delen voor:
1. welk veld wel/niet de cache in mag (skip-list + encrypted),
2. hoe taalvrijgestelde velden (`language=''`) gemerged worden met
taalspecifieke waarden — taalspecifiek wint.

Datatype-skiplist gemodelleerd naar
easyhandling.nl/.../Dynamic/Model/DynamicItemsTable.php::shouldCacheFieldValue().

3 public methods
static buildAll(array $fields, iterable $rows, array $languages): array

Bouw cache voor meerdere talen tegelijk uit één rij-set.

static buildForLanguage(array $fields, iterable $rows, string $language): array

Bouw `name → value` voor één taal. Taalspecifieke rows winnen van
`language=''` rows; non-cacheable velden worden weggelaten.

static shouldCache(\DynamicTypeField $field): bool

Mag deze veldwaarde in de cache landen?

DynamicCacheWarmer

Framework\Dynamic\DynamicCacheWarmer
final class

Vult `metadata.cache.values.{lang}.{field}` in DynamicItems.

IO-laag rond {@see DynamicCacheBuilder}: leest uit DynamicItemsValues,
laat de bouwer de waarde-merge per taal doen, schrijft het resultaat
terug naar DynamicItems.metadata.

Talen kunnen expliciet meegegeven worden, of automatisch gedetecteerd
worden via een DISTINCT-query op DynamicItemsValues + de eigen
DynamicItems.language — gemodelleerd naar
easyhandling.nl/.../Dynamic/Model/DynamicItemsTable.php::resolveValuesMetadataCacheLanguages().

__construct(PDO $pdo, \DynamicStructure $structure)
2 public methods
warmItem(int $itemId, ?array $languages = NULL): void

Warm één item op.

warmType(string|int $typeId, ?array $languages = NULL, int $batchSize = 200): int

Warm alle items van een type op.

DynamicItem

Framework\Dynamic\DynamicItem
final class

Typed, read-only representatie van één DynamicItems-rij.

Veldwaarden (vanuit de JSON-cache) zitten in $values en zijn opvraagbaar
via get(). De meta-kolommen (id, typeId, order, ...) zijn publieke properties.

Gebruik:
$item->get('title'); // ?string — null als veld niet in cache
$item->get('media');
$item->active; // bool

__construct(int $id, int $typeId, string $typeName, int $order, ?int $parentId, bool $active, array $values)
2 public methods
get(string $field): ?string

Geeft de gecachede veldwaarde, of null als het veld ontbreekt.

values(): array

Alle gecachede veldwaarden als array.

DynamicQuery

Framework\Dynamic\DynamicQuery
final class

Immutable, fluent query builder voor DynamicItems.

Leest veldwaarden uitsluitend uit metadata→cache→values→{lang},
zodat DynamicItemsValues volledig buiten beeld blijft.

Gebruik:
$q = new DynamicQuery($pdo);

$items = $q->type(72)
->language('nl')
->onlyActive()
->fields('title', 'media', 'subtitle')
->get();

$first = $q->type(72)->language('nl')->fields('title')->first();

$total = $q->type(72)->onlyActive(false)->count();

Elke setter returnt een clone — de originele query blijft ongewijzigd.
Hierdoor kun je een basis-query hergebruiken:

$base = (new DynamicQuery($pdo))->language('nl')->onlyActive();
$nav = $base->type(72)->fields('title', 'link', 'label')->get();
$news = $base->type(18)->fields('title', 'intro')->limit(5)->get();

Active-logica (identiek aan DynamicItemsTable):
- metadata.active afwezig → actief (default)
- metadata.active = true/1 → actief
- metadata.active = false/0 → inactief

__construct(PDO $pdo)
12 public methods
allCachedFields(): static

Selecteer alle gecachte velden als één JSON-blob en decodeer in PHP.
Handig als je niet weet welke velden een type heeft, of alles wilt zien.
Gebruik ->fields() voor gerichte selects (sneller, expliciet).

count(): int

Geef het aantal rijen (negeert limit/offset).

fields(string ...$names = ?): static

Welke velden uit de cache te selecteren.
Alleen alphanumerieke namen + underscore zijn toegestaan.

Geef '*' als enkel argument om alle gecachte velden op te halen (JSON-blob → PHP decode).
Equivalent aan ->allCachedFields().

first(): ?\DynamicItem

Geef de eerste rij, of null als er geen match is.

get(): array

Voer de query uit en geef alle rijen terug.

language(string $iso): static

Taal voor de JSON-cache path ($.cache.values.{lang}.*).
Alleen lowercase letters, 2–5 tekens (bijv. 'nl', 'en', 'de').

limit(int $limit, int $offset = 0): static
onlyActive(bool $active = true): static

Filter op active-vlag in metadata root.
onlyActive(false) haalt alle items op, ongeacht status.

orderBy(string ...$columns = ?): static

Kolomnamen worden letterlijk in de query gezet — vertrouw alleen
op waarden die vanuit code komen, nooit op user input.

parent(?int $parentId): static

Filter op parent_id.
parent(null) → WHERE parent_id IS NULL (root-items).

toSql(): array

Geef de opgebouwde SQL en parameters terug — handig voor debugging.

type(string|int $typeId): static

Filter op type: geef een integer type_id óf een string type-naam.
Bij een string wordt een subquery gebruikt: type_id = (SELECT id FROM DynamicTypes WHERE name = ?).

DynamicQueryEav

Framework\Dynamic\DynamicQueryEav
final class

EAV-variant van DynamicQuery: leest veldwaarden direct uit DynamicItemsValues.

Bestaat naast {@see DynamicQuery} (die uit metadata.cache.values.* leest) zodat
we beide leespaden tegen elkaar kunnen benchen op echte MariaDB-data.

Drie strategieën, te kiezen via {@see EavStrategy}:

- Subselect : per gevraagd veld een gecorreleerde (SELECT value FROM
DynamicItemsValues WHERE …) — equivalent aan library/Db/Dynamic/Select.php
- LeftJoin : per veld een aparte LEFT JOIN met alias dv_<naam>
- Pivot : één LEFT JOIN op DynamicItemsValues + GROUP BY met
MAX(CASE WHEN v.field_id = N THEN v.value END) per veld

Strategy is constructor-argument: voor benchen instantieer je per strategie
een eigen DynamicQueryEav.

Bij `type(string $name)` wordt de naam direct via DynamicStructure naar een
integer type_id geresolved — anders kunnen we de field_ids niet ophalen die
de EAV-strategieën nodig hebben.

__construct(PDO $pdo, \DynamicStructure $structure, \EavStrategy $strategy = \Framework\Dynamic\EavStrategy::Subselect)
12 public methods
count(): int
fields(string ...$names = ?): static

Welke velden uit DynamicItemsValues te lezen.
Geef '*' als enkel argument om alle velden van het type op te halen
(resolved via DynamicStructure::fieldsForType()).

first(): ?\DynamicItem
get(): array
getStrategy(): \EavStrategy
language(string $iso): static
limit(int $limit, int $offset = 0): static
onlyActive(bool $active = true): static
orderBy(string ...$columns = ?): static
parent(?int $parentId): static
toSql(): array
type(string|int $typeId): static

DynamicQueryInterface

Framework\Dynamic\DynamicQueryInterface
interface

Gedeelde contract voor DynamicQuery-implementaties.

Bestaat in twee varianten:
- DynamicQuery — leest uit metadata.cache.values.{lang}.* (JSON)
- DynamicQueryEav — leest uit DynamicItemsValues (subselect / left-join / pivot)

Beide implementaties zijn fluent + immutable: elke setter returnt een clone.

11 public methods
count(): int
fields(string ...$names = ?): static
first(): ?\DynamicItem
get(): array
language(string $iso): static
limit(int $limit, int $offset = 0): static
onlyActive(bool $active = true): static
orderBy(string ...$columns = ?): static
parent(?int $parentId): static
toSql(): array
type(string|int $typeId): static

DynamicStructure

Framework\Dynamic\DynamicStructure
final class

Lichtgewicht registry voor DynamicTypes en hun velden.

Eén `load()` haalt zowel alle types als alle velden in twee queries op,
waarna alle lookups (id, naam, fields-per-type) zonder DB-hit werken.
Het PDO-object wordt na laden niet bewaard — de instance is daardoor
volledig serialiseerbaar en geschikt voor een file-cache.

Twee laad-modes:

$structure = DynamicStructure::load($pdo); // altijd vers
$structure = DynamicStructure::loadCached($pdo, 'site-easyhandling'); // cached

loadCached() schrijft serialize() naar
`{cacheDir}/{sanitized-key}.dat` (default cacheDir: data/cache/structures).
De cache-key is verplicht en uniek per bron — handig wanneer je met
meerdere websites/databases tegelijk werkt.

Invalideren:

DynamicStructure::invalidate('site-easyhandling');

11 public methods
all(): array
fieldDetailsForType(int $typeId): array

Volledige veld-info per type (incl. datatype + encrypted-flag).
Inclusief geërfde velden via metadata.extends.

fieldNamesForType(int $typeId): array
fieldsForType(int $typeId): array
getTypeById(int $id): ?\DynamicType
getTypeByName(string $name): ?\DynamicType
hasType(string $name): bool
idForName(string $name): int
static invalidate(string $cacheKey, string $cacheDir = 'data/cache/structures'): void

Verwijder de cache-file voor een specifieke key.

static load(PDO $pdo, bool $enabledOnly = true): self

Laad alle (actieve) types + alle velden uit de database (twee queries).

static loadCached(PDO $pdo, string $cacheKey, ?int $ttl = NULL, string $cacheDir = 'data/cache/structures', bool $forceReload = false, bool $enabledOnly = true): self

Laad uit file-cache als beschikbaar; anders {@see load()} + cache schrijven.

DynamicType

Framework\Dynamic\DynamicType
final class

Immutable waarde-object dat één rij uit DynamicTypes vertegenwoordigt.

__construct(int $id, string $name, string $metadataJson = '{}')
2 public methods
isEnabled(): bool

Is dit type actief/enabled?
Absent of true/1 = enabled; false/0 = disabled.

title(): string

Geeft de weergavenaam uit metadata['title'], of de technische naam als fallback.

DynamicTypeField

Framework\Dynamic\DynamicTypeField
final class

Immutable rij uit DynamicTypesFields.

Voldoende velden om te beslissen of een waarde gecached mag worden:
datatype + encrypted bepalen samen of een veld in de
`metadata.cache.values.{lang}.*` mag landen — zie {@see Cache\DynamicCacheBuilder}.

Gemodelleerd naar de checks in:
easyhandling.nl/.../Dynamic/Model/DynamicItemsTable.php::shouldCacheFieldValue()

__construct(int $id, int $typeId, string $name, int $datatype, bool $encrypted)

DynamicWriter

Framework\Dynamic\DynamicWriter
final class

Schrijfpad voor `DynamicItems` + `DynamicItemsValues`.

$writer = new DynamicWriter($pdo, $structure);

$id = $writer->create('contact', ['name' => 'Jan', 'email' => 'a@b']);
$writer->update(42, ['phone' => '06-1234']);
$writer->setActive(42, false);
$writer->delete(42);

// Voor 1-shot find tijdens een edit-flow:
$item = $writer->find(42); // ?DynamicItem (via DynamicQuery)

Auto-cache-warm na elke schrijfactie. Opt-out per call mogelijk via
`$writer->withoutAutoWarm()->update(...)` (handig bij batch-imports).

Validatie: weigert onbekende veldnamen — `fieldsForType()` van
{@see DynamicStructure} fungeert als whitelist (incl. geërfde velden).

__construct(PDO $pdo, \DynamicStructure $structure, ?\DynamicCacheWarmer $warmer = NULL)
7 public methods
create(string|int $type, array $values, string $language = 'nl', ?int $parentId = NULL): int

Maak een nieuw DynamicItem met values. Returneert de nieuwe id.

delete(int $itemId, bool $cascade = true): void

Verwijder item + bijbehorende DynamicItemsValues (en optioneel children).

find(int $itemId, string $language = 'nl'): ?\DynamicItem

Find één DynamicItem op id. Returnt de gehydrateerde DynamicItem of null.
Gebruikt cache-pad (DynamicQuery) via een subquery op id zodat alle
fields uit metadata.cache komen.

setActive(int $itemId, bool $active): void

Schakel `metadata.active` om — geen value-mutaties.

transactional(callable $fn): ?mixed
update(int $itemId, array $values, string $language = 'nl'): void

Update bestaande values van een item. Geeft alleen de gegeven velden door —
de rest blijft staan. Cache wordt automatisch opnieuw gewarmd.

withoutAutoWarm(): self

Eén-shot setting voor de volgende call: skip cache-warm.

EavStrategy

Framework\Dynamic\EavStrategy
enum

Welke SQL-vorm DynamicQueryEav genereert om veldwaarden uit
DynamicItemsValues te lezen.

- Subselect : per veld een gecorreleerde subselect (zoals library/Db/Dynamic/Select.php).
Eenvoudig, maar de planner moet N subselects per rij plannen.
- LeftJoin : per veld een aparte LEFT JOIN op DynamicItemsValues met alias.
Vaak sneller bij veel velden, maar elke join is een extra index-lookup.
- Pivot : één LEFT JOIN op DynamicItemsValues + GROUP BY met MAX(CASE WHEN field_id=…).
Eén round-trip naar de waarde-tabel, beste plan bij veel velden.

3 public methods
static cases(): array
static from(string|int $value): static
static tryFrom(string|int $value): ?static
Cases: Subselect, LeftJoin, Pivot

AfterDeleteEvent

Framework\Events\Db\AfterDeleteEvent
final class
__construct(string $table, string|int $id, int $affected)

AfterInsertEvent

Framework\Events\Db\AfterInsertEvent
final class
__construct(string $table, string|int $id, array $data)

AfterUpdateEvent

Framework\Events\Db\AfterUpdateEvent
final class
__construct(string $table, string|int $id, array $data, int $affected)

BeforeDeleteEvent

Framework\Events\Db\BeforeDeleteEvent
final class
__construct(string $table, string|int $id)

BeforeInsertEvent

Framework\Events\Db\BeforeInsertEvent
final class

Gefired vóór een TableRepository::insert(). Listeners mogen `$data` muteren
via setData() — handig voor tijdstempels of audit-velden.

__construct(string $table, array $data)
2 public methods
getData(): array
setData(array $data): void

BeforeUpdateEvent

Framework\Events\Db\BeforeUpdateEvent
final class
__construct(string $table, string|int $id, array $data)
2 public methods
getData(): array
setData(array $data): void

EventDispatcher

Framework\Events\EventDispatcher
final class

PSR-14 dispatcher. Roept alle listeners voor een event aan, in volgorde
geleverd door de ListenerProvider. Stopt zodra een StoppableEvent
isPropagationStopped() teruggeeft.

__construct(\ListenerProviderInterface $listeners)
1 public method
dispatch(object $event): object

ListenerProvider

Framework\Events\ListenerProvider
final class

Eenvoudige PSR-14 listener-provider met prioriteit.

Listeners worden aangeroepen op type — als de event-class instanceof het
geregistreerde type is, krijgt de listener 'm. Hogere priority = eerst.

$provider->on(UserRegistered::class, fn(UserRegistered $e) => ..., priority: 10);

2 public methods
getListenersForEvent(object $event): iterable
on(string $eventClass, callable $listener, int $priority = 0): void

AltTagger

Framework\Files\AI\AltTagger
interface

Provider-onafhankelijk contract om in één AI-call:
- Per taal een korte ALT-tekst (max 1 zin) te genereren
- Maximaal 10 generieke tags voor de afbeelding op te leveren

Implementaties: {@see GroqAltTagger}. Caller bind 'm in de container
en gebruikt 'm via de Files-facade-routes.

1 public method
tag(string $imageUrl, array $languages): array

GroqAltTagger

Framework\Files\AI\GroqAltTagger
final class

AltTagger via de Groq API (OpenAI-compatible). Default model is
`meta-llama/llama-4-scout-17b-16e-instruct` — vision-capable.

$tagger = new GroqAltTagger($_ENV['GROQ_API_KEY']);
$r = $tagger->tag('https://example.com/foto.jpg', ['nl', 'en', 'de']);
// $r = ['alts' => ['nl' => '...', 'en' => '...', 'de' => '...'], 'tags' => [...]]

Werkt ook met `data:image/jpeg;base64,...` URL's voor lokale files —
Groq's vision endpoint accepteert beide.

__construct(string $apiKey, string $model = 'meta-llama/llama-4-scout-17b-16e-instruct', ?\TransportInterface $transport = NULL)
1 public method
tag(string $imageUrl, array $languages): array

FileQuery

Framework\Files\FileQuery
final class

Filter/sort-parameters voor `FileRepository::findInFolder()` en `::search()`.

Defaults: alfabetisch oplopend op naam, geen filter, geen limiet.

__construct(?string $q = NULL, ?\FileType $type = NULL, string $sort = 'name', string $direction = 'asc', ?int $limit = NULL, int $offset = 0)

FileRecord

Framework\Files\FileRecord
final class

Eén regel uit de files-tabel.

Een file is óf lokaal opgeslagen (`contentHash` set, `externalUrl` null),
óf extern (`externalUrl` set, `contentHash` null). Nooit allebei, nooit geen.

`metadata` is een vrij-vorm associative array (JSON in storage). Voor images:
dimensies; voor externals: provider/embed-id; etc.

__construct(int $id, ?int $folderId, string $name, string $mime, int $size, ?string $contentHash, ?string $externalUrl, \FileType $type, array $metadata, int $createdAt, int $updatedAt)
4 public methods
isExternal(): bool
isLocal(): bool
url(): string

Render-vriendelijke URL: lokaal → /files/{id}/raw, extern → externalUrl.

with(array $changes): self

FileRepository

Framework\Files\FileRepository
interface

Storage-contract voor file-records (DB-laag, los van de blob-bytes).

Implementaties: {@see JsonFileRepository} (default voor demo, geen DB-extensie),
later eventueel een PDO-impl voor MySQL/SQLite.

8 public methods
countInFolder(?int $folderId, \FileQuery $query): int

Tel files in een folder (voor pagination).

create(\FileRecord $record): \FileRecord

Insert een nieuwe row. `id`/`createdAt`/`updatedAt` worden door de
repository gegenereerd; meegegeven waarden worden genegeerd.

delete(int $id): void
find(int $id): ?\FileRecord
findByHash(string $hash): array
findInFolder(?int $folderId, \FileQuery $query): array
search(\FileQuery $query): array

Globale zoek over alle files (folder-onafhankelijk).

update(\FileRecord $record): \FileRecord

Update bestaande row. Geeft de geüpdatete record terug, met nieuwe
`updatedAt`.

FileStorage

Framework\Files\FileStorage
interface

Blob-IO-laag — los van de DB-laag. Werkt content-addressed via sha256.

Twee uploads van dezelfde file produceren één blob (dedup), maar de
`FileRepository` houdt nog steeds twee aparte rows zodat hernoemen/verplaatsen
onafhankelijk werkt.

Implementaties: {@see LocalFileStorage} (default), later eventueel S3.

5 public methods
delete(string $hash): void
exists(string $hash): bool
path(string $hash): ?string

Absoluut bestandspad voor de blob — handig voor X-Sendfile / readfile().
Null als de hash niet bestaat.

read(string $hash): ?string

Lees bytes. Null als de hash niet bestaat.

write(string $hash, string $contents): void

Schrijf bytes; idempotent — als de hash al bestaat, no-op.

FileType

Framework\Files\FileType
enum

Categorie van een file — bepaalt rendering en filter-knoppen in de UI.

Geleid uit de mime-type bij upload of uit de URL-parser bij externals.

4 public methods
static cases(): array
static from(string|int $value): static
static fromMime(string $mime): self
static tryFrom(string|int $value): ?static
Cases: Image, Video, Audio, Pdf, External, Binary

Files

Framework\Files\Files
final class

Facade die `FileRepository` + `FolderRepository` + `FileStorage` koppelt
en de "smart" operaties levert: upload (met dedup), delete (met refcount-
blob-cleanup), externe URL toevoegen.

$files = new Files(
new JsonFileRepository($store),
new JsonFolderRepository($store),
new LocalFileStorage('/data/files'),
);

$rec = $files->upload(folderId: 1, name: 'foo.jpg', contents: $bytes, mime: 'image/jpeg');
$files->delete($rec->id);

Voor low-level operaties (`findByHash`, etc.) kun je de repos direct uit
de container halen — de facade is voor de gebruikelijke schrijfacties.

__construct(\FileRepository $files, \FolderRepository $folders, \FileStorage $storage, \FilesConfig $config = \Framework\Files\FilesConfig::__set_state(array( 'languages' => array ( 0 => 'nl', ), 'languageLabels' => array ( 'nl' => 'Nederlands', ), 'aiEnabled' => false, 'aiModel' => 'grok-2-vision-1212', )))
6 public methods
addExternal(?int $folderId, string $url, ?string $name = NULL, array $metadata = array ( )): \FileRecord

Voeg een externe URL toe als file (YouTube, Vimeo, willekeurige PDF-URL).
Geen blob — alleen een `files`-row met `externalUrl`.

delete(int $fileId): void

Verwijder een file. Bij lokale files wordt na refcount-check de blob
van disk verwijderd als geen andere row 'm gebruikt.

deleteFolder(int $folderId): array

Verwijder een folder + alle descendants + alle files daarin (met
refcount-blob-cleanup per file).

move(int $fileId, ?int $newFolderId): \FileRecord
rename(int $fileId, string $newName): \FileRecord
upload(?int $folderId, string $name, string $contents, string $mime, array $metadata = array ( )): \FileRecord

Upload nieuwe file. Dedup-aware: als een blob met dezelfde hash al
bestaat, wordt 'm hergebruikt — alleen een nieuwe `files`-row.

FilesConfig

Framework\Files\FilesConfig
final class

Configuratie die de caller meegeeft aan {@see Files}. Bepaalt hoe de
front-end zich gedraagt — talen voor multi-lingual ALT-tekst, of de AI-
suggestie zichtbaar is, etc.

$config = new FilesConfig(
languages: ['nl', 'en', 'de'],
languageLabels: ['nl' => 'Nederlands', 'en' => 'English', 'de' => 'Deutsch'],
aiEnabled: true,
);

Defaults zijn NL-only, geen AI. De waarden worden naar de browser gestuurd
via `GET /api/files/config` en gebruikt door file-browser.js om de juiste
inputs te renderen.

__construct(array $languages = array ( 0 => 'nl', ), array $languageLabels = array ( 'nl' => 'Nederlands', ), bool $aiEnabled = false, string $aiModel = 'grok-2-vision-1212')
1 public method
toArray(): array

FolderRecord

Framework\Files\FolderRecord
final class

Eén regel uit de folders-tabel.

`path` is gedenormaliseerd ("/foo/bar/baz") voor snelle breadcrumb-render
en zoek-zonder-recursie. Wordt onderhouden door de repository bij rename/move.

Root = `parentId === null && path === '/'` (of equivalent).

__construct(int $id, ?int $parentId, string $name, string $path, int $createdAt, array $metadata = array ( ))
2 public methods
isRoot(): bool
withMetadata(array $metadata): self

FolderRepository

Framework\Files\FolderRepository
interface

Storage-contract voor folder-records.

9 public methods
all(): array

Alle folders plat — caller bouwt de tree client-side via `parentId`.

children(?int $parentId): array

Direct kinderen van een folder (geen recursie).

create(?int $parentId, string $name): \FolderRecord

Maak een nieuwe folder. `path` wordt gedenormaliseerd uit parent + name.
Faalt als naam al bestaat onder dezelfde parent.

delete(int $id): array

Verwijder folder + alle descendants. Caller is verantwoordelijk voor
het verwijderen van de bijbehorende files (om refcount-blob-cleanup
juist te doen).

find(int $id): ?\FolderRecord
findByPath(string $path): ?\FolderRecord
move(int $id, ?int $newParentId): \FolderRecord

Verplaats — werkt ook descendants bij (path-prefix).
`$newParentId === null` = naar root.

rename(int $id, string $newName): \FolderRecord

Hernoem — werkt ook descendants bij (path-prefix).

update(\FolderRecord $record): \FolderRecord

Update een folder. Voor v1 worden alleen mutaties op `metadata`
toegepast — `name`/`parentId`/`path` gaan via `rename()`/`move()`.

JsonFileRepository

Framework\Files\JsonFileRepository
final class

JSON-backed FileRepository.

__construct(\JsonStore $store)
8 public methods
countInFolder(?int $folderId, \FileQuery $query): int
create(\FileRecord $record): \FileRecord
delete(int $id): void
find(int $id): ?\FileRecord
findByHash(string $hash): array
findInFolder(?int $folderId, \FileQuery $query): array
search(\FileQuery $query): array
update(\FileRecord $record): \FileRecord

JsonFolderRepository

Framework\Files\JsonFolderRepository
final class

JSON-backed FolderRepository.

__construct(\JsonStore $store)
9 public methods
all(): array
children(?int $parentId): array
create(?int $parentId, string $name): \FolderRecord
delete(int $id): array
find(int $id): ?\FolderRecord
findByPath(string $path): ?\FolderRecord
move(int $id, ?int $newParentId): \FolderRecord
rename(int $id, string $newName): \FolderRecord
update(\FolderRecord $record): \FolderRecord

JsonStore

Framework\Files\JsonStore
final class

Gedeeld read/write/atomic-update mechanisme voor de JSON-repos.

Eén JSON-file met `{folders: [...], files: [...], next_id: int}`. Alle writes
gaan via `transaction()` die file-lock + read-modify-write doet zodat parallelle
processen elkaar niet overschrijven.

$store = new JsonStore('/path/to/files.json');
$store->transaction(function (array $data) {
$data['files'][] = [...];
return $data;
});

Niet bedoeld voor productie-load met veel concurrent writers — voor 100k+
files of veel parallel uploads: gebruik straks de PDO-impl. Voor de demo
en kleine apps prima.

__construct(string $path)
2 public methods
read(): array

Lees de hele store. Bij corrupte file wordt een lege store geretourneerd
(caller kan dan opnieuw beginnen).

transaction(callable $fn): ?mixed

Atomaire read-modify-write. Callback krijgt de huidige data, returnt
de nieuwe data; ertussen blijft de file ge-flock'd.

LocalFileStorage

Framework\Files\LocalFileStorage
final class

Disk-backed FileStorage. Layout:

<baseDir>/<sha256[0:2]>/<sha256>.bin

De `[0:2]`-sub-dir voorkomt dat we duizend files in één directory plempen.
Sha256 is exact 64 hex chars; we accepteren niks anders (anti-traversal).

__construct(string $baseDir)
5 public methods
delete(string $hash): void
exists(string $hash): bool
path(string $hash): ?string
read(string $hash): ?string
write(string $hash, string $contents): void

CompositeRule

Framework\Form\Conditional\CompositeRule
final class

A composite conditional: multiple ConditionalRules combined with AND or OR.

Serialises to:
{"logic": "and", "rules": [{"field":"x","operator":"equal","value":"y"}, ...]}

Usage:
CompositeRule::all(
ConditionalRule::whenEqual('type', 'business'),
ConditionalRule::whenEqual('region', 'nl'),
)

4 public methods
static all(\ConditionalRule ...$rules = ?): self

All rules must pass (AND).

static any(\ConditionalRule ...$rules = ?): self

At least one rule must pass (OR).

toArray(): array
toJson(): string

ConditionalRule

Framework\Form\Conditional\ConditionalRule
final class

A single conditional rule: show/hide a field when another field's value
satisfies a comparison.

This value object serialises to the JSON format expected by form-enhanced.js:

{"field": "type", "operator": "equal", "value": "business"}

Usage:
ConditionalRule::when('type', RuleOperator::Equal, 'business')

__construct(string $field, \RuleOperator $operator, string $value)
4 public methods
toArray(): array

Serialise to the JSON structure expected by form-enhanced.js.

toJson(): string
static when(string $field, \RuleOperator $operator, string $value): self
static whenEqual(string $field, string $value): self

Shorthand for the most common case: show when field equals value.

RuleEvaluator

Framework\Form\Conditional\RuleEvaluator
final class

Evaluates ConditionalRule / CompositeRule against a flat values map.

Extracted from Form::process() so that ConditionalValidator (or any other
code that needs to test a rule against submitted values) can reuse the
exact same semantics without duplication.

1 public method
evaluate(\ConditionalRule|\CompositeRule $rule, array $values): bool

RuleOperator

Framework\Form\Conditional\RuleOperator
enum

Comparison operators for conditional field rules.

These values are serialised as strings in the data-conditional JSON
attribute consumed by form-enhanced.js on the client.

3 public methods
static cases(): array
static from(string|int $value): static
static tryFrom(string|int $value): ?static
Cases: Equal, NotEqual, Contains, NotContains, GreaterThan, LessThan, GreaterEqual, LessEqual

FormSchemaException

Framework\Form\Exception\FormSchemaException
final class

Thrown when a JSON form schema cannot be parsed or contains invalid data.

The message always describes which part of the schema is invalid so
CMS developers get actionable feedback.

__construct(string $message = '', int $code = 0, ?Throwable $previous = NULL)
5 public methods
static invalidJson(string $error): self
static missingKey(string $key, string $context = ''): self
static unknownOperator(string $operator): self
static unknownType(string $type, string $context = ''): self
static unknownValidator(string $type): self

AbstractField

Framework\Form\Field\AbstractField
abstract class

Base class for typed form field definitions.

A field is a pure data+metadata object — it carries no HTML knowledge.
The FormRenderer decides how to present each field (label placement,
wrapper elements, error styles, etc.).

Usage:
TextField::create('first_name')
->label('Voornaam')
->placeholder('Jan')
->required();

21 public methods
addValidator(\ValidatorInterface $validator): static
static create(string $name): static
defaultValue(string $value): static
disabled(bool $disabled = true): static
getConditional(): \ConditionalRule|\CompositeRule|null
getConditionalJson(): ?string

Serialise the conditional rule to the JSON format used by form-enhanced.js.
Returns null when no conditional is set.

getDefaultValue(): string
getHint(): string
getLabel(): string
getPlaceholder(): string
getValidators(): array
hasConditional(): bool
hint(string $hint): static

A short helper text shown below the field input.
Example: hint('Gebruik uw zakelijk e-mailadres')

isComposite(): bool

Composite fields hebben sub-inputs (`name[part]`) en eigen render-/validate-pad.
Default = false; CompositeFieldInterface-implementations overschrijven.

isDisabled(): bool
isRequired(): bool
label(string $label): static
placeholder(string $placeholder): static
required(bool $required = true): static
showWhen(\ConditionalRule|\CompositeRule $rule): static

Mark this field as conditionally visible.

Example — show only when another field equals a certain value:
->showWhen(ConditionalRule::whenEqual('type', 'business'))

Example — show when multiple conditions are met:
->showWhen(CompositeRule::all(
ConditionalRule::whenEqual('type', 'business'),
ConditionalRule::whenEqual('region', 'nl'),
))

validate(string $value, array $allValues = array ( )): array

Run all validators against $value.
Returns a list of error messages (empty = valid).

AddressField

Framework\Form\Field\AddressField
final class

Composite veld voor postadressen.
Submit-shape: `<name>[street]`, `<name>[number]`, `<name>[zipcode]`,
`<name>[city]`, en optioneel `<name>[country]`.

Default-opmaak: NL — postcode + plaats op één regel, daaronder straat + nr.

7 public methods
getDefaultCountry(): string
getIncludeCountry(): bool
getParts(): array
includeCountry(bool $on = true, string $default = 'NL'): static
isComposite(): bool
renderComposite(array $value = array ( )): string
validateComposite(array $value, array $allValues = array ( )): array

CheckboxField

Framework\Form\Field\CheckboxField
final class

A single <input type="checkbox">.

Het label naast de checkbox wordt gezet via {@see checkboxLabel()}. Dat
kan ofwel een plain string zijn (auto-escaped) ofwel een {@see NodeInterface}
(El, ElCollection, fragment) — handig om links in te bouwen, bv:

CheckboxField::create('terms')
->checkboxLabel(El::fragment()
->text('Ik accepteer de ')
->add(El::make('a', ['href' => '/voorwaarden'])->text('algemene voorwaarden'))
);

Voor het veelvoorkomende "tekst — link — tekst"-patroon is er de helper
{@see checkboxLabelWithLink()} zodat je geen El-builder hoeft te kennen.

3 public methods
checkboxLabel(\NodeInterface|string $label): static

Stel het label naast de checkbox in. String is plain-text (auto-escaped).
NodeInterface wordt rendered as-is — gebruik dat voor inline-links of formatting.

checkboxLabelWithLink(string $prefix, string $href, string $linkText, string $suffix = '', bool $external = true): static

Convenience: tekst met één inline-link (en optioneel een suffix).

->checkboxLabelWithLink('Ik accepteer de ', '/terms', 'algemene voorwaarden', '.')
→ Ik accepteer de [algemene voorwaarden](/terms).

`$external = true` voegt `target="_blank"` + `rel="noopener noreferrer"` toe.

getCheckboxLabel(): \NodeInterface|string

CompositeFieldInterface

Framework\Form\Field\CompositeFieldInterface
interface

Marker-interface voor velden die uit meerdere sub-inputs bestaan.

Een composite field heeft één naam in het schema (`klantnaam`) maar levert
server-side meerdere `<input>`-tags op (`klantnaam[first]`, `klantnaam[last]`,
etc.). De submit-data komt binnen als geneste array.

Form::process() en validators behandelen composites anders dan atomic-velden:
- $values[$name] is een array, niet een string
- validate() krijgt de array door, niet trim() of (string)cast

Render: composites leveren hun eigen `renderComposite(): string` die de
sub-inputs in één wrapper plaatst. FormRenderer delegeert daaraan.

3 public methods
getParts(): array

Returnt de sub-veld-namen + labels.

renderComposite(): string

Render de complete composite-input (sub-inputs in een wrapper).
Output is HTML-string die door FormRenderer rechtstreeks ingevoegd wordt.

validateComposite(array $value, array $allValues = array ( )): array

Validate sub-data (ipv één string-value). Returns lijst foutmeldingen.

Countries

Framework\Form\Field\Countries
final class

Centrale landen-lijst voor zowel het losse `country`-type
(CountryField via FormFactory) als de `address.country` sub-velden.

Sleutels = ISO-3166-1 alpha-2 codes (uppercase).
Labels = NL-talige landnaam.

Niet uitputtend — meest gebruikte landen + EU. Caller kan eigen lijst
opgeven via `options()` op SelectField of `salutationOptions()`-achtige
setters in andere fields.

2 public methods
static codes(): array
static defaults(): array

CreditCardField

Framework\Form\Field\CreditCardField
final class

Composite veld voor creditcard-invoer.

Submit-shape: `<name>[number]`, `<name>[expiry]`, `<name>[cvc]`.

Geen pretentie van PCI-compliance — bedoeld als front-end widget;
gevoelige data hoort sowieso niet door de eigen server te gaan, gebruik
een payment-provider tokenisatie. We valideren format (Luhn-check op
card-number, MM/YY-format, 3-4-cijfer cvc).

4 public methods
getParts(): array
isComposite(): bool
renderComposite(array $value = array ( )): string
validateComposite(array $value, array $allValues = array ( )): array

DateField

Framework\Form\Field\DateField
final class

Single-date field. Form-value is altijd ISO `YYYY-MM-DD` (of
`YYYY-MM-DDTHH:MM` als time enabled is). De zichtbare display
komt van de JS-component `public/modules/date-picker.js` en
volgt de actieve locale (of de format-override).

DateField::create('birthdate')
->label('Geboortedatum')
->min('1900-01-01')
->max(date('Y-m-d'))
->locale('nl-NL')
->months(1);

22 public methods
dataAttributes(): array

Bouw de data-attributen voor de wrapper-div (gebruikt door FormRenderer).

defaultPattern(?string $key): static
format(?string $token): static
getDefaultPattern(): ?string
getFormat(): ?string
getLocale(): ?string
getMax(): ?string
getMaxMonths(): int
getMin(): ?string
getMonths(): int
getMonthsMobile(): int
getPatterns(): array
getTimeStep(): int
isTimeEnabled(): bool
locale(?string $code): static
max(?string $iso): static
maxMonths(int $count): static
min(?string $iso): static
months(int $count): static
monthsMobile(int $count): static
patterns(array $patterns): static
withTime(bool $enabled = true, int $stepMinutes = 15): static

DateRangeField

Framework\Form\Field\DateRangeField
final class

Range-date field — twee form-velden (from + till) onder één UI.

DateRangeField::create('stay')
->from('checkin', 'Aankomst')
->till('checkout', 'Vertrek')
->min('2026-01-01')
->max('2027-12-31')
->locale('nl-NL')
->months(2)
->patterns([
['key' => 'weekend', 'label' => 'Weekend', 'anchor' => [5,6,0], 'nights' => 3],
['key' => 'week', 'label' => 'Week', 'anchor' => [5,6,0], 'nights' => 7],
]);

AbstractField->name wordt gebruikt voor de wrapper-id en als prefix voor de
input-namen wanneer from()/till() niet expliciet geset zijn.

28 public methods
dataAttributes(): array
defaultPattern(?string $key): static
format(?string $token): static
from(string $name, string $label = ''): static
getDefaultPattern(): ?string
getFormat(): ?string
getFromLabel(): string
getFromName(): string
getLocale(): ?string
getMax(): ?string
getMaxMonths(): int
getMin(): ?string
getMonths(): int
getMonthsMobile(): int
getPatterns(): array
getTillLabel(): string
getTillName(): string
getTimeStep(): int
isTimeEnabled(): bool
locale(?string $code): static
max(?string $iso): static
maxMonths(int $count): static
min(?string $iso): static
months(int $count): static
monthsMobile(int $count): static
patterns(array $patterns): static
till(string $name, string $label = ''): static
withTime(bool $enabled = true, int $stepMinutes = 15): static

EmailField

Framework\Form\Field\EmailField
final class
__construct(string $name)

MultiCheckboxField

Framework\Form\Field\MultiCheckboxField
final class

Een groep checkboxes — meerdere selecties tegelijk mogelijk.

Submit-shape: `<name>[]` (PHP-stijl array). FormResult slaat 'm op als
array van geselecteerde waarden, vergelijkbaar met een composite maar
platter (geen vaste sub-keys, alle items horen tot dezelfde lijst).

In het JSON-schema:
{ "type": "multicheckbox", "name": "talen", "options": {"nl": "Nederlands", ...} }

Niet `composite: true` — gebruikt z'n eigen render-pad maar de submit-data
is een homogene array van strings, niet een geneste struct met sub-keys.

2 public methods
getOptions(): array
options(array $options): static

PersonalNameField

Framework\Form\Field\PersonalNameField
final class

Composite veld voor persoonsnamen.

Submit-shape:
`<name>[first]`, `<name>[middle]`, `<name>[last]`
en optioneel `<name>[salutation]` als `includeSalutation()` aan staat.

Gebruik:
PersonalNameField::create('klantnaam')
->includeSalutation(true)
->required();

In het JSON-schema:
{ "type": "personalname", "name": "klantnaam", "includeSalutation": true }

8 public methods
getIncludeSalutation(): bool
getParts(): array
getSalutationOptions(): array
includeSalutation(bool $on = true): static
isComposite(): bool
renderComposite(array $value = array ( )): string
salutationOptions(array $options): static
validateComposite(array $value, array $allValues = array ( )): array

PhoneNumberField

Framework\Form\Field\PhoneNumberField
final class

Telefoonnummer — atomisch text-veld met `type=tel` en optionele
format-validatie per language. Geen composite (geen sub-velden); de
country-prefix is gewoon onderdeel van de tekstwaarde.

3 public methods
getLanguage(): string
language(string $code): static
validate(string $value, array $allValues = array ( )): array

RadioField

Framework\Form\Field\RadioField
final class

A group of <input type="radio"> buttons.

Example:
RadioField::create('type')
->label('Account type')
->options(['private' => 'Particulier', 'business' => 'Zakelijk']);

2 public methods
getOptions(): array
options(array $options): static

ReCaptchaField

Framework\Form\Field\ReCaptchaField
final class

Form-field voor Google reCAPTCHA. Drie modi:

ReCaptchaField::create()->siteKey('6Lc...')->mode(ReCaptchaMode::V2_CHECKBOX);
ReCaptchaField::create()->siteKey('6Lc...')->mode(ReCaptchaMode::V3)->action('contact');

De FormRenderer plaatst een placeholder-div (`.g-recaptcha`) en zorgt dat
de Google API geladen wordt. Server-side verificatie gebeurt apart via
{@see \Framework\Security\ReCaptcha\ReCaptchaVerifier} — dit veld zelf
doet alleen de UI.

De default `name` is `g-recaptcha-response` (Google's eigen veld).

13 public methods
action(string $action): static

v3-only — actienaam die je meegeeft aan grecaptcha.execute().

static create(string $name = 'g-recaptcha-response'): static
getAction(): ?string
getLocale(): string
getMode(): \ReCaptchaMode
getSiteKey(): string
getSize(): string
getTheme(): string
locale(string $locale): static
mode(\ReCaptchaMode $mode): static
siteKey(string $key): static
size(string $size): static
theme(string $theme): static

SelectField

Framework\Form\Field\SelectField
final class

A `<select>` field.

Default krijgt het veld de `data-mm-select` attribuut, waardoor
`public/modules/custom-select.js` 'm automatisch upgraded naar
een dropdown met search + keyboard navigation. Per veld uit te
zetten met `->customSelect(false)`.

SelectField::create('country')
->label('Land')
->options(['nl' => 'Nederland', 'be' => 'België'])
->placeholder('Kies een land…')
->searchMode('always') // 'auto' | 'always' | 'never'
->searchThreshold(8); // toon search vanaf N opties (mode auto)

8 public methods
customSelect(bool $enabled = true): static

Schakel de custom-select-upgrade in/uit voor dit veld.

getOptions(): array
getSearchMode(): string
getSearchThreshold(): int
isCustomSelect(): bool
options(array $options): static
searchMode(string $mode): static

'auto' | 'always' | 'never' — wanneer de search-input getoond wordt.

searchThreshold(int $n): static

Aantal opties vanaf waar 'auto' search toont.

TextField

Framework\Form\Field\TextField
final class
2 public methods
getType(): string
type(string $type): static

Override HTML input type (e.g. 'password', 'tel', 'url', 'search').

TextareaField

Framework\Form\Field\TextareaField
final class
2 public methods
getRows(): int
rows(int $rows): static

ZipcodeField

Framework\Form\Field\ZipcodeField
final class

Postcode-veld met optionele "afstand tot"-dropdown.

- Atomic mode: één tekstveld met regex-validatie per language.
- Composite mode (`withDistance(true)`): tekstveld + select met km-keuzes.

Submit-shape:
- zonder distance: `<name>` = "1234 AB"
- met distance: `<name>[code]` = "1234 AB", `<name>[distance]` = "10"

11 public methods
distances(array $km): static
getDistances(): array
getLanguage(): string
getParts(): array
getWithDistance(): bool
isComposite(): bool
language(string $code): static
renderComposite(array $value = array ( )): string
validate(string $value, array $allValues = array ( )): array
validateComposite(array $value, array $allValues = array ( )): array
withDistance(bool $on = true): static

Form

Framework\Form\Form
final class

A form definition — an ordered collection of fields and layout rows.

Usage with plain fields:
$form = Form::create('contact', '/contact/submit')
->add(TextField::create('name')->label('Naam'))
->add(EmailField::create('email')->label('E-mail'));

Usage with side-by-side layout:
$form = Form::create('registratie', '/submit')
->add(FieldRow::of(
SelectField::create('land')->label('Land'),
TextField::create('telefoon')->label('Telefoonnummer'),
))
->add(FieldRow::of($postcode, $plaats)->widths([1, 2]));

$result = $form->process($_POST);

6 public methods
add(\FormElementInterface $element): self

Add a field or a FieldRow (side-by-side layout).

static create(string $id, string $action = '', string $method = 'POST'): self
getElements(): array
getField(string $name): ?\AbstractField
getFields(): array
process(array $data): \FormResult

Validate submitted data against all field definitions.

Fields hidden by a conditional rule (given the submitted values)
are skipped entirely — their values are not validated.

FormElementInterface

Framework\Form\FormElementInterface
interface

Marker interface for anything that can be added to a Form.

Both AbstractField and FieldRow implement this, so Form::add()
accepts either without losing type safety.

FormFactory

Framework\Form\FormFactory
final class

Builds a Form from a JSON schema string (typically stored in the database
by the CMS form editor).

This is the bridge between CMS storage and the PHP Form Builder renderer.
The renderer knows nothing about JSON; the CMS knows nothing about PHP classes.

Usage:
$form = FormFactory::fromJson($jsonFromDatabase);
$html = (new FormRenderer())->render($form, $result);

Schema shape:
{
"id": "contact",
"action": "/contact/submit",
"method": "POST", // optional, default POST
"elements": [
{
"type": "field",
"field": { ... }
},
{
"type": "row",
"widths": [1, 2], // optional, CSS fr units
"conditional": { ... }, // optional, row-level
"fields": [ { ... }, ... ]
}
]
}

Field shape:
{
"type": "text|email|textarea|select|checkbox|radio",
"name": "field_name",
"label": "Label tekst", // optional
"placeholder": "...", // optional
"hint": "Hulptekst", // optional
"default": "standaard waarde", // optional
"required": true, // optional
"disabled": false, // optional
"options": {"value": "Label"}, // select / radio only
"rows": 4, // textarea only
"inputType": "password", // text only, overrides type attr
"checkboxLabel": "Ik ga akkoord", // checkbox only
"validators": [ ... ], // optional
"conditional": { ... } // optional, field-level
}

Validator shapes:
{"type": "required"}
{"type": "minLength", "min": 2}
{"type": "maxLength", "max": 255}
{"type": "email"}
{"type": "regex", "pattern": "/^[0-9]+$/", "message": "{label} mag alleen cijfers bevatten."}

Conditional shapes (single):
{"field": "type", "operator": "equal", "value": "zakelijk"}

Conditional shapes (composite):
{"logic": "and", "rules": [ {...}, {...} ]}
{"logic": "or", "rules": [ {...}, {...} ]}

2 public methods
static fromArray(array $data): \Form

Build a Form from an already-decoded array.
Useful when the JSON was decoded upstream (e.g. already fetched from DB).

static fromJson(string $json): \Form

Parse a JSON string and return a fully wired Form object.

FormRenderer

Framework\Form\FormRenderer
class

Renders a Form definition into an El tree.

The renderer is the only place where HTML presentation decisions are made.
Fields know nothing about HTML; FormRenderer knows nothing about validation
rules — it only reads field metadata.

1 public method
render(\Form $form, ?\FormResult $result = NULL): string

FormResult

Framework\Form\FormResult
final class

Immutable result of Form::process().

Usage:
$result = $form->process($_POST);

if ($result->isValid()) {
$name = $result->value('name'); // atomic veld
$email = $result->value('email');
$klant = $result->valueArray('klantnaam'); // composite veld
}

$errors = $result->errorsFor('email'); // string[]

__construct(array $values, array $errors)
7 public methods
allErrors(): array
allValues(): array
errorsFor(string $field): array
hasErrors(string $field): bool
isValid(): bool
value(string $field, string $default = ''): string

Returns een atomic-value als string. Voor composites: gebruik valueArray().
Composite-waarden vallen terug op `$default` als je ze als string leest.

valueArray(string $field, array $default = array ( )): array

Returns een composite-value als geneste array. Atomic-waarden vallen
terug op `$default`.

FieldRow

Framework\Form\Layout\FieldRow
final class

Groups fields side-by-side in a CSS grid row.

Basic usage — equal columns:
FieldRow::of(
SelectField::create('land')->label('Land'),
TextField::create('telefoon')->label('Telefoonnummer'),
)

Custom column widths (CSS fr units):
FieldRow::of($postcode, $plaats)->widths([1, 2])
// → "1fr 2fr" — postcode ≈ 1/3, plaats ≈ 2/3

The whole row can be conditionally hidden:
FieldRow::of($vat, $chamber)->showWhen(ConditionalRule::whenEqual('type', 'zakelijk'))

Responsive: on screens narrower than 640 px the columns collapse to a
single stack — no extra config needed, the CSS handles it.

7 public methods
getConditionalJson(): ?string
getFields(): array
getGridTemplateColumns(): string

CSS grid-template-columns value.
Falls back to "repeat(N, 1fr)" when no custom widths are set.

hasConditional(): bool
static of(\AbstractField ...$fields = ?): self

Create a row with equal-width columns.

showWhen(\ConditionalRule|\CompositeRule $rule): self

Hide/show the entire row based on another field's value.

widths(array $fractions): self

Set custom column proportions in CSS fr units.

Example: ->widths([1, 2]) produces "1fr 2fr"
(first column gets 1/3 of the space, second gets 2/3)

Must contain the same number of values as there are fields.

ConditionalValidator

Framework\Form\Validator\ConditionalValidator
final class

Decorator that wraps another ValidatorInterface and only delegates to it
when a `when`-rule evaluates true against the other submitted values.

This is what makes "verplicht als type=zakelijk" possible without changing
the inner validator. Used by FormFactory when a validator definition has
a `when`-clausule in its JSON-shape.

__construct(\ValidatorInterface $inner, \ConditionalRule|\CompositeRule $when, \RuleEvaluator $evaluator = \Framework\Form\Conditional\RuleEvaluator::__set_state(array( )))
1 public method
validate(string $value, string $label, array $allValues = array ( )): ?string

EmailValidator

Framework\Form\Validator\EmailValidator
final class
1 public method
validate(string $value, string $label, array $allValues = array ( )): ?string

MaxLengthValidator

Framework\Form\Validator\MaxLengthValidator
final class
__construct(int $max)
1 public method
validate(string $value, string $label, array $allValues = array ( )): ?string

MinLengthValidator

Framework\Form\Validator\MinLengthValidator
final class
__construct(int $min)
1 public method
validate(string $value, string $label, array $allValues = array ( )): ?string

ReCaptchaValidator

Framework\Form\Validator\ReCaptchaValidator
final class

Server-side validator voor een reCAPTCHA-token.

$form->add(ReCaptchaField::create()->siteKey($siteKey))
->validator('g-recaptcha-response',
new ReCaptchaValidator(
verifier: new ReCaptchaVerifier($secretKey),
remoteIp: $kernel->request->clientIp(),
));

Voor v3: geef `minScore` mee (default 0.5).

`remoteIp` is optioneel — Google accepteert het verzoek ook zonder, maar
met IP is de risk-scoring iets accurater. Geef 'm bij voorkeur door uit
`ServerRequest::clientIp()`.

__construct(\ReCaptchaVerifier $verifier, ?string $remoteIp = NULL, float $minScore = 0.5, string $message = 'Captcha-validatie mislukt.')
1 public method
validate(string $value, string $label, array $allValues = array ( )): ?string

RegexValidator

Framework\Form\Validator\RegexValidator
final class
__construct(string $pattern, string $message)
1 public method
validate(string $value, string $label, array $allValues = array ( )): ?string

RequiredValidator

Framework\Form\Validator\RequiredValidator
final class
1 public method
validate(string $value, string $label, array $allValues = array ( )): ?string

ValidatorInterface

Framework\Form\Validator\ValidatorInterface
interface

A field validator.

Returns null on success, or a human-readable error message on failure.

1 public method
validate(string $value, string $label, array $allValues = array ( )): ?string

A

Framework\Html\A
final class

Typed wrapper voor <a> — href is verplicht.

Gebruik:
echo (new A('/contact'))->text('Neem contact op')->addClass('btn');
echo (new A('https://example.com'))->text('Extern')->external();

__construct(string $href)
7 public methods
add(\El ...$children = ?): self

Child-element als link-inhoud.

addClass(string ...$tokens = ?): self
aria(string $name, string|bool|null $value): self
attr(string $name, string|int|float|bool|null $value): self
external(): self

Opent in nieuw tabblad, zet automatisch rel="noopener noreferrer".

raw(string $html): self

Raw HTML als link-inhoud (bijv. icon + tekst).

text(string $text): self

Escaped tekst als link-label.

ClassList

Framework\Html\ClassList
final class

Een getypte lijst van CSS-klassen — nauw gemodelleerd naar de DOM classList API.

Gebruik via El::$classList:

$el->classList->add('foo', 'bar');
$el->classList->remove('bar');
$el->classList->contains('foo'); // true
$el->classList->toggle('open');
$el->classList->replace('old', 'new');
$el->classList->item(0); // 'foo'
count($el->classList); // 1
foreach ($el->classList as $cls) { ... }
(string) $el->classList; // 'foo'

Shortcuts op El zelf delegeren hiernaartoe:
$el->addClass('foo')->attr('href', '/x');

__construct(array $tokens = array ( ))
9 public methods
add(string ...$tokens = ?): self

Voeg één of meer klassen toe.
Strings met spaties worden gesplitst (bijv. vanuit HTML-attribuut parsing).
Duplicaten worden overgeslagen.

contains(string $token): bool

Geeft true als $token aanwezig is.

count(): int
getIterator(): ArrayIterator
item(int $index): ?string

Geeft het token op positie $index (0-based), of null als buiten bereik.

remove(string ...$tokens = ?): self

Verwijder één of meer klassen.
Geen fout als de class niet aanwezig is.

replace(string $old, string $new): bool

Vervang $old door $new. Geeft true als de vervanging geslaagd is.

$el->classList->replace('btn-primary', 'btn-secondary');

toArray(): array

Geeft alle tokens terug als array.

toggle(string $token, ?bool $force = NULL): self

Voeg toe als afwezig, verwijder als aanwezig.

$el->classList->toggle('open');
$el->classList->toggle('active', $isActive); // force aan/uit

El

Framework\Html\El
final class

Schone, strikte HTML element builder — referentie-implementatie voor nieuwe code.

Bewuste keuzes:
- El::make() heeft geen content-parameter. Tekst via ->text(), raw HTML via ->raw().
- add() accepteert alleen NodeInterface — geen verborgen raw strings.
- final: niet bedoeld om te extenden. Typed elementen (Img, A, ...) wrappen El.
- Geen ArrayObject, geen magic properties, geen deprecated API's.
- classList en style zijn volwaardige objecten, nauw gemodelleerd naar de DOM.

Gebruik:
$card = El::make('div', ['class' => 'card'])
->add(El::make('h2')->text($titel))
->add(El::make('p')->text($omschrijving));

24 public methods
add(\NodeInterface ...$children = ?): self

Voeg één of meer child-nodes toe (El, Img, A, of elke andere NodeInterface).

addClass(string ...$tokens = ?): self
aria(string $name, string|bool|null $value): self

Stel aria-{name} in. null verwijdert het attribuut.

attr(array|string $name, string|int|float|bool|null $value = NULL): self

Stel één of meer attributen in.

->attr('href', '/home')
->attr('disabled', true) // boolean attribuut
->attr('hidden', false) // weglaten
->attr(['href' => '/x', 'target' => '_blank']) // bulk

false/null = attribuut weglaten.
true = boolean attribuut (aanwezig zonder waarde, bijv. disabled).

childAt(int $index): ?self
children(): \ElCollection

Directe element-children — tekst-nodes en fragmenten uitgesloten.

data(string $name, string|int|null $value): self

Stel data-{name} in. null verwijdert het attribuut.

find(string $selector): \ElCollection

Alle descendants die matchen (depth-first).
Ondersteunde selectors: tag, .class, #id, [attr], combinaties (div.card#main[data-x])

first(string $selector): ?self

Eerste descendant die matcht — short-circuit.

static fragment(): self

Fragment — geen wrapper-tag, alleen children.

static fromHtml(string $html): self

Parseer een raw HTML-string naar een fragment met El-children.
Vereist PHP 8.4 (\Dom\HTMLDocument).

getAttr(string $name): ?string

Geeft de string-waarde van $name, of null als het attribuut afwezig is.
Boolean attributen (disabled, required, ...) geven altijd null — gebruik
hasAttr() om aanwezigheid te testen, isBoolAttr() om het type te bepalen.

hasAttr(string $name): bool

Geeft true als het attribuut aanwezig is (zowel string- als boolean-attributen).

hasClass(string $token): bool
isBoolAttr(string $name): bool

Geeft true als het attribuut een boolean attribuut is (aanwezig zonder waarde).

static make(string $nodeName, array $attrs = array ( )): self

Maak een element. Geen content-parameter — gebruik ->text() of ->raw().

matches(string $selector): bool

Geeft true als dit element zelf overeenkomt met $selector.

raw(string $html): self

Voeg raw/trusted HTML toe — NIET escaped.
Naam is bewust expliciet zodat de keuze zichtbaar is in code-reviews.
Gebruik nooit met user input.

removeAttr(string $name): self
removeClass(string ...$tokens = ?): self
removeStyle(string $property): self
setStyle(string $property, string $value): self

Stel één inline CSS-property in. Accepteert camelCase én kebab-case.

text(string $text): self

Voeg escaped tekst toe — veilig voor user input, XSS-proof.

toggleClass(string $token, ?bool $force = NULL): self

ElCollection

Framework\Html\ElCollection
final class

Getypte, fluent collectie van El-elementen.

Geretourneerd door El::find() en El::children().

$frag->find('.card')
->each(fn(El $el) => $el->addClass('active'));

$frag->find('a')->attr('target', '_blank'); // bulk via __call

count($frag->find('li'));
$frag->find('li')[2];
foreach ($frag->find('p') as $p) { ... }

__construct(array $items = array ( ))
12 public methods
count(): int
each(Closure $callback): self

Roep $callback aan voor elk element — geeft $this terug voor chaining.

$collection->each(fn(El $el) => $el->addClass('active'));

filter(Closure|string $test): self

Filter op callback of CSS-selector — geeft nieuwe ElCollection terug.

$collection->filter('.active')
$collection->filter(fn(El $el) => $el->hasAttr('data-id'))

first(): ?\El

Eerste element, of null als de collectie leeg is.

getIterator(): ArrayIterator
isEmpty(): bool

Geeft true als de collectie leeg is.

last(): ?\El

Laatste element, of null als de collectie leeg is.

map(Closure $callback): array

Map naar een nieuwe array (geen ElCollection — resultaat kan van alles zijn).

$hrefs = $collection->map(fn(El $el) => $el->getAttr('href'));

offsetExists(?mixed $offset): bool
offsetGet(?mixed $offset): ?\El
offsetSet(?mixed $offset, ?mixed $value): void
offsetUnset(?mixed $offset): void

Img

Framework\Html\Img
final class

Typed wrapper voor <img> — src is verplicht, alt expliciet.

Voordelen ten opzichte van El::make('img', ['src' => $src]):
- src is required op type-niveau (geen vergeten, geen typo)
- alt expliciet meedenken (toegankelijkheid)
- width/height fluent en typed (int, geen string-soup)
- IDE-autocompletion zonder attrs-array te kennen

Gebruik:
echo new Img('/images/logo.png', alt: 'Multiminded logo')
->width(200)
->height(60)
->addClass('logo');

// Decoratief beeld (alt leeg per WAI-ARIA spec):
echo new Img('/images/bg.jpg');

__construct(string $src, string $alt = '')
5 public methods
addClass(string ...$tokens = ?): self
attr(string $name, string|int|float|bool|null $value): self
height(int $height): self
loading(string $value): self
width(int $width): self

NodeInterface

Framework\Html\NodeInterface
interface

Contract voor alles wat als HTML gerenderd kan worden.

Implementaties:
- El — algemene element builder
- Img — <img> met verplichte src
- A — <a> met verplichte href
- (toekomstige typed elements)

Door NodeInterface te gebruiken als parameter-type in El::add() kunnen
typed wrappers naast El-instanties worden toegevoegd aan een boom,
zonder dat El zijn final-status verliest.

StyleDeclaration

Framework\Html\StyleDeclaration
final class

Inline CSS style declaration — gemodelleerd naar de DOM CSSStyleDeclaration API.

Gebruik via El::$style:

$el->style->set('color', 'red');
$el->style->set('fontSize', '16px'); // camelCase → kebab-case
$el->style->get('color'); // 'red'
$el->style->has('color'); // true
$el->style->remove('color');
(string) $el->style; // 'font-size: 16px'

Shortcuts op El zelf voor fluent chaining:
$el->style('color', 'red')->style('margin', '0');
$el->removeStyle('color');

__construct(array $properties = array ( ))
8 public methods
count(): int
static fromString(string $css): self
get(string $property): ?string

Geeft de waarde van $property terug, of null als afwezig.

getIterator(): ArrayIterator
has(string $property): bool

Geeft true als de property aanwezig is.

properties(): array

Geeft alle property-namen (kebab-case) terug.

remove(string $property): self

Verwijder een CSS-property.

set(string $property, string $value): self

Stel een CSS-property in. Accepteert camelCase én kebab-case.
Lege waarde verwijdert de property.

CachingTransport

Framework\Http\CachingTransport
final class

Decorator-Transport die GET/HEAD-responses naar disk cached.

Activatie per Request via {@see Request::withCacheTtl()}; zonder ttl
gaat de call rechtstreeks naar de inner transport (geen cache, geen schrijfwerk).

Cacht alleen 2xx-responses — error-responses worden niet bewaard.

__construct(\TransportInterface $inner, string $cacheDir)
2 public methods
invalidate(\Request $request): void
send(\Request $request): \Response

Client

Framework\Http\Client
final class

High-level facade boven {@see Transport}.

Wrapt veelgebruikte patronen (get, post, postJson) en houdt de Transport
herbruikbaar — dezelfde Client kan tegen elke Transport (curl, caching,
fake-in-test) draaien.

__construct(\TransportInterface $transport, \Headers $defaultHeaders = \Framework\Http\Headers::__set_state(array( 'values' => array ( ), 'originalCase' => array ( ), )))
4 public methods
get(\Url|string $url): \Response
post(\Url|string $url, ?string $body = NULL): \Response
postJson(\Url|string $url, array $data): \Response
send(\Request $request): \Response

CurlTransport

Framework\Http\CurlTransport
final class

Default Transport-implementatie via ext-curl.

Geen automatische debug-headers, geen ingebakken cache, geen JSON-decoding.
Eén verantwoordelijkheid: bytes heen, bytes terug.

__construct(?string $caInfo = NULL)
1 public method
send(\Request $request): \Response

Headers

Framework\Http\Headers
final class

Case-insensitive multi-value HTTP-headers.

Behoudt de oorspronkelijke schrijfwijze van een header (`Set-Cookie` blijft
`Set-Cookie`) maar matcht op lowercase, zoals de HTTP-spec voorschrijft.
Multi-value omdat headers als `Set-Cookie` of `Via` meerdere keren mogen
voorkomen — dat ging verloren in de oude `library/Http/Headers` (die
eigenlijk een `stdClass`-string-map was).

__construct(iterable $entries = array ( ))
11 public methods
get(string $name): ?string

Geef de eerste waarde, of null.

getAll(string $name): array
has(string $name): bool
names(): array
static parseResponseString(string $raw): self

Parse de raw response-header string van curl naar een Headers-object.
Slaat status-line en lege regels over.

toArray(): array
toAssociative(): array
toLines(): array

Plaag voor logging/curl: ['Name: value', ...]. Multi-values produceren
meerdere regels.

with(string $name, string $value): self
withAdded(string $name, string $value): self
without(string $name): self

HttpException

Framework\Http\HttpException
class

Gegooid bij netwerk-fouten, timeouts, of niet-decodeerbare responses.
HTTP-statuscodes 4xx/5xx leveren géén exception op — die staan in {@see Response}.

__construct(string $message, ?\Request $request = NULL, ?Throwable $previous = NULL)

Method

Framework\Http\Method
enum

HTTP-methodes — compleet in tegenstelling tot library/Http/RequestMethod
(die mist PUT/PATCH/HEAD/OPTIONS terwijl HEAD wél gebruikt wordt).

4 public methods
allowsBody(): bool

Mag deze methode een body hebben? (Strict gezien mag GET/HEAD/DELETE wel een body, maar in praktijk doe je dat niet.)

static cases(): array
static from(string|int $value): static
static tryFrom(string|int $value): ?static
Cases: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS

Request

Framework\Http\Request
final class

Immutable HTTP-request value-object.

Bevat alle informatie om een aanroep te doen — maar voert 'm niet uit.
Het uitvoeren is de verantwoordelijkheid van een {@see Transport}.

Bouw via with-pattern:

$req = (new Request(Method::GET, Url::parse('https://api.example.com/v1/items')))
->withHeader('Authorization', 'Bearer …')
->withQuery(['page' => 2])
->withTimeout(5.0)
->withCacheTtl(300);

__construct(\Method $method, \Url $url, \Headers $headers = \Framework\Http\Headers::__set_state(array( 'values' => array ( ), 'originalCase' => array ( ), )), ?string $body = NULL, float $timeout = 30.0, bool $followRedirects = true, bool $verifySsl = true, ?int $cacheTtl = NULL)
14 public methods
cacheKey(): string

Stabiele cache-key — voor {@see CachingTransport}. Sluit auth-headers uit
(Authorization, Cookie) zodat per-user requests niet de cache delen.

static get(\Url|string $url): self
static post(\Url|string $url, ?string $body = NULL): self
static postJson(\Url|string $url, array $data): self
withBody(?string $body): self
withCacheTtl(?int $seconds): self
withFollowRedirects(bool $follow): self
withHeader(string $name, string $value): self
withHeaders(\Headers $headers): self
withMethod(\Method $method): self
withQuery(array $query, bool $merge = true): self
withTimeout(float $seconds): self
withUrl(\Url $url): self
withVerifySsl(bool $verify): self

Response

Framework\Http\Response
final class

Immutable HTTP-response: status, headers, raw body.

In tegenstelling tot library/Http/Response wordt de body NIET automatisch
gedecodeerd — dat is opt-in via {@see json()} of {@see assoc()}.
Zo blijft de raw body altijd beschikbaar voor binaire/non-JSON responses.

__construct(int $status, \Headers $headers, string $body)
11 public methods
static asJson(?mixed $data, int $status = 200): self

Bouw een JSON-response. Naam is `asJson` (niet `json`) omdat de
instance-methode {@see Response::json()} al bestaat als decoder voor
inkomende responses.

assoc(): array
static html(string $body, int $status = 200): self

200 OK met `text/html`-Content-Type — de standaard voor pagina-handlers.

isClientError(): bool
isOk(): bool
isRedirect(): bool
isServerError(): bool
json(bool $associative = false): ?mixed

Decodeer de body als JSON. Geeft null bij parse-fouten.
Gebruik {@see assoc()} als je een associative-array wil.

static notFound(string $body = '404 Not Found'): self

404 met optionele body — handig in een fallback-route.

static redirect(string $location, int $status = 302): self

302 Found (default) of een andere 3xx; lege body, alleen `Location`-header.

static text(string $body, int $status = 200): self

200 OK met `text/plain`-Content-Type.

ServerRequest

Framework\Http\ServerRequest
final class

Inbound HTTP-request — de aanvraag die de server ontvangt.

Naast de PSR-7-achtige opzet (method/url/headers/body) zit er ergonomie op:
- query/body als {@see Parameters} → typed coercion via define()
- cookies als Parameters
- JSON-body wordt automatisch geparsed als Content-Type application/json is
(PHP's $_POST is dan leeg)
- clientIp() kijkt naar X-Forwarded-For voor reverse-proxies

Immutable: with*-methodes geven een nieuwe instance terug. Routes gebruiken
dat om route-params toe te voegen zonder de "huidige" request te muteren.

__construct(\Method $method, \Url $url, \Headers $headers, \Parameters $query, \Parameters $body, \Parameters $cookies, string $rawBody = '', array $routeParams = array ( ))
7 public methods
static capture(): self

Bouw een ServerRequest uit globals (PHP-FPM / mod_php). Voor tests:
gebruik de constructor direct met je eigen waarden.

clientIp(array $trustedProxies = array ( )): string

Geeft het IP van de client. Vertrouwt X-Forwarded-For alleen als REMOTE_ADDR
in $trustedProxies staat — anders gebruik je domweg REMOTE_ADDR.

isAjax(): bool
isJson(): bool
isPost(): bool
param(int $index, string $default = ''): string

Route-parameter op positie-index (capture-group uit het pattern).

withRouteParams(array $params): self

TransportInterface

Framework\Http\TransportInterface
interface

Stuurt een {@see Request} de wereld in en levert een {@see Response} op.

Implementaties:
- {@see CurlTransport} — echte HTTP-call via curl
- {@see CachingTransport} — decorator die cache-hits buiten de inner-transport om afhandelt
- tests gebruiken meestal een eigen FakeTransport

1 public method
send(\Request $request): \Response

Url

Framework\Http\Url
final class

Immutable URL-value-object met fluent with*-pattern voor query-merging.

Vervangt de mix van parse_url + http_build_query + handmatige fallback
uit library/Http/Request.

__construct(string $scheme = 'https', string $host = '', ?int $port = NULL, string $path = '/', array $query = array ( ), ?string $fragment = NULL, ?string $user = NULL, ?string $pass = NULL)
6 public methods
static parse(string $url): self
relative(): string

Pad + query + fragment, zónder scheme/host.

withParam(string $name, ?mixed $value): self

Voeg/overschrijf één query-parameter.

withPath(string $path): self
withQuery(array $extra, bool $merge = true): self
withoutParam(string $name): self

Verwijder één query-parameter.

GdBackend

Framework\Image\Backend\GdBackend
final class

GD-implementatie van {@see ImageBackendInterface}.

GD is gebouwd in PHP en bijna overal aanwezig — handig als fallback
wanneer ext-imagick ontbreekt. Ondersteunt jpg/png/webp; AVIF alleen
als PHP gebouwd is tegen libavif (vaak niet — gooit dan een
ImageBackendException).

Voor productie met avif-support en betere kleur-management is
{@see ImagickBackend} sterker. De keuze gebeurt in bootstrap.

__construct()
1 public method
transform(string $imageBytes, \TransformSpec $spec): string

ImageBackendException

Framework\Image\Backend\ImageBackendException
final class

Backend kon niet decoden / encoden / transformeren — meestal corrupte
input of een format-mismatch. Mapt naar HTTP 422 in de handler.

__construct(string $message = '', int $code = 0, ?Throwable $previous = NULL)

ImageBackendInterface

Framework\Image\Backend\ImageBackendInterface
interface

Pure transform-laag: image-bytes in, image-bytes uit.

Geen IO, geen cache, geen network — alleen de berekening. De Transformer
en Handler zijn verantwoordelijk voor source-IO en cache-write.

V1: alleen {@see ImagickBackend}. libvips/GD-backends kunnen later
worden toegevoegd via dezelfde interface.

1 public method
transform(string $imageBytes, \TransformSpec $spec): string

ImagickBackend

Framework\Image\Backend\ImagickBackend
final class

Imagick-implementatie van {@see ImageBackendInterface}.

Mapping van fit-modes naar Imagick-API:
- Cover → cropThumbnailImage (vult target, snijdt af)
- Contain → thumbnailImage met `bestfit=true` + zwarte/transparante padding
- Fit → scaleImage met aspect-ratio behouden, geen padding
- Crop → exact cropImage centered (vereist W én H)

Output-format via {@see Format} → setImageFormat. AVIF/WebP vereisen
een Imagick gebouwd tegen libheif/libwebp — vrijwel alle moderne
distros hebben dit.

__construct()
1 public method
transform(string $imageBytes, \TransformSpec $spec): string

VariantCache

Framework\Image\Cache\VariantCache
final class

Schrijft / leest bytes onder een relative path binnen een root-directory.

Wordt twee keer gebruikt:
- Voor de getransformeerde varianten (`data/cache/img/`)
- Voor geëxtraheerde video-frames (`data/cache/img/frames/`)

Schrijven gaat atomair (tmp-file + rename) zodat een crash midden in de
write geen halve files achterlaat — een halve file zou nginx als geldige
cache-hit serveren en de browser zou corrupt content krijgen.

Path-traversal guard: relative path mag geen `..` of absolute prefix bevatten.

__construct(string $root)
4 public methods
exists(string $relativePath): bool
path(string $relativePath): string
read(string $relativePath): ?string
write(string $relativePath, string $bytes): void

FitMode

Framework\Image\FitMode
enum

Hoe een afbeelding in z'n target-box past. Spiegelt CSS object-fit
voor consistentie met Framework\Media\DisplayMode.

5 public methods
static cases(): array
static from(string|int $value): static
static fromToken(string $token): self
token(): string
static tryFrom(string|int $value): ?static
Cases: Cover, Contain, Fit, Crop

Format

Framework\Image\Format
enum

Output-formaat voor de transform.

Format::Auto is een marker — UrlBuilder weigert 'm. Image::url() resolved
Auto naar een concreet formaat op basis van de Accept-header van de
huidige request, vóór 'ie de URL bouwt.

6 public methods
static cases(): array
extension(): string
static from(string|int $value): static
static fromExtension(string $ext): self
mimeType(): string
static tryFrom(string|int $value): ?static
Cases: Webp, Avif, Jpeg, Png, Auto

HmacSigner

Framework\Image\HmacSigner
final class

HMAC-SHA256 signer met rotation-support.

`sign()` gebruikt altijd het huidige secret. `verify()` probeert eerst
het huidige, dan (als gezet) het vorige — zodat tijdens een rotation-
grace-period URLs gegenereerd onder het oude secret nog geldig zijn.

Beide vergelijkingen via `hash_equals()` om timing-attacks te voorkomen.

__construct(string $currentSecret, ?string $previousSecret = NULL)
2 public methods
sign(string $payload): string
verify(string $payload, string $signature): bool

ImageHandler

Framework\Image\Http\ImageHandler
final class

HTTP-handler voor `/img/...`-routes.

Flow:
1. Parse URL → ParsedUrl (intern hash óf extern URL + sig-verify)
2. Variant-cache check → hit: serve direct, miss: stap 3
3. Resolve source-bytes (FileStorage óf remote-fetch+ingest)
4. StillFrame-extractie (passthrough / GIF frame 0 / video frame via ffmpeg)
Voor video: tussencache zodat ffmpeg niet per maat opnieuw runt
5. Transform via backend
6. Schrijf naar variant-cache zodat nginx volgende requests direct serveert

__construct(\UrlParser $urlParser, \FileStorageSource $internalSource, \RemoteUrlSource $remoteSource, \StillFrameRegistry $stillFrames, \Transformer $transformer, \VariantCache $variantCache, \VariantCache $frameCache)

Image

Framework\Image\Image
final class

Publieke facade voor URL-generatie.

Source mag zijn:
- {@see FileRecord} — gebruikt `contentHash` voor lokale, of `externalUrl`
voor externe (YouTube/Vimeo etc. zijn doorgaans niet relevant voor
image-transform, maar Realworks-style "extern via URL" werkt zo)
- `string` met sha256-hash (64 hex chars) — direct internal
- `string` met http(s) URL — extern, met HMAC-signing

Format::Auto resolveert op aanroep-tijd op basis van de huidige
Accept-header. Dat gebeurt via de `acceptHeaderProvider`-closure
zodat we op één Image-instance per request blijven (singleton in
de container) en de URL toch context-bewust is.

__construct(\UrlBuilder $builder, Closure $acceptHeaderProvider)
1 public method
url(\FileRecord|string $source, ?int $width = NULL, ?int $height = NULL, \FitMode $fit = \Framework\Image\FitMode::Cover, \Format $format = \Framework\Image\Format::Auto, int $quality = 75, int $dpr = 1): string

FileStorageSource

Framework\Image\Source\FileStorageSource
final class

Resolveert een sha256-hash naar bytes via de bestaande FileStorage.

Mime-type komt uit `finfo_buffer()` op de eerste paar KB — zo werkt 't
ook voor files die zonder extensie in storage zijn gezet (b.v. via
Realworks-import).

__construct(\FileStorage $storage)
1 public method
resolve(string $identifier): \SourceResult

RemoteFetchException

Framework\Image\Source\RemoteFetchException
final class

Externe fetch faalde (timeout, non-2xx, te groot, niet-toegestaan content-type).
Mapt naar HTTP 502 / 415 in de handler afhankelijk van de oorzaak.

__construct(string $message, string $reason, ?Throwable $previous = NULL)

RemoteUrlSource

Framework\Image\Source\RemoteUrlSource
final class

Haalt een externe URL op, valideert content-type + grootte, en ingest
de bytes in de gedeelde FileStorage. Volgende calls met dezelfde URL
(en dus dezelfde bytes/hash) zijn dedupe-gratis.

Gooit {@see RemoteFetchException} bij netwerkfouten, niet-2xx-status,
verboden content-type of een body groter dan `maxBytes`.

__construct(\Client $client, \FileStorage $storage, int $maxBytes = 20971520, float $timeoutSeconds = 5.0, array $allowedMimePrefixes = array ( 0 => 'image/', 1 => 'video/', ), string $userAgent = 'FrameworkImage/1.0 (+https://framework.multiminded.nl/image)')
1 public method
resolve(string $identifier): \SourceResult

SourceNotFoundException

Framework\Image\Source\SourceNotFoundException
final class

Source-resolver kon de identifier niet vinden.
Mapt naar HTTP 404 in de handler.

__construct(string $message = '', int $code = 0, ?Throwable $previous = NULL)

SourceResolverInterface

Framework\Image\Source\SourceResolverInterface
interface

Levert ruwe bytes voor een transform-pijplijn.

Identifier-vorm verschilt per implementatie:
- {@see FileStorageSource}: een sha256-hash
- {@see RemoteUrlSource}: een http(s) URL

Implementaties moeten {@see SourceNotFoundException} gooien als de
identifier niet leidt tot bytes.

1 public method
resolve(string $identifier): \SourceResult

SourceResult

Framework\Image\Source\SourceResult
final class

Resultaat van een SourceResolver: ruwe bytes + mime-type + content-hash.

Bytes kunnen image, gif of video zijn — dat bepaalt de StillFrameExtractor.
`sourceHash` is sha256 van de bytes; gebruikt als cache-key voor
geëxtraheerde frames (zodat twee URLs die naar dezelfde video wijzen
hetzelfde frame delen).

__construct(string $rawBytes, string $mimeType, string $sourceHash)

SpecCodec

Framework\Image\SpecCodec
final class

Codeert / decodeert een TransformSpec van/naar het canonieke URL-token.

Format: {w}x{h}-{fit}-q{quality}[-dpr{n}]
- w of h mag ontbreken (bv. "800" of "x600")
- fit en quality zijn altijd aanwezig (URLs blijven leesbaar)
- dpr verschijnt alleen als > 1

Encoder is strikt (canonieke volgorde, voorspelbare cache-key).
Decoder is permissief: tokens mogen in willekeurige volgorde — handig
bij handmatig getypte URLs of toekomstige migraties.

2 public methods
decode(string $token, \Format $format): \TransformSpec
encode(\TransformSpec $spec): string

GifFrameExtractor

Framework\Image\StillFrame\GifFrameExtractor
final class

Extract het eerste frame van een (animated) GIF via Imagick.

Output is een **PNG** — lossless intermediate, want we willen geen
kwaliteitsverlies vóór de Transformer er wat mee doet. De Transformer
kiest het uiteindelijke output-format.

Vereist `ext-imagick`. Als de extensie ontbreekt gooit de constructor
een StillFrameException — bootstrap moet 'm dan niet binden.

__construct()
1 public method
extract(string $rawBytes, string $mimeType): string

PassthroughExtractor

Framework\Image\StillFrame\PassthroughExtractor
final class

No-op extractor voor formaten die de ImageBackend rechtstreeks aankan
(jpg/png/webp/avif/svg). Geeft de bytes ongewijzigd door.

1 public method
extract(string $rawBytes, string $mimeType): string

StillFrameException

Framework\Image\StillFrame\StillFrameException
final class

Fout bij frame-extractie (gif/video). Mapt naar HTTP 422 / 500 in de handler.

__construct(string $message = '', int $code = 0, ?Throwable $previous = NULL)

StillFrameExtractor

Framework\Image\StillFrame\StillFrameExtractor
interface

Vertaalt ruwe source-bytes naar **image-bytes** die de Transformer kan
verwerken. Implementaties zijn responsible voor:
- jpg/png/webp/avif/svg → bytes ongewijzigd doorgeven
- gif → frame 0 extracten als losse image
- mp4/mov/webm → frame extractie via ffmpeg

De selectie gebeurt in {@see StillFrameRegistry} op basis van mime-type.

1 public method
extract(string $rawBytes, string $mimeType): string

StillFrameRegistry

Framework\Image\StillFrame\StillFrameRegistry
final class

Kiest een {@see StillFrameExtractor} op basis van het mime-type.

Default-mapping:
- image/gif → GifFrameExtractor
- video/* → VideoFrameExtractor
- alles anders → PassthroughExtractor (jpg/png/webp/avif/svg)

Bootstrap kan via de constructor extractors weglaten als de
vereiste deps ontbreken (bv. geen Imagick → geen Gif-handler).

__construct(\StillFrameExtractor $passthrough, ?\StillFrameExtractor $gif = NULL, ?\StillFrameExtractor $video = NULL)
2 public methods
extract(string $rawBytes, string $mimeType): string
pick(string $mimeType): \StillFrameExtractor

VideoFrameExtractor

Framework\Image\StillFrame\VideoFrameExtractor
final class

Extract een frame uit een video via een ffmpeg subprocess.

Werkt via stdin/stdout pipes — geen temp-files nodig:
ffmpeg -i pipe:0 -ss <seconds> -frames:v 1 -f image2 -vcodec mjpeg pipe:1

Output is JPEG (lossy maar compact); de Transformer re-encodeert toch
naar het uiteindelijke formaat. Default frame-time is 1 seconde — dat
vermijdt zwarte/intro-frames die vaak op t=0 staan.

`$ffmpegBinary` is configureerbaar (settings.php) zodat servers met
een non-standard pad (bv. `/usr/local/bin/ffmpeg`) ook werken.

__construct(string $ffmpegBinary = 'ffmpeg', float $frameTimeSeconds = 1.0, int $timeoutSeconds = 10)
2 public methods
extract(string $rawBytes, string $mimeType): string
static isFfmpegAvailable(string $binary = 'ffmpeg'): bool

Cheap check — caller (bootstrap) kan beslissen of de extractor
geregistreerd moet worden.

TransformBounds

Framework\Image\TransformBounds
final class

Project-bounds voor TransformSpec — voorkomt dat een geldige URL een
50000×50000-render kan triggeren die de RAM opvreet.

Config in config/settings.php onder de 'image'-sleutel.

__construct(int $maxWidth = 2600, int $maxHeight = 2600, int $maxDpr = 3)
1 public method
validate(\TransformSpec $spec): void

TransformSpec

Framework\Image\TransformSpec
final class

Wat we met een afbeelding willen doen — onafhankelijk van waar 'ie vandaan komt.

Validatie hier is intrinsiek (logische correctheid). Project-bounds
(max-w/h/dpr) leven in {@see TransformBounds} omdat die per project
configureerbaar zijn (config/settings.php).

__construct(?int $width = NULL, ?int $height = NULL, \FitMode $fit = \Framework\Image\FitMode::Cover, \Format $format = \Framework\Image\Format::Webp, int $quality = 75, int $dpr = 1)
3 public methods
effectiveHeight(): ?int
effectiveWidth(): ?int
withFormat(\Format $format): self

Transformer

Framework\Image\Transformer
final class

Orchestreert: bounds-validate → backend transform.

Geen IO. Caller (Handler) is verantwoordelijk voor source-IO en
cache-write. Deze klasse is dunne glue tussen TransformBounds en
de backend zodat beide testbaar blijven zonder elkaar.

__construct(\ImageBackendInterface $backend, \TransformBounds $bounds)
1 public method
transform(string $imageBytes, \TransformSpec $spec): string

InvalidUrlException

Framework\Image\Url\InvalidUrlException
final class
__construct(string $message = '', int $code = 0, ?Throwable $previous = NULL)

ParsedUrl

Framework\Image\Url\ParsedUrl
final class

Resultaat van UrlParser. Discriminate via isInternal() / isExternal().

4 public methods
static external(string $sourceUrl, \TransformSpec $spec): self
static internal(string $fileHash, \TransformSpec $spec): self
isExternal(): bool
isInternal(): bool

UrlBuilder

Framework\Image\Url\UrlBuilder
final class

Bouwt URLs voor de image-handler.

- Intern: /img/{prefix}/{file-hash}/{spec}.{ext}
- Extern: /img/extern/{prefix}/{url-hash}/{spec}.{ext}?u={base64url}&sig={hmac}

Format::Auto wordt door deze klasse niet geresolved — caller moet
eerst een concreet formaat kiezen (Image-facade doet dat op basis van
Accept-header).

__construct(\SpecCodec $codec, \HmacSigner $signer)
3 public methods
buildExternal(string $sourceUrl, \TransformSpec $spec): string
buildInternal(string $fileHash, \TransformSpec $spec): string
signaturePayload(string $sourceUrl, string $specToken, string $extension): string

UrlParser

Framework\Image\Url\UrlParser
final class

Parseert URLs gegenereerd door UrlBuilder en verifieert HMAC voor extern.

__construct(\SpecCodec $codec, \HmacSigner $signer)
1 public method
parse(string $path, string $queryString): \ParsedUrl

FileLogger

Framework\Log\FileLogger
final class

Logger die naar een bestand schrijft. PSR-3 compliant.

Eén regel per record, formaat:
[2026-05-05T10:23:14+02:00] LEVEL: message {context-as-json}

Filtert op minLevel: alles onder die drempel wordt genegeerd.

$logger = new FileLogger('/var/log/app.log', LogLevel::INFO);
$logger->info('User logged in', ['user_id' => 42]);

__construct(string $path, string $minLevel = 'debug')
1 public method
log(?mixed $level, Stringable|string $message, array $context = array ( )): void

AbstractSource

Framework\Media\AbstractSource
abstract class

Abstract base voor alle media-bronnen. Subclasses
(`Source\Image`, `Source\Video`, `Source\YouTube`, `Source\Vimeo`) weten hoe
je hun URL embed't en welke HTML-attrs relevant zijn voor hun type. De main
`Renderer` orchestreert ze; per-type detail leeft binnen de subclass — geen
centrale type-match.

Constructor blijft compatibel: alle subclasses accepteren `url`, optioneel
`width` en `height`. URL-validatie is **lazy** — een `Source\YouTube` met
een onparsebare URL gooit niet bij constructie maar bij `videoId()` (of
geeft null terug). CMS-content is soms placeholder; we vallen niet om.

Naamgeving: project-conventie is `Abstract`-prefix voor base-classes
waarvan je niet zelf een instance maakt.

__construct(string $url, ?int $width = NULL, ?int $height = NULL)
2 public methods
isEmpty(): bool
render(\Variant $variant, \RenderOptions $options): \El

Render deze bron als inner-element (`<img>`, `<video>`, `<iframe>`, …).
De main Renderer wrapt 'm vervolgens in z'n variant-container met
overlay/play-button/aspect-ratio. Variant geeft toegang tot
posterUrl/overlayUrl/hoverUrl en variant-specifieke options.

Breakpoints

Framework\Media\Breakpoints
final class

Bekende variant-keys met hun default CSS media-query.

Een `Variant` heeft een optionele `when:` (eigen CSS media query string).
Is die null en is de variant-key bekend, dan wordt de default uit deze tabel
gebruikt. Onbekende keys zonder eigen `when:` matchen nooit — dat dwingt
callers om voor custom-keys (bv. `'compact'`) expliciet een query mee te
geven.

`desktop` is hier expliciet een variant zoals elke andere (geen impliciete
"altijd zichtbaar"-default). Dat voorkomt het 769–1024px gat waarin een
desktop-video per ongeluk op tablet zou kunnen lekken.

2 public methods
static knownKeys(): array
static resolve(string $key, ?string $when): ?string

Geeft de effectieve CSS media-query voor een variant.
Eigen `$when` wint; anders fallback op {@see self::DEFAULTS}; anders null.

Decoder

Framework\Media\Decoder
final class

Mixed input → `Media`.

Eén plek waar alle alias-mapping uit legacy CMS-content gebeurt.
`Decoder::decode()` accepteert:
- `null` of `''` → lege Media (geen variants)
- `string` JSON → decoded en doorgereikt
- `string` URL → image (of video/youtube/vimeo via auto-detect),
onder `variants['desktop']`
- `array` / `object` → key-mapping (zie hieronder)
- `Media` → as-is (idempotent)

Geaccepteerde keys** (snake_case én camelCase, eerste match wint):
type 'image'|'video'|'youtube'|'vimeo'
src / image / image_src
image_mobile / imageMobile
video / youtube / vimeo
width / height
poster / video_poster / videoPoster
overlay / overlay_url
hover / hover_url
desktop, mobile, tablet, dark, print, ... genest object (variant-shorthand)

Variant-shorthand**: zodra de input minimaal één breakpoint-key bevat
(een key die in `Breakpoints::DEFAULTS` staat), worden die keys
recursief gedecoded en geplaatst onder `Media::variants[<key>]`.

Platte input** (zonder breakpoint-keys) wordt geplaatst onder
`variants['desktop']` — desktop is gewoon een eersterangs variant zoals
elke andere, geen impliciete fallback.

1 public method
static decode(?mixed $input): \Media

DisplayMode

Framework\Media\DisplayMode
enum

Hoe de media past binnen z'n container.

- Fit: proportioneel binnen de container, hele beeld zichtbaar (`object-fit: contain`)
- Cover: vult de container, kan croppen (`object-fit: cover`)
- Resize: fysiek geresized naar exacte width/height (server-side url-transform; alleen
bruikbaar via een MediaTransformer — anders gedraagt 'ie zich als Cover)

3 public methods
static cases(): array
static from(string|int $value): static
static tryFrom(string|int $value): ?static
Cases: Fit, Cover, Resize

Media

Framework\Media\Media
final class

Een set `Variant`s die samen "de media" vormen voor één content-item.

Een Media heeft N variants, elk gekoppeld aan een breakpoint-key:
`'desktop'`, `'mobile'`, `'tablet'`, `'dark'`, `'print'`, of een custom
key zoals `'compact'`. De Renderer toont per viewport/context één variant
via een scoped `<style>`-block dat z'n CSS media queries genereert uit
`Variant::$when` (of `Breakpoints::DEFAULTS` voor bekende keys).

Callers werken zelden direct met `Variant` — bijna alles loopt via
`Media::from()` (decoder) en `Media::render()` (decode + render in één call).

echo Media::render($cmsRow, width: 1600, aspectRatio: '16/9');

$media = Media::from($cmsRow);
echo $media->render(width: 800); // instance helper
echo $media->getVariant('mobile')?->source->url;

Manuele constructie blijft beschikbaar voor volledige controle:

use Framework\Media\Source\{Image, Video};

$media = new Media([
'desktop' => new Variant(new Video('/promo.mp4')),
'mobile' => new Variant(new Image('/hero-mobile.jpg')),
'dark' => new Variant(new Image('/hero-dark.jpg')),
]);

Readonly value-object — geen mutators in v1. Als je variants moet kunnen
uitbreiden na constructie, voegen we later `withVariant()` toe.

__construct(array $variants = array ( ))
7 public methods
static element(?mixed $input, ?int $width = NULL, ?int $height = NULL, \DisplayMode|string|null $mode = NULL, ?string $aspectRatio = NULL, \PosterMode|string $poster = \Framework\Media\PosterMode::Default, ?string $posterUrl = NULL, bool $showPlayButton = false, bool $autoplay = false, bool $loop = false, bool $controls = true, bool $muted = true, bool $lazy = true, ?string $fetchPriority = NULL, ?string $alt = NULL, ?string $cssClass = NULL): \El

Zoals `render()` maar returnt het `El`-object zodat caller er nog op
kan doorbouwen.

static from(?mixed $input): self

Decode mixed input (string URL / JSON / array / object / Media) naar
een Media. Zie {@see Decoder::decode()} voor de geaccepteerde shapes.

getDefaultVariant(): ?\Variant

Eén variant terug die "altijd" zichtbaar zou moeten zijn — handig voor
fallback-rendering, og-image-extractie, alt-text, etc.

Strategie: 'desktop' wint, anders eerste niet-lege variant, anders null.

getVariant(string $key): ?\Variant
hasVariant(string $key): bool
isEmpty(): bool
static render(?mixed $input, ?int $width = NULL, ?int $height = NULL, \DisplayMode|string|null $mode = NULL, ?string $aspectRatio = NULL, \PosterMode|string $poster = \Framework\Media\PosterMode::Default, ?string $posterUrl = NULL, bool $showPlayButton = false, bool $autoplay = false, bool $loop = false, bool $controls = true, bool $muted = true, bool $lazy = true, ?string $fetchPriority = NULL, ?string $alt = NULL, ?string $cssClass = NULL): string

Convenience: decode + render in één call. De named args mappen 1-op-1
op `RenderOptions`. Voor cross-type/per-variant overrides bouw je een
`Media` expliciet en roep je `$media->render(...)` aan.

echo Media::render($cmsRow,
width: 1600, height: 600, mode: 'cover', aspectRatio: '16/9',
autoplay: true, muted: true, poster: 'thumbnail',
);

PosterMode

Framework\Media\PosterMode
enum

Hoe de Renderer een poster (placeholder) bepaalt voor een video-source.

- None: géén poster — `<video>` toont de eerste frame zelf
- Default: gebruik wat op `MediaItem::$poster` gezet is (anders fallback per type)
- Thumbnail: forceer auto-thumbnail (YouTube hqdefault.jpg, video → /original/→/thumbnail/.jpg)
- Custom: expliciet gezette URL (dat is wat al op `RenderOptions::$posterUrl` staat)

3 public methods
static cases(): array
static from(string|int $value): static
static tryFrom(string|int $value): ?static
Cases: None, Default, Thumbnail, Custom

RenderOptions

Framework\Media\RenderOptions
final class

Render-instellingen — gewoon een readonly bag van display-opties.

Defaults zijn bewust *conservatief*: lazy-load aan, geen autoplay, controls
voor zelf-gehoste video aan, gemute uit. Dit volgt browser-defaults waar
mogelijk en blijft accessible (geen surprise-audio).

__construct(?int $width = NULL, ?int $height = NULL, ?\DisplayMode $mode = NULL, ?string $aspectRatio = NULL, \PosterMode $poster = \Framework\Media\PosterMode::Default, ?string $posterUrl = NULL, bool $showPlayButton = false, bool $autoplay = false, bool $loop = false, bool $controls = true, bool $muted = true, bool $lazy = true, ?string $fetchPriority = NULL, ?string $alt = NULL, ?string $cssClass = NULL)
1 public method
with(?int $width = NULL, ?int $height = NULL, ?\DisplayMode $mode = NULL, ?string $aspectRatio = NULL, ?\PosterMode $poster = NULL, ?string $posterUrl = NULL, ?bool $showPlayButton = NULL, ?bool $autoplay = NULL, ?bool $loop = NULL, ?bool $controls = NULL, ?bool $muted = NULL, ?bool $lazy = NULL, ?string $fetchPriority = NULL, ?string $alt = NULL, ?string $cssClass = NULL): self

Kleine fluent helpers — handig voor templates die er één optie aan willen
zetten zonder de hele constructor opnieuw te tikken.

Renderer

Framework\Media\Renderer
final class

`Media` + `RenderOptions` → `Framework\Html\El`-tree.

Per-type rendering wordt gedelegeerd naar `Source::render()`. De Renderer
doet alleen het orchestratie-werk:
- Per variant een wrapper-element met `data-variant="<key>"`,
eigen `aspect-ratio` (uit Variant.options of defaults), play-button,
overlay, hover.
- Alle variants samen in een `mm-media-stack`-container met daarin een
scoped `<style>`-block dat per breakpoint toggelt welke variant
`display: block` krijgt.

Single-variant short-circuit: als er maar één variant is, geen stack/style
— gewoon de variant-wrapper teruggeven.

Wrap-structuur — meerdere variants:

<div class="mm-media-stack" data-mm-id="m-7f3a" data-mm-component="media">
<style>...</style>
<div class="mm-media" data-variant="desktop" style="aspect-ratio:16/9">...</div>
<div class="mm-media" data-variant="mobile" style="aspect-ratio:1/1">...</div>
<div class="mm-media" data-variant="dark">...</div>
</div>

Wrap-structuur — single variant:

<div class="mm-media" data-mm-component="media" data-variant="desktop"
style="aspect-ratio:..."> ... </div>

__construct(?\Image $imageBuilder = NULL)
1 public method
render(\Media $media, ?\RenderOptions $defaults = NULL): \El

Image

Framework\Media\Source\Image
final class

Image bron — rendert een `<img>`-element. Ondersteunt `loading="lazy"`,
`fetchpriority`, `width`/`height`, `alt`. Geen poster (heeft geen video).

__construct(string $url, ?int $width = NULL, ?int $height = NULL)
1 public method
render(\Variant $variant, \RenderOptions $options): \El

Video

Framework\Media\Source\Video
final class

Self-hosted video — rendert een `<video>`-element met optionele autoplay,
loop, controls, muted, preload en poster.

Poster-resolutie:
- `PosterMode::None` → geen poster
- `PosterMode::Custom` → `RenderOptions::$posterUrl` (verplicht set)
- `PosterMode::Thumbnail` → forceer auto-poster via legacy-conventie
`/original/...mp4` → `/thumbnail/...mp4.jpg`
- `PosterMode::Default` → `Variant::$posterUrl` als die er is, anders auto

__construct(string $url, ?int $width = NULL, ?int $height = NULL)
1 public method
render(\Variant $variant, \RenderOptions $options): \El

Vimeo

Framework\Media\Source\Vimeo
final class

Vimeo embed — rendert een `<iframe>` met de embed-URL via `Url::vimeoEmbed()`.

Poster-resolutie:
- `PosterMode::None` → geen poster
- `PosterMode::Custom` → `$options->posterUrl`
- `PosterMode::Default` → `Variant::$posterUrl` (geen auto — Vimeo
vereist een API-call die niet in de renderer
hoort; caller of een PosterResolver-service
moet 'm vooraf invullen)
- `PosterMode::Thumbnail` → idem als Default (geen auto beschikbaar)

__construct(string $url, ?int $width = NULL, ?int $height = NULL)
3 public methods
posterUrl(\Variant $variant, \RenderOptions $options): ?string
render(\Variant $variant, \RenderOptions $options): \El
videoId(): ?string

Lazy-parse: Vimeo video-id of null als URL niet parsebaar is.

YouTube

Framework\Media\Source\YouTube
final class

YouTube embed — rendert een `<iframe>` met de embed-URL via `Url::youTubeEmbed()`.
Honoreert autoplay/loop/muted/controls. Loop vereist `playlist={id}` (dat
regelt `Url::youTubeEmbed()`).

Poster-resolutie:
- `PosterMode::None` → geen poster
- `PosterMode::Custom` → `$options->posterUrl`
- `PosterMode::Thumbnail` → `Url::youTubePoster($url)` (hqdefault)
- `PosterMode::Default` → `Variant::$posterUrl` als die er is, anders
`Url::youTubePoster($url)`

Note: een `<iframe>` heeft géén native `poster=`-attribuut. De Renderer
tekent de poster zelf als overlay-image als 'ie wordt teruggegeven.
Daarom hangt de poster-resolutie hier op de Source en niet hard in de
iframe-element.

__construct(string $url, ?int $width = NULL, ?int $height = NULL)
3 public methods
posterUrl(\Variant $variant, \RenderOptions $options): ?string

Auto-poster URL (hqdefault.jpg). Wordt door de Renderer als overlay-image
gebruikt voor het 'klik-om-te-spelen'-pattern.

render(\Variant $variant, \RenderOptions $options): \El
videoId(): ?string

Lazy-parse: geeft het YouTube video-id of null als de URL niet parsebaar is.
Wordt niet in de constructor gevalideerd zodat placeholder-URLs uit het
CMS geen exceptions veroorzaken.

Url

Framework\Media\Url
final class

Pure URL-helpers voor YouTube/Vimeo embedding + thumbnail-detectie.
Geen IO, geen statefull dependencies. Alle functies zijn deterministisch.

6 public methods
static videoThumbnail(string $videoUrl): string

Auto-thumbnail voor zelf-gehoste video's volgens legacy-conventie:
`/original/{path}.mp4` → `/thumbnail/{path}.mp4.jpg`. Werkt alleen als
de server zo'n endpoint heeft; geen garantie dat 't bestaat.

static vimeoEmbed(string $urlOrId, bool $autoplay = false, bool $loop = false, bool $muted = true): string

Bouw een Vimeo `<iframe>` embed-URL.

static vimeoId(string $url): ?string

Extract de Vimeo video-ID uit een URL.

static youTubeEmbed(string $urlOrId, bool $controls = true, bool $autoplay = false, bool $loop = false, bool $muted = true): string

Bouw een YouTube `<iframe>` embed-URL met opties.

static youTubeId(string $url): ?string

Extract de YouTube video-ID uit een URL.

Ondersteunt:
- youtu.be/{id}
- youtube.com/watch?v={id}
- youtube.com/embed/{id}
- youtube.com/v/{id}
- youtube.com/shorts/{id}

static youTubePoster(string $urlOrId, string $quality = 'hqdefault'): ?string

YouTube poster (default kwaliteit). `hqdefault` (480×360) is altijd
beschikbaar; `maxresdefault` (1280×720) niet voor elke video.

Variant

Framework\Media\Variant
final class

Eén concrete media-variant zoals 'ie binnen een bepaald breakpoint
gerenderd wordt. Hangt typisch onder een `MediaItem` met een key (bv.
`'desktop'`, `'mobile'`, `'dark'`).

Een Variant bevat alles wat per breakpoint kan verschillen:
- `source` — URL + type (image/video/youtube/vimeo/raw) + dimensions
- `when` — CSS media query (`(max-width: 768px)`, `print`, …);
null = laat `Breakpoints` de default voor de key kiezen
- `options` — per-variant render-overrides (eigen aspect-ratio,
autoplay, controls, etc.)
- `posterUrl` — placeholder voor video-types
- `overlayUrl` — image die over de media heen ligt (badge/logo)
- `hoverUrl` — image die op hover wordt getoond (alleen images)

Geen recursie: een Variant bevat geen sub-variants. De volledige set zit op
`MediaItem.variants`. Callers maken zelden zelf een Variant aan — meestal
loopt alles via `MediaItem::from()` → `Decoder`.

__construct(\AbstractSource $source, ?string $when = NULL, ?\RenderOptions $options = NULL, ?string $posterUrl = NULL, ?string $overlayUrl = NULL, ?string $hoverUrl = NULL)
1 public method
isEmpty(): bool

Csrf

Framework\Security\Csrf
final class

CSRF-token beheer bovenop {@see Session}.

$csrf = new Csrf($session);
$token = $csrf->token();
echo $csrf->field(); // <input type="hidden" name="_csrf" value="...">

if (!$csrf->validate($_POST['_csrf'] ?? '')) {
throw new \RuntimeException('Invalid CSRF token');
}

$csrf->rotate(); // post-login / post-form: nieuwe token

Token = 32 random bytes (hex) per session. Validatie via `hash_equals`
(constant-time vergelijking, voorkomt timing-attacks).

Legacy `Utils::validCSRFcookie()` (deterministische sha256 van SERVER_NAME +
REMOTE_ADDR — niet echt CSRF-veilig, maar gedeeld over migrating callers)
is bereikbaar via {@see Csrf::legacy()} / {@see Csrf::legacyValid()}.

__construct(\Session $session)
7 public methods
clear(): void

Verwijder uitsluitend de CSRF-token uit de session (bv. bij logout
als je de rest van de session wilt behouden).

field(string $name = '_csrf'): string

Geeft een ready-to-use hidden form-field met de huidige token.

static legacy(): void

Deterministisch hash-cookie zoals legacy `Utils::setCSRFcookie()` /
`validCSRFcookie()`. **Niet** echt CSRF-veilig (gedeeld over sessies
met dezelfde server-name + IP) — alleen voor pages die nog niet zijn
gemigreerd.

Roept `setcookie()` aan met dezelfde flags als legacy:
path=/, secure=true, httpOnly=true, expire=session.

Validatie: `Csrf::legacyValid()` — vergelijkt `$_COOKIE['ehid']` met
dezelfde hash (`sha256(SERVER_NAME + REMOTE_ADDR)`).

static legacyValid(): bool
rotate(): string

Genereer een nieuwe token (forceer rotation). Gebruik na login of na
form-submit om token-replay te voorkomen.

token(): string

Geeft de huidige token terug; genereert er één als 'ie nog niet bestaat.

validate(string $given): bool

Valideer een token via constant-time vergelijking.
Return false als de session geen token heeft of de waarden afwijken.

Encryption

Framework\Security\Encryption
final class

Encryptie compatible met EasyHandling's bestaande encryptiestrings.

Algoritme exact gelijk aan legacy `\Encryption`** — anders kunnen we
bestaande ciphertexts in onze databases niet meer decrypten:

stage1 = AES-128-CTR( SALT || plaintext, key, randomIV )
stage2 = AES-128-CTR( IV || stage1, key, fixedIV )
stage3 = base62( random_digit_byte || hex(stage2) )

Defaults van constructor matchen de legacy constants — instances zonder
argumenten lezen/schrijven dus exact dezelfde format als `\Encryption`.

Voor nieuwe data met eigen sleutels: nieuwe instance met eigen $key/$salt.

Belangrijk:** legacy hardcoded de keys in source-code. Geef ze hier mee
via constructor (uit config/.env), niet hardcoderen in nieuwe code.

__construct(string $key = '8F9148711AFDA868CCF6C668E384D', string $salt = '8YB+3ZYB6%Z5CCxh^-XN88UQUBWKkrenUK39BcMkG958MvfgC9', string $fixedIv = 'F%?9y<xxEy;_N9hF', string $cipher = 'aes-128-ctr')
2 public methods
decrypt(string $ciphertext): string

Decrypt; bij ongeldige input geeft het de input ongewijzigd terug
(legacy gedrag — sommige callers gooien ongecodeerde strings erin
en verwachten ze terug).

encrypt(?string $plaintext): ?string

ReCaptchaMode

Framework\Security\ReCaptcha\ReCaptchaMode
enum
3 public methods
static cases(): array
static from(string|int $value): static
static tryFrom(string|int $value): ?static
Cases: V2_CHECKBOX, V2_INVISIBLE, V3

ReCaptchaResult

Framework\Security\ReCaptcha\ReCaptchaResult
final class

Resultaat van een Google reCAPTCHA-verificatie.

Voor v2 is `score`/`action` null. Voor v3 zit daar de risk-score
(0.0 = bot, 1.0 = mens) en de action-naam die bij `grecaptcha.execute`
is meegegeven.

__construct(bool $success, array $errorCodes = array ( ), ?float $score = NULL, ?string $action = NULL, ?string $hostname = NULL, ?string $challengeTimestamp = NULL)
2 public methods
errorCodesAsString(): string
passes(float $minScore = 0.5): bool

ReCaptchaVerifier

Framework\Security\ReCaptcha\ReCaptchaVerifier
final class

Server-side verifier voor Google reCAPTCHA v2 (checkbox/invisible) en v3.

Pure data-flow: Form-input (token) → Google API → ReCaptchaResult.
Geen logging/sessions/redirects — caller bepaalt wat 'ie met `success`
en `score` doet.

$verifier = new ReCaptchaVerifier($secretKey);
$result = $verifier->verify($_POST['g-recaptcha-response'] ?? '', $_SERVER['REMOTE_ADDR'] ?? null);
if (!$result->passes(0.5)) { … }

Voor tests: injecteer een eigen {@see Transport} (bv. een spy of stub)
zodat de Google-call gemockt is.

__construct(string $secretKey, \TransportInterface $transport = \Framework\Http\CurlTransport::__set_state(array( 'caInfo' => NULL, )))
1 public method
verify(string $token, ?string $ip = NULL): \ReCaptchaResult

ArraySessionStore

Framework\Session\ArraySessionStore
final class

In-memory store — bedoeld voor tests, niet voor productie.
Eén instance per test; sessies overleven niet tussen requests/processes.

7 public methods
count(): int

Test-helper: aantal records nu in de store.

delete(string $id): void
gc(int $now): int
ids(): array

Test-helper: alle ids in de store.

read(string $id): ?\SessionRecord
touch(string $id, int $lastActivityAt): void
write(string $id, \SessionRecord $record): void

FileSessionStore

Framework\Session\FileSessionStore
final class

File-backed sessie-opslag — één bestand per sessie-id onder een directory.

Bedoeld voor apps zonder DB of voor dev-omgevingen. Voor productie-load met
meerdere webservers heb je een gedeelde store nodig — gebruik dan
{@see PdoSessionStore} of een Redis-implementatie.

$store = new FileSessionStore($appRoot . '/data/sessions');
$session = new Session($store);

Bestandsformaat: JSON-serialized SessionRecord met UNIX-timestamp velden.
Filename = `<id>.session` zodat je per ongeluk niet andere files in dezelfde
dir matcht bij een listing.

__construct(string $directory)
5 public methods
delete(string $id): void
gc(int $now): int
read(string $id): ?\SessionRecord
touch(string $id, int $lastActivityAt): void
write(string $id, \SessionRecord $record): void

Flash

Framework\Session\Flash
final class

Flash-berichten — éénmalige meldingen die over één request-grens leven
(typisch: server-side validatie-fout, redirect, volgende GET toont 'em).

$flash->add('success', 'Event opgeslagen');
$flash->add('error', 'Ongeldig formulier');
header('Location: /events'); exit;

// Volgende request:
foreach ($flash->all() as $type => $msgs) { … } // verwijdert direct
// of:
$errors = $flash->get('error'); // verwijdert alleen 'error'
$still = $flash->peek('error'); // niet-consumerend lezen

Storage: array<string, list<string>> onder sessie-key `_flash`.

`get()` en `all()` consumeren de berichten — daarna zijn ze weg uit de
sessie. `peek()` en `has()` zijn niet-consumerend.

Geen HTML-rendering in deze klasse: het type ('success'/'error'/etc.) is een
vrije string die de template gebruikt om kleur/icoon te kiezen.

__construct(\Session $session)
5 public methods
add(string $type, string $message): void
all(): array
get(string $type): array
has(string $type): bool
peek(string $type): array

PdoSessionStore

Framework\Session\PdoSessionStore
final class

PDO-backed sessie-opslag bovenop {@see Adapter}.

Vereist een tabel met dit schema (caller is verantwoordelijk voor migraties):

CREATE TABLE sessions (
id CHAR(64) NOT NULL PRIMARY KEY,
payload MEDIUMTEXT NOT NULL,
ip_address VARCHAR(45) NULL,
user_agent VARCHAR(300) NULL,
last_activity INT UNSIGNED NOT NULL,
expires_at INT UNSIGNED NOT NULL,
INDEX idx_sessions_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Tijden worden bewust als UNIX-timestamps opgeslagen — portable tussen
MySQL/MariaDB/SQLite/Postgres en geen timezone-conversies.

Tabelnaam is configureerbaar maar wordt strikt gevalideerd ([a-zA-Z_][a-zA-Z0-9_]*).

__construct(\Adapter $db, string $table = 'sessions')
5 public methods
delete(string $id): void
gc(int $now): int
read(string $id): ?\SessionRecord
touch(string $id, int $lastActivityAt): void
write(string $id, \SessionRecord $record): void

Session

Framework\Session\Session
final class

Cookie + store-backed sessie. Generiek — kent geen "user"-concept; caller
stopt zelf user_id/role/etc. in de payload via `set()`.

$session = new Session(new PdoSessionStore($db));
$session->start();
if (!$session->has('user_id')) {
// login flow:
$session->regenerate(); // fixation-veilig nieuwe id
$session->set('user_id', 42);
}

$userId = $session->get('user_id'); // bij latere requests
$session->destroy(); // logout

Veilig per default:
- 64-hex token (256 bits entropy via random_bytes)
- HttpOnly + Secure + SameSite=Lax cookie
- Versie-invalidatie: bump SessionConfig::$version → bestaande sessies
worden automatisch leeggegooid bij start()
- last_activity wordt geknepen tot 1x per touchThrottleSeconds (default 60s)
- Probabilistic GC (1/gcDivisor requests, default 1/50)

Niet-veilig per default:
- Cookie-write gebeurt via setcookie(); test-injectie via constructor-arg
- REMOTE_ADDR wordt rauw gelezen (geen X-Forwarded-For-fallback) — caller
moet zelf een trusted-proxy laag hebben als die nodig is

__construct(\SessionStoreInterface $store, \SessionConfig $config = \Framework\Session\SessionConfig::__set_state(array( 'cookieName' => 'session', 'lifetimeSeconds' => 28800, 'secure' => true, 'httpOnly' => true, 'sameSite' => 'Lax', 'cookiePath' => '/', 'cookieDomain' => NULL, 'touchThrottleSeconds' => 60, 'gcDivisor' => 50, 'version' => 1, )), ?Closure $cookieWriter = NULL)
11 public methods
all(): array
clear(): void

Wis de payload, behoud het sessie-id.

destroy(): void

Verwijder de sessie volledig (store + cookie).

get(string $key, ?mixed $default = NULL): ?mixed
has(string $key): bool
id(): ?string
isStarted(): bool
regenerate(bool $deleteOld = true): void

Genereer een nieuw sessie-id, behoud de payload.
Voer dit uit bij privilege-escalatie (login, role-switch) tegen
session-fixation.

remove(string $key): void
set(string $key, ?mixed $value): void
start(): void

Lees de cookie en hydrateer de sessie. Idempotent — een tweede aanroep
binnen hetzelfde request is een no-op.

SessionConfig

Framework\Session\SessionConfig
final class

Configuratie voor {@see Session}. Defaults zijn veilig:
`Secure`, `HttpOnly`, `SameSite=Lax`, 8 uur levensduur.

`version` is een caller-controlled integer: bump bij elke wijziging in
payload-structuur (nieuwe keys, verwijderde keys, andere shape) zodat
bestaande sessies automatisch geïnvalideerd worden i.p.v. te crashen
met `Undefined property` op oude data.

__construct(string $cookieName = 'session', int $lifetimeSeconds = 28800, bool $secure = true, bool $httpOnly = true, string $sameSite = 'Lax', string $cookiePath = '/', ?string $cookieDomain = NULL, int $touchThrottleSeconds = 60, int $gcDivisor = 50, int $version = 1)

SessionRecord

Framework\Session\SessionRecord
final class

Eén regel uit een SessionStore — de geserialiseerde toestand van één sessie.

`lastActivityAt` en `expiresAt` zijn UNIX-timestamps (integer seconden) zodat
de implementatie van de store los staat van DB-specifieke datum-types.

__construct(array $payload, int $lastActivityAt, int $expiresAt, ?string $ipAddress = NULL, ?string $userAgent = NULL)

SessionStoreInterface

Framework\Session\SessionStoreInterface
interface

Storage-contract voor sessies. Implementaties (PdoSessionStore, ArraySessionStore)
houden alleen CRUD bij — de hogere-orde logica (cookie, regeneration,
versie-invalidatie, throttling) zit in {@see Session}.

5 public methods
delete(string $id): void

Verwijder één sessie. Idempotent: stilzwijgend als afwezig.

gc(int $now): int

Verwijder verlopen sessies (expires_at <= $now).

read(string $id): ?\SessionRecord

Lees een sessie. Null als afwezig of verlopen.

touch(string $id, int $lastActivityAt): void

Werk last_activity bij zonder de rest van de rij aan te raken.

write(string $id, \SessionRecord $record): void

Schrijf (insert of replace) een sessie.

Map

Framework\Stdlib\Map
class

Eenvoudige key/value map met string|int keys (array-key).

Verschil met de legacy Stdlib\Map:
- O(1) lookups (interne storage is een echte PHP-array, geen pair-list).
- ArrayAccess gebruikt dezelfde keys als set()/get() (consistent).
- Geen __get/__set magic — expliciet via set()/get().
- IteratorAggregate i.p.v. Iterator (geen handmatige positie-tracking).

__construct(iterable $items = array ( ))
14 public methods
count(): int
get(string|int $key, ?mixed $default = NULL): ?mixed
getIterator(): Traversable
has(string|int $key): bool
keys(): array
merge(iterable $other, bool $overwrite = false): static

Voeg een andere map/iterable samen met deze. Bij overlap behoudt $this
z'n waarde tenzij $overwrite = true.

offsetExists(?mixed $offset): bool
offsetGet(?mixed $offset): ?mixed
offsetSet(?mixed $offset, ?mixed $value): void
offsetUnset(?mixed $offset): void
set(string|int $key, ?mixed $value): static
toArray(): array
unset(string|int $key): bool
values(): array

ParamType

Framework\Stdlib\ParamType
enum
3 public methods
static cases(): array
static from(string|int $value): static
static tryFrom(string|int $value): ?static
Cases: INT, FLOAT, BOOL, STRING, DATE, DATETIME, ARRAY

Parameters

Framework\Stdlib\Parameters
class

Map met type-coercion via define(). Handig voor querystring/form-input
waar je strings hebt en typed waarden wil.

Verschil met legacy Stdlib\Parameters:
- Erft van Map (string|int keys, expliciete storage) i.p.v. \stdClass-magic.
- definitions zijn private — niet meer onderdeel van de iterator/values.
- get() coerced volgens definitie; raw() geeft de ongecoerced waarde.

__construct(iterable $items = array ( ))
3 public methods
define(string $name, \ParamType $type, ?mixed $default = NULL): static
get(string|int $key, ?mixed $default = NULL): ?mixed

Geef de waarde, gecoerced naar het gedefinieerde type.
Geen definitie? Dan gewoon de raw waarde.

raw(string|int $key, ?mixed $default = NULL): ?mixed

Geef de raw waarde zonder coercion.

Address

Framework\Support\Address
final class

Postadres als immutable DTO.

$a = new Address(
street: 'Amstelplein',
houseNumber: '1',
postcode: '1096 HA',
city: 'Amsterdam',
country: 'NL',
);

$a->lines(); // ['Amstelplein 1', '1096 HA Amsterdam', 'NL']
(string) $a; // "Amstelplein 1\n1096 HA Amsterdam\nNL"

__construct(string $street, string $houseNumber, string $postcode, string $city, string $country = 'NL', ?string $houseNumberSuffix = NULL, ?string $region = NULL)
3 public methods
equals(self $other): bool
lines(): array

Postadres opgesplitst in regels, klaar voor weergave.

withHouseNumber(string $number, ?string $suffix = NULL): self

Color

Framework\Support\Color
final class

Kleur als RGBA-DTO (0-255 + alpha 0-1).

Color::fromHex('#a5b4fc'); // r=165 g=180 b=252 a=1
Color::fromHex('#abc'); // shorthand expanded
Color::fromRgb(255, 0, 0)->toHex(); // "#ff0000"
Color::fromHex('#000', alpha: 0.5)->toRgba(); // "rgba(0,0,0,0.5)"

__construct(int $r, int $g, int $b, float $alpha = 1.0)
10 public methods
equals(self $other): bool
static fromHex(string $hex, float $alpha = 1.0): self

Accepteert "#abc", "#aabbcc", "abc", "aabbcc", "#aabbccdd" (RGBA hex).

static fromRgb(int $r, int $g, int $b, float $alpha = 1.0): self
isLight(): bool

Quick check: is een witte- of zwarte voorgrond beter?

luminance(): float

Relatieve luminantie volgens WCAG (0-1).

toHex(bool $withAlpha = false): string
toRgb(): string
toRgba(): string
static tryParse(string $input): ?self
withAlpha(float $alpha): self

Currency

Framework\Support\Currency
enum

ISO-4217 currency codes (subset). Aanvullen waar nodig.

`subunits` zegt hoeveel decimalen het minor unit heeft (cents):
EUR/USD/GBP → 2
JPY/KRW → 0
BHD/KWD → 3

5 public methods
static cases(): array
static from(string|int $value): static
subunits(): int
symbol(): string
static tryFrom(string|int $value): ?static
Cases: EUR, USD, GBP, JPY

DateRange

Framework\Support\DateRange
final class

Periode tussen twee datums (inclusief). Beide kanten mogen open zijn:

open vóór → from = null (bv. "tot en met 5 mei")
open na → till = null (bv. "vanaf 5 mei")
beide gesloten → range

$r = new DateRange(new DateTimeImmutable('2026-05-01'), new DateTimeImmutable('2026-05-05'));
$r->contains(new DateTimeImmutable('2026-05-03')); // true

Voor de format()-helper geef je de drie woorden mee — geen App-singleton coupling.

__construct(?DateTimeImmutable $from, ?DateTimeImmutable $till)
8 public methods
static between(DateTimeInterface $from, DateTimeInterface $till): self
contains(DateTimeInterface $date): bool
format(string $pattern = 'd MMMM YYYY', string $locale = 'nl', array $words = array ( 0 => 'from', 1 => '–', 2 => 'until', )): string

Format-helper. Vertaalde woorden geef je expliciet mee — voor "vanaf X",
"X t/m Y" en "tot Y". Default English; gebruik bijv. ['vanaf', 't/m', 'tot'].

static from(DateTimeInterface $from): self
isEmpty(): bool
isOpen(): bool
overlaps(self $other): bool
static until(DateTimeInterface $till): self

EmailAddress

Framework\Support\EmailAddress
final class

E-mailadres als value object.

$a = new EmailAddress('info@multiminded.nl');
$b = new EmailAddress('info@multiminded.nl', 'Multiminded');
$c = EmailAddress::parse('Multiminded <info@multiminded.nl>');

(string) $a; // "info@multiminded.nl"
(string) $b; // "Multiminded <info@multiminded.nl>"

Validatie via filter_var; bij ongeldig adres → InvalidArgumentException.
Voor non-throwing parsing: {@see tryParse()}.

__construct(string $email, ?string $name = NULL)
6 public methods
domain(): string

Domein (na @), lowercase.

equals(\EmailAddress $other): bool
localPart(): string

Lokaal deel (vóór @).

static parse(string $input): self

Parse "Naam <email@addr.com>" of "email@addr.com" (kale variant).

static tryParse(string $input): ?self
withName(?string $name): self

Format

Framework\Support\Format
final class

Pure formatter-helpers — getallen, prijzen, datums.

In tegenstelling tot de legacy `Format` + `Utils`-mix:
- geen `\App::getStaticInstance()` voor vertalingen
- geen globale `domain_lng` constante; locale per call
- geen email/phone-validatie hier (zit in `EmailAddress` resp. dedicated VOs)

Range-functies (formatDateRange/formatTimeRange) zijn niet meegekomen — die
leunen zwaar op een Translator. Migreren zodra we PSR-compatibele
vertaalservice hebben.

6 public methods
static date(DateTimeInterface|string $date, string $pattern = 'd MMMM YYYY', string $locale = 'nl'): string

Datum-formatter via IntlDateFormatter.

static float(float $value, int $decimals = 2, bool $compact = true): string

Float NL-stijl. $compact: gehele waarden zonder decimalen, anders trailing zeroes
eruit (`12,50` blijft `12,5`).

static parseFloat(string $value): float

Parse zowel NL-stijl ("1.234,56") als US-stijl ("1234.56") naar float.
Legere of niet-numerieke invoer → 0.0.

static parsePrice(string $price): float

Idem als parseFloat — gehouden voor leesbaarheid op call-site.

static phoneNl(string $value): string

NL-telefoonnummer normaliseren (0612345678 / +31612345678 → +31-612345678).

static price(float $value, int $decimals = 2, string $decimalSep = ',', string $thousandSep = '.', string|bool $shortHand = false): string

Prijs NL-stijl. `$shortHand` vervangt `,00` — true → `,-`, anders je eigen string.

Iban

Framework\Support\Iban
final class

IBAN value object met validatie (mod-97 + landlengte) en formatting.

$iban = new Iban('NL91 ABNA 0417 1643 00');
$iban->compact(); // "NL91ABNA0417164300"
$iban->formatted(); // "NL91 ABNA 0417 1643 00"
$iban->countryCode();// "NL"

__construct(string $iban)
7 public methods
bban(): string

Bank/account-deel — alles na de check digits.

checkDigits(): string
compact(): string

"NL91ABNA0417164300"

countryCode(): string
equals(self $other): bool
formatted(): string

"NL91 ABNA 0417 1643 00" — groepen van 4 tekens.

static tryParse(string $iban): ?self

Inflect

Framework\Support\Inflect
final class

Engelse meervouds-/enkelvoudsregels — origineel:
http://kuwamoto.org/2007/12/17/improved-pluralizing-in-php-actionscript-and-ror/

Dezelfde regelset als legacy Inflect, gemoderniseerd: strict types,
private const arrays, return-types.

3 public methods
static pluralize(string $word): string
static singularize(string $word): string
static withCount(int $count, string $word): string

"1 item" of "5 items" — handig voor UI.
Voorheen `pluralize_if`; renamed naar `withCount` (leesbaarder bij call-site).

Money

Framework\Support\Money
final class

Money — bedrag in minor units (cents) + currency. Immutable, integer-based.

Money::eur(19.99); // €19.99
Money::fromMinor(1999, EUR); // idem
$a->plus($b); // gooit als currencies verschillen
$a->times(0.21); // BTW-berekening

Geen float-rekenwerk intern — voorkomt 0.1 + 0.2 = 0.30000000000000004 ellende.

__construct(int $minor, \Currency $currency)
17 public methods
compareTo(self $other): int
divide(float $divisor): self
equals(self $other): bool
static eur(float $amount): self
format(string|bool $shortHand = false): string

Pretty-print: "€ 19,99". Gebruikt {@see Format::price} voor NL-stijl.
Voor andere stijlen: gebruik major() en bouw zelf op.

static fromMajor(float $amount, \Currency $currency = \Framework\Support\Currency::EUR): self

Vanuit major units (euro's): 19.99 → 1999 cent EUR. Half-up rounding.

static fromMinor(int $minor, \Currency $currency = \Framework\Support\Currency::EUR): self
isNegative(): bool
isPositive(): bool
isZero(): bool
major(): float
minus(self $other): self
negate(): self
plus(self $other): self
times(float $factor): self
static usd(float $amount): self
static zero(\Currency $currency = \Framework\Support\Currency::EUR): self

PhoneNumber

Framework\Support\PhoneNumber
final class

Telefoonnummer-DTO. Eerste implementatie ondersteunt NL primair (0xx, +31)
en accepteert internationale nummers (+xxx) als pass-through.

$p = PhoneNumber::nl('0612345678'); // → +31612345678
$p->isMobile(); // true (begint met 6)
$p->national(); // "06-12345678"
$p->international(); // "+31 6 12345678"

Voor volledige nummerlogica per land: libphonenumber. Deze klasse is een
lichte VO voor de meest voorkomende NL-cases.

__construct(string $raw, string $defaultCountry = 'NL')
6 public methods
equals(self $other): bool
international(): string

"+31 6 12345678" voor mobiel; anders "+31 NN NNN NN NN" eenvoudig.

isMobile(): bool

Mobiel volgens NL-conventie: dial+31 6XXXXXXXX. Voor andere landen geeft
deze functie false (niet ondersteund).

national(): string

"06-12345678" voor NL, anders e164.

static nl(string $raw): self
static tryParse(string $raw, string $defaultCountry = 'NL'): ?self

TimeRange

Framework\Support\TimeRange
final class

Tijdsduur tussen twee tijdstippen — net als DateRange maar met tijd-pattern.

$r = TimeRange::between('09:00', '17:00');
$r->format('H:mm', 'nl', ['om', 'uur', 'van', 'tot']);

Standaard: "09:00 – 17:00".

__construct(?DateTimeImmutable $from, ?DateTimeImmutable $till)
3 public methods
static between(DateTimeInterface|string $from, DateTimeInterface|string $till): self
duration(): DateInterval
format(string $pattern = 'H:mm', string $locale = 'nl', array $words = array ( 0 => 'at', 1 => '', 2 => 'from', 3 => 'until', )): string

Format zoals legacy `formatTimeRange`. Vier woorden:
[0] "om" (single time)
[1] "uur" (suffix achter de tijd)
[2] "van" (range start)
[3] "tot" (range separator)

TemplateNotFoundException

Framework\View\TemplateNotFoundException
final class
__construct(string $message = '', int $code = 0, ?Throwable $previous = NULL)

View

Framework\View\View
final class

Minimale PHP-template-renderer. Vervangt het `ob_start() + require + ob_get_clean()`-
patroon met expliciete variabele-passing — geen "vergeten variabele = stille
Undefined-warning"-bugs meer.

$view = new View('/app/pages');
echo $view->render('admin/login', [
'error' => $error,
'csrfField' => $csrf->field(),
]);

Templates zijn gewone .php-bestanden onder de geconfigureerde basispath.
`'admin/login'` → `<basisPath>/admin/login.php`. Submappen mogen, maar de
resolved path moet binnen basisPath blijven (anti-traversal).

In het template zijn de keys uit `$data` beschikbaar als variabelen via
`extract()`. `$this` is **niet** beschikbaar — de template draait in een
gebonden-aan-null closure zodat 'ie niet bij de View-instance kan.

Geen layout-inheritance, geen sections, geen template-engine. Voor layouts:
compose expliciet door je template als variable mee te geven aan een
outer-template:

$page = $view->render('admin/login', ['error' => $error]);
$output = $view->render('admin/layout', ['title' => 'Login', 'content' => $page]);

__construct(string $basePath)
2 public methods
exists(string $template): bool

Test of een template-bestand bestaat.

render(string $template, object|array $data = array ( )): string

Render een template met de meegegeven data en geef de output terug.

`$data` kan een associative array zijn óf een (readonly) view-model-object —
publieke properties van het object worden als variabelen in de template
geëxporteerd.

ViewException

Framework\View\ViewException
class
__construct(string $message = '', int $code = 0, ?Throwable $previous = NULL)