Auto-gegenereerd via Reflection — blijft altijd in sync met src/.
Container
Framework\App\ContainerMinimale 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): voidget(string $id): ?mixedhas(string $id): boolinstance(string $id, ?mixed $instance): voidmake(string $id): objectTyped resolver — return-type matcht de meegegeven class-string.
Gooit ContainerException als de service van het verkeerde type is.
singleton(string $id, Closure|string $factory): voidContainerException
Framework\App\ContainerExceptionGenerieke 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\EnvLoaderMinimale .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): ?stringLees een omgevingsvariabele — zoekt in $_ENV, $_SERVER en getenv().
Geeft $default terug als de variabele niet bestaat.
static isLoaded(): boolGeeft true als EnvLoader::load() al is aangeroepen.
static load(string $path): voidLaad een .env-bestand. Idempotent: tweede aanroep met hetzelfde
bestand heeft geen effect.
Kernel
Framework\App\KernelKernel — 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): voidstatic boot(string $appRoot, bool $debug = false, bool $registerErrorHandler = true): selfBoot een Kernel met de standaard kern-services geregistreerd.
CMS-bootstrap (Website, etc.) gebeurt daarna door de aanroeper.
static current(): selfget(string $id): ?mixedhandle(\ServerRequest $request): \ResponseVerwerk 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): voidrun(): voidVerwerk 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): voidSwap voor tests. Geef null om te resetten.
Layout
Framework\App\LayoutPaginawrapper 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 = ''): stringRender een 404-pagina, gewikkeld in de standaard layout.
wrap(string $title, string $content): stringWikkel content in de volledige HTML-pagina.
NotFoundException
Framework\App\NotFoundExceptionGegooid door {@see Container::get()} als de gevraagde id niet geregistreerd is.
__construct(string $message = '', int $code = 0, ?Throwable $previous = NULL)Route
Framework\App\RouteEé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(): arraymatchesMethod(string $method): boolIs `$method` toegestaan voor deze route? Lege methods-list = altijd ja.
middleware(callable ...$middleware = ?): selfVoeg éé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\RouteResultResultaat 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(): boolisMethodNotAllowed(): boolisNotFound(): boolstatic matched(\Response $response): selfstatic methodNotAllowed(array $allowed): selfstatic notFound(): selfRouter
Framework\App\RouterRouter — 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): \RouteMatch alle HTTP-methods op dit pattern.
delete(string $pattern, callable $action): \Routedispatch(\ServerRequest $request): \RouteResultget(string $pattern, callable $action): \Routematch(array $methods, string $pattern, callable $action): \RouteMatch een expliciete set methods.
patch(string $pattern, callable $action): \Routepost(string $pattern, callable $action): \Routeput(string $pattern, callable $action): \Routeuse(callable $middleware): selfRegistreer globale middleware (loopt vóór de per-route middleware).
ArrayCache
Framework\Cache\ArrayCacheIn-memory PSR-16 cache. Bedoeld voor tests en korte-levensduur scoping
binnen een enkel request.
8 public methods
clear(): booldelete(string $key): booldeleteMultiple(iterable $keys): boolget(string $key, ?mixed $default = NULL): ?mixedgetMultiple(iterable $keys, ?mixed $default = NULL): iterablehas(string $key): boolset(string $key, ?mixed $value, DateInterval|int|null $ttl = NULL): boolsetMultiple(iterable $values, DateInterval|int|null $ttl = NULL): boolFileCache
Framework\Cache\FileCacheFile-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(): booldelete(string $key): booldeleteMultiple(iterable $keys): boolget(string $key, ?mixed $default = NULL): ?mixedgetMultiple(iterable $keys, ?mixed $default = NULL): iterablehas(string $key): boolset(string $key, ?mixed $value, DateInterval|int|null $ttl = NULL): boolsetMultiple(iterable $values, DateInterval|int|null $ttl = NULL): boolInvalidCacheKey
Framework\Cache\InvalidCacheKey__construct(string $message = '', int $code = 0, ?Throwable $previous = NULL)Domain
Framework\Cms\DomainCMS-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): boolstatic fromSettings(object $entry): selfHydrateer uit een settings.php-entry (object met domain/id/language_iso/...).
matches(string $host): boolWebsite
Framework\Cms\WebsiteWebsite — 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(): selfstatic loadFrom(string $appRoot, ?string $host = NULL): selfLaad settings.php en bepaal het huidige domein op basis van de host.
setting(string $key, ?mixed $default = NULL): ?mixedLees een waarde uit settings (top-level keys).
Adapter
Framework\Db\AdapterDunne 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(): intpdo(): PDOEscape-hatch voor code die direct PDO nodig heeft.
query(string $sql, array $params = array (
), callable|string|null $into = NULL): PDOStatementVoer een query uit met optionele bind-params en optioneel rij-prototype.
transactional(callable $fn): ?mixedVoer `$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\OperatorVergelijkings-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(): arraystatic from(string|int $value): staticstatic from2(string $op): selfResolve een loose string naar een case (case-insensitive, '<>' alias).
static tryFrom(string|int $value): ?staticEq, NotEq, Lt, Lte, Gt, Gte, Like, NotLike, In, NotIn, IsNull, IsNotNull, BetweenProfilingAdapter
Framework\Db\ProfilingAdapterAdapter-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): PDOStatementresetStats(): voidstats(): arrayQuery
Framework\Db\QueryFluent, 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(): intdelete(): intMassa-delete binnen het filter. Gooit exceptie zonder filter; gebruik
`->deleteAll()` voor truncate-achtige actie.
deleteAll(): intexists(): boolfirst(): ?arrayget(): arraylimit(int $limit, int $offset = 0): staticnest(string $combiner = 'AND'): staticOpen een nieuwe group; returned de inner Query. Sluiten met `unnest()`.
orNest(): staticorWhere(string $column, ?mixed $opOrValue, ?mixed $value = NULL): staticorWhereGroup(callable $build): staticorderBy(string $column, string $direction = 'asc'): staticpaginate(int $page = 1, int $perPage = 20): arrayPagineer: returnt items + meta (page, perPage, total, totalPages, hasMore).
toSql(): arrayunnest(): staticSluit een nest af en returneer de parent met de child-children gekopieerd.
update(array $data): intMassa-update binnen het filter. Gooit een exceptie wanneer er geen filter is —
gebruik `->updateAll($data)` om dat bewust te accepteren.
updateAll(array $data): intwhere(string $column, ?mixed $opOrValue, ?mixed $value = NULL): staticwhere(col, value) → '='
where(col, op, value)
whereBetween(string $column, ?mixed $from, ?mixed $till): staticwhereGroup(callable $build, string $combiner = 'AND'): staticGroup 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): staticwhereLike(string $column, string $pattern): staticwhereNotIn(string $column, array $values): staticwhereNotLike(string $column, string $pattern): staticwhereNotNull(string $column): staticwhereNull(string $column): staticSql
Framework\Db\SqlPure 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): arraySET-clausule voor UPDATE/INSERT.
static compileLimit(?int $limit, int $offset = 0): stringstatic compileOrderBy(array $orders): stringBouw ORDER BY clausule. Accepteert ['col' => 'asc'|'desc'] map.
static compileWhere(array $nodes): arrayCompileer 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): stringstatic quoteTable(string $name): stringTableRepository
Framework\Db\TableRepositoryGenerieke 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): selfSchakel een strikte kolom-whitelist in.
countBy(array $criteria = array (
)): intdelete(string|int $id): boolfind(string|int $id): ?arrayfindBy(array $criteria, ?int $limit = NULL): arrayfindOneBy(array $criteria): ?arrayinsert(array $data): string|intquery(): \Querytransactional(callable $fn): ?mixedRoep $fn aan binnen één PDO-transactie. Bij exception → rollback.
update(string|int $id, array $data): boolupsert(array $data, array $on): string|intDebug
Framework\Debug\DebugOntwikkelaarshulpmiddelen — 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 = ?): neverDump en stop uitvoering (exit).
static dump(?mixed ...$vars = ?): voidDump één of meer variabelen naar de output.
Toont bestandsnaam + regelnummer van de aanroepende plek.
static render(?mixed $var): stringGeeft de geformatteerde dump terug als string — handig om in El te embedden.
$el->raw(Debug::render($data));
static trace(): stringHuidige call-stack als leesbare string.
ErrorHandler
Framework\Debug\ErrorHandlerDevelopment 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): voidstatic isDebug(): boolDetecteer 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(): voidRegistreer 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\DynamicCacheBuilderPure 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): arrayBouw cache voor meerdere talen tegelijk uit één rij-set.
static buildForLanguage(array $fields, iterable $rows, string $language): arrayBouw `name → value` voor één taal. Taalspecifieke rows winnen van
`language=''` rows; non-cacheable velden worden weggelaten.
static shouldCache(\DynamicTypeField $field): boolMag deze veldwaarde in de cache landen?
DynamicCacheWarmer
Framework\Dynamic\DynamicCacheWarmerVult `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): voidWarm één item op.
warmType(string|int $typeId, ?array $languages = NULL, int $batchSize = 200): intWarm alle items van een type op.
DynamicItem
Framework\Dynamic\DynamicItemTyped, 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): ?stringGeeft de gecachede veldwaarde, of null als het veld ontbreekt.
values(): arrayAlle gecachede veldwaarden als array.
DynamicQuery
Framework\Dynamic\DynamicQueryImmutable, 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(): staticSelecteer 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(): intGeef het aantal rijen (negeert limit/offset).
fields(string ...$names = ?): staticWelke 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(): ?\DynamicItemGeef de eerste rij, of null als er geen match is.
get(): arrayVoer de query uit en geef alle rijen terug.
language(string $iso): staticTaal voor de JSON-cache path ($.cache.values.{lang}.*).
Alleen lowercase letters, 2–5 tekens (bijv. 'nl', 'en', 'de').
limit(int $limit, int $offset = 0): staticonlyActive(bool $active = true): staticFilter op active-vlag in metadata root.
onlyActive(false) haalt alle items op, ongeacht status.
orderBy(string ...$columns = ?): staticKolomnamen worden letterlijk in de query gezet — vertrouw alleen
op waarden die vanuit code komen, nooit op user input.
parent(?int $parentId): staticFilter op parent_id.
parent(null) → WHERE parent_id IS NULL (root-items).
toSql(): arrayGeef de opgebouwde SQL en parameters terug — handig voor debugging.
type(string|int $typeId): staticFilter 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\DynamicQueryEavEAV-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(): intfields(string ...$names = ?): staticWelke velden uit DynamicItemsValues te lezen.
Geef '*' als enkel argument om alle velden van het type op te halen
(resolved via DynamicStructure::fieldsForType()).
first(): ?\DynamicItemget(): arraygetStrategy(): \EavStrategylanguage(string $iso): staticlimit(int $limit, int $offset = 0): staticonlyActive(bool $active = true): staticorderBy(string ...$columns = ?): staticparent(?int $parentId): statictoSql(): arraytype(string|int $typeId): staticDynamicQueryInterface
Framework\Dynamic\DynamicQueryInterfaceGedeelde 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(): intfields(string ...$names = ?): staticfirst(): ?\DynamicItemget(): arraylanguage(string $iso): staticlimit(int $limit, int $offset = 0): staticonlyActive(bool $active = true): staticorderBy(string ...$columns = ?): staticparent(?int $parentId): statictoSql(): arraytype(string|int $typeId): staticDynamicStructure
Framework\Dynamic\DynamicStructureLichtgewicht 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(): arrayfieldDetailsForType(int $typeId): arrayVolledige veld-info per type (incl. datatype + encrypted-flag).
Inclusief geërfde velden via metadata.extends.
fieldNamesForType(int $typeId): arrayfieldsForType(int $typeId): arraygetTypeById(int $id): ?\DynamicTypegetTypeByName(string $name): ?\DynamicTypehasType(string $name): boolidForName(string $name): intstatic invalidate(string $cacheKey, string $cacheDir = 'data/cache/structures'): voidVerwijder de cache-file voor een specifieke key.
static load(PDO $pdo, bool $enabledOnly = true): selfLaad 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): selfLaad uit file-cache als beschikbaar; anders {@see load()} + cache schrijven.
DynamicType
Framework\Dynamic\DynamicTypeImmutable waarde-object dat één rij uit DynamicTypes vertegenwoordigt.
__construct(int $id, string $name, string $metadataJson = '{}')2 public methods
isEnabled(): boolIs dit type actief/enabled?
Absent of true/1 = enabled; false/0 = disabled.
title(): stringGeeft de weergavenaam uit metadata['title'], of de technische naam als fallback.
DynamicTypeField
Framework\Dynamic\DynamicTypeFieldImmutable 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\DynamicWriterSchrijfpad 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): intMaak een nieuw DynamicItem met values. Returneert de nieuwe id.
delete(int $itemId, bool $cascade = true): voidVerwijder item + bijbehorende DynamicItemsValues (en optioneel children).
find(int $itemId, string $language = 'nl'): ?\DynamicItemFind éé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): voidSchakel `metadata.active` om — geen value-mutaties.
transactional(callable $fn): ?mixedupdate(int $itemId, array $values, string $language = 'nl'): voidUpdate bestaande values van een item. Geeft alleen de gegeven velden door —
de rest blijft staan. Cache wordt automatisch opnieuw gewarmd.
withoutAutoWarm(): selfEén-shot setting voor de volgende call: skip cache-warm.
EavStrategy
Framework\Dynamic\EavStrategyWelke 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(): arraystatic from(string|int $value): staticstatic tryFrom(string|int $value): ?staticSubselect, LeftJoin, PivotAfterDeleteEvent
Framework\Events\Db\AfterDeleteEvent__construct(string $table, string|int $id, int $affected)AfterInsertEvent
Framework\Events\Db\AfterInsertEvent__construct(string $table, string|int $id, array $data)AfterUpdateEvent
Framework\Events\Db\AfterUpdateEvent__construct(string $table, string|int $id, array $data, int $affected)BeforeDeleteEvent
Framework\Events\Db\BeforeDeleteEvent__construct(string $table, string|int $id)BeforeInsertEvent
Framework\Events\Db\BeforeInsertEventGefired 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(): arraysetData(array $data): voidBeforeUpdateEvent
Framework\Events\Db\BeforeUpdateEvent__construct(string $table, string|int $id, array $data)2 public methods
getData(): arraysetData(array $data): voidEventDispatcher
Framework\Events\EventDispatcherPSR-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): objectListenerProvider
Framework\Events\ListenerProviderEenvoudige 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): iterableon(string $eventClass, callable $listener, int $priority = 0): voidAltTagger
Framework\Files\AI\AltTaggerProvider-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): arrayGroqAltTagger
Framework\Files\AI\GroqAltTaggerAltTagger 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): arrayFileQuery
Framework\Files\FileQueryFilter/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\FileRecordEé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(): boolisLocal(): boolurl(): stringRender-vriendelijke URL: lokaal → /files/{id}/raw, extern → externalUrl.
with(array $changes): selfFileRepository
Framework\Files\FileRepositoryStorage-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): intTel files in een folder (voor pagination).
create(\FileRecord $record): \FileRecordInsert een nieuwe row. `id`/`createdAt`/`updatedAt` worden door de
repository gegenereerd; meegegeven waarden worden genegeerd.
delete(int $id): voidfind(int $id): ?\FileRecordfindByHash(string $hash): arrayfindInFolder(?int $folderId, \FileQuery $query): arraysearch(\FileQuery $query): arrayGlobale zoek over alle files (folder-onafhankelijk).
update(\FileRecord $record): \FileRecordUpdate bestaande row. Geeft de geüpdatete record terug, met nieuwe
`updatedAt`.
FileStorage
Framework\Files\FileStorageBlob-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): voidexists(string $hash): boolpath(string $hash): ?stringAbsoluut bestandspad voor de blob — handig voor X-Sendfile / readfile().
Null als de hash niet bestaat.
read(string $hash): ?stringLees bytes. Null als de hash niet bestaat.
write(string $hash, string $contents): voidSchrijf bytes; idempotent — als de hash al bestaat, no-op.
FileType
Framework\Files\FileTypeCategorie 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(): arraystatic from(string|int $value): staticstatic fromMime(string $mime): selfstatic tryFrom(string|int $value): ?staticImage, Video, Audio, Pdf, External, BinaryFiles
Framework\Files\FilesFacade 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 (
)): \FileRecordVoeg een externe URL toe als file (YouTube, Vimeo, willekeurige PDF-URL).
Geen blob — alleen een `files`-row met `externalUrl`.
delete(int $fileId): voidVerwijder een file. Bij lokale files wordt na refcount-check de blob
van disk verwijderd als geen andere row 'm gebruikt.
deleteFolder(int $folderId): arrayVerwijder een folder + alle descendants + alle files daarin (met
refcount-blob-cleanup per file).
move(int $fileId, ?int $newFolderId): \FileRecordrename(int $fileId, string $newName): \FileRecordupload(?int $folderId, string $name, string $contents, string $mime, array $metadata = array (
)): \FileRecordUpload nieuwe file. Dedup-aware: als een blob met dezelfde hash al
bestaat, wordt 'm hergebruikt — alleen een nieuwe `files`-row.
FilesConfig
Framework\Files\FilesConfigConfiguratie 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(): arrayFolderRecord
Framework\Files\FolderRecordEé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(): boolwithMetadata(array $metadata): selfFolderRepository
Framework\Files\FolderRepositoryStorage-contract voor folder-records.
9 public methods
all(): arrayAlle folders plat — caller bouwt de tree client-side via `parentId`.
children(?int $parentId): arrayDirect kinderen van een folder (geen recursie).
create(?int $parentId, string $name): \FolderRecordMaak een nieuwe folder. `path` wordt gedenormaliseerd uit parent + name.
Faalt als naam al bestaat onder dezelfde parent.
delete(int $id): arrayVerwijder folder + alle descendants. Caller is verantwoordelijk voor
het verwijderen van de bijbehorende files (om refcount-blob-cleanup
juist te doen).
find(int $id): ?\FolderRecordfindByPath(string $path): ?\FolderRecordmove(int $id, ?int $newParentId): \FolderRecordVerplaats — werkt ook descendants bij (path-prefix).
`$newParentId === null` = naar root.
rename(int $id, string $newName): \FolderRecordHernoem — werkt ook descendants bij (path-prefix).
update(\FolderRecord $record): \FolderRecordUpdate een folder. Voor v1 worden alleen mutaties op `metadata`
toegepast — `name`/`parentId`/`path` gaan via `rename()`/`move()`.
JsonFileRepository
Framework\Files\JsonFileRepositoryJSON-backed FileRepository.
__construct(\JsonStore $store)8 public methods
countInFolder(?int $folderId, \FileQuery $query): intcreate(\FileRecord $record): \FileRecorddelete(int $id): voidfind(int $id): ?\FileRecordfindByHash(string $hash): arrayfindInFolder(?int $folderId, \FileQuery $query): arraysearch(\FileQuery $query): arrayupdate(\FileRecord $record): \FileRecordJsonFolderRepository
Framework\Files\JsonFolderRepositoryJSON-backed FolderRepository.
__construct(\JsonStore $store)9 public methods
all(): arraychildren(?int $parentId): arraycreate(?int $parentId, string $name): \FolderRecorddelete(int $id): arrayfind(int $id): ?\FolderRecordfindByPath(string $path): ?\FolderRecordmove(int $id, ?int $newParentId): \FolderRecordrename(int $id, string $newName): \FolderRecordupdate(\FolderRecord $record): \FolderRecordJsonStore
Framework\Files\JsonStoreGedeeld 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(): arrayLees de hele store. Bij corrupte file wordt een lege store geretourneerd
(caller kan dan opnieuw beginnen).
transaction(callable $fn): ?mixedAtomaire read-modify-write. Callback krijgt de huidige data, returnt
de nieuwe data; ertussen blijft de file ge-flock'd.
LocalFileStorage
Framework\Files\LocalFileStorageDisk-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): voidexists(string $hash): boolpath(string $hash): ?stringread(string $hash): ?stringwrite(string $hash, string $contents): voidCompositeRule
Framework\Form\Conditional\CompositeRuleA 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 = ?): selfAll rules must pass (AND).
static any(\ConditionalRule ...$rules = ?): selfAt least one rule must pass (OR).
toArray(): arraytoJson(): stringConditionalRule
Framework\Form\Conditional\ConditionalRuleA 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(): arraySerialise to the JSON structure expected by form-enhanced.js.
toJson(): stringstatic when(string $field, \RuleOperator $operator, string $value): selfstatic whenEqual(string $field, string $value): selfShorthand for the most common case: show when field equals value.
RuleEvaluator
Framework\Form\Conditional\RuleEvaluatorEvaluates 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): boolRuleOperator
Framework\Form\Conditional\RuleOperatorComparison 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(): arraystatic from(string|int $value): staticstatic tryFrom(string|int $value): ?staticEqual, NotEqual, Contains, NotContains, GreaterThan, LessThan, GreaterEqual, LessEqualFormSchemaException
Framework\Form\Exception\FormSchemaExceptionThrown 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): selfstatic missingKey(string $key, string $context = ''): selfstatic unknownOperator(string $operator): selfstatic unknownType(string $type, string $context = ''): selfstatic unknownValidator(string $type): selfAbstractField
Framework\Form\Field\AbstractFieldBase 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): staticstatic create(string $name): staticdefaultValue(string $value): staticdisabled(bool $disabled = true): staticgetConditional(): \ConditionalRule|\CompositeRule|nullgetConditionalJson(): ?stringSerialise the conditional rule to the JSON format used by form-enhanced.js.
Returns null when no conditional is set.
getDefaultValue(): stringgetHint(): stringgetLabel(): stringgetPlaceholder(): stringgetValidators(): arrayhasConditional(): boolhint(string $hint): staticA short helper text shown below the field input.
Example: hint('Gebruik uw zakelijk e-mailadres')
isComposite(): boolComposite fields hebben sub-inputs (`name[part]`) en eigen render-/validate-pad.
Default = false; CompositeFieldInterface-implementations overschrijven.
isDisabled(): boolisRequired(): boollabel(string $label): staticplaceholder(string $placeholder): staticrequired(bool $required = true): staticshowWhen(\ConditionalRule|\CompositeRule $rule): staticMark 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 (
)): arrayRun all validators against $value.
Returns a list of error messages (empty = valid).
AddressField
Framework\Form\Field\AddressFieldComposite 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(): stringgetIncludeCountry(): boolgetParts(): arrayincludeCountry(bool $on = true, string $default = 'NL'): staticisComposite(): boolrenderComposite(array $value = array (
)): stringvalidateComposite(array $value, array $allValues = array (
)): arrayCheckboxField
Framework\Form\Field\CheckboxFieldA 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): staticStel 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): staticConvenience: 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|stringCompositeFieldInterface
Framework\Form\Field\CompositeFieldInterfaceMarker-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(): arrayReturnt de sub-veld-namen + labels.
renderComposite(): stringRender 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 (
)): arrayValidate sub-data (ipv één string-value). Returns lijst foutmeldingen.
Countries
Framework\Form\Field\CountriesCentrale 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(): arraystatic defaults(): arrayCreditCardField
Framework\Form\Field\CreditCardFieldComposite 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(): arrayisComposite(): boolrenderComposite(array $value = array (
)): stringvalidateComposite(array $value, array $allValues = array (
)): arrayDateField
Framework\Form\Field\DateFieldSingle-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(): arrayBouw de data-attributen voor de wrapper-div (gebruikt door FormRenderer).
defaultPattern(?string $key): staticformat(?string $token): staticgetDefaultPattern(): ?stringgetFormat(): ?stringgetLocale(): ?stringgetMax(): ?stringgetMaxMonths(): intgetMin(): ?stringgetMonths(): intgetMonthsMobile(): intgetPatterns(): arraygetTimeStep(): intisTimeEnabled(): boollocale(?string $code): staticmax(?string $iso): staticmaxMonths(int $count): staticmin(?string $iso): staticmonths(int $count): staticmonthsMobile(int $count): staticpatterns(array $patterns): staticwithTime(bool $enabled = true, int $stepMinutes = 15): staticDateRangeField
Framework\Form\Field\DateRangeFieldRange-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(): arraydefaultPattern(?string $key): staticformat(?string $token): staticfrom(string $name, string $label = ''): staticgetDefaultPattern(): ?stringgetFormat(): ?stringgetFromLabel(): stringgetFromName(): stringgetLocale(): ?stringgetMax(): ?stringgetMaxMonths(): intgetMin(): ?stringgetMonths(): intgetMonthsMobile(): intgetPatterns(): arraygetTillLabel(): stringgetTillName(): stringgetTimeStep(): intisTimeEnabled(): boollocale(?string $code): staticmax(?string $iso): staticmaxMonths(int $count): staticmin(?string $iso): staticmonths(int $count): staticmonthsMobile(int $count): staticpatterns(array $patterns): statictill(string $name, string $label = ''): staticwithTime(bool $enabled = true, int $stepMinutes = 15): staticEmailField
Framework\Form\Field\EmailField__construct(string $name)MultiCheckboxField
Framework\Form\Field\MultiCheckboxFieldEen 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(): arrayoptions(array $options): staticPersonalNameField
Framework\Form\Field\PersonalNameFieldComposite 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(): boolgetParts(): arraygetSalutationOptions(): arrayincludeSalutation(bool $on = true): staticisComposite(): boolrenderComposite(array $value = array (
)): stringsalutationOptions(array $options): staticvalidateComposite(array $value, array $allValues = array (
)): arrayPhoneNumberField
Framework\Form\Field\PhoneNumberFieldTelefoonnummer — 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(): stringlanguage(string $code): staticvalidate(string $value, array $allValues = array (
)): arrayRadioField
Framework\Form\Field\RadioFieldA group of <input type="radio"> buttons.
Example:
RadioField::create('type')
->label('Account type')
->options(['private' => 'Particulier', 'business' => 'Zakelijk']);
2 public methods
getOptions(): arrayoptions(array $options): staticReCaptchaField
Framework\Form\Field\ReCaptchaFieldForm-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): staticv3-only — actienaam die je meegeeft aan grecaptcha.execute().
static create(string $name = 'g-recaptcha-response'): staticgetAction(): ?stringgetLocale(): stringgetMode(): \ReCaptchaModegetSiteKey(): stringgetSize(): stringgetTheme(): stringlocale(string $locale): staticmode(\ReCaptchaMode $mode): staticsiteKey(string $key): staticsize(string $size): statictheme(string $theme): staticSelectField
Framework\Form\Field\SelectFieldA `<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): staticSchakel de custom-select-upgrade in/uit voor dit veld.
getOptions(): arraygetSearchMode(): stringgetSearchThreshold(): intisCustomSelect(): booloptions(array $options): staticsearchMode(string $mode): static'auto' | 'always' | 'never' — wanneer de search-input getoond wordt.
searchThreshold(int $n): staticAantal opties vanaf waar 'auto' search toont.
TextField
Framework\Form\Field\TextField2 public methods
getType(): stringtype(string $type): staticOverride HTML input type (e.g. 'password', 'tel', 'url', 'search').
TextareaField
Framework\Form\Field\TextareaField2 public methods
getRows(): introws(int $rows): staticZipcodeField
Framework\Form\Field\ZipcodeFieldPostcode-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): staticgetDistances(): arraygetLanguage(): stringgetParts(): arraygetWithDistance(): boolisComposite(): boollanguage(string $code): staticrenderComposite(array $value = array (
)): stringvalidate(string $value, array $allValues = array (
)): arrayvalidateComposite(array $value, array $allValues = array (
)): arraywithDistance(bool $on = true): staticForm
Framework\Form\FormA 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): selfAdd a field or a FieldRow (side-by-side layout).
static create(string $id, string $action = '', string $method = 'POST'): selfgetElements(): arraygetField(string $name): ?\AbstractFieldgetFields(): arrayprocess(array $data): \FormResultValidate 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\FormElementInterfaceMarker 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\FormFactoryBuilds 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): \FormBuild a Form from an already-decoded array.
Useful when the JSON was decoded upstream (e.g. already fetched from DB).
static fromJson(string $json): \FormParse a JSON string and return a fully wired Form object.
FormRenderer
Framework\Form\FormRendererRenders 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): stringFormResult
Framework\Form\FormResultImmutable 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(): arrayallValues(): arrayerrorsFor(string $field): arrayhasErrors(string $field): boolisValid(): boolvalue(string $field, string $default = ''): stringReturns 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 (
)): arrayReturns een composite-value als geneste array. Atomic-waarden vallen
terug op `$default`.
FieldRow
Framework\Form\Layout\FieldRowGroups 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(): ?stringgetFields(): arraygetGridTemplateColumns(): stringCSS grid-template-columns value.
Falls back to "repeat(N, 1fr)" when no custom widths are set.
hasConditional(): boolstatic of(\AbstractField ...$fields = ?): selfCreate a row with equal-width columns.
showWhen(\ConditionalRule|\CompositeRule $rule): selfHide/show the entire row based on another field's value.
widths(array $fractions): selfSet 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\ConditionalValidatorDecorator 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 (
)): ?stringEmailValidator
Framework\Form\Validator\EmailValidator1 public method
validate(string $value, string $label, array $allValues = array (
)): ?stringMaxLengthValidator
Framework\Form\Validator\MaxLengthValidator__construct(int $max)1 public method
validate(string $value, string $label, array $allValues = array (
)): ?stringMinLengthValidator
Framework\Form\Validator\MinLengthValidator__construct(int $min)1 public method
validate(string $value, string $label, array $allValues = array (
)): ?stringReCaptchaValidator
Framework\Form\Validator\ReCaptchaValidatorServer-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 (
)): ?stringRegexValidator
Framework\Form\Validator\RegexValidator__construct(string $pattern, string $message)1 public method
validate(string $value, string $label, array $allValues = array (
)): ?stringRequiredValidator
Framework\Form\Validator\RequiredValidator1 public method
validate(string $value, string $label, array $allValues = array (
)): ?stringValidatorInterface
Framework\Form\Validator\ValidatorInterfaceA 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 (
)): ?stringA
Framework\Html\ATyped 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 = ?): selfChild-element als link-inhoud.
addClass(string ...$tokens = ?): selfaria(string $name, string|bool|null $value): selfattr(string $name, string|int|float|bool|null $value): selfexternal(): selfOpent in nieuw tabblad, zet automatisch rel="noopener noreferrer".
raw(string $html): selfRaw HTML als link-inhoud (bijv. icon + tekst).
text(string $text): selfEscaped tekst als link-label.
ClassList
Framework\Html\ClassListEen 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 = ?): selfVoeg één of meer klassen toe.
Strings met spaties worden gesplitst (bijv. vanuit HTML-attribuut parsing).
Duplicaten worden overgeslagen.
contains(string $token): boolGeeft true als $token aanwezig is.
count(): intgetIterator(): ArrayIteratoritem(int $index): ?stringGeeft het token op positie $index (0-based), of null als buiten bereik.
remove(string ...$tokens = ?): selfVerwijder één of meer klassen.
Geen fout als de class niet aanwezig is.
replace(string $old, string $new): boolVervang $old door $new. Geeft true als de vervanging geslaagd is.
$el->classList->replace('btn-primary', 'btn-secondary');
toArray(): arrayGeeft alle tokens terug als array.
toggle(string $token, ?bool $force = NULL): selfVoeg toe als afwezig, verwijder als aanwezig.
$el->classList->toggle('open');
$el->classList->toggle('active', $isActive); // force aan/uit
El
Framework\Html\ElSchone, 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 = ?): selfVoeg één of meer child-nodes toe (El, Img, A, of elke andere NodeInterface).
addClass(string ...$tokens = ?): selfaria(string $name, string|bool|null $value): selfStel aria-{name} in. null verwijdert het attribuut.
attr(array|string $name, string|int|float|bool|null $value = NULL): selfStel éé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): ?selfchildren(): \ElCollectionDirecte element-children — tekst-nodes en fragmenten uitgesloten.
data(string $name, string|int|null $value): selfStel data-{name} in. null verwijdert het attribuut.
find(string $selector): \ElCollectionAlle descendants die matchen (depth-first).
Ondersteunde selectors: tag, .class, #id, [attr], combinaties (div.card#main[data-x])
first(string $selector): ?selfEerste descendant die matcht — short-circuit.
static fragment(): selfFragment — geen wrapper-tag, alleen children.
static fromHtml(string $html): selfParseer een raw HTML-string naar een fragment met El-children.
Vereist PHP 8.4 (\Dom\HTMLDocument).
getAttr(string $name): ?stringGeeft 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): boolGeeft true als het attribuut aanwezig is (zowel string- als boolean-attributen).
hasClass(string $token): boolisBoolAttr(string $name): boolGeeft true als het attribuut een boolean attribuut is (aanwezig zonder waarde).
static make(string $nodeName, array $attrs = array (
)): selfMaak een element. Geen content-parameter — gebruik ->text() of ->raw().
matches(string $selector): boolGeeft true als dit element zelf overeenkomt met $selector.
raw(string $html): selfVoeg 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): selfremoveClass(string ...$tokens = ?): selfremoveStyle(string $property): selfsetStyle(string $property, string $value): selfStel één inline CSS-property in. Accepteert camelCase én kebab-case.
text(string $text): selfVoeg escaped tekst toe — veilig voor user input, XSS-proof.
toggleClass(string $token, ?bool $force = NULL): selfElCollection
Framework\Html\ElCollectionGetypte, 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(): inteach(Closure $callback): selfRoep $callback aan voor elk element — geeft $this terug voor chaining.
$collection->each(fn(El $el) => $el->addClass('active'));
filter(Closure|string $test): selfFilter op callback of CSS-selector — geeft nieuwe ElCollection terug.
$collection->filter('.active')
$collection->filter(fn(El $el) => $el->hasAttr('data-id'))
first(): ?\ElEerste element, of null als de collectie leeg is.
getIterator(): ArrayIteratorisEmpty(): boolGeeft true als de collectie leeg is.
last(): ?\ElLaatste element, of null als de collectie leeg is.
map(Closure $callback): arrayMap naar een nieuwe array (geen ElCollection — resultaat kan van alles zijn).
$hrefs = $collection->map(fn(El $el) => $el->getAttr('href'));
offsetExists(?mixed $offset): booloffsetGet(?mixed $offset): ?\EloffsetSet(?mixed $offset, ?mixed $value): voidoffsetUnset(?mixed $offset): voidImg
Framework\Html\ImgTyped 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 = ?): selfattr(string $name, string|int|float|bool|null $value): selfheight(int $height): selfloading(string $value): selfwidth(int $width): selfNodeInterface
Framework\Html\NodeInterfaceContract 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\StyleDeclarationInline 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(): intstatic fromString(string $css): selfget(string $property): ?stringGeeft de waarde van $property terug, of null als afwezig.
getIterator(): ArrayIteratorhas(string $property): boolGeeft true als de property aanwezig is.
properties(): arrayGeeft alle property-namen (kebab-case) terug.
remove(string $property): selfVerwijder een CSS-property.
set(string $property, string $value): selfStel een CSS-property in. Accepteert camelCase én kebab-case.
Lege waarde verwijdert de property.
CachingTransport
Framework\Http\CachingTransportDecorator-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): voidsend(\Request $request): \ResponseClient
Framework\Http\ClientHigh-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): \Responsepost(\Url|string $url, ?string $body = NULL): \ResponsepostJson(\Url|string $url, array $data): \Responsesend(\Request $request): \ResponseCurlTransport
Framework\Http\CurlTransportDefault 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): \ResponseHeaders
Framework\Http\HeadersCase-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): ?stringGeef de eerste waarde, of null.
getAll(string $name): arrayhas(string $name): boolnames(): arraystatic parseResponseString(string $raw): selfParse de raw response-header string van curl naar een Headers-object.
Slaat status-line en lege regels over.
toArray(): arraytoAssociative(): arraytoLines(): arrayPlaag voor logging/curl: ['Name: value', ...]. Multi-values produceren
meerdere regels.
with(string $name, string $value): selfwithAdded(string $name, string $value): selfwithout(string $name): selfHttpException
Framework\Http\HttpExceptionGegooid 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\MethodHTTP-methodes — compleet in tegenstelling tot library/Http/RequestMethod
(die mist PUT/PATCH/HEAD/OPTIONS terwijl HEAD wél gebruikt wordt).
4 public methods
allowsBody(): boolMag deze methode een body hebben? (Strict gezien mag GET/HEAD/DELETE wel een body, maar in praktijk doe je dat niet.)
static cases(): arraystatic from(string|int $value): staticstatic tryFrom(string|int $value): ?staticGET, POST, PUT, PATCH, DELETE, HEAD, OPTIONSRequest
Framework\Http\RequestImmutable 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(): stringStabiele cache-key — voor {@see CachingTransport}. Sluit auth-headers uit
(Authorization, Cookie) zodat per-user requests niet de cache delen.
static get(\Url|string $url): selfstatic post(\Url|string $url, ?string $body = NULL): selfstatic postJson(\Url|string $url, array $data): selfwithBody(?string $body): selfwithCacheTtl(?int $seconds): selfwithFollowRedirects(bool $follow): selfwithHeader(string $name, string $value): selfwithHeaders(\Headers $headers): selfwithMethod(\Method $method): selfwithQuery(array $query, bool $merge = true): selfwithTimeout(float $seconds): selfwithUrl(\Url $url): selfwithVerifySsl(bool $verify): selfResponse
Framework\Http\ResponseImmutable 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): selfBouw een JSON-response. Naam is `asJson` (niet `json`) omdat de
instance-methode {@see Response::json()} al bestaat als decoder voor
inkomende responses.
assoc(): arraystatic html(string $body, int $status = 200): self200 OK met `text/html`-Content-Type — de standaard voor pagina-handlers.
isClientError(): boolisOk(): boolisRedirect(): boolisServerError(): booljson(bool $associative = false): ?mixedDecodeer 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'): self404 met optionele body — handig in een fallback-route.
static redirect(string $location, int $status = 302): self302 Found (default) of een andere 3xx; lege body, alleen `Location`-header.
static text(string $body, int $status = 200): self200 OK met `text/plain`-Content-Type.
ServerRequest
Framework\Http\ServerRequestInbound 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(): selfBouw een ServerRequest uit globals (PHP-FPM / mod_php). Voor tests:
gebruik de constructor direct met je eigen waarden.
clientIp(array $trustedProxies = array (
)): stringGeeft het IP van de client. Vertrouwt X-Forwarded-For alleen als REMOTE_ADDR
in $trustedProxies staat — anders gebruik je domweg REMOTE_ADDR.
isAjax(): boolisJson(): boolisPost(): boolparam(int $index, string $default = ''): stringRoute-parameter op positie-index (capture-group uit het pattern).
withRouteParams(array $params): selfTransportInterface
Framework\Http\TransportInterfaceStuurt 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): \ResponseUrl
Framework\Http\UrlImmutable 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): selfrelative(): stringPad + query + fragment, zónder scheme/host.
withParam(string $name, ?mixed $value): selfVoeg/overschrijf één query-parameter.
withPath(string $path): selfwithQuery(array $extra, bool $merge = true): selfwithoutParam(string $name): selfVerwijder één query-parameter.
GdBackend
Framework\Image\Backend\GdBackendGD-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): stringImageBackendException
Framework\Image\Backend\ImageBackendExceptionBackend 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\ImageBackendInterfacePure 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): stringImagickBackend
Framework\Image\Backend\ImagickBackendImagick-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): stringVariantCache
Framework\Image\Cache\VariantCacheSchrijft / 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): boolpath(string $relativePath): stringread(string $relativePath): ?stringwrite(string $relativePath, string $bytes): voidFitMode
Framework\Image\FitModeHoe een afbeelding in z'n target-box past. Spiegelt CSS object-fit
voor consistentie met Framework\Media\DisplayMode.
5 public methods
static cases(): arraystatic from(string|int $value): staticstatic fromToken(string $token): selftoken(): stringstatic tryFrom(string|int $value): ?staticCover, Contain, Fit, CropFormat
Framework\Image\FormatOutput-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(): arrayextension(): stringstatic from(string|int $value): staticstatic fromExtension(string $ext): selfmimeType(): stringstatic tryFrom(string|int $value): ?staticWebp, Avif, Jpeg, Png, AutoHmacSigner
Framework\Image\HmacSignerHMAC-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): stringverify(string $payload, string $signature): boolImageHandler
Framework\Image\Http\ImageHandlerHTTP-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\ImagePublieke 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): stringFileStorageSource
Framework\Image\Source\FileStorageSourceResolveert 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): \SourceResultRemoteFetchException
Framework\Image\Source\RemoteFetchExceptionExterne 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\RemoteUrlSourceHaalt 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): \SourceResultSourceNotFoundException
Framework\Image\Source\SourceNotFoundExceptionSource-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\SourceResolverInterfaceLevert 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): \SourceResultSourceResult
Framework\Image\Source\SourceResultResultaat 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\SpecCodecCodeert / 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): \TransformSpecencode(\TransformSpec $spec): stringGifFrameExtractor
Framework\Image\StillFrame\GifFrameExtractorExtract 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): stringPassthroughExtractor
Framework\Image\StillFrame\PassthroughExtractorNo-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): stringStillFrameException
Framework\Image\StillFrame\StillFrameExceptionFout 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\StillFrameExtractorVertaalt 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): stringStillFrameRegistry
Framework\Image\StillFrame\StillFrameRegistryKiest 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): stringpick(string $mimeType): \StillFrameExtractorVideoFrameExtractor
Framework\Image\StillFrame\VideoFrameExtractorExtract 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): stringstatic isFfmpegAvailable(string $binary = 'ffmpeg'): boolCheap check — caller (bootstrap) kan beslissen of de extractor
geregistreerd moet worden.
TransformBounds
Framework\Image\TransformBoundsProject-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): voidTransformSpec
Framework\Image\TransformSpecWat 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(): ?inteffectiveWidth(): ?intwithFormat(\Format $format): selfTransformer
Framework\Image\TransformerOrchestreert: 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): stringInvalidUrlException
Framework\Image\Url\InvalidUrlException__construct(string $message = '', int $code = 0, ?Throwable $previous = NULL)ParsedUrl
Framework\Image\Url\ParsedUrlResultaat van UrlParser. Discriminate via isInternal() / isExternal().
4 public methods
static external(string $sourceUrl, \TransformSpec $spec): selfstatic internal(string $fileHash, \TransformSpec $spec): selfisExternal(): boolisInternal(): boolUrlBuilder
Framework\Image\Url\UrlBuilderBouwt 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): stringbuildInternal(string $fileHash, \TransformSpec $spec): stringsignaturePayload(string $sourceUrl, string $specToken, string $extension): stringUrlParser
Framework\Image\Url\UrlParserParseert URLs gegenereerd door UrlBuilder en verifieert HMAC voor extern.
__construct(\SpecCodec $codec, \HmacSigner $signer)1 public method
parse(string $path, string $queryString): \ParsedUrlFileLogger
Framework\Log\FileLoggerLogger 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 (
)): voidAbstractSource
Framework\Media\AbstractSourceAbstract 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(): boolrender(\Variant $variant, \RenderOptions $options): \ElRender 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\BreakpointsBekende 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(): arraystatic resolve(string $key, ?string $when): ?stringGeeft de effectieve CSS media-query voor een variant.
Eigen `$when` wint; anders fallback op {@see self::DEFAULTS}; anders null.
Decoder
Framework\Media\DecoderMixed 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): \MediaDisplayMode
Framework\Media\DisplayModeHoe 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(): arraystatic from(string|int $value): staticstatic tryFrom(string|int $value): ?staticFit, Cover, ResizeMedia
Framework\Media\MediaEen 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): \ElZoals `render()` maar returnt het `El`-object zodat caller er nog op
kan doorbouwen.
static from(?mixed $input): selfDecode mixed input (string URL / JSON / array / object / Media) naar
een Media. Zie {@see Decoder::decode()} voor de geaccepteerde shapes.
getDefaultVariant(): ?\VariantEé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): ?\VarianthasVariant(string $key): boolisEmpty(): boolstatic 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): stringConvenience: 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\PosterModeHoe 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(): arraystatic from(string|int $value): staticstatic tryFrom(string|int $value): ?staticNone, Default, Thumbnail, CustomRenderOptions
Framework\Media\RenderOptionsRender-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): selfKleine fluent helpers — handig voor templates die er één optie aan willen
zetten zonder de hele constructor opnieuw te tikken.
Renderer
Framework\Media\Renderer`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): \ElImage
Framework\Media\Source\ImageImage 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): \ElVideo
Framework\Media\Source\VideoSelf-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): \ElVimeo
Framework\Media\Source\VimeoVimeo 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): ?stringrender(\Variant $variant, \RenderOptions $options): \ElvideoId(): ?stringLazy-parse: Vimeo video-id of null als URL niet parsebaar is.
YouTube
Framework\Media\Source\YouTubeYouTube 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): ?stringAuto-poster URL (hqdefault.jpg). Wordt door de Renderer als overlay-image
gebruikt voor het 'klik-om-te-spelen'-pattern.
render(\Variant $variant, \RenderOptions $options): \ElvideoId(): ?stringLazy-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\UrlPure URL-helpers voor YouTube/Vimeo embedding + thumbnail-detectie.
Geen IO, geen statefull dependencies. Alle functies zijn deterministisch.
6 public methods
static videoThumbnail(string $videoUrl): stringAuto-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): stringBouw een Vimeo `<iframe>` embed-URL.
static vimeoId(string $url): ?stringExtract de Vimeo video-ID uit een URL.
static youTubeEmbed(string $urlOrId, bool $controls = true, bool $autoplay = false, bool $loop = false, bool $muted = true): stringBouw een YouTube `<iframe>` embed-URL met opties.
static youTubeId(string $url): ?stringExtract 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'): ?stringYouTube poster (default kwaliteit). `hqdefault` (480×360) is altijd
beschikbaar; `maxresdefault` (1280×720) niet voor elke video.
Variant
Framework\Media\VariantEé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(): boolCsrf
Framework\Security\CsrfCSRF-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(): voidVerwijder uitsluitend de CSRF-token uit de session (bv. bij logout
als je de rest van de session wilt behouden).
field(string $name = '_csrf'): stringGeeft een ready-to-use hidden form-field met de huidige token.
static legacy(): voidDeterministisch 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(): boolrotate(): stringGenereer een nieuwe token (forceer rotation). Gebruik na login of na
form-submit om token-replay te voorkomen.
token(): stringGeeft de huidige token terug; genereert er één als 'ie nog niet bestaat.
validate(string $given): boolValideer een token via constant-time vergelijking.
Return false als de session geen token heeft of de waarden afwijken.
Encryption
Framework\Security\EncryptionEncryptie 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): stringDecrypt; bij ongeldige input geeft het de input ongewijzigd terug
(legacy gedrag — sommige callers gooien ongecodeerde strings erin
en verwachten ze terug).
encrypt(?string $plaintext): ?stringReCaptchaMode
Framework\Security\ReCaptcha\ReCaptchaMode3 public methods
static cases(): arraystatic from(string|int $value): staticstatic tryFrom(string|int $value): ?staticV2_CHECKBOX, V2_INVISIBLE, V3ReCaptchaResult
Framework\Security\ReCaptcha\ReCaptchaResultResultaat 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(): stringpasses(float $minScore = 0.5): boolReCaptchaVerifier
Framework\Security\ReCaptcha\ReCaptchaVerifierServer-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): \ReCaptchaResultArraySessionStore
Framework\Session\ArraySessionStoreIn-memory store — bedoeld voor tests, niet voor productie.
Eén instance per test; sessies overleven niet tussen requests/processes.
7 public methods
count(): intTest-helper: aantal records nu in de store.
delete(string $id): voidgc(int $now): intids(): arrayTest-helper: alle ids in de store.
read(string $id): ?\SessionRecordtouch(string $id, int $lastActivityAt): voidwrite(string $id, \SessionRecord $record): voidFileSessionStore
Framework\Session\FileSessionStoreFile-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): voidgc(int $now): intread(string $id): ?\SessionRecordtouch(string $id, int $lastActivityAt): voidwrite(string $id, \SessionRecord $record): voidFlash
Framework\Session\FlashFlash-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): voidall(): arrayget(string $type): arrayhas(string $type): boolpeek(string $type): arrayPdoSessionStore
Framework\Session\PdoSessionStorePDO-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): voidgc(int $now): intread(string $id): ?\SessionRecordtouch(string $id, int $lastActivityAt): voidwrite(string $id, \SessionRecord $record): voidSession
Framework\Session\SessionCookie + 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(): arrayclear(): voidWis de payload, behoud het sessie-id.
destroy(): voidVerwijder de sessie volledig (store + cookie).
get(string $key, ?mixed $default = NULL): ?mixedhas(string $key): boolid(): ?stringisStarted(): boolregenerate(bool $deleteOld = true): voidGenereer een nieuw sessie-id, behoud de payload.
Voer dit uit bij privilege-escalatie (login, role-switch) tegen
session-fixation.
remove(string $key): voidset(string $key, ?mixed $value): voidstart(): voidLees de cookie en hydrateer de sessie. Idempotent — een tweede aanroep
binnen hetzelfde request is een no-op.
SessionConfig
Framework\Session\SessionConfigConfiguratie 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\SessionRecordEé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\SessionStoreInterfaceStorage-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): voidVerwijder één sessie. Idempotent: stilzwijgend als afwezig.
gc(int $now): intVerwijder verlopen sessies (expires_at <= $now).
read(string $id): ?\SessionRecordLees een sessie. Null als afwezig of verlopen.
touch(string $id, int $lastActivityAt): voidWerk last_activity bij zonder de rest van de rij aan te raken.
write(string $id, \SessionRecord $record): voidSchrijf (insert of replace) een sessie.
Map
Framework\Stdlib\MapEenvoudige 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(): intget(string|int $key, ?mixed $default = NULL): ?mixedgetIterator(): Traversablehas(string|int $key): boolkeys(): arraymerge(iterable $other, bool $overwrite = false): staticVoeg een andere map/iterable samen met deze. Bij overlap behoudt $this
z'n waarde tenzij $overwrite = true.
offsetExists(?mixed $offset): booloffsetGet(?mixed $offset): ?mixedoffsetSet(?mixed $offset, ?mixed $value): voidoffsetUnset(?mixed $offset): voidset(string|int $key, ?mixed $value): statictoArray(): arrayunset(string|int $key): boolvalues(): arrayParamType
Framework\Stdlib\ParamType3 public methods
static cases(): arraystatic from(string|int $value): staticstatic tryFrom(string|int $value): ?staticINT, FLOAT, BOOL, STRING, DATE, DATETIME, ARRAYParameters
Framework\Stdlib\ParametersMap 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): staticget(string|int $key, ?mixed $default = NULL): ?mixedGeef de waarde, gecoerced naar het gedefinieerde type.
Geen definitie? Dan gewoon de raw waarde.
raw(string|int $key, ?mixed $default = NULL): ?mixedGeef de raw waarde zonder coercion.
Address
Framework\Support\AddressPostadres 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): boollines(): arrayPostadres opgesplitst in regels, klaar voor weergave.
withHouseNumber(string $number, ?string $suffix = NULL): selfColor
Framework\Support\ColorKleur 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): boolstatic fromHex(string $hex, float $alpha = 1.0): selfAccepteert "#abc", "#aabbcc", "abc", "aabbcc", "#aabbccdd" (RGBA hex).
static fromRgb(int $r, int $g, int $b, float $alpha = 1.0): selfisLight(): boolQuick check: is een witte- of zwarte voorgrond beter?
luminance(): floatRelatieve luminantie volgens WCAG (0-1).
toHex(bool $withAlpha = false): stringtoRgb(): stringtoRgba(): stringstatic tryParse(string $input): ?selfwithAlpha(float $alpha): selfCurrency
Framework\Support\CurrencyISO-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(): arraystatic from(string|int $value): staticsubunits(): intsymbol(): stringstatic tryFrom(string|int $value): ?staticEUR, USD, GBP, JPYDateRange
Framework\Support\DateRangePeriode 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): selfcontains(DateTimeInterface $date): boolformat(string $pattern = 'd MMMM YYYY', string $locale = 'nl', array $words = array (
0 => 'from',
1 => '–',
2 => 'until',
)): stringFormat-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): selfisEmpty(): boolisOpen(): booloverlaps(self $other): boolstatic until(DateTimeInterface $till): selfEmailAddress
Framework\Support\EmailAddressE-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(): stringDomein (na @), lowercase.
equals(\EmailAddress $other): boollocalPart(): stringLokaal deel (vóór @).
static parse(string $input): selfParse "Naam <email@addr.com>" of "email@addr.com" (kale variant).
static tryParse(string $input): ?selfwithName(?string $name): selfFormat
Framework\Support\FormatPure 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'): stringDatum-formatter via IntlDateFormatter.
static float(float $value, int $decimals = 2, bool $compact = true): stringFloat NL-stijl. $compact: gehele waarden zonder decimalen, anders trailing zeroes
eruit (`12,50` blijft `12,5`).
static parseFloat(string $value): floatParse zowel NL-stijl ("1.234,56") als US-stijl ("1234.56") naar float.
Legere of niet-numerieke invoer → 0.0.
static parsePrice(string $price): floatIdem als parseFloat — gehouden voor leesbaarheid op call-site.
static phoneNl(string $value): stringNL-telefoonnummer normaliseren (0612345678 / +31612345678 → +31-612345678).
static price(float $value, int $decimals = 2, string $decimalSep = ',', string $thousandSep = '.', string|bool $shortHand = false): stringPrijs NL-stijl. `$shortHand` vervangt `,00` — true → `,-`, anders je eigen string.
Iban
Framework\Support\IbanIBAN 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(): stringBank/account-deel — alles na de check digits.
checkDigits(): stringcompact(): string"NL91ABNA0417164300"
countryCode(): stringequals(self $other): boolformatted(): string"NL91 ABNA 0417 1643 00" — groepen van 4 tekens.
static tryParse(string $iban): ?selfInflect
Framework\Support\InflectEngelse 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): stringstatic singularize(string $word): stringstatic 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\MoneyMoney — 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): intdivide(float $divisor): selfequals(self $other): boolstatic eur(float $amount): selfformat(string|bool $shortHand = false): stringPretty-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): selfVanuit major units (euro's): 19.99 → 1999 cent EUR. Half-up rounding.
static fromMinor(int $minor, \Currency $currency = \Framework\Support\Currency::EUR): selfisNegative(): boolisPositive(): boolisZero(): boolmajor(): floatminus(self $other): selfnegate(): selfplus(self $other): selftimes(float $factor): selfstatic usd(float $amount): selfstatic zero(\Currency $currency = \Framework\Support\Currency::EUR): selfPhoneNumber
Framework\Support\PhoneNumberTelefoonnummer-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): boolinternational(): string"+31 6 12345678" voor mobiel; anders "+31 NN NNN NN NN" eenvoudig.
isMobile(): boolMobiel 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): selfstatic tryParse(string $raw, string $defaultCountry = 'NL'): ?selfTimeRange
Framework\Support\TimeRangeTijdsduur 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): selfduration(): DateIntervalformat(string $pattern = 'H:mm', string $locale = 'nl', array $words = array (
0 => 'at',
1 => '',
2 => 'from',
3 => 'until',
)): stringFormat 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__construct(string $message = '', int $code = 0, ?Throwable $previous = NULL)View
Framework\View\ViewMinimale 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): boolTest of een template-bestand bestaat.
render(string $template, object|array $data = array (
)): stringRender 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__construct(string $message = '', int $code = 0, ?Throwable $previous = NULL)