12: Geavanceerd

Phlo blijft opzettelijk modulair. Je kunt een app klein houden en alleen de resources activeren die het nodig heeft, of meerdere bronpaden en resourcegroepen combineren.

12.1: App-code en runtime-resources

App-specifieke code hoort in de app zelf. Plaats het niet in /opt/phlo/resources/.

/opt/phlo/resources/ is de Phlo runtime catalogus: framework-brede bronnen die mogelijk gedeeld worden tussen meerdere apps en opzettelijk naast de runtime worden onderhouden. Alleen generieke, stabiele code hoort daar thuis.

Als je code tussen apps wilt delen, maak dan eerst een expliciete gedeelde module of app-bibliotheekpad met een duidelijke eigenaar. Promoot code alleen naar de Phlo runtime catalogus wanneer het echt frameworkfunctionaliteit is.

Een runtime-resource kan een object, functie, stijl of script bieden. Metadata aan de bovenkant van het bestand helpt het Phlo Control Center en de handleiding:

@ summary: Send app notifications
@ package: notifications
@ frontend: false
@ backend: true

method send($message){
	return HTTP(%creds->notify->url, POST: ['message' => $message])
}

12.2: Meerdere brondirectory's

Houd de standaard eenvoudig: app-bron in het app-pad. Voeg alleen extra paden toe wanneer een codebase echt gedeeld moet worden.

Padkeuzes moeten voorspelbaar blijven:

12.3: Integreren met bestaande PHP

Gebruik Phlo naast bestaande PHP door de runtime te laden en de Phlo entrypoint alleen verantwoordelijk te maken voor de routes die de app beheert. Bestaande statische bestanden blijven rechtstreeks door de webserver worden bediend.

<?php
require('/opt/phlo/phlo.php');
phlo_app (
	id: 'Legacy',
	host: 'dev.legacy.test',
	build: true,
	debug: true,
	app: '/var/www/legacy/',
);

12.4: Beveiliging en bezoekers

Voor openbare sites is de gebruikelijke basislijn:

{
    "resources": [
        "cookies",
        "security/security",
        "security/token",
        "session",
        "useragent",
        "visitors",
        "phlo.async",
        "DOM/form"
    ]
}

Voor lokale ontwikkeling kun je tracking uitsluiten:

{
    "exclude": [
        "visitors",
        "useragent"
    ]
}

De visitors resource volgt de betrokkenheid, niet alleen het aantal hits. Een klein heartbeat-script accumuleert actieve tijd terwijl het tabblad daadwerkelijk zichtbaar is (een monotone klok, gepauzeerd op hidden, gewist met een keepalive-verzoek bij unload) in active_seconds, en registreert een per-pagina rij in een visitor_pages tabel, zodat je de tijd per pagina kunt zien, niet alleen de landings-URL. Het is standaard bewust van toestemming (zie de cookiewall hieronder): met toestemming slaat het het IP-adres en de volledige browser, OS en apparaat op; zonder toestemming wordt de bezoeker geïdentificeerd door een dagelijkse hash zonder IP en een gehashte browserstring. Bots worden overgeslagen.

12.4.1 CSP-modi

security/security stelt de basisreactiekoppen in (Referrer-Policy, nosniff, frame- en cross-origin-beleid) en een Content-Security-Policy. Kies het beleid dat overeenkomt met het oppervlak van de app en roep de bijbehorende methode aan:

Modus Beleid Gebruik voor
%security->strict() nonce-gebaseerd: alleen 'nonce-...' scripts en stijlen, plus Cache-Control: no-store apps met een XSS-oppervlak (gebruikersinhoud, formulieren)
%security->basic() 'self' scripts, inline stijlen toegestaan statische of vertrouwde-inhoud sites
%security->marketing() zoals basic, plus img-src https: openbare pagina's die externe afbeeldingen ophalen
%security->api() default-src 'none' JSON-only eindpunten

Onder debug versoepelen basic en marketing script-src naar 'unsafe-inline' zodat de inline debugconsole kan draaien; strict gebruikt altijd een nonce, zodat een app met een XSS-oppervlak vergrendeld blijft, zelfs met debug aan. In strict modus, render de per-verzoek nonce op je <script>/<style> tags via %app->nonce.

12.5: Cookiewall: GDPR-toestemming

DOM/cookiewall is een ingebouwde, subtiele toestemmingsbanner. Activeer het in 3 stappen:

1. Resource in data/app.json:

{ "resources": [..., "DOM/cookiewall"] }

2. Banner in je lay-out:

view layout:
<body>
	{{ %cookiewall->banner }}
	<main>...</main>
</body>

De banner verschijnt alleen wanneer de bezoeker nog geen keuze heeft gemaakt. Twee knoppen: "Alleen essentieel" en "Accepteren". De keuze wordt opgeslagen in een cookie cookieChoice ('essential' of 'all'), geldig voor 1 jaar.

3. Tracking volgt de keuze automatisch: de ingebouwde visitors resource heeft geen beveiliging en geen analytics-script nodig. De heartbeat bevat de keuze, en de server slaat IP, browser, OS en apparaat alleen op na "Accepteren"; zonder toestemming telt de bezoeker nog steeds mee, anoniem (een dagelijkse hash, geen IP).

Methode Retourneert
%cookiewall->hasChosen() true zodra de bezoeker iets heeft gekozen
%cookiewall->canTrack true alleen voor de 'all' keuze
%cookiewall->canAnalytics Alias van canTrack, semantisch nuttig voor een analytics-brug
%cookiewall->choice 'essential' / 'all' / null

Talen: Engels is standaard, en het vertaalt automatisch zodra het taalsysteem (en()) is geladen, zodat een meertalige app geen extra configuratie nodig heeft. Overschrijf prop labels om de teksten voor een vaste taal in te stellen, of prop translate om vertaling in of uit te schakelen.

12.5.1: Captcha

security/captcha is een zelf-contained slider-puzzle captcha: geen externe service en geen scripts van derden. De server kiest een geheime gap-positie, rendert de achtergrond en het losse stuk met GD, en de bezoeker sleept het stuk op zijn plaats; de gap-positie verlaat nooit de server. Het vereist de GD-extensie.

Render de widget binnen je formulier met %captcha->widget:

<form.async method=post action="/signup">
	...
	{{ %captcha->widget }}
	<button>Sign up</button>
</form>

De widget bevat twee verborgen velden, captcha_x en captcha_t, die door het gebundelde script worden ingevuld met de droppositie en de drag-telemetrie. Bij indienen, lees die twee velden en controleer de actie:

if (!captcha::verify($x, $telemetry)){
	return apply(errors: ['captcha' => 'Drag the slider to continue'])
}
captcha::consume()

verify($x, $telemetry) controleert de droppositie tegen het geheim van de server en weigert niet-menselijke slepen (te snel, te weinig monsters, geen padvariatie). Het wist de uitdaging niet, dus roep consume() pas aan na een succesvolle controle.

12.5.2: Social login

security/social voegt "Aanmelden met Google" (en Microsoft en Apple) toe bovenop security/OAuth2: het bouwt de autorisatie-URL en verandert de callback-code in een geverifieerd profiel, zodat jouw app alleen de gebruikerskant beheert. Het leest de client_id en client_secret van elke provider uit een [google] (of [microsoft], [apple]) sectie in data/creds.ini; een provider zonder inloggegevens is simpelweg niet beschikbaar.

Een aanmelding bestaat uit twee routes. De eerste stuurt de bezoeker naar de provider met een eenmalige state die in de sessie wordt bewaard:

route GET auth google {
	%session->social_state = $state = bin2hex(random_bytes(16))
	return location(social::authUrl('google', $state))
}

De tweede is de redirect URI die je hebt geregistreerd bij de provider, /auth/google/callback. Controleer de state, wissel de code om, en je krijgt een genormaliseerd, handtekening-geverifieerd profiel terug:

route GET auth google callback {
	if (!hash_equals((string)%session->social_state, (string)%req->query['state'])) return location('/login')
	$profile = social::profile('google', (string)%req->query['code'])
	if (!$profile || !$profile['verified']) return location('/login')
	// $profile: provider, uid, email, name, verified
}

profile() verifieert de handtekening van het id_token tegen de JWKS van de provider voordat enige claim wordt vertrouwd, en controleert de doelgroep, vervaldatum en uitgever. Sleutel het account op provider plus uid (het stabiele onderwerp), en beschouw de e-mail als een claim: neem een bestaand lokaal account alleen op basis van e-mail aan wanneer de provider daadwerkelijk het eigendom ervan heeft bewezen. De resource heeft geen mening over gebruikers, sessies of routes; dat blijft aan jou.

12.6: Worker mode

In the classic request lifecycle, request state gets a fresh boundary for every request. With thread: true in phlo_app(...), the runtime stays in memory between requests in a FrankenPHP worker.

The performance gain is large (no boot per request), but three rules apply:

1. No die() or exit() in the HTTP path. Both kill the entire worker, not just the current request. view(), apply() and location() prepare or render a response but do not terminate PHP execution, so return them from a route guard or otherwise let the routine end immediately afterwards.

2. No request state in static properties. PHP static properties and function-local statics survive between requests. Phlo clears its computed-static cache (obj::$classProps) during the worker reset, but session, user, payload, time and DB state still belong on an instance with an explicit lifetime or on %req.

3. Mark long-lived objects with $objPers = true. By default Phlo clears its instance map between requests. For objects you explicitly want to reuse (DB connection, prepared statements), set $this->objPers = true so the cleanup leaves them alone.

The database connection is the usual example, and it is transient by default: %MySQL does not keep its PDO across requests, so an idle connection the database has dropped (the low-traffic "server has gone away") can never be reused stale. Opt into one persistent connection with prop %MySQL.objPers = true from app.phlo; query() reconnects and retries once on a dropped connection, so that opt-in stays safe.

Combining with build: true is not allowed: build writes files between requests, and in a worker that is a race condition. Phlo throws a runtime error if you enable both.

12.7: Hulpmiddelen aanpassen zonder te forkeren

Soms wil je dat een gedeelde resource net iets anders werkt in één app, zonder die resource te kopiëren of te veranderen. Vanuit elk .phlo-bestand kun je een node in een andere klasse injecteren of overschrijven door de node te benoemen als %<class>.<node>:

static %visitors.table = 'control.visitors'
prop %visitors.db = 'control'
method %model.greet => 'hi'

De eerste regel overschrijft de static $table van het visitors model; de tweede voegt een db prop toe aan visitors; de derde voegt een greet method toe aan model. Tijdens de build verwijdert de transpiler de %<class>. prefix en schrijft de node in <class>: een bestaande node met die naam wordt overschreven, er wordt een nieuwe toegevoegd. De doelklasse moet deel uitmaken van de build (de resource moet geladen zijn), anders wordt de modifier stilletjes genegeerd. Houd het nodetype identiek aan wat je vervangt (static met static, prop met prop): de hele node wordt verwisseld.

Een praktisch voorbeeld: laat het gedeelde visitors model schrijven naar een centrale analytics database, terwijl alle andere queries in de app op de eigen verbinding van de app blijven:

static %visitors.table = 'control.visitors'

Dit houdt de gedeelde resource agnostisch terwijl elke app zijn eigen interpretatie geeft.

12.8: File metadata: the complete @ reference

Every .phlo file can open with @ key: value lines. Any key is stored as file metadata; these have engine or tooling meaning:

Key Effect
@ class: Override the PHP class name
@ extends: PHP inheritance (default: obj)
@ implements: PHP interfaces, comma-separated
@ use: PHP use statement (Full\Name as Alias)
@ namespace: PHP namespace
@ type: class (default), abstract class, interface or trait
@ summary: One-line description, shown in the manual, reflection and the Phlo Control Center
@ package: Group name for tooling
@ frontend: / @ backend: Marks a resource frontend- or backend-only
@ requires: Dependencies, resolved when the resource is enabled; name? is optional, php-ext: and creds: entries are informational
@ provides: / @ binds: Frontend APIs offered / selectors hooked; feeds reflect::selectorGraph
@ tags: Free-form labels, shown in reflection indexes and matched by the manual search
@ advice: Developer guidance, shown on the object card, in reflect::objectIndex and in the Phlo Control Center

12.8.1 Writing metadata a reader can use

@ summary and @ advice are both expected on a resource. They answer different questions and neither replaces the other. The summary says what it is, in one sentence: someone scanning an index decides from that line whether to open the resource at all. The advice says how to use it well: what to configure, what people get wrong, what the resource deliberately does not do. Write the sentence you would say to someone about to use it for the first time.

On a node inside the file, prefer a type over a comment. A type is checked by PHP, shows up in the manual signature and in reflection without any work, and cannot fall out of date:

static day($file):?string => ...

Three things to know before adding one. There is no declare(strict_types=1), so scalars are coerced and a declared type only bites on null and on values that cannot convert. :void cannot go on an arrow node, because => compiles to a return. And do not type a configuration property that an app redeclares in a subclass, such as static table or static objCache: a typed property must keep its type in every child, which turns a one-line model into a migration.

Write a node comment only for what a type cannot say: a reason, an ordering constraint, a trap. The comment goes on the line before the node, and its first line has to be a complete sentence, because reflection uses that line on its own as the summary. A first line that runs on into the next is served everywhere as half a thought.

12.9: Best practices

Laatst bijgewerkt op 23-08-2026

We gebruiken essentiële cookies om deze site te laten werken. Met uw toestemming gebruiken we ook analytics om de site te verbeteren.