# Phlo Manual

Auto-generated reference of every built-in object and function in the Phlo runtime. Source: `/srv/control/phlo/resources/` plus core `/srv/control/phlo/phlo.php`.

## Core

### %cookies

Cookies data object

Advice: Reads and writes a cookie as a property. Every cookie it sets is httponly, samesite Lax and secure on an https request, so a script cannot read it and it does not travel to another site. That also means a value you need in the browser does not belong here. lifetimeDays sets how long they last; unsetting the property removes the cookie on the visitor's side too.

- File: `resources/cookies.phlo`
- `cookies->controller` (line 10)
- `cookies->lifetimeDays:int` (line 12)
- `cookies->objSet($key, $value, array $options = []):bool` (line 14)
- `cookies->__unset($key):void` (line 22)

### %lang

Language and translation resource

Advice: %lang prints the current app language through its view, so it drops straight into a link or an attribute. Beyond that it is the translation layer behind nl() and en(): a phrase is hashed, looked up in langs/<lang>.ini, and translated by the AI in the background when it is missing, so the first visitor reads the source text and the next one reads the translation. Editing a source phrase changes its hash and orphans the old translation, so a rewrite costs a re-translation.

- File: `resources/lang.phlo`
- `function nl($text, ...$args):string` (line 11)
- `function en($text, ...$args):string` (line 12)
- `lang::asyncBatch($from, $to, $json):void` (line 14)
- `lang->view` (line 21)
- `lang->model:string` (line 23)
- `lang->instructions` (line 24)
- `lang::fileCache:array` (line 25)
- `lang->file($lang):string` (line 27)
- `lang->escape($value):string` (line 29)
- `lang->unescape($value):string` (line 30)
- `lang->lineValue($line, $eq):string` (line 32)
- `lang->readAll($file):array` (line 38)
- `lang->search($file, $hash):?string` (line 54)
- `lang->lookup($hash):?string` (line 100)
- `lang->save($lang, $pairs):void` (line 115)
- `lang->transContext:string` (line 129)
- `lang->browser:?string` (line 131)
- `lang->cookie:?string` (line 132)
- `lang->detect($text, $fallback = 'en'):string` (line 133)
- `lang->hash($from, $text):string` (line 142)
- `lang->translation($from, $text, ...$args):string` (line 143)
- `lang->translate($from, $to, $text):string` (line 162)
- `lang->translateBatch($from, $to, $texts):array` (line 171)

### %lastmod

Build-time stamp of when each page's source last changed, read at runtime for sitemap lastmod and for showing a date to the reader

Advice: Call lastmod::stamp from a build hook, so the dates are read where the sources are. Resolving them at runtime does not work on a release node, which carries no .phlo sources, and a deploy rewrites every mtime, so every page would claim to have changed on the day it was deployed. Pages in %app->pages resolve by convention; declare prop %lastmod.sources as uri => file path for the rest. A page that resolves to nothing simply gets no lastmod, which is the right answer: a crawler that catches a site inventing them stops trusting the field domain-wide.

- File: `resources/lastmod.phlo`
- `lastmod::file($dir = null):string` (line 13)
- `lastmod->sources:array` (line 17)
- `lastmod::resolve($uri):?string` (line 20)
- `lastmod::day($file):?string` (line 27)
- `lastmod::uriOf($page):string` (line 29)
- `lastmod::stamp($dir = null):array` (line 31)
- `lastmod->map:array` (line 40)
- `lastmod->for($uri):?string` (line 42)

### %manifest

PWA web app manifest: declare the body, get the manifest.json route, head link and correct serving

Advice: Set the body from your app (prop %manifest.body => arr(...)) and put %manifest->head in your head view, because that head view is the only thing that links the manifest from a page. %manifest itself is the manifest document, the way %seo is the sitemap. Apps with multiple manifest variants keep their own routes and serve each body through manifest::output().

- File: `resources/manifest.phlo`
- `route GET manifest.json` (line 10)
- `manifest->body` (line 15)
- `manifest->maxAge:int` (line 16)
- `manifest::encode($body):string` (line 18)
- `manifest::output($body, $maxAge = null):void` (line 20)
- `manifest->view` (line 25)
- `manifest->head` (line 27)

### %manual

Self-writing manual at /manual: app description, source reflection and recent commits, plus an optional AI summary

Advice: Nothing to configure: include the resource and the page describes the app it runs in. It reads data/app.md, reflects the live source and reads git log, so it never goes stale. A product layer under paths.resources appears as its own section once it carries a layer.json in its repo root and a heading in its README; without that file a layer stays out of sight, which is how the framework itself stays out. The page is standalone (inline css, no javascript, no layout) and every render that changes the content is stored as data/manual.html without its session token, so the last state survives without the app. The markdown of data/app.md is rendered server-side, so the page needs no runtime and no namespace configuration. The AI summary is optional and keyed on data/app.md, so it costs nothing per visit; without the AI resource or a key the rest of the page still works. Put the route behind your auth gate and add manual to release.exclude, since a manual carrying the source does not belong on a customer server.

- File: `resources/manual.phlo`
- `manual::instruction` (line 11)
- `manual->translate:bool` (line 13)
- `manual->labels:array` (line 14)
- `manual->label($key):string` (line 33)
- `manual::appInfo:string` (line 35)
- `manual::commits(string $path, int $limit = 12):array` (line 37)
- `manual::repos:array` (line 60)
- `manual::commitList(int $limit = 20):array` (line 81)
- `manual::head:string` (line 93)
- `manual::routes:array` (line 98)
- `manual::routeGroups:array` (line 100)
- `manual::nodesByFile:array` (line 110)
- `manual::fileRow(string $file):obj` (line 131)
- `manual::sources:array` (line 143)
- `manual::dirLabel(string $dir):string` (line 172)
- `manual::cacheFile:string` (line 185)
- `manual::cached:?obj` (line 187)
- `manual::configured:bool` (line 194)
- `manual::appName:string` (line 199)
- `manual::model:string` (line 201)
- `manual::summary:obj` (line 206)
- `manual::pageFile:string` (line 229)
- `manual::store(string $body, string $page):bool` (line 231)
- `manual::mdBlocks(string $md):array` (line 238)
- `manual::mdMarker(string $line):?obj` (line 324)
- `manual::mdBreaks(?obj $here, obj $marker):bool` (line 329)
- `manual::mdPara(array &$para, array &$blocks):void` (line 331)
- `manual::mdCells(string $line):array` (line 337)
- `manual::mdItems(array $lines, int $indent):array` (line 339)
- `manual::mdInline(string $text):string` (line 368)
- `manual::mdFormat(string $text):string` (line 377)
- `route both GET manual` (line 383)
- `manual->page` (line 394)
- `manual->summaryBlock` (line 407)
- `manual->summaryText($row)` (line 414)
- `manual->infoBlock` (line 420)
- `manual->infoText(array $blocks)` (line 426)
- `manual->mdBlock($block)` (line 436)
- `manual->mdList($block)` (line 457)
- `manual->mdBullets(array $items)` (line 462)
- `manual->mdOrdered(array $items)` (line 469)
- `manual->mdItem($item)` (line 476)
- `manual->mdBody($item)` (line 485)
- `manual->mdTable($block)` (line 491)
- `manual->filesBlock` (line 511)
- `manual->filesTable(array $groups)` (line 517)
- `manual->fileNodes($file)` (line 528)
- `manual->routesBlock` (line 552)
- `manual->routesTable(array $groups)` (line 558)
- `manual->routeRow(array $route)` (line 569)
- `manual->routeLine(string $method, string $path, string $summary)` (line 577)
- `manual->commitsBlock` (line 586)
- `manual->commitsList(array $commits)` (line 592)

### %payload

POST, PUT, PATCH, QUERY and file-upload data object

Advice: Everything a request carried in its body, whichever way it was sent: JSON, a form, or multipart with files, including PUT and PATCH, which PHP itself leaves for you to unpack. An uploaded file arrives as a file resource rather than a temp path. It is raw input from outside, so read it, never trust it: what you save belongs behind field validation.

- File: `resources/payload.phlo`
- `payload->controller` (line 11)

### %seo

Multilingual SEO: sitemap.xml, robots.txt, hreflang + head meta (description/OG/Twitter/canonical)

Advice: Serves sitemap.xml and robots.txt itself and writes the head meta, so a page needs no SEO markup of its own. It reads from the app: pages for the sitemap, slugs for translated URLs, description, image and structuredData for the head, so filling those is the whole job. Every language of a page points at the others with hreflang, and a lastmod resource, when there is one, dates the sitemap.

- File: `resources/seo.phlo`
- `route GET sitemap.xml` (line 11)
- `route GET robots.txt` (line 13)
- `seo->robots:string` (line 15)
- `seo->intl($uri):string` (line 23)
- `seo->uri($page):string` (line 28)
- `seo->field($page, $key):mixed` (line 30)
- `seo->lastmod($page):?string` (line 40)
- `seo->locale:string` (line 49)
- `seo->ogTitle:string` (line 55)
- `seo->ogDescr` (line 56)
- `seo->ogImageFile:string` (line 57)
- `seo->ogImage:string` (line 58)
- `seo->canonical:string` (line 59)
- `seo->ogType:string` (line 60)
- `seo->sitemapPages:array` (line 61)
- `seo->sitemapLangs:array` (line 62)
- `seo->siteName:string` (line 63)
- `seo->twitterCard:bool` (line 64)
- `seo->structuredData` (line 69)
- `seo->schemaData:?array` (line 71)
- `seo->noIndex:bool` (line 84)
- `seo->view` (line 86)
- `seo->page($page)` (line 94)
- `seo->xlink($lang, $uri)` (line 108)
- `seo->link($lang, $uri)` (line 109)
- `seo->head` (line 111)

### %session

Session data object

Advice: The session as an object: read a key, write a key, done. Call objRegenerateId() the moment someone logs in or changes role, so a token from before cannot be reused. Sessions are per browser, so anything that must survive a device belongs in a model. Override options to steer the cookie: a flow that returns to you with a cross-site POST, such as an OIDC provider posting its callback, needs cookie_samesite None with cookie_secure true, because a Lax cookie is not sent on that request and the state you stored is then unreadable.

- File: `resources/session.phlo`
- `session::options:array` (line 10)
- `session->controller` (line 12)
- `session->__set($key, $value)` (line 15)
- `session->__unset($key)` (line 16)
- `session->__isset($key):bool` (line 17)
- `session->objRegenerateId($deleteOld = true):void` (line 19)

### %stream

Raw data stream beside the JSON command channel: stream() emits text or binary chunks under any content type, app.stream() consumes them via fetch dispatching on the response type

Advice: The channel beside the command stream, for what is not JSON: an image, a PDF, a zip, a file to download. Give it a content type, and a name when it should arrive as a download rather than be shown. On the page app.stream() picks its handling from the type that came back, so a caller does not have to know in advance what it is getting.

- File: `resources/stream.phlo`
- `function stream($data = null, string $type = 'application/octet-stream', ?string $name = null):void` (line 11)

### %tasks

Cron runner for %app->tasks. One cron entry per app triggers this every minute.

Advice: Everything scheduled in one place: %app->tasks holds what runs and when, and one cron entry per app triggers this every minute. A task takes a lock, so a run that lasts longer than its interval does not start over itself. Timing is per minute, so anything finer belongs in a daemon rather than here.

- File: `resources/tasks.phlo`
- `tasks::dir:string` (line 10)
- `tasks::run:void` (line 12)
- `tasks::saveRun($name, $do, $schedule, $return)` (line 27)
- `tasks::due($name, $task, $now):bool` (line 33)
- `tasks::fire($do)` (line 51)
- `tasks::lastRun($name):int` (line 61)
- `tasks::markRun($name, $ts):int|false` (line 66)
- `tasks::lock($name):bool` (line 68)
- `tasks::unlock($name):bool` (line 75)

### %useragent

User agent information

Advice: Reads operating system, browser and device out of the user agent string. That string is a claim, not a fact: it can be turned off, faked or shortened, so use it for statistics and never as a condition for something that matters. What a browser can do is worth asking the browser itself.

- File: `resources/useragent.phlo`
- `useragent->source:?string` (line 10)
- `useragent->os:string` (line 12)
- `useragent->osV:string` (line 28)
- `useragent->osFull:string` (line 39)
- `useragent->name:string` (line 49)
- `useragent->version:string` (line 66)
- `useragent->full:string` (line 77)
- `useragent->device:string` (line 86)

### %visitors

Visitor tracking via heartbeat

Advice: Counts visitors from the page itself with a heartbeat, so time on page and who is online now are real rather than guessed from page loads. It keeps a token instead of an IP for recognition, and bots are filtered out before anything is written. A heartbeat every few seconds is a write per visitor, so watch the table on a busy site and prune it.

- File: `resources/visitors.phlo`
- `visitors::table:string` (line 12)
- `visitors::columns` (line 13)
- `visitors::history:array` (line 15)
- `visitors::online:int` (line 16)
- `visitors::lastHour:int` (line 17)
- `visitors::isBot(?string $ua):bool` (line 19)
- `visitors::parseReferrer(string $url):string` (line 24)
- `route PUT heartbeat` (line 31)

### %websocket

Server-side WebSocket handler via phloWS

Advice: Enable this class only when websockets are configured for the host

- File: `resources/websocket.phlo`
- `websocket::connect($wsHost, $wsToken, $wsSocket):bool` (line 10)
- `websocket::auth($wsHost, $wsToken, $wsSocket):bool` (line 11)
- `websocket::receive($wsHost, $wsToken, $wsSocket, $data):bool` (line 12)
- `websocket::close($wsHost, $wsToken, $wsSocket):bool` (line 13)

### %WhatsApp

WhatsApp client for phloWA using whatsapp-web.js

Advice: Speaks to a phloWA instance, which holds the actual WhatsApp session, so this resource is a client and not a connection: without that service running, nothing is sent. A contact is a full WhatsApp address, and one ending in @g is a group, which number() and isGroup() sort out. It is an unofficial route, so treat volume and content with the care an account you cannot replace deserves.

- File: `resources/WhatsApp.phlo`
- `WhatsApp->__construct(public string $url, public string $secret)` (line 11)
- `WhatsApp::channel($channel):static` (line 13)
- `WhatsApp->number($contact):string` (line 15)
- `WhatsApp->isGroup($contact):bool` (line 16)
- `WhatsApp->status:obj` (line 18)
- `WhatsApp->health:obj` (line 19)
- `WhatsApp->qr:obj` (line 20)
- `WhatsApp->disconnect:obj` (line 21)
- `WhatsApp->read($chat):obj` (line 23)
- `WhatsApp->reaction($msg, $emoji):obj` (line 24)
- `WhatsApp->text($to, $text):obj` (line 26)
- `WhatsApp->image($to, file $file, $text = void):obj` (line 27)
- `WhatsApp->location($to, $lat, $lon, $text):obj` (line 28)
- `WhatsApp->document($to, file $file, $text = void):obj` (line 29)
- `WhatsApp->audio($to, file $file):obj` (line 31)
- `WhatsApp->voice($to, file $file):obj` (line 32)
- `WhatsApp->poll($to, $name, array $options, bool $multi = false):obj` (line 34)
- `WhatsApp->startTyping($to):obj` (line 36)
- `WhatsApp->stopTyping($to):obj` (line 37)
- `WhatsApp->request($action, ...$data):obj` (line 39)

### Functions

- `active(bool $cond, string $classList = void):string`: Build active class attribute for UI state
- `age(int $time):int`: Get age in seconds since a given timestamp
- `age_human(int $age):string`: Convert age in seconds to human readable text
- `apcu($key, $cb, int $duration = 3600, bool $log = true)`: Cache callback results in APCu
- `await(...$jobs):array`: Run app targets in parallel, via the daemon pool or one-shot CLI processes
- `button(...$args):string`: DOM form tags for button, input, select and textarea
- `camel(string $text):string`: Convert text to camelCase
- `chunk(...$cmds):void`: Stream JSON chunks over CLI or Server-Sent Events
- `create(iterable $items, \Closure $keyCb, ?\Closure $valueCb = null):array`: Create associative array from iterable using callbacks
- `en($text, ...$args):string`: Language and translation resource
- `exec_stream(string $cmd, ?int $timeoutSec = 0):Generator`: Stream shell command output via yielding
- `HTTP(string $url, array $headers = [], bool $JSON = false, $POST = null, $PUT = null, $PATCH = null, $QUERY = null, bool $DELETE = false, string|bool|null $agent = null, string|bool $cookies = false, int $timeout = 15, &$response = null)`: HTTP request helper via cURL
- `input(...$args):string`: DOM form tags for button, input, select and textarea
- `n8n($webhook, ?array $data = null, $test = false)`: Call n8n webhook endpoint
- `nl($text, ...$args):string`: Language and translation resource
- `notify(string $title, string $body = void, string $type = 'info', string $level = 'info', ?string $user = null):void`: Notification to the central hub: POST to [notify].url (secret header) via Phlo's HTTP() function. No-op without [notify] config.
- `phlo(?string $phloName = null, ...$args):mixed`
- `phlo_app(...$args):void`
- `phlo_async(string $cb, ...$args):bool`: Run an app target in the background, via the daemon pool or a one-shot CLI process
- `phlo_cli(array $args):void`
- `phlo_dispatch(string $target, array $args = []):mixed`
- `phlo_exception(Throwable $e):void`
- `phlo_exists(string $obj):bool`: Check if compiled Phlo class exists
- `phlo_load(bool $http):void`
- `phlo_serve():void`
- `phlo_stream(string $cb, ...$args):Generator`: Stream an app target's output line by line, via the daemon pool or a one-shot CLI process
- `phlo_sync(string $cb, ...$args)`: Run an app target synchronously, via the daemon pool or a one-shot CLI process
- `phlo_thread():void`
- `select(...$args):string`: DOM form tags for button, input, select and textarea
- `setting(?string $key = null, $value = null):mixed`: Persistent app settings in data/settings.json: setting() lists everything, setting(key) reads one value or null, setting(key, value) writes
- `slug(string $text):string`: Convert text to URL slug
- `stream($data = null, string $type = 'application/octet-stream', ?string $name = null):void`: Raw data stream beside the JSON command channel: stream() emits text or binary chunks under any content type, app.stream() consumes them via fetch dispatching on the response type
- `tag(string $tagName, ?string $inner = null, ...$args):string`: Generate HTML tag string with attributes
- `textarea(...$args):string`: DOM form tags for button, input, select and textarea
- `time_human(?int $time = null):string`: Convert timestamp age to human label
- `wsCast($wsTarget = 'all', $wsHost = host, $wsPort = daemon, $wsExcept = void, ...$data)`: Broadcast a message to WebSocket clients via the daemon's cast bridge

## AI

### %AI

Unified AI facade with engine auto-detect

Advice: One door for every engine: %AI->chat reads the engine from the model name, so gpt goes to OpenAI, claude to Claude and gemini to Gemini, while via names one outright. Every engine answers in the same shape, with answer, model, finish and a token count, so swapping models is a one-word change. A model no rule matches falls back to OpenAI, and each engine still needs credentials of its own.

- File: `resources/AI/AI.phlo`
- `AI->model:string` (line 11)
- `AI::engines` (line 12)
- `AI::http(string $url, array $headers, bool $json = true, mixed $post = null):string` (line 13)
- `AI->resolve(...$args):array` (line 14)
- `AI->chat(...$args):obj` (line 22)
- `AI->stream(...$args):Generator` (line 26)
- `AI->embedding(...$args):array` (line 30)
- `AI->vision(...$args):obj|Generator` (line 34)
- `AI->transcribe(...$args):obj` (line 38)

### %Claude

Anthropic Claude API

Advice: Anthropic has no embedding endpoint, so embedding() quietly goes out through OpenAI and needs that key too. system travels as its own field here rather than as a first message, which context() settles, so the same call works on either engine. vision() fetches an image URL itself and sends it inline as base64, so a large photo becomes a large request.

- File: `resources/AI/Claude.phlo`
- `Claude::model` (line 11)
- `Claude::context(...$args):array` (line 13)
- `Claude::tool($tool):array` (line 23)
- `Claude->embedding($input, $model = 'text-embedding-3-small'):array` (line 33)
- `Claude->vision($text, $image, $stream = false, ...$args):obj|Generator` (line 35)
- `Claude->chat(...$args):obj` (line 42)
- `Claude->parseSSE(string $url, array $headers, array $payload):Generator` (line 59)
- `Claude->stream(...$args):Generator` (line 91)
- `Claude->request($uri, ...$args)` (line 104)

### %DeepSeek

DeepSeek API (OpenAI-compatible, extends OpenAI)

Advice: OpenAI-compatible, so it inherits the whole OpenAI resource and only swaps endpoint, model and credentials; anything that works there works here unless DeepSeek itself lacks it. Embeddings are such a gap: they are routed to OpenAI, so that key has to be present as well.

- File: `resources/AI/DeepSeek.phlo`
- `DeepSeek::model` (line 12)
- `DeepSeek::endpoint` (line 13)
- `DeepSeek::cred` (line 14)
- `DeepSeek::label` (line 15)
- `DeepSeek->embedding($input, $model = 'text-embedding-3-small'):array` (line 17)

### %Gemini

Google Gemini API

Advice: Google puts the model in the path rather than in the body, so a wrong model name reads as a wrong URL. Its embeddings come from text-embedding-004 with a different vector length than OpenAI's, so a collection filled by one engine cannot be searched with the other.

- File: `resources/AI/Gemini.phlo`
- `Gemini::model` (line 11)
- `Gemini::endpoint` (line 12)
- `Gemini::context(...$args):array` (line 14)
- `Gemini::tool($tool):array` (line 25)
- `Gemini->embedding($input, $model = 'text-embedding-004'):array` (line 35)
- `Gemini->vision($text, $image, $stream = false, ...$args):obj|Generator` (line 37)
- `Gemini->chat(...$args):obj` (line 45)
- `Gemini->parseSSE(string $url, array $headers, array $payload):Generator` (line 60)
- `Gemini->stream(...$args):Generator` (line 91)
- `Gemini->request($path, ...$args)` (line 99)

### %Grok

xAI Grok API (OpenAI-compatible, extends OpenAI)

Advice: OpenAI-compatible, so it inherits the whole OpenAI resource and only swaps endpoint, model and credentials. Embeddings are not part of the deal and go out through OpenAI, so that key has to be present as well.

- File: `resources/AI/Grok.phlo`
- `Grok::model` (line 12)
- `Grok::endpoint` (line 13)
- `Grok::cred` (line 14)
- `Grok::label` (line 15)
- `Grok->embedding($input, $model = 'text-embedding-3-small'):array` (line 17)

### %OpenAI

Basic OpenAI functions

Advice: Write a conversation as system, user and assistant instead of assembling messages yourself; context() folds them into the right order. Each answer carries tokens_in and tokens_out, so a run can be metered or capped without reading the raw response. A refused request is raised as an error rather than returned, which is the opposite of how the connectors behave, so wrap a call you cannot afford to lose. Pass token to use a key other than the configured one.

- File: `resources/AI/OpenAI.phlo`
- `OpenAI::model` (line 11)
- `OpenAI::endpoint` (line 12)
- `OpenAI::cred` (line 13)
- `OpenAI::label` (line 14)
- `OpenAI::voices` (line 15)
- `OpenAI::context(...$args):array` (line 16)
- `OpenAI::tool($tool):array` (line 23)
- `OpenAI->chat(...$args):obj` (line 37)
- `OpenAI->embedding($input, $model = 'text-embedding-3-small'):array` (line 47)
- `OpenAI->parseSSE(string $url, array $headers, array $payload):Generator` (line 48)
- `OpenAI->stream(...$args):Generator` (line 79)
- `OpenAI->transcribe($file, $model = 'whisper-1', ...$args):obj` (line 89)
- `OpenAI->vision($text, $image, $stream = false, ...$args):obj` (line 100)
- `OpenAI->request($uri, $JSON = true, $token = null, ...$args)` (line 106)

### Functions

- `answer($question, ...$options):?string`: Simple AI answering helper

## connectors

### %Connector

Base class for API connectors: credentials, JSON requests, retries, pagination and a normalized result contract

Advice: Make one with Connector::make(); it reads its own section from %creds, so keys live in data/creds.ini or in PHLO__Section__key in the environment and never in your code. Every call answers in the same shape, ok with status and data or ok false with error, and nothing is thrown, so test ->ok rather than catching. Raise retries above zero to let GET, HEAD and QUERY back off and try again on 429 and 5xx; writes are never retried, because a repeated POST would book twice.

- File: `resources/connectors/Connector.phlo`
- `Connector::section` (line 11)
- `Connector::api` (line 12)
- `Connector->__construct(?array $config = null)` (line 14)
- `Connector::make(?array $config = null):static` (line 25)
- `Connector->base:string` (line 27)
- `Connector->headers:array` (line 28)
- `Connector::fields:array` (line 30)
- `Connector->configured(...$keys):bool` (line 32)
- `Connector->missing(...$keys):?obj` (line 39)
- `Connector::bearer($token):string` (line 43)
- `Connector::basic($user, $pass):string` (line 44)
- `Connector::build(string $method, string $url, ?array $query = null, array $headers = [], mixed $json = null, mixed $form = null):array` (line 46)
- `Connector::ok($data, int $status = 200):obj` (line 61)
- `Connector::fail($error, int $status = 0):obj` (line 62)
- `Connector::errorMessage($data, string $raw, int $status):string` (line 64)
- `Connector::parse($raw, int $status = 200):obj` (line 79)
- `Connector::retryable($method, int $status):bool` (line 86)
- `Connector::backoff(int $attempt, $response):int` (line 88)
- `Connector->dispatch(array $req):obj` (line 97)
- `Connector->request(string $method, string $url, ?array $query = null, array $headers = [], mixed $json = null, mixed $form = null):obj` (line 123)
- `Connector->get(string $url, ?array $query = null, array $headers = []):obj` (line 129)
- `Connector->post(string $url, mixed $json = null, array $headers = []):obj` (line 130)
- `Connector->put(string $url, mixed $json = null, array $headers = []):obj` (line 131)
- `Connector->patch(string $url, mixed $json = null, array $headers = []):obj` (line 132)
- `Connector->query(string $url, mixed $json = null, array $headers = []):obj` (line 133)
- `Connector->del(string $url, array $headers = []):obj` (line 134)
- `Connector->form(string $url, array $fields, array $headers = []):obj` (line 135)
- `Connector->paginate(string $url, callable $extract, ?array $query = null, string $param = 'page', int $start = 1, int $max = 0):array` (line 137)

### %EBoekhouden

e-Boekhouden.nl connector: session auth from an API token, relations and sales invoices

Advice: Authenticates with a session rather than a token per call: the first call trades api_token for one, later calls in the same request reuse it, and a new request starts over. Sessions are limited, so gather the work you need per request instead of making a connector per call.

- File: `resources/connectors/finance/EBoekhouden.phlo`
- `EBoekhouden::section` (line 12)
- `EBoekhouden::api` (line 13)
- `EBoekhouden->sessionToken:string` (line 15)
- `EBoekhouden->headers:array` (line 17)
- `EBoekhouden::fields:array` (line 19)
- `EBoekhouden->session:obj` (line 28)
- `EBoekhouden->guard:?obj` (line 39)
- `EBoekhouden->relations(array $query = []):obj` (line 44)
- `EBoekhouden->createRelation(array $relation):obj` (line 49)
- `EBoekhouden->invoices(array $query = []):obj` (line 54)
- `EBoekhouden->createInvoice(array $invoice):obj` (line 59)

### %ExactOnline

Exact Online connector (OAuth2): read sales invoices and accounts, create sales invoices

Advice: The division number sits in the base URL, so a connector speaks to exactly one administration and a second administration needs a second connector with its own config. Exact rotates the refresh token on every refresh, which is why the token store locks; running two apps on one refresh token logs both out.

- File: `resources/connectors/finance/ExactOnline.phlo`
- `ExactOnline::section` (line 12)
- `ExactOnline::tokenUrl` (line 13)
- `ExactOnline->base:string` (line 15)
- `ExactOnline::fields:array` (line 17)
- `ExactOnline->guard:?obj` (line 28)
- `ExactOnline->invoices(array $query = []):obj` (line 30)
- `ExactOnline->accounts(array $query = []):obj` (line 35)
- `ExactOnline->createInvoice(array $invoice):obj` (line 40)

### %GoogleCalendar

Google Calendar connector (OAuth2): read events and create events

Advice: Shares the Google section with Sheets, so one refresh token has to carry both scopes. calendarId defaults to primary, which is the mailbox of the account that authorized, not a shared agenda; give a calendar address for those. Google returns times as RFC3339 with an offset, so keep the timezone rather than casting to a local timestamp.

- File: `resources/connectors/cloud/GoogleCalendar.phlo`
- `GoogleCalendar::section` (line 12)
- `GoogleCalendar::tokenUrl` (line 13)
- `GoogleCalendar->base:string` (line 15)
- `GoogleCalendar::fields:array` (line 17)
- `GoogleCalendar->guard:?obj` (line 27)
- `GoogleCalendar->events(string $calendarId = 'primary', array $query = []):obj` (line 29)
- `GoogleCalendar->createEvent(array $event, string $calendarId = 'primary'):obj` (line 34)

### %GoogleSheets

Google Sheets connector (OAuth2): read ranges and append rows

Advice: Shares the Google section with Calendar, so one refresh token has to carry both scopes. A range is A1 notation including the tab name, e.g. Sheet1!A:D. append() writes with USER_ENTERED, so the sheet parses what you send just as a typist would and a leading zero or a date-like string is reinterpreted; pass RAW when the value has to stay untouched.

- File: `resources/connectors/cloud/GoogleSheets.phlo`
- `GoogleSheets::section` (line 12)
- `GoogleSheets::tokenUrl` (line 13)
- `GoogleSheets->base:string` (line 15)
- `GoogleSheets::fields:array` (line 17)
- `GoogleSheets->guard:?obj` (line 27)
- `GoogleSheets->values($spreadsheetId, string $range):obj` (line 29)
- `GoogleSheets->append($spreadsheetId, string $range, array $rows, string $valueInputOption = 'USER_ENTERED'):obj` (line 34)

### %Lightspeed

Lightspeed Retail (V3) connector: read customers and sales, create customers

Advice: Retail V3, so cluster_id names the account and the key and secret authenticate. Lightspeed rate limits per account with a leaky bucket, so raise retries rather than firing calls in a loop. findCustomer() picks its search field from what you pass: an address with an at sign searches on email, anything else on phone.

- File: `resources/connectors/shops/Lightspeed.phlo`
- `Lightspeed::section` (line 12)
- `Lightspeed->base:string` (line 14)
- `Lightspeed->headers:array` (line 16)
- `Lightspeed::fields:array` (line 18)
- `Lightspeed->customers(array $query = []):obj` (line 31)
- `Lightspeed->findCustomer($participant):obj` (line 36)
- `Lightspeed->customer($id):obj` (line 42)
- `Lightspeed->sales(array $query = []):obj` (line 47)
- `Lightspeed->createCustomer(array $customer):obj` (line 52)

### %MessageBird

MessageBird connector: send SMS

Advice: Needs access_key and an originator, the sender shown to the recipient; some countries refuse an alphanumeric originator, so a real number is the safer choice. Pass an array as the recipient to send one message to several numbers at once.

- File: `resources/connectors/chat/MessageBird.phlo`
- `MessageBird::section` (line 12)
- `MessageBird->base:string` (line 14)
- `MessageBird->headers:array` (line 16)
- `MessageBird::fields:array` (line 18)
- `MessageBird::errorMessage($data, string $raw, int $status):string` (line 24)
- `MessageBird->sms($to, $body, array $extra = []):obj` (line 29)

### %MicrosoftGraph

Microsoft Graph connector (app-only client credentials): read users and calendars, send mail, create events

Advice: This is the app-only flow: the token belongs to the registration, not to a person, so it needs admin-consented application permissions and every call names the mailbox it acts on. Tokens are cached in APCu for their lifetime, so a run without APCu fetches one per request. Set mailbox to spare yourself passing a user each time.

- File: `resources/connectors/cloud/MicrosoftGraph.phlo`
- `MicrosoftGraph::section` (line 12)
- `MicrosoftGraph->base:string` (line 14)
- `MicrosoftGraph->headers:array` (line 16)
- `MicrosoftGraph::fields:array` (line 18)
- `MicrosoftGraph->token:string` (line 29)
- `MicrosoftGraph->fetchToken:string` (line 35)
- `MicrosoftGraph->mailbox($user = null):string` (line 53)
- `MicrosoftGraph->users(array $query = []):obj` (line 55)
- `MicrosoftGraph->user($id):obj` (line 60)
- `MicrosoftGraph->events($user = null, array $query = []):obj` (line 65)
- `MicrosoftGraph->sendMail($message, $user = null, bool $save = true):obj` (line 72)
- `MicrosoftGraph->createEvent(array $event, $user = null):obj` (line 79)

### %Moneybird

Moneybird connector: read contacts and invoices, create sales invoices

Advice: Needs an administration_id and a personal access token with rights for contacts and invoices. findContact() searches on one match, so use it to look up rather than to list. An invoice is created as a draft: sending or booking it is a separate step in Moneybird.

- File: `resources/connectors/finance/Moneybird.phlo`
- `Moneybird::section` (line 12)
- `Moneybird->base:string` (line 14)
- `Moneybird->headers:array` (line 16)
- `Moneybird::fields:array` (line 18)
- `Moneybird->contacts(array $query = []):obj` (line 24)
- `Moneybird->findContact($query):obj` (line 29)
- `Moneybird->contact($id):obj` (line 34)
- `Moneybird->invoices(array $query = []):obj` (line 39)
- `Moneybird->createContact(array $contact):obj` (line 44)
- `Moneybird->createInvoice(array $invoice):obj` (line 49)

### %OAuthConnector

Base class for OAuth2 connectors: stored, auto-refreshed bearer access tokens via TokenStore, on the OAuth2 primitive

Advice: Use this instead of Connector when the provider hands out access tokens that expire. Give it client_id, client_secret and a refresh_token and TokenStore keeps the access token and renews it in time; a subclass only names its section and tokenUrl.

- File: `resources/connectors/OAuthConnector.phlo`
- `OAuthConnector::tokenUrl` (line 12)
- `OAuthConnector->oauthKey:string` (line 14)
- `OAuthConnector->token:?string` (line 16)
- `OAuthConnector->headers:array` (line 18)
- `OAuthConnector->authed:bool` (line 20)

### %Resend

Resend connector: send transactional email via the HTTP API

Advice: Needs api_key and a from_email on a domain you verified with Resend, otherwise the send is refused. send() takes HTML; leave it out for a plain text mail and add anything else Resend accepts through extra.

- File: `resources/connectors/chat/Resend.phlo`
- `Resend::section` (line 12)
- `Resend->base:string` (line 14)
- `Resend->headers:array` (line 16)
- `Resend::fields:array` (line 18)
- `Resend->send($to, $subject, $html = void, array $extra = []):obj` (line 24)

### %Shopify

Shopify Admin API connector: read customers, orders and products; create draft orders and products; update inventory

Advice: Needs shop_domain including myshopify.com and an admin access token; api_version defaults to 2024-01 and a Shopify version is supported for a limited time, so set the one you tested against rather than leaning on the default. setInventory() writes an absolute level, not a difference, so read the current one first when you mean to add. A draft order is not an order until it is completed in Shopify.

- File: `resources/connectors/shops/Shopify.phlo`
- `Shopify::section` (line 12)
- `Shopify->base:string` (line 14)
- `Shopify->headers:array` (line 16)
- `Shopify::fields:array` (line 18)
- `Shopify->customers(array $query = []):obj` (line 28)
- `Shopify->searchCustomers($query, int $limit = 10):obj` (line 33)
- `Shopify->customer($id):obj` (line 38)
- `Shopify->orders(array $query = []):obj` (line 43)
- `Shopify->products(array $query = []):obj` (line 48)
- `Shopify->createDraftOrder(array $order):obj` (line 53)
- `Shopify->createProduct(array $product):obj` (line 58)
- `Shopify->setInventory($inventoryItemId, $locationId, int $available):obj` (line 63)

### %Slack

Slack connector: post messages, read channel history and list channels

Advice: Needs a bot token, and the bot has to be invited into the channel it posts in, else the call comes back ok false with not_in_channel. Slack answers HTTP 200 even when it refuses, so read the outcome from the result, never from the status.

- File: `resources/connectors/chat/Slack.phlo`
- `Slack::section` (line 12)
- `Slack::api` (line 13)
- `Slack->headers:array` (line 15)
- `Slack::fields:array` (line 17)
- `Slack->result(obj $res):obj` (line 26)
- `Slack->send($channel, $text, array $extra = []):obj` (line 33)
- `Slack->history($channel, int $limit = 20):obj` (line 38)
- `Slack->channels(int $limit = 100, string $types = 'public_channel'):obj` (line 43)

### %Telegram

Telegram Bot API connector: send messages, photos and documents; poll updates

Advice: Needs a bot token from BotFather and a chat_id, and a person has to have written to the bot before it can write to them. Like Slack, the API answers 200 on refusal, which the connector already translates into ok false. photo() and document() take a URL or an existing file_id, not raw bytes.

- File: `resources/connectors/chat/Telegram.phlo`
- `Telegram::section` (line 12)
- `Telegram->base:string` (line 14)
- `Telegram::fields:array` (line 16)
- `Telegram->result(obj $res):obj` (line 25)
- `Telegram->send($chatId, $text, array $extra = []):obj` (line 32)
- `Telegram->photo($chatId, $photo, $caption = void, array $extra = []):obj` (line 37)
- `Telegram->document($chatId, $document, $caption = void, array $extra = []):obj` (line 44)
- `Telegram->updates(int $offset = 0, int $limit = 100):obj` (line 51)

### %TokenStore

Persisted OAuth2 token store with automatic refresh via the OAuth2 resource

Advice: Tokens are kept as one file per key under data/tokens with 0600 rights, so a refresh survives a restart and every worker shares it. A refresh takes an exclusive lock, which matters with providers that rotate the refresh token: without it the losing request is left holding a dead one.

- File: `resources/connectors/TokenStore.phlo`
- `TokenStore::path($key):string` (line 11)
- `TokenStore::read($key):array` (line 13)
- `TokenStore::write($key, array $token):void` (line 21)
- `TokenStore::valid(array $token):bool` (line 30)
- `TokenStore::store($res, $refresh):array` (line 32)
- `TokenStore::lock($key)` (line 44)
- `TokenStore::access($key, $tokenUrl, $clientId, $clientSecret, array $seed = []):?string` (line 55)

### %Twilio

Twilio connector: send SMS and read message status

Advice: Needs account_sid and auth_token, plus either a from_number in E.164 or a messaging_service_sid; without one of the two the send is refused before it leaves. Twilio speaks form encoding rather than JSON, which sms() handles, so pass extra fields in Twilio's own capitalized names. A returned sid says it was accepted, not delivered; message() tells you what became of it.

- File: `resources/connectors/chat/Twilio.phlo`
- `Twilio::section` (line 12)
- `Twilio->base:string` (line 14)
- `Twilio->headers:array` (line 16)
- `Twilio::fields:array` (line 18)
- `Twilio->sms($to, $body, array $extra = []):obj` (line 28)
- `Twilio->message($sid):obj` (line 39)

## DB

### %DB

Database engine class

Advice: The shape every driver fills in, and the reason a model can move between MySQL, PostgreSQL, SQLite and a JSON file untouched. Reads come in named flavours, so ask for what you want back: record for one, records keyed by id, rows in order, column, pair and item. Anything you pass as an argument is bound, never pasted into the SQL, so a value from a visitor is safe by construction. A connection that went away is reconnected and the query is tried once more, which is what keeps a long-lived worker alive.

- File: `resources/DB/DB.phlo`
- `DB->PDO:\PDO` (line 12)
- `DB->fieldQuotes:string` (line 13)
- `DB->savepoint:int` (line 14)
- `DB->insertIgnore:string` (line 15)
- `DB->insertOnConflict:string` (line 16)
- `DB->load(string $table, string $columns = '*', string $where = void, string $joins = void, string $group = void, string $limit = void, string $order = void, ...$args)` (line 18)
- `DB->query($query, ...$args)` (line 30)
- `DB->queryRun($query, $args, $retry):\PDOStatement` (line 37)
- `DB->goneAway($e):bool` (line 61)
- `DB->column(...$args):array` (line 63)
- `DB->item(...$args)` (line 64)
- `DB->pair(...$args):array` (line 65)
- `DB->group(...$args):array` (line 66)
- `DB->records(...$args):array` (line 67)
- `DB->rows(...$args):array` (line 68)
- `DB->record(...$args):?obj` (line 69)
- `DB->quoteList(array $ids):string` (line 70)
- `DB->quoteId($id):string` (line 75)
- `DB->create(string $table, ...$data)` (line 77)
- `DB->lastId` (line 86)
- `DB->change(string $table, string $where, ...$data):int` (line 88)
- `DB->delete(string $table, string $where, ...$args):int` (line 98)
- `DB->begin:?bool` (line 99)
- `DB->commit:?bool` (line 100)
- `DB->rollback:?bool` (line 101)
- `DB->transaction($callback)` (line 103)

### %JSONDB

JSON file database driver. One JSONDB instance = one JSON file = one model table. No joins, no transactions, no schema introspection.

Advice: A model in a JSON file, for a set of records that stays small and readable: no joins, no transactions, and every write rewrites the whole file. It understands only equality and IN in a where, and raw SQL is refused outright, so keep the model plain. Move to SQLite the moment the file grows or two processes start writing.

- File: `resources/DB/JSONDB.phlo`
- `JSONDB::__handle` (line 12)
- `JSONDB->__construct(private string $file)` (line 13)
- `JSONDB->PDO:\PDO` (line 17)
- `JSONDB->fieldQuotes:string` (line 18)
- `JSONDB->lastInsertedId` (line 19)
- `JSONDB->quoteList(array $ids):string` (line 26)
- `JSONDB->objRead:array` (line 28)
- `JSONDB->objWrite(array $data):int|false` (line 29)
- `JSONDB->objNextId(array $data):int` (line 30)
- `JSONDB->objFilter(array $data, string $where = void, ...$args):array` (line 32)
- `JSONDB->objSelect(string $where = void, string $limit = void, string $order = void, ...$args):array` (line 56)
- `JSONDB->create(string $table, ...$data)` (line 70)
- `JSONDB->change(string $table, string $where, ...$data):int` (line 84)
- `JSONDB->delete(string $table, string $where, ...$args):int` (line 101)
- `JSONDB->load(string $table, string $columns = '*', string $where = void, string $joins = void, string $group = void, string $limit = void, string $order = void, ...$args):JSON_result` (line 110)
- `JSONDB->query($query, ...$args)` (line 115)
- `JSONDB->begin:?bool` (line 117)
- `JSONDB->commit:?bool` (line 118)
- `JSONDB->rollback:?bool` (line 119)

### %JSON_result

Minimal PDOStatement-like wrapper for JSONDB result arrays

Advice: Exists so JSONDB can hand back something that behaves like a PDO statement, which is what lets the ORM stay unaware of which driver answered. You do not build one yourself; you meet it as the return value of a JSONDB query.

- File: `resources/DB/JSON.result.phlo`
- `JSON_result::__handle` (line 10)
- `JSON_result->data:array` (line 11)
- `JSON_result->__construct(array $data)` (line 12)
- `JSON_result->fetchAll($mode = 2, $class = 'obj'):array` (line 14)
- `JSON_result->fetchObject($class = 'obj'):?obj` (line 45)
- `JSON_result->fetch($mode = 2)` (line 53)
- `JSON_result->fetchColumn($col = 0)` (line 60)
- `JSON_result->rowCount:int` (line 67)

### %model

Phlo ORM class with unified columns and schema

Advice: A model is a class with fields, and everything else follows from that: columns, form, validation and relations. Named arguments in a read are equality, so records(active: 1) is a where and nothing more; reach for query() when you need a comparison, a LIKE or an IN. A parent field gives back the record itself rather than an id, and children and many-relations are fetched once for a whole result set instead of per row. Set objValidate to have field rules enforced on save, objAudit to log every change, and objCache to keep reads in APCu.

- File: `resources/DB/model.phlo`
- `model::DB` (line 12)
- `model::objCache` (line 13)
- `model::objRecordLimit` (line 14)
- `model::objAudit` (line 15)
- `model::objValidate` (line 16)
- `model::idColumn` (line 17)
- `model::idType` (line 18)
- `model::canView` (line 20)
- `model::canCreate` (line 21)
- `model::canChange` (line 22)
- `model::canDelete` (line 23)
- `model::state:obj` (line 25)
- `model::columns:string` (line 26)
- `model::_columns:string` (line 33)
- `model::fields:array` (line 38)
- `model::_fields:array` (line 43)
- `model::field($name)` (line 49)
- `model::create(...$args):?static` (line 51)
- `model::objCreateCommit($record, $pk):?static` (line 61)
- `model::objRunValidation($data):bool` (line 70)
- `model::objErrors:array` (line 81)
- `model::createRecord(...$args)` (line 82)
- `model::change($where, ...$args):int` (line 83)
- `model::delete($where, ...$args):int` (line 98)
- `model::objDeleteCommit($where, $args, $records):int` (line 107)
- `model::objLogChange($where, ...$args):int` (line 116)
- `model->objSave:?static` (line 118)
- `model->objSaveCreate($pk, $pkValue):?static` (line 139)
- `model::transaction($callback)` (line 147)
- `model::query:query` (line 148)
- `model::column(...$args):array` (line 150)
- `model::item(...$args)` (line 151)
- `model::pair(...$args):array` (line 152)
- `model::records(...$args):array` (line 153)
- `model::recordCount(...$args)` (line 154)
- `model::record(...$args):?static` (line 155)
- `model::recordsLoad($args, $fetch, $fetchMode, $saveRelations = false)` (line 162)
- `model::objRel($key):array` (line 187)
- `model->objState:array` (line 192)
- `model->objGet($key)` (line 193)
- `model->objIn($ids, $db = null):string` (line 194)
- `model->objMirror(string $bucket, $key)` (line 200)
- `model->getParent($key)` (line 209)
- `model->getChildren($key)` (line 232)
- `model->getMany($key)` (line 250)
- `model->getCount($key):int` (line 274)
- `model->getLast($key)` (line 304)
- `model::objResolveClass($name):string` (line 330)
- `model::objShortName($class = null):string` (line 331)
- `model::objParents:array` (line 333)
- `model::objChildren:array` (line 339)
- `model::objMany:array` (line 345)

### %MySQL

MySQL handler via DB class

Advice: Reads host, database, user and password from the mysql section of %creds. It is the default assumption of the ORM, so a model that names no engine of its own ends up here.

- File: `resources/DB/MySQL.phlo`
- `MySQL->PDO:\PDO` (line 12)

### %PostgreSQL

PostgreSQL resource

Advice: Quotes identifiers with double quotes rather than backticks and has no INSERT IGNORE, so a duplicate is skipped with ON CONFLICT DO NOTHING. Postgres folds an unquoted name to lower case, so a column called createdAt in a schema is not the same one you get back unquoted.

- File: `resources/DB/PostgreSQL.phlo`
- `PostgreSQL->PDO:\PDO` (line 12)
- `PostgreSQL->fieldQuotes:string` (line 13)
- `PostgreSQL->insertIgnore:string` (line 14)
- `PostgreSQL->insertOnConflict:string` (line 15)
- `PostgreSQL->lastId` (line 21)

### %Qdrant

Embeddings resource with Qdrant

Advice: Embeddings are cached in APCu for four weeks per input, so repeating a search costs nothing at the AI end. create() opens a collection at 1536 dimensions, the length of an OpenAI vector, so state the size yourself when another engine fills it. search() without input sends a zero vector, which lists a collection rather than searching it.

- File: `resources/DB/Qdrant.phlo`
- `Qdrant->get(string $input, ?string $model = null):array` (line 11)
- `Qdrant->collections:array` (line 13)
- `Qdrant->create($collection, $size = 1536, $distance = 'Cosine'):bool` (line 14)
- `Qdrant->upsert($collection, $id, $input, ...$payload)` (line 15)
- `Qdrant->delete($collection, ...$ids)` (line 16)
- `Qdrant->search($collection, $input = null, $top = 100):array` (line 17)
- `Qdrant->drop($collection)` (line 18)
- `Qdrant->request($uri, ...$data)` (line 20)

### %query

Fluent query builder for Phlo ORM

Advice: For everything named arguments cannot say: eq, gt, like, in, isNull, order, limit and offset, chained and closed with records, record, column, item or count. Column names are quoted for the driver you are on, so the same chain works on MySQL and Postgres. Values go in as bindings, so a search box can be passed straight through.

- File: `resources/DB/query.phlo`
- `query->class` (line 11)
- `query->conditions` (line 12)
- `query->bindings` (line 13)
- `query->orderBy` (line 14)
- `query->limitVal` (line 15)
- `query->offsetVal` (line 16)
- `query->fq:string` (line 17)
- `query->q($column):string` (line 18)
- `query->eq($column, $value):static` (line 24)
- `query->neq($column, $value):static` (line 25)
- `query->gt($column, $value):static` (line 26)
- `query->gte($column, $value):static` (line 27)
- `query->lt($column, $value):static` (line 28)
- `query->lte($column, $value):static` (line 29)
- `query->like($column, $value):static` (line 30)
- `query->in($column, array $values):static` (line 31)
- `query->isNull($column):static` (line 32)
- `query->notNull($column):static` (line 33)
- `query->between($column, $min, $max):static` (line 34)
- `query->raw($sql, ...$bindings):static` (line 35)
- `query->where($condition, ...$values):static` (line 36)
- `query->order($order):static` (line 42)
- `query->limit($limit):static` (line 47)
- `query->offset($offset):static` (line 52)
- `query->build:array` (line 56)
- `query->records:array` (line 64)
- `query->record:?model` (line 65)
- `query->column:array` (line 66)
- `query->item` (line 67)
- `query->count` (line 68)
- `query->delete:int` (line 69)

### %SQLite

SQLite resource

Advice: One file, one database, given as a path: %SQLite('/path/db.sqlite'). It writes with a lock over the whole file, so it fits a single site or a worker but not a set of processes writing at once. Perfect where you want the ORM without a server.

- File: `resources/DB/SQLite.phlo`
- `SQLite::__handle` (line 12)
- `SQLite->__construct(private string $file)` (line 13)
- `SQLite->PDO:\PDO` (line 14)
- `SQLite->insertIgnore:string` (line 15)

## DOM

### %charts

Lightweight dependency-free SVG charts: sparkline, bars and donut. Use charts::spark/bars/donut.

Advice: Three small SVG charts rendered on the server, without a library and without a script, so they show up in a mail, a PDF and a page with a strict policy alike. They are meant for a number in context, not for exploring data: no axes, no legend, no tooltips.

- File: `resources/DOM/charts.phlo`
- `charts::spark($values, $color = '#888', $w = 240, $h = 48, $label = null):string` (line 10)
- `charts::bars($values, $color = '#888', $w = 240, $h = 48, $label = null):string` (line 30)
- `charts::donut($parts, $colors = null, $size = 120):string` (line 48)

### %connection

One verdict on the connection, drawn from the traffic itself: app.online, offline on <body>, app.connection.on('change')

Advice: Nothing runs on a clock here, and the browser's own opinion is not asked either. The verdict comes from what actually happened: a request that got no answer at all, or a websocket reconnect that failed, means offline; any answer at all, or a socket that opens, means online. A socket that merely closes says nothing, because sockets drop for a hundred reasons while HTTP is fine, and the reconnect that follows is the only probe there is. Read app.online, style on body.offline, and subscribe with app.connection.on('change', cb) to pause a live view or refuse what needs the server. It decides nothing about what the visitor sees; that is the app's.

- File: `resources/DOM/connection.phlo`

### %cookiewall

Subtle GDPR cookie-consent banner. English by default; auto-translates when the lang system (en()) is loaded. Override prop labels for a fixed language, or prop translate to force it on/off.

Advice: Asks once and keeps the answer in a cookie, so it stays out of the way afterwards. canTrack and canAnalytics are what the rest of the app should ask before it loads anything; the banner itself blocks nothing. It translates itself when the language system is loaded, so it speaks the visitor's language without a second set of texts.

- File: `resources/DOM/cookiewall.phlo`
- `cookiewall::__handle` (line 11)
- `cookiewall->choice` (line 13)
- `cookiewall->hasChosen:bool` (line 14)
- `cookiewall->canTrack:bool` (line 15)
- `cookiewall->canAnalytics:bool` (line 16)
- `cookiewall->translate:bool` (line 18)
- `cookiewall->labels:array` (line 19)
- `cookiewall->label($key):string` (line 25)
- `route async POST cookiewall accept all` (line 27)
- `route async POST cookiewall accept essential` (line 32)
- `cookiewall->banner` (line 37)

### %CSS_fixes

Single Page App basic CSS boilerplate fixes

Advice: The handful of corrections nearly every app makes anyway: border-box sizing, no tap delay on anything clickable, no spinners on a number field, collapsed table borders and a [hidden] that actually hides. Note that it also clears the focus outline on inputs and buttons, so give focus a visible state of your own or keyboard users lose their place.

- File: `resources/DOM/CSS.fixes.phlo`

### %CSS_var

CSS variable proxy via app.var

Advice: app.var reads and writes CSS custom properties as if they were an object, so the server can change a colour, a size or a spacing with a command instead of a stylesheet swap. It writes on the root element, so what you set applies everywhere that inherits it.

- File: `resources/DOM/CSS.var.phlo`

### %datatags

Single Page App datatag plugin

Advice: Turn any element into a request without writing a handler: data-get, data-post, data-put, data-patch or data-delete holds the path, and with post, put and patch every other data attribute travels along as a field. That is why an element that also carries data-confirm is left alone here: the dialog resource asks first and clicks it again afterwards. Attribute names arrive dash-lowered as the browser gives them, so keep them one word.

- File: `resources/DOM/datatags.phlo`

### %dialog

Single Page App dialog resource

Advice: Replaces alert, confirm and prompt with a real dialog element, so they no longer block the page and no longer break an automated session. They answer a promise, so await them. Put data-confirm on a link or a button to ask before it does anything: the question is asked once, then the original action runs. The button labels live in app.dlgLabels, so an app writes its own language straight into that object.

- File: `resources/DOM/dialog.phlo`

### %exists

onExist helper for dynamic SPA elements

Advice: on() binds to what is there now, which is why it does not survive a page swap; onExist runs your callback the first time an element appears and only then, however it got there. That makes it the right hook for anything a plugin has to prepare once, and it is what the store and the numpad use themselves.

- File: `resources/DOM/exists.phlo`

### %ffmpeg

ffmpeg-wasm for the DOM: encode a canvas timeline to MP4, decode source frames via WebCodecs (seek fallback), transcode/run arbitrary ffmpeg. Exposes the ready singleton `ffmpeg` (and class `Ffmpeg`).

Advice: Encodes and converts video in the browser through ffmpeg-wasm, so no file has to leave the machine and no server has to be equipped for it. That comes at a price: several megabytes of wasm on first use, and encoding costs real time and memory, so it fits a clip rather than an hour of video. It loads the multithreaded core only on a cross-origin isolated page and falls back to a single-threaded one otherwise, which still works but is markedly slower.

- File: `resources/DOM/ffmpeg.phlo`

### %form

Single Page App form handler and input state saver

Advice: A form with class async submits over the same channel as the rest and answers with commands rather than a new page, using its own method attribute. Method and target are read as attributes, never as properties of the form: a field named action or method shadows the property with the element itself, and a form for a record with such a field would then never leave the page. Without an action the form posts to the page it is on, resolved against the origin like every other request. Apart from that, this keeps the DOM honest: what a visitor types is written back into the attributes, so the state that is saved and restored on a back button matches what is on screen.

- File: `resources/DOM/form.phlo`

### %image_resizer

Client-side file upload image resizer

Advice: Scales a picked file down in the browser before it is uploaded, so a phone photo of several megabytes leaves as a few hundred kilobytes. It only shrinks, keeps the aspect ratio, and gives a data URL back through the callback, which can go straight into a preview or a form field. The image field uses it, so an upload through the CMS is already covered.

- File: `resources/DOM/image.resizer.phlo`

### %keyboard

On-screen keyboard for touch devices, with selectable layout

Advice: Put data-keyboard on a field to open a keyboard on focus; data-keyboard="azerty" picks the layout. Inside a dialog the keys mount in that dialog; data-keyboard-dock on an element there places them in the flow. phlo.keyboard.layouts takes extra layouts.

- File: `resources/DOM/keyboard.phlo`

### %link

Single Page App async link handler

Advice: A link with class async is fetched and swapped in instead of loading the page, and everything else keeps working: a target, a modifier click and an outside link go to the browser untouched. An anchor is remembered across the swap, so a deep link scrolls to the right place after the new content has arrived.

- File: `resources/DOM/link.phlo`

### %markdown

Client-side markdown parser

Advice: Parses markdown in the browser, for text that arrives after the page: a chat message, a preview while typing, an answer streaming in. It renders what it is given, so escape or clean anything a visitor wrote before showing it to someone else. Markdown that is already known at render time is cheaper to parse on the server.

- File: `resources/DOM/markdown.phlo`

### %numpad

On-screen numeric keypad for touch input, bound to a field

Advice: Put data-numpad="<selector>" on a container; empty containers get the standard keys, containers with their own data-numpad-key buttons keep them. Without a selector the pad writes to the first field of its own form.

- File: `resources/DOM/numpad.phlo`

### %presentation

Timeline presentation player for the DOM: timed image/video layers, transitions, subtitles and language alternatives from a presentation.json payload. Exposes the class `PresentationPlayer`; each transition carries its own pp-* CSS animation and the matching canvas curve for deterministic export rendering. boot() wires every .pp-embed, from an inline JSON script child or a data-src payload URL. A document keymap steers the fullscreen, focused or only player: space/k toggles, arrows seek and set volume, m mutes, c toggles subtitles, f fullscreen, home/end jump.

Advice: Plays a presentation from one JSON payload against its own clock, and seeks the audio and video to match it, so a slow machine drops frames rather than drifting out of step. That also means a presentation without audio runs perfectly well. Alternative languages live in the same payload, so switching language reloads nothing.

- File: `resources/DOM/presentation.phlo`

### %recorder

Record the screen, a camera/mic or a canvas via MediaRecorder -> Blob. Optionally transcodes to MP4 with the DOM/ffmpeg resource when it is loaded. Exposes the ready singleton `recorder` (and class `Recorder`).

Advice: Records the screen, a camera or a canvas through MediaRecorder and answers with a blob. The browser decides the container, which is usually WebM and on Safari is not, so transcode when the file has to be played anywhere; loading the ffmpeg resource gives you MP4. Recording needs a real gesture from the visitor and a secure origin, so it cannot be started from a script alone.

- File: `resources/DOM/recorder.phlo`

### %shorthands

onChange, onClick and onInput event shorthands

Advice: Nothing more than onClick, onChange and onInput for the three most common cases of on(). Same behaviour and the same limitation: they bind to what exists at that moment, so use onExist for anything that appears later.

- File: `resources/DOM/shorthands.phlo`

### %store

Stateful binding engine

Advice: Bind an element to a value with data-bind and it follows every change, in both directions on an input. data-bind-attr does the same for an attribute, data-each repeats a template over a list, and app.calc holds values derived from others and recalculated on their own. app.persist keeps a path across a reload and app.sync keeps it equal across tabs or over a websocket. On a first render the DOM wins over an empty store, so server-rendered content is not blanked before the store has been filled.

- File: `resources/DOM/store.phlo`

### %template

Single Page App client-side templating

Advice: Add cb's to the templates object and output via apply(template: [$name => $rows, $name2 => $rows2, etc])

- File: `resources/DOM/template.phlo`

### %timestamps

DOM live timestamps

Advice: Create an app.tsLabels array to overwrite the tsBase labels in any language

- File: `resources/DOM/timestamps.phlo`

### %toasts

Simple toast resource

Advice: app.mod.toast(msg) for a short message that needs no answer, gone after four seconds or on a click. Because it is a command, the server can raise one from a route without a line of frontend code. Anything a visitor must confirm belongs in the dialog resource instead.

- File: `resources/DOM/toasts.phlo`

### %visible

onVisible and onVisibleIn helpers for DOM visibility

Advice: Reacts to an element entering or leaving the viewport at ten percent visible, which is what you want for lazy loading, counting a view or starting an animation at the right moment. Give only cbOut and it fires once and stops watching, so a one-off costs nothing afterwards. onVisibleIn watches inside a scrolling container rather than the window.

- File: `resources/DOM/visible.phlo`

### %websocket

Client-side WebSocket handler

Advice: Reconnects on its own with a backoff that triples up to half a minute, and tries at once when the network says it is back or the tab becomes visible again. send() answers true or false and never complains: whether a message that could not go out is queued or dropped is the app's call. Opens the connection and keeps it open, reconnecting on its own, and treats what arrives as commands, so the server can update a page with the same instructions a route uses. It survives a page swap, which a hand-bound on() does not. A token belongs in a cookie rather than in the URL, since a URL ends up in logs.

- File: `resources/DOM/websocket.phlo`

## fields

### %field

Base ORM field

Advice: Every field type extends this one and is made with field(type: 'x'), which resolves to the resource field_x; anything else you pass becomes a property, so title, required, length, pattern and enum need no declaration. Override input() for the form and label() for the list, and let objColumns name the columns the field owns, so a field that stores nothing returns an empty array.

- File: `resources/fields/field.phlo`
- `function field($type, ...$args):field` (line 11)
- `field::__handle` (line 13)
- `field->title:string` (line 15)
- `field->input($record):string` (line 17)
- `field->label($record)` (line 18)
- `field->objColumns:array` (line 20)
- `field->objValidate($value):?string` (line 22)

### %field_bool

Boolean field

Advice: Stores 1 or 0 and never null, so a checkbox that is left alone still saves a value. Set true and false to change the two symbols a list shows.

- File: `resources/fields/bool.phlo`
- `field_bool->true` (line 12)
- `field_bool->false` (line 13)
- `field_bool->label($record):string` (line 15)
- `field_bool->input($record):string` (line 16)
- `field_bool->parse($record):int` (line 17)
- `field_bool->nullable:bool` (line 18)
- `field_bool->objColumns:array` (line 20)

### %field_child

Child relation field

Advice: The mirror of a parent field elsewhere: it reads the records that point back at this one and owns no column itself. It cannot be edited here, only followed, so change a child from its own record. Set key when the foreign key is not named after the parent model.

- File: `resources/fields/child.phlo`
- `field_child->list` (line 12)
- `field_child->change` (line 13)
- `field_child->create` (line 14)
- `field_child->record` (line 15)
- `field_child->count($record):string` (line 17)
- `field_child->last($record)` (line 18)
- `field_child->label($record):string` (line 19)
- `field_child->input($record):string` (line 20)
- `field_child->link($record):string` (line 21)
- `field_child->objKey($parentModel):string` (line 23)
- `field_child->objOwns($record, $parentId, $parentModel):bool` (line 25)

### %field_date

Date field

Advice: Renders in the reader's language through IntlDateFormatter, so a Dutch visitor sees a Dutch date without any work. Without the intl extension it falls back to a built-in Dutch and English month list, so any other language reads as English there. Set format to a date() pattern when the notation has to be fixed rather than local.

- File: `resources/fields/date.phlo`
- `field_date->handle` (line 12)
- `field_date->format` (line 13)
- `field_date->months:array` (line 14)
- `field_date->label($record):string` (line 19)
- `field_date->objColumns:array` (line 30)

### %field_datetime

Date-time field

Advice: Stores a unix timestamp rather than a formatted string, and shows an age icon that runs blue under an hour, yellow under a day and red beyond. Fields called created and changed are kept out of the form because the model writes those itself.

- File: `resources/fields/datetime.phlo`
- `field_datetime->handle` (line 12)
- `field_datetime->change` (line 13)
- `field_datetime->create` (line 14)
- `field_datetime->label($record):string` (line 16)
- `field_datetime->labelIconClass($value):string` (line 17)
- `field_datetime->input($record):string` (line 18)
- `field_datetime->parse($record):void` (line 19)
- `field_datetime->objColumns:array` (line 25)

### %field_email

Email field

Advice: Only changes how a stored address is shown, as a mailto link. It validates nothing, so add pattern or required when the address has to be real.

- File: `resources/fields/email.phlo`
- `field_email->label($record)` (line 13)

### %field_file

File field

Advice: An upload is stored under a random token instead of its own name, so two people can send the same filename and no visitor can guess a neighbouring URL. That takes two columns, name and name_token, which objColumns already claims. Set accept to narrow the picker, and path and uri when the files live somewhere other than the default. On disk the extension is lowercase whatever the upload said, so a route that lowercases it as well finds FOTO.JPG under token.jpg.

- File: `resources/fields/file.phlo`
- `field_file->canDelete` (line 12)
- `field_file->delete` (line 13)
- `field_file->path` (line 14)
- `field_file->uri` (line 15)
- `field_file->length` (line 16)
- `field_file->accept` (line 17)
- `field_file->label($record):string` (line 19)
- `field_file->input($record):string` (line 20)
- `field_file->parse($record):void` (line 29)
- `field_file->read($record, $path = null):file` (line 41)
- `field_file->write($file)` (line 47)
- `field_file->writePath($file, $path = null):string` (line 48)
- `field_file->ext(string $filename):string` (line 55)
- `field_file->objColumns:array` (line 57)

### %field_image

Image field

Advice: A file field that also writes a thumbnail, and the browser scales the picture down before it is sent, so a phone photo does not travel at full size. thumbSize is the thumbnail edge in pixels; a list shows the thumbnail and a record shows the full image.

- File: `resources/fields/image.phlo`
- `field_image->delete` (line 12)
- `field_image->uri` (line 13)
- `field_image->path` (line 14)
- `field_image->thumbPath` (line 15)
- `field_image->thumbSize` (line 16)
- `field_image->thumbUri` (line 17)
- `field_image->placeholder` (line 18)
- `field_image->record` (line 20)
- `field_image->label($record):string` (line 22)
- `field_image->preview($record):string` (line 23)
- `field_image->input($record):string` (line 24)
- `field_image->write($file)` (line 33)
- `field_image->objColumns:array` (line 35)

### %field_many

Many-to-many relation field

Advice: Editing is opt-in: a model that sets create/change on a many field gets the checkbox picker from input(); CMS.API::syncMany() then rewrites the pivot table on save.

- File: `resources/fields/many.phlo`
- `field_many->list` (line 12)
- `field_many->record` (line 13)
- `field_many->create` (line 14)
- `field_many->change` (line 15)
- `field_many->count($record):int` (line 17)
- `field_many->label($record):string` (line 18)
- `field_many->link($record):string` (line 19)
- `field_many->input($record):string` (line 21)
- `field_many->sync($model, $parentId)` (line 28)
- `field_many->objOwns($record, $parentId, $parentModel):bool` (line 36)
- `field_many->objColumns:array` (line 41)

### %field_multiselect

Multi-select via checkboxes; stores the choice as CSV in one hidden field (no save change needed).

Advice: Joins the checked boxes into one comma separated column, so it needs no pivot table and no extra save logic. Reach for many instead when the choices are records with a life of their own.

- File: `resources/fields/multiselect.phlo`
- `field_multiselect->label($record):string` (line 12)
- `field_multiselect->input($record):string` (line 17)
- `field_multiselect->objColumns:array` (line 24)

### %field_number

Number field

Advice: decimals sets both the step of the input and the formatting of the list. min is 0 out of the box, so state it yourself when negative values are allowed.

- File: `resources/fields/number.phlo`
- `field_number->decimals` (line 12)
- `field_number->length` (line 13)
- `field_number->min` (line 14)
- `field_number->label($record):string` (line 16)
- `field_number->input($record):string` (line 17)
- `field_number->parse($record):void` (line 19)
- `field_number->objColumns:array` (line 26)

### %field_parent

Parent relation field

Advice: Points at another model through obj and offers every record of it in one select, so keep it to sets a person can still scroll. Reading the field gives you the record itself, not the id.

- File: `resources/fields/parent.phlo`
- `field_parent->label($record):string` (line 12)
- `field_parent->input($record):string` (line 13)
- `field_parent->link($record, $content = null):string` (line 14)
- `field_parent->options:array` (line 15)
- `field_parent->objColumns:array` (line 17)

### %field_password

Password field

Advice: Never gives the stored value back: a list prints dots and an empty form field leaves the current password untouched. It hashes with bcrypt on save, so keep the plain value nowhere.

- File: `resources/fields/password.phlo`
- `field_password->list` (line 12)
- `field_password->required` (line 13)
- `field_password->minlength` (line 14)
- `field_password->placeholder` (line 15)
- `field_password->input($record):string` (line 17)
- `field_password->label($record):string` (line 18)
- `field_password->parse($record)` (line 19)
- `field_password->objColumns:array` (line 21)

### %field_price

Price field

Advice: A number field fixed at two decimals. Store the amount in the unit you bill in and leave the formatting to the field.

- File: `resources/fields/price.phlo`
- `field_price->decimals` (line 12)

### %field_select

Select field

Advice: Stores the chosen option itself rather than a key, so renaming an option later leaves older records pointing at wording you no longer offer. Pass the accepted values as options.

- File: `resources/fields/select.phlo`
- `field_select->input($record):string` (line 12)
- `field_select->objColumns:array` (line 14)

### %field_text

Text field

Advice: length is both the limit and the choice of input: over 250 characters the field renders a textarea instead of a single line.

- File: `resources/fields/text.phlo`
- `field_text->length` (line 12)
- `field_text->multiline:bool` (line 13)
- `field_text->label($record):string` (line 15)
- `field_text->input($record):string` (line 16)
- `field_text->inputField($record):string` (line 17)
- `field_text->inputMulti($record):string` (line 18)
- `field_text->objColumns:array` (line 20)

### %field_token

Token field

Advice: Fills itself with a random token when a record is made and refuses to change afterwards, which makes it the id to use for anything that ends up in a URL. handle is true, so a record can be looked up by this column instead of by its id.

- File: `resources/fields/token.phlo`
- `field_token->length` (line 12)
- `field_token->default:string` (line 13)
- `field_token->create` (line 14)
- `field_token->change` (line 15)
- `field_token->search` (line 16)
- `field_token->handle` (line 17)
- `field_token->label($record):string` (line 19)
- `field_token->parse($record)` (line 20)
- `field_token->objColumns:array` (line 22)

### %field_virtual

Virtual field

Advice: Owns no column and is never saved, so use it to show something derived next to the real fields. The record supplies the value through a prop or method of the same name.

- File: `resources/fields/virtual.phlo`
- `field_virtual->create` (line 12)
- `field_virtual->change` (line 13)
- `field_virtual->objColumns:array` (line 15)

### %field_wysiwyg

WYSIWYG field

Advice: Stores whatever HTML the editor produces, including anything a user pasted in, so clean or escape it before it reaches a public page.

- File: `resources/fields/wysiwyg.phlo`
- `field_wysiwyg->input($record):string` (line 12)
- `field_wysiwyg->objColumns:array` (line 20)

### Functions

- `field($type, ...$args):field`: Base ORM field

## files

### %CSV

CSV reader resource

Advice: Reads the first line as the header and picks its own separator by counting: more commas than semicolons and it is a comma, otherwise a semicolon. Every row comes back keyed by header name, so a file with duplicate or empty headers loses columns. It reads, it does not write.

- File: `resources/files/CSV.phlo`
- `CSV::__handle` (line 10)
- `CSV->__construct(string $filename, ?string $path = null)` (line 11)
- `CSV->objFile:string` (line 17)
- `CSV->objRead:void` (line 19)

### %DOCX

DOCX reader resource

Advice: Pulls the plain text out of a .docx and nothing more: no styling, no tables as tables, no images. Paragraphs come out as a list, which is what makes it usable for search and for feeding a model, and DOCX::toText() is the one-liner for that.

- File: `resources/files/DOCX.phlo`
- `DOCX->__construct(string $file)` (line 11)
- `DOCX::toText(string $file):string` (line 24)

### %file

File resource

Advice: Wrap a path in %file and everything about it is one call away: contents, size, mime, hashes, human dates, and a token() derived from the file's sha1, so the same content always yields the same token. Watch the difference between file and name: file is where it sits, name is what it is called, and ext and mime read the name. An upload therefore keeps its extension while the temp path has none.

- File: `resources/files/file.phlo`
- `file::__handle` (line 10)
- `file->__construct(public string $file, ?string $name = null, $contents = null, ...$args)` (line 11)
- `file->append(string $data):int|false` (line 17)
- `file->basename:string` (line 18)
- `file->base64:string` (line 19)
- `file->contents:string|false` (line 20)
- `file->contentsINI(bool $parse = true):array|false` (line 21)
- `file->contentsJSON($assoc = null)` (line 22)
- `file->copy($to):bool` (line 23)
- `file->created:int|false` (line 24)
- `file->createdAge:int` (line 25)
- `file->createdHuman:string` (line 26)
- `file->curl($type = null, $filename = null):CURLFile` (line 27)
- `file->delete:bool` (line 28)
- `file->exists:bool` (line 29)
- `file->ext:string` (line 30)
- `file->filename:string` (line 31)
- `file->getLine:string|false` (line 32)
- `file->getLength(int $length):string|false` (line 33)
- `file->is(string $file):bool` (line 34)
- `file->md5:string|false` (line 35)
- `file->mime:string` (line 36)
- `file->modified:int|false` (line 37)
- `file->modifiedAge:int` (line 38)
- `file->modifiedHuman:string` (line 39)
- `file->move($to):bool` (line 40)
- `file->name:string` (line 41)
- `file->output($download = false)` (line 42)
- `file->path:string` (line 43)
- `file->pathRel:string` (line 44)
- `file->pointer` (line 45)
- `file->readable:bool` (line 46)
- `file->src:string` (line 47)
- `file->size:int|false` (line 48)
- `file->sizeHuman(int $precision = 0):string` (line 49)
- `file->sha1:string|false` (line 50)
- `file->shortenTo(int $length):string` (line 51)
- `file->title:string` (line 52)
- `file->token($length = 20):string` (line 53)
- `file->type:string` (line 54)
- `file->touch:bool` (line 55)
- `file->writable:bool` (line 56)
- `file->writeINI($data, bool $deleteEmpty = false):bool` (line 57)
- `file->writeJSON($data, bool $deleteEmpty = false):bool` (line 58)
- `file->writeJSONplain($data, bool $deleteEmpty = false):bool` (line 59)
- `file->write(string $data, bool $deleteEmpty = false):bool` (line 60)
- `file->objInfo:array` (line 67)

### %img

GD image resource

Advice: Only scales down, never up: a request larger than the original returns the image untouched, so a thumbnail never looks stretched. The output format follows the extension you save to, so saving a .jpg writes JPEG at quality 85 whatever came in. Pass crop with a width and a height to fill the frame instead of fitting inside it, and top or bottom to choose which part survives.

- File: `resources/files/img.phlo`
- `img::detect($data):?string` (line 10)
- `img::__handle` (line 20)
- `img->__construct(public string $file)` (line 21)
- `img->src:GdImage` (line 23)
- `img->width:int` (line 24)
- `img->height:int` (line 25)
- `img->scale($width = null, $height = null, $crop = false):static` (line 27)
- `img->ext($file = null):string` (line 58)
- `img->source($format = null):string` (line 60)
- `img->save($file = null):bool` (line 66)
- `img->write($format = null, $file = null)` (line 71)

### %INI

Generic INI resource

Advice: The same shape as the JSON resource, saved when the object goes out of scope, but writing flattens the file: comments and section headers do not survive a round trip. Keep it to values a program owns; a file a person edits by hand deserves to be read rather than rewritten.

- File: `resources/files/INI.phlo`
- `INI->objFile:string` (line 10)
- `INI::__handle` (line 12)
- `INI->__construct(string $filename, ?string $path = null, bool $parse = true)` (line 13)
- `INI->objRead($parse = true)` (line 19)
- `INI->objWrite:int|false` (line 20)
- `INI->__destruct` (line 22)

### %JSON

Generic JSON resource

Advice: The file behaves as an object: read a key, write a key, and the file is saved when the object goes out of scope. That last part is the trap: nothing is written until then, so call objWrite() yourself when the request may end otherwise. Names are read against data/ and a slash in the name becomes a dot, so no name can escape the directory.

- File: `resources/files/JSON.phlo`
- `JSON::__handle` (line 11)
- `JSON->__construct(string $filename, ?string $path = null, $assoc = null)` (line 12)
- `JSON->objFile:string` (line 18)
- `JSON->objTouch:bool` (line 20)
- `JSON->objRead($assoc = null)` (line 21)
- `JSON->objWrite($data, $flags = null)` (line 22)
- `JSON->__destruct` (line 24)

### %PDF

PDF generator and reader

Advice: Two halves that share a name. Reading uses the pdftotext binary, so it needs poppler-utils on the machine and gives nothing on a scan without a text layer. Writing renders HTML through mPDF, and mode is the mPDF one: D sends a download, I shows it in the browser, S returns the bytes as a string, so leaving the default on an API route pushes a download at your caller.

- File: `resources/files/PDF.phlo`
- `PDF::toText(string $file):string` (line 10)
- `PDF->title:?string` (line 22)
- `PDF->author:?string` (line 23)
- `PDF->subject:?string` (line 24)
- `PDF->keywords:?string` (line 25)
- `PDF->creator:string` (line 26)
- `PDF->filename:string` (line 28)
- `PDF->mode:string` (line 29)
- `PDF->fromHTML($HTML):string` (line 31)

### %UBL

UBL 2.1 invoice XML (PEPPOL BIS Billing 3.0) from a normalized invoice structure. Use UBL::invoice($data).

Advice: Builds the invoice XML that PEPPOL expects from a plain array with supplier, customer and lines. Tax is grouped by rate and each group gets its own subtotal, so lines at 21 and 9 percent land in the right boxes on their own. It formats and escapes the amounts you hand it but checks nothing: a total that does not match its lines is written out as given, and a receiver will reject it.

- File: `resources/files/UBL.phlo`
- `UBL::invoice(array $data):string` (line 10)
- `UBL::xmlLine($idx, $line, $qty, $net, $rate, $currency = 'EUR'):string` (line 67)
- `UBL::partyXml($wrapper, $name, $info, $vatNumber):string` (line 82)
- `UBL::n($v):string` (line 103)
- `UBL::esc($v):string` (line 104)

### %XLSX

XLSX reader resource

Advice: Reads a workbook without a library by unpacking the zip itself, and gives every sheet by name with the first row as the header. Values arrive as the sheet stored them, so a date is the serial number Excel keeps and a percentage is a fraction; convert those yourself. Formulas give the last calculated value, so a sheet that was never opened after an edit hands you the old one.

- File: `resources/files/XLSX.phlo`
- `XLSX->__construct(string $file)` (line 16)

## payments

### %Stripe

Thin Stripe wrappers: Checkout, Billing Portal, prices, customers, subscriptions and webhook verification. Call Stripe::boot($secret) first. Requires the Stripe PHP SDK (composer: stripe/stripe-php).

Advice: A thin layer over the Stripe SDK, which has to be installed and booted with Stripe::boot($secret) before anything else. Checkout and the billing portal are hosted by Stripe, so card details never touch your server. Verify a webhook before you read it: an unverified POST is a stranger claiming a payment, and verifyWebhook() is what turns it into a fact.

- File: `resources/payments/Stripe.phlo`
- `Stripe::boot($secret, $version = '2025-09-30.clover'):void` (line 10)
- `Stripe::price($lookupKey)` (line 17)
- `Stripe::customer($id)` (line 22)
- `Stripe::createCustomer(array $data)` (line 32)
- `Stripe::checkout(array $params)` (line 34)
- `Stripe::portal($customerId, $returnUrl)` (line 36)
- `Stripe::verifyWebhook($payload, $sigHeader, $secret)` (line 38)
- `Stripe::subscriptions($customerId, $status = 'all', $limit = 10)` (line 44)
- `Stripe::subscription($id)` (line 46)
- `Stripe::product($id)` (line 48)

### %SumUp

SumUp connector: card-present checkouts on paired Solo readers via the Cloud API, transaction lookup and history

Advice: Cloud-initiated terminal payments: the POS creates a checkout for a paired reader over HTTPS, the reader wakes up with the amount, and the result arrives on the return_url webhook or by polling transaction(). A checkout must start on the device within 60 seconds and only one checkout can be active per reader at a time. Treat the webhook as a wake-up call, never as the verdict: re-fetch the transaction with transaction() before recording a payment, so a forged POST can never book money.

- File: `resources/payments/SumUp.phlo`
- `SumUp::section` (line 12)
- `SumUp::api` (line 13)
- `SumUp->headers:array` (line 15)
- `SumUp::fields:array` (line 17)
- `SumUp->merchant:string` (line 26)
- `SumUp->readers:obj` (line 31)
- `SumUp->reader(?string $readerId = null):string` (line 36)
- `SumUp->createReaderCheckout(int $amountMinor, string $currency = 'EUR', ?string $readerId = null, ?string $description = null, ?string $returnUrl = null):obj` (line 41)
- `SumUp->terminateReaderCheckout(?string $readerId = null):obj` (line 54)
- `SumUp->transaction(string $clientTransactionId):obj` (line 64)
- `SumUp->transactions(array $query = []):obj` (line 69)

## security

### %audit

Audit log for model mutations (opt-in via static idColumn/objAudit). Schema: resources/security/audit.sql

Advice: Off unless a model sets objAudit, and then every create, change and delete is written: an update as the difference between before and after, a create as the new row, a delete as the row that went. Pass exclude for columns you would rather not keep, a password hash or a token; the log outlives the record, so what goes in is a decision, not a detail. purge() is there because a log nobody prunes eventually costs more than the table it watches.

- File: `resources/security/audit.phlo`
- `audit::log($model, $action, $before = [], $after = [], $exclude = []):void` (line 10)
- `audit::diff($before, $after):array` (line 31)
- `audit::history($model, $recordId, $limit = 50):array` (line 40)
- `audit::byUser($model, $userId, $fromTs = 0, $limit = 100):array` (line 48)
- `audit::purge($model, $olderThanSeconds = 31536000)` (line 55)

### %captcha

Self-contained interactive slider-puzzle captcha (no external service). The server picks a secret gap position and renders the background plus a loose piece with GD; the client drags the piece into place. verify() checks the end position plus human drag behaviour (time, path, variation). Single-use and session-bound; the gap position never leaves the server.

Advice: Runs entirely on your own server, so no visitor is handed to a third party and nothing has to be disclosed in a privacy statement. The gap position never leaves the server, and it judges the drag as well as the endpoint, so a script that jumps straight to the answer is refused. verify() does not consume the puzzle: call consume() yourself, and only on success, or a failed attempt costs the visitor their challenge. It needs GD.

- File: `resources/security/captcha.phlo`
- `captcha::W` (line 11)
- `captcha::H` (line 12)
- `captcha::P` (line 13)
- `captcha::tol` (line 14)
- `captcha::ttl` (line 15)
- `captcha::issue:array` (line 17)
- `captcha::images($gapX, $gapY):array` (line 24)
- `captcha::verify($x, $telemetry):bool` (line 59)
- `captcha::consume:void` (line 80)
- `captcha->widget:string` (line 84)
- `captcha->field($bg, $piece, $gapY, $w, $h, $p)` (line 90)

### %creds

Credentials resolver from env and ini sources

Advice: One place for every secret, filled from data/creds.ini and from the environment, where PHLO__Section__key sets a value and PHLO_<HOST>__Section__key overrides it for one host, with <HOST> the request host uppercased and every other character an underscore. Values are wrapped so a var_dump or an error page shows stars instead of the secret. Keep the ini file out of the repository and let the environment win on a server.

- File: `resources/security/creds.phlo`
- `creds->__construct(?array $values = null)` (line 10)
- `creds->resolve:array` (line 17)
- `creds->loadINI(string $file):array` (line 25)
- `creds->envValues(bool $hostScoped = false):array` (line 31)
- `creds->hostKey:string` (line 50)
- `creds->envAssign(array &$target, array $parts, string $value):void` (line 56)
- `creds->merge(array &$base, array $add):void` (line 71)
- `creds->objGet($key)` (line 81)
- `creds->objInfo:array` (line 86)

### %CSRF

Rotating async CSRF protection for Phlo requests

Advice: Put %CSRF in your head view and it writes the meta tag the frontend reads from. The rest you wire yourself: verify() checks the X-CSRF-Token header against the session, and update() answers with a fresh token as a command the page applies to its meta tag. A route that does both makes a stolen token worth one request at most, which is the whole point; a route that only verifies keeps one token for the life of the session. It protects a session, so it says nothing about an API authenticated with a bearer token.

- File: `resources/security/CSRF.phlo`
- `CSRF->view` (line 12)
- `CSRF->token:string` (line 13)
- `CSRF->verify:bool` (line 14)
- `CSRF->update:array` (line 15)

### %JWT

Sign and verify compact HS256 JSON Web Tokens (RFC 7519), secure by default

Advice: HS256 only, and a token that claims another algorithm is refused rather than tried, which is the classic way these are broken. The secret must be at least 32 bytes, an expiry is always written, and verify() throws with a 401 instead of returning false, so a route can simply call it. Name an issuer and it is checked as well. A signed token is readable by anyone holding it, so keep secrets out of the claims.

- File: `resources/security/JWT.phlo`
- `JWT->__construct(public string $secret, public string $issuer = void, public int $leeway = 30)` (line 11)
- `JWT->sign(array $claims, int $ttl = 3600):string` (line 13)
- `JWT->verify(string $token):array` (line 22)
- `JWT->sig(string $body):string` (line 38)
- `JWT->encode($data):string` (line 39)
- `JWT->decode(string $data):string` (line 40)

### %OAuth2

Stateless OAuth2 client: build the authorize URL and exchange/refresh tokens. Token storage and config are the caller's responsibility. The protocol primitive under TokenStore and OAuthConnector.

Advice: The bare protocol: build an authorize URL, trade a code for tokens, refresh. It keeps nothing and knows nothing about your app, which is what makes it usable for any provider. For a connector you almost never need it directly, since TokenStore and OAuthConnector do the keeping for you; reach for it when you run the login yourself.

- File: `resources/security/OAuth2.phlo`
- `OAuth2::authorizeUrl($endpoint, array $params):string` (line 11)
- `OAuth2::token($tokenUrl, $clientId, $clientSecret, $grantType, array $extra = []):array` (line 13)
- `OAuth2::exchangeCode($tokenUrl, $clientId, $clientSecret, $code, $redirectUri = null, array $extra = []):array` (line 27)
- `OAuth2::refresh($tokenUrl, $clientId, $clientSecret, $refreshToken, array $extra = []):array` (line 29)

### %rate

Rate-limit (fixed window) on the rate_limit table. Schema: resources/security/rate.sql

Advice: A fixed window, counted in one atomic statement, so two requests arriving together cannot both slip past the limit. Storage db survives a restart and is shared across machines; apcu is faster but lives in one server's shared memory, so it is gone after a restart and says nothing about a second machine. Because the window is fixed rather than sliding, a caller can spend a full limit at the end of one window and again at the start of the next.

- File: `resources/security/rate.phlo`
- `rate::check($key, $limit, $windowSeconds, $storage = 'db'):bool` (line 11)
- `rate::checkApcu($key, $limit, $windowSeconds):bool` (line 21)
- `rate::status($key, $limit, $windowSeconds):obj` (line 28)
- `rate::reset($key)` (line 35)
- `rate::purge($olderThanSeconds = 604800)` (line 36)

### %security

Generic security resource

Advice: Pick a profile per app rather than writing headers yourself: strict allows only nonced scripts and styles, basic allows your own files, marketing also allows images from anywhere, and api shuts everything off and marks the response as an API. Async responses get no policy, because the page they land in already has one. Add a CDN or a media host to sources and a domain that may frame you to whitelist; those are the two escape hatches, and everything else stays closed. Under debug, basic and marketing let inline scripts through so the debug console runs, so a production site with debug on is running a weaker policy than it thinks.

- File: `resources/security/security.phlo`
- `security->whitelist:array` (line 11)
- `security->sources:array` (line 12)
- `security->setNonce:string` (line 14)
- `security->frameProtect($mode = 'DENY')` (line 16)
- `security->frameWhitelist:string` (line 17)
- `security->sourceList:string` (line 19)
- `security->strict:void` (line 21)
- `security->basic:void` (line 30)
- `security->marketing:void` (line 35)
- `security->api:void` (line 40)
- `security->base:void` (line 47)

### %social

Reusable social login (OIDC) on top of OAuth2: build the authorize URL and turn a callback code into a verified profile. Google, Microsoft and Apple. No user, session or route handling - that is the caller's responsibility.

Advice: The id_token is verified against the provider's JWKS (RS256 only, key looked up by kid) before any claim is read, on top of issuer, audience (must equal client_id), expiry and, when supplied, nonce. `verified` reports what the provider actually proved: Microsoft omits email_verified, so it counts only when the optional xms_edov claim states the tenant owns the address. Treat an unverified email as a claim, never as an identity: match users on provider + sub. Apple's client_secret is an ES256 JWT signed with the .p8 key (team_id/key_id/client_id + key from creds).

- File: `resources/security/social.phlo`
- `social::providers:array` (line 11)
- `social::config($provider):?array` (line 38)
- `social::configured($provider):bool` (line 50)
- `social::authUrl($provider, $state, $nonce = void):string` (line 55)
- `social::profile($provider, $code, $nonce = void):?array` (line 69)
- `social::decodeIdToken($jwt):?array` (line 86)
- `social::algs:array` (line 93)
- `social::verifySignature($provider, $jwt):bool` (line 95)
- `social::jwks($provider):array` (line 108)
- `social::key($provider, $kid):string` (line 122)
- `social::jwkToPem(array $jwk):string` (line 129)
- `social::der($tag, $content):string` (line 141)
- `social::derInt($bytes):string` (line 148)
- `social::b64urlDecode($data):string` (line 155)
- `social::verifyClaims($provider, array $cfg, array $claims, $nonce = void):bool` (line 162)
- `social::verifyIssuer($provider, array $claims):bool` (line 170)
- `social::normalize($provider, array $claims):array` (line 181)
- `social::b64url($data):string` (line 199)
- `social::appleSecret` (line 201)
- `social::derToJose($der):string` (line 219)
- `social::pad32($x):string` (line 227)

### Functions

- `decrypt($encrypted, $key):string|false`: Encrypt and decrypt secretbox payloads using a key
- `encrypt($data, $key):string`: Encrypt and decrypt secretbox payloads using a key
- `token(int $length = 8, ?string $input = null):string`: Generate deterministic or random lowercase token

