preflow / view
Preflow view — template engine interfaces and asset pipeline
Requires
- php: >=8.4
- ext-dom: *
- matthiasmullie/minify: ^1.3
- preflow/core: ^0.74 || @dev
Requires (Dev)
- chillerlan/php-qrcode: ^5.0
- mpdf/mpdf: ^8.2
- phpunit/phpunit: ^11.0
- preflow/devtools: ^0.54 || @dev
- smalot/pdfparser: ^2.0
Suggests
- chillerlan/php-qrcode: Renders QR codes (^5.0)
- mpdf/mpdf: Renders PDFs (^8.2)
- preflow/devtools: Runs the SEO audit from the console: preflow seo:audit
Provides
None
Conflicts
- preflow/core: <0.74
- preflow/devtools: <0.54
- preflow/media: <0.67
- preflow/routing: <0.54
Replaces
None
- dev-main
- v0.74.0
- v0.73.0
- v0.72.1
- v0.72.0
- v0.71.1
- v0.71.0
- v0.70.0
- v0.69.2
- v0.69.1
- v0.69.0
- v0.68.1
- v0.68.0
- v0.67.0
- v0.66.2
- v0.66.1
- v0.66.0
- v0.65.0
- v0.64.0
- v0.63.2
- v0.63.1
- v0.63.0
- v0.62.0
- v0.61.0
- v0.60.1
- v0.60.0
- v0.59.0
- v0.58.1
- v0.58.0
- v0.57.0
- v0.56.1
- v0.56.0
- v0.55.0
- v0.54.1
- v0.54.0
- v0.53.2
- v0.53.1
- v0.53.0
- v0.52.0
- v0.51.0
- v0.50.0
- v0.49.1
- v0.49.0
- v0.48.5
- v0.48.4
- v0.48.3
- v0.48.2
- v0.48.1
- v0.48.0
- v0.47.1
- v0.47.0
- v0.46.1
- v0.46.0
- v0.45.0
- v0.44.0
- v0.43.0
- v0.42.1
- v0.42.0
- v0.41.2
- v0.41.1
- v0.41.0
- v0.40.0
- v0.39.0
- v0.38.0
- v0.37.0
- v0.36.0
- v0.35.0
- v0.34.1
- v0.34.0
- v0.33.0
- v0.32.0
- v0.31.0
- v0.30.0
- v0.29.0
- v0.28.0
- v0.27.1
- v0.27.0
- v0.26.0
- v0.25.1
- v0.25.0
- v0.24.1
- v0.24.0
- v0.23.0
- v0.22.1
- v0.22.0
- v0.21.0
- v0.20.0
- v0.19.2
- v0.19.1
- v0.19.0
- v0.18.1
- v0.18.0
- v0.17.2
- v0.17.1
- v0.17.0
- v0.16.2
- v0.16.1
- v0.16.0
- v0.15.0
- v0.14.2
- v0.14.1
- v0.14.0
- v0.13.1
- v0.13.0
- v0.12.0
- v0.11.4
- v0.11.3
- v0.11.2
- v0.11.1
- v0.11.0
- v0.10.4
- v0.10.3
- v0.10.2
- v0.10.1
- v0.10.0
- v0.9.1
- v0.9.0
- v0.8.0
- v0.7.0
- v0.6.0
- v0.4.1
- v0.4.0
- v0.3.3
- v0.3.2
- v0.3.1
- v0.3.0
- v0.2.3
- v0.2.2
- v0.2.1
- v0.2.0
- v0.1.1
- v0.1.0
This package is auto-updated.
Last update: 2026-11-13 14:26:04 UTC
README
Engine-agnostic template interfaces and asset pipeline for Preflow. Defines the contracts that engine adapters (preflow/twig, preflow/blade) implement. Ships a CSP-nonce-aware asset collector with deduplication.
Installation
composer require preflow/view
Requires PHP 8.4+. Install an engine adapter to render templates:
composer require preflow/twig # Twig 3 composer require preflow/blade # Laravel Blade
What's included
| Component | Description |
|---|---|
TemplateEngineInterface |
Engine contract: render, exists, addFunction, addGlobal, getTemplateExtension, addNamespace |
TemplateEngineFactory |
Builds a third template engine named by app.engine (0.65) |
LazyTemplateEngine |
Stands in for the factory's engine until its first render (keeps what was registered) |
TemplateEngineContext |
What core passes the factory: template dirs, assets, debug, cache path, GEO, SEO, live parts, page cache; bound in the container for every engine |
SharingTemplateEngine |
The app's engine when it is not Twig: keeps every function and global, so another engine can share() them (0.65) |
TemplateFunctionDefinition |
Value object describing a template function (name, callable, isSafe) |
TemplateExtensionProvider |
Interface for packages that supply template functions and globals |
AssetCollector |
Collects CSS/JS across components, deduplicates by xxh3 hash |
JsPosition |
Enum: Head, Body, Inline |
NonceGenerator |
Generates one random nonce per request for CSP |
TemplateEngineInterface
The central contract. Engine adapters implement this to plug into Preflow.
use Preflow\View\TemplateEngineInterface; $html = $engine->render('blog/post', ['post' => $post]); $engine->exists('partials/nav'); // bool $engine->getTemplateExtension(); // 'twig' or 'blade.php' $engine->addGlobal('siteName', 'My App'); // available in all templates $engine->addFunction(new TemplateFunctionDefinition( name: 'greet', callable: fn (string $name) => "Hello, {$name}!", isSafe: true, // skip output escaping ));
Your own engine: TemplateEngineFactory (0.65)
app.engine in config/app.php is 'twig', 'blade', or the class name of a
Preflow\View\TemplateEngineFactory. Core stands a LazyTemplateEngine in for the engine while it boots (packages register their
functions, routing scans app/pages for templateExtension(), a static method of the factory),
and on the first render (or exists()) gets the factory from the container — after the service
providers ran, so its constructor may take services a provider binds — calls create() and hands
the engine everything registered until then. The engine then gets every package's template
functions (component(), hd, csp_nonce(), the form helpers, t()…), renders file pages
(app/pages/*.{extension}) and components (Name/Name.{extension}, passed as an absolute path).
// config/app.php 'engine' => App\View\PhpTemplateEngineFactory::class, final class PhpTemplateEngineFactory implements TemplateEngineFactory { public static function templateExtension(): string { return 'phtml'; } public function create(TemplateEngineContext $context): TemplateEngineInterface { return new PhpTemplateEngine($context->templateDirs); } }
TemplateEngineContext (readonly) carries what the built-in engines get: templateDirs
(app/pages, then the project root), assets (the AssetCollector), debug, cachePath
(storage/cache/templates, emptied by cache:clear, may not exist yet), geo, seo (null while
SEO is off), liveParts and pageCache. Pick a template extension that isn't php: a
component's template sits beside its class. Any other name throws at boot, listing the accepted
forms. Check the engine with Preflow\Testing\Contract\TemplateEngineContract (preflow/testing).
Another engine beside the app's: TemplateEngineContext, SharingTemplateEngine (0.65)
Core binds the TemplateEngineContext it built the app's engine from in the container, for every
engine (Twig: cachePath is storage/cache/twig/…; Blade: storage/cache/blade; a factory:
storage/cache/templates), so a package can build another engine like it. When the app's engine
is not Twig, the container's TemplateEngineInterface is a SharingTemplateEngine wrapping it
(engine() gives the wrapped one, a LazyTemplateEngine for a factory): it renders as that engine
and keeps every function and global registered on it, and share($other) gives $other all of
them, those registered before and after. Namespaces stay the app engine's. Register functions and
globals at boot (in a provider): a Twig engine that has rendered refuses new ones, so one registered
later still reaches the app's engine but that sharer misses it (logged with error_log, never thrown). Folio renders its own Twig
templates so on any app engine (see the Folio README, "Template engines"). A Twig app's engine is the
TwigEngine itself, as before.
A fragment answered with its page's nonce: NonceGenerator::adopt() (0.65)
NonceGenerator::adopt(string $nonce): bool makes the request use another nonce (a base64 value
of at least 22 characters; anything else is ignored); release() goes back to the request's own,
adopted() tells. Core calls adopt() with HypermediaProtocols::pageNonce() when a fragment
request's client sends its page's nonce back, so the fragment's scripts and styles (component
assets, a driver's effect scripts) carry the page's nonce and pass its Content-Security-Policy.
The value is untrusted client input: never from a plain request or a history restore, released
when the kernel has answered (after-response rebuilds and internal renders use the own nonce), and
the page cache never stores such a response (refused:page-nonce). A driver must not print the
nonce into the page; its client reads script.nonce when it sends the request.
TemplateExtensionProvider
Feature packages implement this interface to register template functions without depending on a specific engine.
use Preflow\View\TemplateExtensionProvider; use Preflow\View\TemplateFunctionDefinition; final class MyExtensionProvider implements TemplateExtensionProvider { public function getTemplateFunctions(): array { return [ new TemplateFunctionDefinition( name: 'myHelper', callable: fn (string $arg) => strtoupper($arg), isSafe: true, ), ]; } public function getTemplateGlobals(): array { return ['appVersion' => '1.0']; } }
Built-in providers: ComponentsExtensionProvider, HtmxExtensionProvider, TranslationExtensionProvider.
AssetCollector API
// Add CSS (key is optional; defaults to xxh3 hash of content) $assets->addCss('.btn { ... }'); $assets->addCss('.btn { ... }', key: 'btn-styles'); // explicit dedup key // Add JS $assets->addJs('console.log("body")', JsPosition::Body); $assets->addJs('console.log("head")', JsPosition::Head); $assets->addJs('console.log("inline")', JsPosition::Inline); // Render (called by engine extensions; also usable directly) $assets->renderHead(); // <script nonce="...">head JS</script> $assets->renderAssets(); // <script nonce="...">body JS</script><script nonce="...">inline JS</script> $assets->renderCss(); $assets->renderJsHead(); $assets->renderJsBody(); $assets->renderJsInline(); // CSP nonce for the current request $assets->getNonce(); // base64 random, stable within one request
Every <style> and <script> tag rendered by AssetCollector carries the same nonce attribute. Preflow sends a matching Content-Security-Policy header itself (strict by default; override per directive in config/security.php, sent as report-only in debug mode). For a <script> or <style> you write by hand, add nonce="{{ csp_nonce() }}"; without it the browser blocks the tag.
Identical blocks (same xxh3 hash) are deduplicated automatically — safe to include the same component multiple times.
Forked collectors and inspection
fork() creates an isolated child AssetCollector that shares the same nonce but collects assets independently. Use it when rendering a sub-component that should not pollute the parent collector (e.g. fragment responses).
$child = $assets->fork(); $html = $componentRenderer->renderFragment($component, $child); // $assets is untouched; $child holds only the fragment's CSS/JS
hasCss() and hasJs() let you check before rendering — useful to decide whether to append inline styles to a fragment response.
if ($child->hasCss() || $child->hasJs()) { $html .= $child->renderHead() . $child->renderAssets(); }
reprinting(fn () => …) (0.66) runs a render with nothing marked as printed and no CSS scope, then
puts both back: a page rendered after another one failed part-way (an app's error page) gets every
collected asset again.
An app's error pages: ErrorPageRenderer (0.66)
The application answers errors on a live site with app/pages/_errors/<status>.<ext> (else
_errors/error.<ext>) through this renderer, falling back to the built-in page when there is no
such template or it fails to render. See "Your own error pages" in the core README.
GEO: Markdown for AI search
GEO (generative engine optimisation) gives AI assistants and search engines a clean Markdown version of your public
pages, and a map of them in /llms.txt (llmstxt.org). It is off until you turn it on; while
it is off, nothing about your pages changes.
Turning it on
Create config/geo.php:
<?php return [ 'enabled' => true, // The heading and summary of llms.txt. Leave them null to take them from Folio's "site" // singleton (the fields named in site_fields, in each language), else the app name. 'name' => null, 'summary' => null, 'site_fields' => ['name' => 'name', 'summary' => 'meta_description'], // Folio types whose published records llms.txt lists, and a boolean field that keeps a record out. 'types' => ['page'], 'noindex_field' => null, // Serve /llms-full.txt (every listed page's Markdown in one file). 'full' => true, // The audit reports llms-full.txt above this size (MB) as too large to be useful (since 0.55). 'full_max_mb' => 5, // Pages without a Markdown view; also left out of llms.txt. 'exclude' => ['/thank-you'], ];
Then every public page has a Markdown view:
/about.md(or/aboutasked for withAccept: text/markdown),/index.mdfor the home page,/de/index.mdfor the German home page.- The page is rendered as an anonymous visitor sees it: no cookies, no session, so nothing personal ends up in it.
- Pages that don't answer 200 (redirects too), are
noindex(<meta name="robots" content="noindex">or anX-Robots-Tagheader), Folio previews and excluded paths have none: their.mdURL is a 404. Asked for withAccept: text/markdown, they (and redirects, JSON, files…) answer as they always do. - The Markdown,
/llms.txtand/llms-full.txtare sent withCache-Control: public, max-age=3600. - The Markdown starts with the page's title, a
Source:line with its canonical URL and, when the page names one, anUpdated:line with the date its content last changed (itsog:updated_timeorarticle:modified_time; with SEO on that isPageMeta::$modified, for Folio records their publish time). It is sent withLink: <canonical URL>; rel="canonical", so search engines index the HTML page, not its.mdURL. Since 0.55. - HTML pages that have one point to it with
<link rel="alternate" type="text/markdown">and aLinkheader.
What ends up in the Markdown
By default the page's <main> element (else <body>) is converted: headings, paragraphs, links and images with
alt text (as absolute URLs), lists, tables, quotes and code. Navigation, header and footer outside <main> drop
out, and so do scripts, styles, SVG, form controls, hidden elements and anything you mark with data-geo="skip":
<div class="share-buttons" data-geo="skip">…</div>
When a component's HTML doesn't tell the whole story (an interactive player, a chart, data in attributes), write its Markdown yourself in a geo block. It takes the place of that component's HTML in the Markdown view and prints nothing in the HTML page:
{% apply geo %}
## {{ job.title }}
- Salary: {{ job.salary }}
- Location: {{ job.city }}
{{ job.summary }}
{% endapply %}
In Blade:
@geo ## {{ $job->title }} - Salary: {{ $job->salary }} @endgeo
Values you print inside a block come out as plain text (HTML entities decoded). A parent component's block covers
its children; an empty block leaves the component out. A block in a page or layout template outside any component
comes before the page's converted HTML; to have only the block, mark the page's own HTML data-geo="skip".
This is the case for Folio record detail templates (a job, a gallery object): they are not components, so their
geo block is a page-level block and comes in addition to the converted HTML of the template. When the block
should stand alone, mark the template's own HTML data-geo="skip":
{% apply geo %}
## {{ record.title }}
- Salary: {{ record.salary }}
{% endapply %}
<article class="job-detail" data-geo="skip">…</article>
llms.txt and llms-full.txt
/llms.txt lists your pages, grouped by section, each linking to its Markdown view:
# Job Board > Jobs in tech, explained. ## Pages - [Welcome](https://club.test/index.md) - [About us](https://club.test/about.md): Who we are ## Other languages - [de](https://club.test/de/llms.txt)
Each language has its own (/de/llms.txt); the default language's file links the others. Their heading is in the
file's language (translation key geo.other_languages: en, de and fr ship with preflow/view; a site renames it, or
adds another language, in its own lang/{locale}/geo.php). A language without the key falls back to the site's
fallback language, then English (default it, fallback de, no lang/it/geo.php: "Andere Sprachen").
/llms-full.txt (and /de/llms-full.txt) holds the Markdown of every listed page in the same order, each under its
title and source URL; 'full' => false turns it off. Pages without a Markdown view (404, noindex…) are left out of
it.
With Folio, the published records of the types in your config are listed in each language: title from the
title field (else name, else the first list column), description from meta_description, and the record's URL
in that language. The home page comes first. Types behind a frontend permission are never listed. The index does
not render pages to find a noindex, so a record's "SEO & sharing" settings (with SEO on) or noindex_field are
what keep it out of llms.txt.
Adding, changing and ordering entries
Since 0.50 the pages llms.txt lists come from the public pages list the sitemap reads too; add and filter pages
there (see "The public pages" under SEO below). GeoEntries::source() and filter() still work, but are deprecated.
Records with their own detail pages (jobs, gallery objects…) and pages outside Folio are added through
GeoEntries, from one of your service providers. A source gets the language and returns GeoEntry objects; a
filter gets the whole list and returns the list you want:
use Preflow\View\Geo\GeoEntries; use Preflow\View\Geo\GeoEntry; public function register(Container $container): void { $geo = $container->get(GeoEntries::class); // Each job's detail page in its own section: listed in llms.txt, kept out of llms-full.txt. $geo->source(fn (string $locale): array => array_map( fn (Job $job): GeoEntry => new GeoEntry( title: $job->title($locale), url: $job->url($locale), // absolute, or a path on this site: "/de/jobs/senior-dev" description: $job->teaser($locale), section: 'Jobs', full: false, ), $container->get(JobRepository::class)->published(), )); // Drop the not-found page, and put the guide's chapters in reading order. $chapters = ['Getting started' => 1, 'Rules' => 2, 'Strategy' => 3]; $geo->filter(fn (array $entries, string $locale): array => array_map( fn (GeoEntry $e): GeoEntry => isset($chapters[$e->title]) ? new GeoEntry($e->title, $e->url, $e->description, 'Guide', $chapters[$e->title]) : $e, array_filter($entries, fn (GeoEntry $e): bool => !str_ends_with($e->url, '/not-found')), )); }
new GeoEntry(title, url, description = '', section = 'Pages', order = 100, full = true). Entries are sorted by
section, in the order the sections first appear (the home page's section, order 0, first), then by order, then
by title. Filters run after all sources, in the order they were registered.
SEO: share cards, structured data, sitemap
SEO adds what search engines and link previews read: robots, Open Graph and Twitter card tags, theme-color and
JSON-LD in each public page's <head>, and /robots.txt, /sitemap.xml and /.well-known/security.txt. Public
pages also get an ETag, so a repeat visit can be answered with 304 Not Modified. It is off until you turn it on;
while it is off, nothing about your pages changes.
It only adds tags a page doesn't already have: a robots meta, og:image or theme-color your layout prints wins.
<title> and <meta name="description"> stay your layout's job.
Turning it on
Create config/seo.php (the skeleton has one with every key commented):
<?php return [ 'enabled' => true, // og:site_name and the WebSite name in JSON-LD. Null takes it from Folio's "site" singleton // (the field site_fields names, in each language), else app.name. 'name' => null, 'site_fields' => ['name' => 'name', 'description' => 'meta_description'], // Who runs the site: a schema.org Organization or Person, on the home page. 'owner' => [ 'type' => 'Organization', 'name' => 'Job Board', 'url' => 'https://club.example', 'logo' => 'https://club.example/logo.png', 'same_as' => ['https://social.example/jobboard'], ], // Per Folio type: the fields the share title, description and image come from (the first // that isn't empty; an image field's first asset) and the Open Graph type. 'types' => [ 'post' => ['title' => ['title'], 'description' => ['lead', 'body'], 'image' => ['cover'], 'og_type' => 'article'], ], // Folio types in the sitemap; null lists every type with a public frontend route. 'listed_types' => null, 'robots' => [ 'enabled' => true, 'disallow' => ['/--component/', '/*/--component/'], // Folio adds its admin path itself 'ai_training' => true, // false shuts out AI training crawlers 'extra' => [], // raw lines, appended as they are ], 'sitemap' => true, // Served only with a contact (mailto: or https: URL); Expires is always 180 days ahead. 'security_txt' => ['contact' => 'mailto:security@club.example', 'policy' => null], 'theme_color' => ['light' => '#ffffff', 'dark' => '#111111'], // or one '#hex' 'etag' => true, ];
With Folio on SQL storage, run preflow migrate before turning SEO on: the "SEO & sharing" section is stored in a
new _seo column, and saving a record in the editor fails until it exists.
What a page gets:
<meta name="robots" content="max-image-preview:large">, ornoindexfor a page hidden from search engines.og:title,og:description,og:image(with width, height and alt),og:url,og:type,og:site_name,og:localeand its alternates,og:updated_timewhen the page knows when it last changed (modified; Folio records: their publish time; since 0.55), andtwitter:card(summary_large_imagewith an image).- JSON-LD: the
WebSiteand its owner on each language's home page, aWebPageon every page, and the nodes the page or its type adds (below). A page that prints its ownapplication/ld+json(in the head or the body) gets none of ours. - Pages that are
noindex(their own robots meta, anX-Robots-Tagheader, or the record's setting) and private responses (Cache-Control: privateorno-store: members' pages, gated Folio pages, share previews) get no cards and no JSON-LD, unless the page says its content is public (public_content, below). Paths in robots.txt's Disallow that a package adds (Folio's admin) get no tags at all. - No
ETagfor private responses, responses that set a cookie, or requests that carry the session cookie. - A
304carries noContent-Security-Policy: the browser keeps the policy it stored with the cached page, whose nonces match that page (a fresh nonce would block its inline scripts and styles). Since 0.53.2.
Folio pages
On a Folio page, the record fills the cards. Each value comes from the first of:
- the record's SEO & sharing section in the editor (share title and description per language, share image),
- the fields
typesmaps for its type, - the built-in fields:
meta_title,title,namefor the title,meta_descriptionfor the description, - for the description and image, the
sitesingleton: its own SEO & sharing section, then itssite_fieldsdescription. A page without a title of its own keeps its<title>.
Each value is chosen on its own: a page can have its own share title, a description from its lead field and the
site's share image. Share texts count only in languages the record is visible in. Images go through the media
library's share format (1200×630 JPEG, with the crop editors set for it); private, deleted and non-image assets are
skipped. The section's settings (hide from search engines, leave out of the sitemap, leave out of the AI index) are
content: they take effect when the record is published, and a share-preview link shows the working copy as
noindex. A record GEO's noindex_field hides is noindex too.
Pages outside Folio: seo()
A controller's template or a file-routed page sets its own values; keys are PageMeta's, in snake_case. A template's
values are merged over what a Folio record set.
{% do seo({
title: job.title,
description: job.teaser,
image: job.cover_url, {# an image URL; or the array MediaLibrary::shareImage() returns #}
og_type: 'article',
noindex: false,
modified: job.updated_at, {# ISO 8601: og:updated_time, dateModified, the Markdown's Updated line (0.55) #}
schema: {'@type': 'JobPosting', name: job.title},
}) %}
image takes a URL as it is (no size or alt). For the media library's 1200×630 share crop with its size and alt
text, the controller passes MediaLibrary::shareImage($assetId, $locale) to the template (say as share_image),
which gives it on: image: share_image.
A page whose content is the same for everyone but whose response is private, because it embeds one visitor's
tokens or state (a gallery page with the visitor's likes), says so with public_content: true, on its own or with
other keys: {% do seo({public_content: true}) %}. It then gets the cards and JSON-LD of a public page; the response
stays as it is (its Cache-Control untouched, no ETag), and noindex still wins. Since 0.53.1. The flag is set
per template: put seo({public_content: true}) in the template of the public page itself, not in a layout shared
with members' or gated pages (a gated Folio type answers private, no-store too, and would get cards).
In Blade: @seo(['title' => $job->title, 'description' => $job->teaser]). In PHP, set or merge the request's
Preflow\View\Seo\SeoContext (set(new PageMeta(...)), merge([...])). An unknown key in a template's seo() is
ignored (debug mode logs it); PageMeta::with() and merge() in PHP reject one.
The description: <meta name="description"> and seo_description()
With SEO on, a page's <head> gets <meta name="description"> next to og:description, from the same text: the
page's own description (a Folio record's share text, seo({description: …})), else the site's default. It is cleaned the
same way (no tags, collapsed whitespace) and cut at a word to 200 characters. A layout that prints its own
<meta name="description"> keeps it; none is added, as with every other tag. A noindex or private page gets
none, like its other cards (since 0.72).
A template that needs the text itself (a visible lead, a <title> suffix) calls seo_description(): the same merged
description, as plain text (cleaned like the tags, but not cut at 200 characters), '' when neither the page nor the
site has one or SEO is off. Both engines escape it. Call it after the page's own seo({…}), as a layout does when it
renders after its child template. Inside GEO's Markdown renders it returns the page's own description only, never the
site's.
{% do seo({description: job.teaser}) %}
<p class="lead">{{ seo_description() }}</p>
In Blade: @seo(['description' => $job->teaser]) then <p class="lead">@seo_description</p>.
The public pages: sitemap and llms.txt
/sitemap.xml and GEO's /llms.txt list the same pages, from Preflow\View\Seo\PublicPages. With Folio, the
published records of every type with a public frontend route are in it (no permission; narrowed by
listed_types for the sitemap, by GEO's types for llms.txt), in each language they are visible in, with their
publish time as <lastmod> and their other languages as hreflang alternates. A record hidden from search engines
is in neither; the other two settings take it out of one.
Add your own pages with a source, and drop, change or reorder pages with a filter, from one of your service providers:
use Preflow\View\Seo\PublicPage; use Preflow\View\Seo\PublicPages; public function register(Container $container): void { $pages = $container->get(PublicPages::class); // Each job's page: in the sitemap and llms.txt, not in llms-full.txt. $pages->source(fn (string $locale): array => array_map( fn (Job $job): PublicPage => new PublicPage( title: $job->title($locale), url: $job->url($locale), // absolute, or a path on this site description: $job->teaser($locale), section: 'Jobs', lastmod: $job->updatedAt->format(DATE_ATOM), alternates: ['en' => $job->url('en'), 'de' => $job->url('de')], llmsFull: false, ), $container->get(JobRepository::class)->published(), )); // The not-found page in no list; the tools pages in the sitemap only. $pages->filter(fn (array $pages, string $locale): array => array_map( fn (PublicPage $p): PublicPage => str_contains($p->url, '/tools/') ? $p->with(['llms' => false, 'llmsFull' => false]) : $p, array_filter($pages, fn (PublicPage $p): bool => !str_ends_with($p->url, '/not-found')), )); }
new PublicPage(title, url, description = '', section = 'Pages', order = 100, lastmod = null, alternates = [], sitemap = true, llms = true, llmsFull = true); with([...]) changes some of them. Pages are sorted as GEO's entries
were (sections in the order they first appear, then order, then title).
Moving a GEO source or filter here: GeoEntries::source() and filter() still work in 0.50 but are deprecated,
and their entries never reach the sitemap. Replace GeoEntries with PublicPages and GeoEntry with
PublicPage; full: false becomes llmsFull: false, and new GeoEntry($e->title, $e->url, …) in a filter becomes
$p->with([...]):
// Before $container->get(GeoEntries::class)->filter(fn (array $entries) => array_filter( $entries, fn (GeoEntry $e) => !str_ends_with($e->url, '/not-found'), )); // After: out of llms.txt and the sitemap $container->get(PublicPages::class)->filter(fn (array $pages) => array_filter( $pages, fn (PublicPage $p) => !str_ends_with($p->url, '/not-found'), ));
Structured data for your types
Domain schemas (a job offer, a product, an event) are your site's: register a builder for a Folio type with
Preflow\View\Seo\SchemaRegistry. It gets the record's values in the page's language, the language and the page's
PageMeta (absolute url, title, description, image), and returns one node or a list. Its nodes join the
page's graph after the WebPage, escaped safely for the page:
use Preflow\View\Seo\PageMeta; use Preflow\View\Seo\Schema; use Preflow\View\Seo\SchemaRegistry; $schemas = $container->get(SchemaRegistry::class); $schemas->forType('job', fn (array $job, string $locale, PageMeta $meta): array => [ '@type' => 'JobPosting', 'title' => $meta->title, 'description' => $meta->description, 'url' => $meta->url, 'datePosted' => $job['posted_at'], 'employmentType' => $job['part_time'] ? 'PART_TIME' : 'FULL_TIME', 'hiringOrganization' => ['@type' => 'Organization', 'name' => 'Job Board', 'sameAs' => 'https://club.example'], 'jobLocation' => ['@type' => 'Place', 'address' => ['@type' => 'PostalAddress', 'addressLocality' => $job['city']]], ]); // The owner per language instead of the one in config (same shape as 'owner'). $schemas->owner(fn (string $locale): array => ['type' => 'Person', 'name' => 'Ada', 'url' => 'https://ada.example', 'same_as' => []]);
Schema::breadcrumbList(), Schema::article() and Schema::imageObject() build the common shapes, for builders and
seo({schema: …}) alike.
Site defaults without Folio
Folio gives the site name, description and share image from its site singleton and keeps its admin out of
robots.txt (all but its legacy /_uploads/ images, which public pages show). A site without Folio can do the
same from a provider:
use Preflow\View\Seo\PageMeta; use Preflow\View\Seo\SeoSite; $site = $container->get(SeoSite::class); $site->defaults(fn (string $locale): PageMeta => new PageMeta( title: 'Job Board', // the site's name description: 'New jobs every Friday', // pages without their own image: ['url' => '/img/share.jpg', 'width' => 1200, 'height' => 630, 'alt' => 'Our club room'], )); $site->disallow('/admin/'); // in robots.txt, and no head tags there $site->allow('/admin/uploads/'); // an exception under it (Allow:), head tags as usual
SEO audit
preflow seo:audit checks the site's public pages for the problems that cost visitors and search engines:
a missing title, duplicate texts, images without alt, broken internal links, language versions that don't
point at each other, a 404 page that answers 200. It is run by hand, never as part of a deploy. Each
environment checks itself: the pages are rendered inside the app, with no network, as an anonymous visitor
would get them and with the debug toolbar off. Run it where the site runs (on the live server for the live
site, locally for your copy).
preflow seo:audit [--fresh] [--limit=N] [--url=/path] [--budget=600]
It audits what PublicPages lists (the same pages as the sitemap, every language, with the sitemap's and
llms.txt's exclusions), prints the progress, the number of findings per severity and the findings grouped
by severity and check. There is no score. Severities are critical (visitors or search engines are
blocked or misled), warning (should be fixed) and notice (worth a look).
The report and resuming
A full run saves its report in storage/data/seo-audit.json (the site's own storage, so each environment
has its own); Folio shows it (Settings → SEO audit). The report says complete, or incomplete with
done/total pages. A run stops itself before the time budget (--budget, default 600 seconds, which keeps
it under the roughly twelve minutes some servers allow a shell process) and says how to go on: run the
command again and it continues where it stopped. --fresh starts over. Running it again after a complete
run starts a new audit. While one audit runs, a second says "another audit is running" and does nothing.
Folio runs the same audit from Settings → SEO audit, one 20-second step per web request (see Settings → SEO audit in Folio's README). The command and the screen continue the same saved report.
--limit=N (the first N pages) and --url=/path (one page, without the site checks) are quick checks:
they print their findings and never touch the saved report, so they can't wipe the progress of a long run.
The exit code is 0 whenever the audit ran, with or without findings and finished or not (and when another audit is running); 1 for a bad flag, an app that doesn't boot or a run that fails. Use the saved report, not the exit code, to decide whether the site is clean.
The Audits service
Preflow\View\Audit\Audits (since 0.74) is the audit as a service; the command and Folio both run it.
Core binds it as a lazy singleton where the audit is set up (next to the PageRenderer), so a request that
never audits builds nothing.
| Method | What |
|---|---|
step(int $timeBudgetSeconds, bool $fresh = false, ?\Closure $progress = null): AuditReport |
One step of the full audit: continues the saved run (or starts over with $fresh, after a complete run or without one), audits pages until all are done or the budget would be passed (at least one page), saves and returns the report. Throws AuditRunning while another full run holds the lock. $progress gets (done, total, url) after each page. |
quick(?int $limit, ?string $url, int $timeBudgetSeconds, ?\Closure $progress = null): AuditReport |
The quick checks (--limit, --url): no lock, the saved report left alone. Both null is refused (InvalidArgumentException). |
report(): ?AuditReport |
The saved report, or null (none, or unreadable). |
running(): bool |
Whether another full run holds the lock right now: a non-blocking probe that creates no file. |
file(): string |
The report file (storage/data/seo-audit.json). |
use Preflow\View\Audit\Audits; use Preflow\View\Audit\AuditRunning; try { $report = $container->get(Audits::class)->step(20); } catch (AuditRunning) { // the command or another browser is running it: try again later }
Lock and concurrency. One full run per site at a time, whoever starts it: the command and every web
step take the same lock, storage/data/seo-audit.json.lock (an exclusive flock, tried three times 20 ms
apart). A second run, from the CLI or the web, gets AuditRunning and does nothing (the command says so and
exits 0; Folio shows "Another audit is running" with Try again). Steps one after another, from either side,
continue the same report. The report and the lock file must be writable by every account that runs the
audit: the web server's user and, if it is a different account, the one that runs preflow seo:audit. The
running() probe opens the lock file read-write (r+), so it also needs write permission to answer for a
lock the other account holds; without it, it answers "not running" and the step itself finds out.
What it checks
Pages (each public page that answers 2xx; the status.* rows cover the rest):
| Id | Severity | What |
|---|---|---|
status.error |
critical | The page answers something other than 200 in this environment (5xx included) |
status.redirect |
warning | The page redirects; list the address it ends at instead |
title.missing |
critical | No <title> |
title.duplicate |
warning | Several pages of a language share a title |
description.missing |
warning | No meta description |
description.duplicate |
notice | Several pages of a language share a description |
h1.count |
notice | No <h1>, or more than one |
img.alt-missing |
warning | An image without an alt attribute (an empty alt="" is fine) |
og.image-alt-missing |
notice | A share image without its alt text |
aria-hidden.focusable |
warning | A focusable element inside aria-hidden content |
canonical.missing |
notice | No canonical link |
canonical.foreign-host |
warning | The canonical link points to another site |
noindex.in-sitemap |
critical | The page is noindex but the sitemap lists it |
hreflang.unpaired |
warning | A language version that doesn't link back, or an alternate that isn't one of the site's public pages |
hreflang.x-default-missing |
notice | A page with language alternates but no x-default |
geo.markdown-thin |
warning | GEO is on and the page's Markdown view is empty, or holds under a third of the words its <main> shows (pages under 50 words: only an empty one; what the converter leaves out, such as data-geo="skip", isn't counted; text without spaces, such as Chinese, counts as few words). Renders each .md once more. Since 0.55 |
geo.type-schema-missing |
notice | SEO is on and three or more pages under one address segment (/jobs/…) carry only generic JSON-LD (WebPage, BreadcrumbList…), nothing that says what they are about; one finding per group. Since 0.55 |
link.broken |
critical | An internal link whose target answers 404, 410 or 5xx (also redirect loops) |
link.broken |
notice | An internal link that goes through two or more redirects |
Links, images and other targets are checked for their status once per run, not per page; query-string URLs
and paths robots.txt disallows are left out; files in public/ count as fine; 401 and 403 count as
members-only, not broken. A not-yet-made image size counts as fine (see below).
The site (once per run, after the pages):
| Id | Severity | What |
|---|---|---|
robots.missing |
warning | No robots.txt |
sitemap.missing |
warning | No sitemap |
sitemap.invalid |
critical | The sitemap isn't valid XML with a urlset or sitemapindex |
sitemap.non200 |
critical | A URL in the sitemap doesn't answer 200 |
llms.missing |
warning | No /llms.txt for a language (when GEO is on) |
llms.full-too-large |
warning | A language's /llms-full.txt is larger than full_max_mb in config/geo.php (default 5). Renders the whole file (every listed page's Markdown) once per language, unless public/ holds a copy. Since 0.55 |
notfound.status |
critical | An address that doesn't exist answers something other than 404 |
notfound.no-home-link |
warning | The 404 page has no link to the home page |
favicon.raster-missing |
warning | No linked raster icon of at least 48 px and no /favicon.ico |
apple-touch.missing |
notice | No apple-touch icon |
manifest.missing |
notice | No web app manifest |
etag.no-304 |
notice | The home page doesn't answer 304 to its own ETag |
etag.unstable |
notice | Two renders a second apart give different ETags |
etag.cookie |
notice | The home page sets a cookie for new visitors, so it gets no ETag |
share-image.missing |
notice | No default share image |
owner.missing |
notice | No site owner for structured data |
A check that throws is not a crash: a page check reports one warning per check, "The check failed on N pages
and was skipped there"; a site or run check reports under its group id (etag, sitemap, icons, notfound). Out of scope: DNS and email setup, links to other sites, page speed, and judging the text itself.
No trace
An audit render is marked as internal (Preflow\Core\Http\InternalRender, the request attribute
preflow.internal_render) and leaves nothing behind. Honouring the marker, the app writes no not-found
entry and counts no redirect hit; media generates no image variant (a variant that is allowed but not yet
made answers 404 with the header X-Preflow-Variant: pending, which the audit counts as fine; anything
else missing is a plain 404 and a finding); no debug toolbar is added; no session file is written. Mail the page queues
with Notifier::sendLater() is dropped, not held back (since 0.74, in every internal render: the audit's,
GEO's and page cache rebuilds; Notifier::discardingQueued()), also when a step runs inside a web request
whose end flushes the queue: that request sends only its own mail. Notifier::send() and Mailer::send()
send at once and are the exception: a page that sends mail synchronously on a GET still sends it. The page
renders as a visitor: nobody signed in, no session, PHP's superglobals as an anonymous visitor's (no
cookies, no form data, REMOTE_ADDR 127.0.0.1), and with Folio the published site (no working copies, even
for a step run from the admin). Site code that has side effects on a visit (counters, logs) can skip
them:
use Preflow\Core\Http\InternalRender; if (!InternalRender::is($request)) { $this->visits->count($request->getUri()->getPath()); }
Limits worth knowing
- A page whose links were already queued for their own turn isn't rendered again for the link check: its
own result (
status.errororstatus.redirect) stands, and a broken link to it shows up there, not aslink.brokenon the linking page. - One page with very many new link targets renders them all in one go, so a single page can take a while.
- The ETag check sees headers the app sets through the response, not cookies set by a native
setcookie(). - If the 404 page's home link is an absolute production URL, auditing locally reports
notfound.no-home-linkfalsely; the link is matched by host. - In debug mode a failing component's dev panel can still appear inside an otherwise answering page.
- A site installed under a sub-path isn't supported.
Writing site checks
Add your own checks from a service provider, to the AuditRegistry; they run after the built-in ones and
report in the same shape, so they show up in the console and in Folio. There are three kinds:
PageCheck: looks at each audited page on its own (check(RenderedPage $page)).RunCheck: sees every page throughcollect()and reports infinish(). A run can stop and continue in another process, so what it collected must come back throughstate()(JSON-safe) andrestore().SiteCheck: runs once for the whole site and may render pages through thePageRenderer.
A Finding has the check id, a severity (Severity::Critical, Warning, Notice), a message that says what
is wrong and what to do, and optionally a page URL, a locale and a Target that tells Folio where to fix it:
new Target('record', 'job', '42') links to the record's editor, 'asset' to the library.
use Preflow\View\Audit\AuditRegistry; use Preflow\View\Audit\Finding; use Preflow\View\Audit\PageRenderer; use Preflow\View\Audit\Severity; use Preflow\View\Audit\SiteCheck; use Preflow\View\Audit\Target; final class JobsWithoutSalary implements SiteCheck { public function __construct(private readonly JobRepository $jobs) {} public function id(): string { return 'job.salary-missing'; } public function check(PageRenderer $renderer): array { $findings = []; foreach ($this->jobs->all() as $job) { if ($job->salary === null) { $findings[] = new Finding( $this->id(), Severity::Warning, "{$job->title} has no salary; add one so it can show in search results.", target: new Target('record', 'job', (string) $job->id), ); } } return $findings; } } // in a service provider's boot(): $container->get(AuditRegistry::class)->addSite(new JobsWithoutSalary($container->get(JobRepository::class)));
Pages from controllers
PageResponder renders a page template from a controller: render($template, $context = [], $status = 200, $headers = []) returns an HTML ResponseInterface. It gives the template the
request (path, method, isHtmx, query) and route variables a file page gets, injects
the CSS/JS collected while rendering (AssetCollector::injectInto()), and answers
text/html; charset=UTF-8. Keys in $context win over request and route. Controllers get
it by constructor injection. Use it instead of a file page for a 404, a POST, or a template
chosen at run time (see "Pages from controllers" in the core README).
QR codes
qr_svg($data, $options) returns an inline SVG (safe HTML, no escaping). In Twig it is a function, in Blade the
directive @qr_svg(...).
{{ qr_svg('https://example.org/ticket/42', { size: 160, ecc: 'Q' }) }}
@qr_svg('https://example.org/ticket/42', ['size' => 160])
Options: size (pixels, default 200, at least 21), margin (modules, default 2), ecc (L, M, Q, H;
default M), color and background (a CSS colour for SVG: hex, rgb()/hsl() or a name; anything else throws an
InvalidArgumentException). Every light module takes the background colour, so a coloured or inverted code stays
readable (check inverted ones with your scanner; not every reader handles them). In PHP, Preflow\View\Qr\QrCode::svg() and
QrCode::png() (PNG colours must be hex; the size is rounded down to whole pixels per module). QR codes need the
suggested package chillerlan/php-qrcode (^5.0); without it you get a RuntimeException: "QR codes need the
chillerlan/php-qrcode library. Install it with: composer require chillerlan/php-qrcode".
PDFs from templates
A PDF is a document class plus a template. The template is ordinary Twig or Blade of the site's engine; the renderer turns the HTML into a PDF with mPDF.
use Preflow\View\Pdf\{PageSettings, PdfDocument, PdfRenderer, PdfResponse}; final class CertificateDocument extends PdfDocument { public string $name = ''; // public properties are the template variables public ?Job $job = null; public function template(): string { return __DIR__ . '/certificate.twig'; } // name or absolute path public function resolveState(): void { $this->job = /* load what the template needs */; } public function page(): PageSettings { return new PageSettings(format: 'A4', orientation: 'L', marginTop: 20, footerTemplate: __DIR__ . '/footer.twig'); } public function fileName(): string { return 'certificate.pdf'; } } $doc = new CertificateDocument(); $doc->name = 'Ada Lovelace'; $bytes = $renderer->render($doc); // PDF bytes, never written to disk $html = $renderer->html($doc); // an HTML preview, needs no PDF library return PdfResponse::download($bytes, $doc->fileName()); // or PdfResponse::inline()
PdfRenderer is bound in the container of a booted app (when preflow/view and a template engine are installed).
resolveState() runs before every render and preview. PageSettings takes format, orientation (P or L),
margins in mm, headerTemplate/footerTemplate (templates rendered with the same variables and repeated on every
page, also when your CSS has @page rules) and a custom width/height in mm (together, they win over format and
orientation). A template is tied to one engine's syntax, so use the extension of the engine you run.
html() is only a preview: a browser lays the page out, mPDF does not, so page numbers are empty, page breaks are a
dashed line and the layout is approximate. Check the real thing with render(). PdfResponse sends
application/pdf with Cache-Control: private, no-store.
Configure in config/view.php:
return [ 'pdf' => [ 'temp_dir' => 'storage/cache/pdf', // mPDF's cache and scaled image copies; relative to the project root 'fonts' => ['Certificate Script' => 'resources/fonts/script.ttf'], // CSS font-family name => file 'default_font' => 'dejavusanscondensed', ], ];
fonts registers the regular face only; bold and italic print in it too. A name that matches a font family bundled
with mPDF (for example DejaVu Sans or Times) is refused with an InvalidArgumentException: register the file
under another name. default_font may be such a configured name. Bind your own PdfEngine to use another renderer.
Building blocks
Twig functions (Blade: @pdf_...(...), all return safe HTML):
| Function | Does |
|---|---|
pdf_page_number(), pdf_page_count() |
"Page X of Y" in header, footer or body (empty in the preview) |
pdf_page_break() |
starts a new page |
pdf_table($rows, $columns, $options) |
a table whose header row repeats on every page; $columns maps row key to header text; options class, widths; cells are escaped |
pdf_image($assetIdOrPath, $options) |
an image, scaled down; options width_mm, max_px (1200), allow_private, alt |
pdf_qr($data, $options) |
an inline SVG QR code; size_mm (30) plus the qr_svg options |
pdf_image takes an absolute file path or an asset id and nothing else (asset ids come from a bound
Preflow\View\Pdf\PdfImageSource, which preflow/media binds): no URLs, no
relative paths. It reads the file from the server, so it must never get user input. A path must be an image file
(one getimagesize() reads, or an SVG); anything else throws an InvalidArgumentException. A private media asset
needs allow_private: true. JPEG, PNG, GIF and WebP images longer than max_px are scaled to a JPEG in
temp_dir/mpdf for the render and removed afterwards (mPDF sweeps old files there, should a process die first);
formats GD cannot read (for example AVIF) are not scaled, so keep them small. An image that has to be scaled is
refused when it is over 40 megapixels; a smaller one goes in as it is.
Security
mPDF fetches what the template output points to: <img src>, CSS url() and similar, local files and URLs
alike. Never print raw user-supplied URLs or paths into a PdfDocument template; pass images through pdf_image,
which accepts image files only.
CSS limits
mPDF supports a good part of CSS 2 but no flexbox and no grid. Lay pages out with tables, floats and fixed widths, and check the PDF itself.
Suggested packages and errors
Both are optional (suggest); without them the matching call throws a RuntimeException:
mpdf/mpdf(^8.2): "PDFs need the mPDF library. Install it with: composer require mpdf/mpdf".html()still works.chillerlan/php-qrcode(^5.0): see above.pdf_qrneeds it too.- Scaling images needs PHP's
gdextension.