12: Advanced
Phlo stays deliberately modular. You can keep an app small and activate only the resources it needs, or combine multiple source paths and resource groups.
12.1: App code and runtime resources
App-specific code belongs in the app itself. Do not put it in /opt/phlo/resources/.
/opt/phlo/resources/ is the Phlo runtime catalog: framework-wide resources that may be shared across multiple apps and are deliberately maintained alongside the runtime. Only generic, stable code belongs there.
If you want to share code between apps, first create an explicit shared module or app library path with a clear owner. Only promote code to the Phlo runtime catalog when it is truly framework functionality.
A runtime resource can provide an object, function, style or script. Metadata at the top of the file helps the Phlo Control Center and the manual:
@ summary: Send app notifications
@ package: notifications
@ frontend: false
@ backend: true
method send($message){
return HTTP(%creds->notify->url, POST: ['message' => $message])
}12.2: Multiple source paths
Keep the default simple: app source in the app path. Only add extra paths when a codebase genuinely needs to be shared.
Path choices should stay predictable:
- app source in
/var/www/example.nl/ - runtime in
/opt/phlo/ - release in
/var/www/example.nl/release/ - data and credentials in
/var/www/example.nl/data/
12.3: Integrating with existing PHP
Use Phlo alongside existing PHP by loading the runtime and making the Phlo entrypoint responsible only for the routes the app handles. Existing static files keep being served directly by the webserver.
<?php
require('/opt/phlo/phlo.php');
phlo_app (
id: 'Legacy',
host: 'dev.legacy.test',
build: true,
debug: true,
app: '/var/www/legacy/',
);12.4: Security and visitors
For public sites, the usual baseline is:
{
"resources": [
"cookies",
"security/security",
"security/token",
"session",
"useragent",
"visitors",
"phlo.async",
"DOM/form"
]
}
For local dev you can exclude tracking:
{
"exclude": [
"visitors",
"useragent"
]
}
The visitors resource tracks engagement, not just hits. A small heartbeat script accrues active time while the tab is actually visible (a monotonic clock, paused on hidden, flushed with a keepalive request on unload) into active_seconds, and records a per-page row in a visitor_pages table so you see time spent per page, not only the landing URL. It is consent-aware out of the box (see the cookiewall below): with consent it stores the IP and the full browser, OS and device; without it the visitor is keyed by a daily hash with no IP and a hashed browser string. Bots are skipped.
12.4.1 CSP modes
security/security sets the baseline response headers (Referrer-Policy, nosniff, frame and cross-origin policies) and a Content-Security-Policy. Pick the policy that matches the app's surface and call its method:
| Mode | Policy | Use for |
|---|---|---|
%security->strict() |
nonce-based: only 'nonce-...' scripts and styles, plus Cache-Control: no-store |
apps with an XSS surface (user content, forms) |
%security->basic() |
'self' scripts, inline styles allowed |
static or trusted-content sites |
%security->marketing() |
like basic, plus img-src https: |
public pages that pull remote images |
%security->api() |
default-src 'none' |
JSON-only endpoints |
Under debug, basic and marketing relax script-src to 'unsafe-inline' so the inline debug console runs; strict always uses a nonce, so an XSS-surface app stays locked even with debug on. In strict mode, render the per-request nonce on your <script>/<style> tags through %app->nonce.
12.5: Cookiewall: GDPR consent
DOM/cookiewall is a built-in, subtle consent banner. Activate it in 3 steps:
1. Resource in data/app.json:
{ "resources": [..., "DOM/cookiewall"] }
2. Banner in your layout:
view layout:
<body>
{{ %cookiewall->banner }}
<main>...</main>
</body>
The banner only appears when the visitor hasn't made a choice yet. Two buttons: "Essential only" and "Accept". The choice is stored in a cookie cookieChoice ('essential' or 'all'), valid for 1 year.
3. Tracking follows the choice by itself: the built-in visitors resource needs no guard and no analytics script. Its heartbeat carries the choice, and the server stores IP, browser, OS and device only after "Accept"; without consent the visitor still counts, anonymously (a daily hash, no IP).
| Method | Returns |
|---|---|
%cookiewall->hasChosen() |
true once the visitor has chosen something |
%cookiewall->canTrack |
true only for the 'all' choice |
%cookiewall->canAnalytics |
Alias of canTrack, semantically useful for an analytics bridge |
%cookiewall->choice |
'essential' / 'all' / null |
Languages: English by default, and it auto-translates once the lang system (en()) is loaded, so a multilingual app needs no extra setup. Override prop labels to set the texts for a fixed language, or prop translate to force translation on or off.
12.5.1: Captcha
security/captcha is a self-contained slider-puzzle captcha: no external service and no third-party scripts. The server picks a secret gap position, renders the background and the loose piece with GD, and the visitor drags the piece into place; the gap position never leaves the server. It needs the GD extension.
Render the widget inside your form with %captcha->widget:
<form.async method=post action="/signup">
...
{{ %captcha->widget }}
<button>Sign up</button>
</form>
The widget includes two hidden fields, captcha_x and captcha_t, that the bundled script fills with the drop position and the drag telemetry. On submit, read those two fields and gate the action:
if (!captcha::verify($x, $telemetry)){
return apply(errors: ['captcha' => 'Drag the slider to continue'])
}
captcha::consume()
verify($x, $telemetry) checks the drop position against the server's secret and rejects non-human drags (too fast, too few samples, no path variation). It does not clear the challenge, so call consume() only after a successful check.
12.5.2: Social login
security/social adds "Sign in with Google" (and Microsoft and Apple) on top of security/OAuth2: it builds the authorize URL and turns the callback code into a verified profile, so your app only handles the user side. It reads each provider's client_id and client_secret from a [google] (or [microsoft], [apple]) section in data/creds.ini; a provider with no credentials is simply unavailable.
A login is two routes. The first sends the visitor to the provider with a one-time state kept in the session:
route GET auth google {
%session->social_state = $state = bin2hex(random_bytes(16))
return location(social::authUrl('google', $state))
}
The second is the redirect URI you registered with the provider, /auth/google/callback. Check the state, exchange the code, and you get back a normalised, signature-verified profile:
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() verifies the id_token signature against the provider's JWKS before any claim is trusted, and checks the audience, expiry and issuer. Key the account on provider plus uid (the stable subject), and treat the email as a claim: adopt an existing local account by email only when the provider actually proved ownership of it. The resource has no opinion on users, sessions or routes; that stays yours.
12.6: Worker mode
By default Phlo runs per request: PHP process starts, handles the request, process ends. With thread: true in phlo_app(...), the runtime stays in memory between requests, intended for FrankenPHP, ReactPHP or RoadRunner.
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. Use return or let a terminating call (view(), apply(), location()) send the response.
2. No request state in static properties. Statics survive between requests. Data from request A leaks into request B. Statics are only safe for class structure or computed metadata that is identical for all requests, not for session, user, payload, time or DB state.
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: Modifying resources without forking
Sometimes you want a shared resource to behave just slightly differently in one app, without copying or changing that resource. From any .phlo file, you can inject or override a node in a different class by naming the node as %<class>.<node>:
static %visitors.table = 'control.visitors'
prop %visitors.db = 'control'
method %model.greet => 'hi'
The first line overrides the static $table of the visitors model; the second adds a db prop to visitors; the third adds a greet method to model. During the build, the transpiler strips the %<class>. prefix and writes the node into <class>: an existing node with that name is overwritten, a new one is added. The target class must be part of the build (its resource loaded), otherwise the modifier is silently ignored. Keep the node type identical to what you replace (static with static, prop with prop): the entire node is swapped.
A practical example: have the shared visitors model write to a central analytics database, while all other queries in the app stay on the app's own connection:
static %visitors.table = 'control.visitors'
This keeps the shared resource agnostic while every app gives it its own interpretation.
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 |
@ advice: |
Developer guidance, shown in reflect::objectIndex |
12.9: Best practices
- Keep entrypoints explicit; avoid hidden configuration.
- Let release output come from
build::release. - Never put credentials in source files.
- Use reflection to verify resources, routes and functions.
- Add abstraction only when multiple apps genuinely benefit from it.