preflow / folio
Folio — the admin for Preflow sites: edit and publish models and pages, media library, page rendering
Requires
- php: >=8.4
- nyholm/psr7: ^1.8
- preflow/ai: ^0.66 || @dev
- preflow/auth: ^0.69 || @dev
- preflow/blocks: ^0.75 || @dev
- preflow/components: ^0.1 || @dev
- preflow/core: ^0.75 || @dev
- preflow/data: ^0.76 || @dev
- preflow/devtools: ^0.20 || @dev
- preflow/form: ^0.75 || @dev
- preflow/htmx: ^0.65 || @dev
- preflow/i18n: ^0.76 || @dev
- preflow/media: ^0.76 || @dev
- preflow/notify: ^0.69 || @dev
- preflow/publishing: ^0.75 || @dev
- preflow/routing: ^0.70 || @dev
- preflow/twig: ^0.65 || @dev
- preflow/validation: ^0.52 || @dev
- preflow/view: ^0.74 || @dev
- symfony/html-sanitizer: ^7.0
Requires (Dev)
- phpunit/phpunit: ^11.0
Suggests
- openspout/openspout: ^4 — Excel export of admin lists (the Export button shows once it is installed)
- preflow/codes: Codes; Folio adds their admin (the built-in extension "codes")
- preflow/members: Member accounts; Folio adds their admin (the built-in extension "members", which needs preflow/rbac)
- preflow/rbac: Stored roles and staff accounts; Folio adds their admin (the built-in extension "rbac")
Provides
None
Conflicts
- preflow/ai: <0.66
- preflow/auth: <0.69
- preflow/blocks: <0.75
- preflow/codes: <0.68
- preflow/core: <0.75
- preflow/data: <0.76
- preflow/form: <0.75
- preflow/htmx: <0.65
- preflow/i18n: <0.76
- preflow/media: <0.76
- preflow/members: <0.68
- preflow/notify: <0.69
- preflow/publishing: <0.75
- preflow/rbac: <0.69.1
- preflow/routing: <0.70
- preflow/twig: <0.65
- preflow/validation: <0.52
- preflow/view: <0.74
Replaces
None
- dev-main
- v0.76.0
- v0.75.0
- 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
This package is auto-updated.
Last update: 2026-11-14 19:29:48 UTC
README
Folio is the admin for Preflow sites. Require it into any Preflow app and you get an admin (mounted at a configurable path) to edit and publish the site's models and pages, a media library, and rendering of the pages it manages — without touching the framework.
Installation
composer require preflow/folio
Requires PHP 8.4+. Composes preflow/data, preflow/view, preflow/routing, and
preflow/form (all already present in a standard Preflow app).
Ready-wired app:
composer create-project preflow/starter-folio myappgives you a seeded job board with Folio already configured. The steps below are for adding Folio to an existing app. If Composer refuses a dev version, allow it in yourcomposer.json("minimum-stability": "dev","prefer-stable": true) or require it explicitly withcomposer require preflow/folio:@dev.
Getting started
Folio is opt-in: a plain Preflow app (preflow/starter) does not wire it for you; preflow/starter-folio does. Three small steps.
1. Register the service provider
In config/providers.php:
return [ \App\Providers\AppServiceProvider::class, \Preflow\Folio\FolioServiceProvider::class, ];
2. Choose the admin mount path (optional)
The admin mounts at /folio by default. To change it, add config/folio.php:
<?php return [ 'path' => '/admin', // or whatever doesn't collide with your app ];
Cache files: Folio keeps folio-extensions.cache and
folio-translations-{locale}.php in storage/cache. config/folio.php 'cache_path' moves
them (preflow/blocks' blocks.cache follows config/blocks.php 'cache_path', default storage/cache): an absolute path is used as it is, a relative one is taken from the project root.
Folio creates the directory when it writes. Core's own caches (storage/cache/data, compiled
templates) are not affected. A test suite can point cache_path at a temp directory, so
booting the app never writes into the project's cache:
return [ 'cache_path' => sys_get_temp_dir() . '/my-site-folio-cache', ];
The admin login uses the session from config/auth.php. By default core starts it on
every request, so public pages get a session cookie too. To keep public pages
cookie-free, limit the session to the admin path:
'session' => [ 'cookie' => 'site_admin', 'paths' => ['/folio'], // prefixes, matched per path segment ],
Requests outside those prefixes get no session, but they are still checked for CSRF:
csrf_field() prints a stateless token there (with a real app.key; see the CSRF section of
preflow/core).
The staff login ({path}/login, from config/folio.php path) is a CSRF sign-in path: a
login form left open until the session expired still signs in when the browser's origin
check passes (the password is the proof), instead of failing with a 403. Other admin forms
whose session expired are sent back with a warning and their fields kept, never applied.
3. Define a content type
Content types are JSON files in config/models/ (the existing data.models_path).
Create config/models/page.json:
{
"key": "page",
"table": "page",
"storage": "json",
"id_field": "uuid",
"label": "Pages",
"fields": {
"title": { "type": "string", "searchable": true, "validate": ["required"] },
"slug": { "type": "string", "searchable": true, "validate": ["required"] },
"body": { "type": "text" },
"status": { "type": "string", "validate": ["required", "in:draft,published"] }
}
}
That's it. Start the app and visit /folio — a Pages area appears with create / edit /
delete. Pages with status: published render on the public frontend at their slug
(e.g. /about), resolved by a lowest-priority catch-all so your own routes always win.
storage: "json" writes one flat file per record under storage/data/page/ — zero
config, no migration. Switch a type to a database by changing storage to a configured
driver (e.g. sqlite); the admin and rendering are storage-agnostic.
How it fits together
| Concern | Owner |
|---|---|
Type definition (page.json) |
preflow/data (TypeRegistry, DynamicRecord) |
| Record CRUD + validation | preflow/data (DataManager::*Type()) |
| Auto-form from the type | preflow/form |
| Shipped admin (dashboard, CRUD), routing, slug rendering | preflow/folio |
Admin and frontend templates live under @folio/* and are overridable: drop a same-named
template in resources/folio/ to replace the shipped one.
Template engines. Folio's own templates are Twig: the admin, its fallback frontend templates
(@folio/frontend/page.twig, @folio/frontend/{type}.twig), extensions' @{name}/… templates and
their overrides in resources/folio/. They render through a Twig engine Folio owns, whatever
config/app.php engine is (Twig, Blade or a TemplateEngineFactory): every template name
ending in .twig goes there. Any other name is an app template, such as a type's
frontend.template written for the app's engine ("template": "pages/job.phtml"), and renders
through the app's engine, where the folio_* functions (folio_get, folio_list, folio_find,
folio_alternate_links, …) are registered too. Folio's Twig engine is built like
the app's (core's TemplateEngineContext; compiled templates under the app engine's cache folder,
…/folio-twig) and gets every function and global of the app's engine (SharingTemplateEngine),
so csrf_field(), t(), hd work in Folio's templates as before. On a Twig app both are the app's
engine: nothing changes there. Preflow\Folio\View\FolioEngine (a container service) is the
engine Folio renders with; a package that renders Folio's or its own Folio extension's Twig
templates uses it (preflow/rbac does). Block components and other components render through the
app's engine, as everywhere else.
The admin ships its own htmx (@folio/htmx.js) whatever the app's hypermedia driver
(config/app.php hypermedia) is: its screens never use the app's driver, and Folio adds htmx to
core's HypermediaProtocols, so its requests are treated as htmx requests (an expired session
reloads the editor, a signed-out one is sent to the login) next to the app's own driver.
Access
Every admin action asks one permission through can() (see preflow/auth).
Content types have {type}.view, create, edit, publish and delete.
- Viewing a list or a form needs
view. - Saving a draft, autosave, discard, restore, sharing and reordering need
edit. - Publishing and unpublishing need
publish. Without it, "Publish" and "Publish at" are not offered, and a posted_action=publishis refused (403) without saving.
Assets have assets.view, upload, edit and delete.
The dashboard needs folio.dashboard. Without it, login lands on the first
section the user may view.
The Translations screen needs translations.edit (an admin role with * has it).
Choosing one's own admin language needs no permission.
Settings → Redirects needs redirects.manage. The automatic redirects need no permission,
and choosing where a deleted or unpublished page's old links go needs only the right to that
delete or unpublish (see Redirects).
Block editor fragments need edit on any type.
New permissions. A permission that arrives with an update is never given to a role by Folio.
Folio keeps the list of permissions this site has seen (Preflow\Folio\Permissions\KnownPermissions,
table folio_known_permissions, since 0.53; created by preflow migrate or folio:extensions:apply
on SQL storage, JSON storage needs neither). The first time it is
read, every enabled permission becomes known silently, except redirects.manage: it came with 0.52,
shortly before the list (0.53), and roles with an explicit permission list never received it. After that, each new one counts as new until someone dismisses
it or adds it to a role. A permission of a switched-off extension is new only once the extension is
switched on. With preflow/rbac, preflow migrate prints the new ones ("New permissions to
review in Settings → Roles: …") without marking them known; without it, it prints nothing. With preflow/rbac the notice, the sidebar count and the
dashboard line are shown to whoever may edit roles; see
rbac. Before preflow migrate has run, they show nothing.
The sidebar, dashboard cards, list buttons and form buttons follow the same answers. A form the user may not save is shown read-only. Each check is also enforced on the server, so a hidden button is never the only protection. A refused htmx request (autosave, block picker) shows its message as a toast.
Defaults. Without a permission package, every signed-in staff user may do
everything, as before. For roles from a config file, add preflow/roles; for roles
and permissions edited in Folio itself, add preflow/rbac:
// config/roles.php return ['staff' => [ 'admin' => ['*'], 'editor' => ['folio.dashboard', 'page.view', 'page.edit', 'assets.*'], ]];
The own account page ({prefix}/_account) asks no permission
(FolioPermissionMiddleware::SIGNED_IN): every signed-in record user may open it, an account
without roles too. Any other admin handler without a rule is refused (fail closed).
Staff login
Who may sign in. Only staff: Folio signs them in through one guard of config/auth.php,
named by config/folio.php 'guard' (default: the app's default guard, as before 0.65). Its
population must be staff:
- a guard with
'population' => 'staff', or one without a population (Folio marks it as staff, as before), is used; - a guard that declares another population (
'members', or an app's own such aspreflow/starter's'employers') stops the boot with aRuntimeExceptionnaming the guard and its population, so the admin never opens to the people that guard signs in. Set up a staff guard (aSessionGuardon your staff accounts,'population' => 'staff', aspreflow/starter-foliohas) and make it the default guard or name it here:
// config/folio.php: the app's default guard stays the employers' return ['path' => '/folio', 'guard' => 'staff'];
Upgrading an app made from preflow/starter before 0.65 that added Folio: its
config/auth.php is its own copy, and its employers guard has no population. Folio then still
takes that guard as staff, and every employer keeps full admin access. Add
'population' => 'employers' to the employers guard yourself; Folio then refuses to boot until
you set up a staff guard as above.
A guard that config/auth.php does not define stops the boot too.
Preflow\Folio\Users\StaffUsers (a container service) gives packages the staff guard's name
(guard()) and user provider (provider(), null without auth); preflow/rbac's config-user check
and staff:import-config read it, so they follow 'guard' too. Folio marks its guard as
staff (Access::markStaffGuard()); preflow/members' Viewer::isStaff() counts a user of any staff
guard, so it agrees with guard. A user signed in on another guard on the same request (an
employer, a member) is sent to the admin login.
The login page has the same options for every site:
- Remember me. The checkbox appears when the staff guard has
rememberconfigured (see preflow/auth). A config-only site gets it too once it configures one.
The rest applies only when the staff guard has record users: a RecordUserProvider,
directly or in a ChainUserProvider. A site whose staff are config users only keeps its
login exactly as before: the same page, no throttle, no forgot link, and no new routes.
- Throttle. Sign-in is throttled (
folio.login, 10 attempts per 15 minutes, counted per client and per e-mail; a sign-in clears the e-mail's count, not the client's). Over the limit the page answers 429. Every refusal reads "Invalid email or password.": an unknown address, a wrong password, a disabled account and a config user alike. An unknown address, or an invited account without a password yet, costs a hash, like a known one. A record's sign-in stampslast_login_at. - Forgot password (
{prefix}/forgot,folio.forgot, 5 per hour). The answer is the same for every address. A mail goes only to an active record account. - Set password (
GET {prefix}/password/{token}shows the form,POST {prefix}/passwordconsumes the token). It is throttled (folio.password). The minimum length isconfig/folio.phppassword_min(default 10). A used, expired or unknown link gets the same 410. Setting a password signs the account in, bumps its epoch, and revokes its remember-me logins and other links. - Mails (invite and reset) are composed by preflow/notify (a plain text part and an HTML
part) and queued until after the response, in the recipient's interface language, else the
site's default language. Where preflow/rbac is installed they are rbac's (
rbac-mail.*in rbac'slang/{en,de,fr}/rbac-mail.php), whichever page sends them; without rbac, Folio's own (folio.mail.*inlang/{en,de,fr}/folio.php). The link is built fromapp.url. Without it no link is mailed: forgot logs it and answers as always, rbac's actions say "Set app.url to send sign-in links.". Staff mode needsconfig/notify.php,APP_KEYandapp.url.
Preflow\Folio\Users\StaffPasswords (a container service) is the API for packages that
manage the accounts (preflow/rbac). It works for any record user type of the staff guard:
invite(): a link valid for 7 days, voiding earlier invites.sendReset(): a link valid for 1 hour.consume()setPassword(): saves on the record as stored now (only the password and epoch); false, with nothing saved, when the account is disabled or gone meanwhile.verify()revokeLinks()forgetDevices()account()stampLogin(): onlylast_login_at, on the record as stored now.
Records are saved without hooks or revisions.
Revision authors. Revisions store who saved them: the e-mail for a config user, the
id for any other. Preflow\Folio\Users\AuthorNames shows an id as the account's
name (else its e-mail), a disabled account included: a record user's from its record, any other
user from the staff guard's provider (findById(); its name/email, as public properties or
getName()/getEmail(), so a model behind DataManagerUserProvider shows by name too). It shows
an e-mail or an unknown value as stored.
Overriding the dashboard
The dashboard is the one core admin action an app can replace without forking: a class
App\Folio\Overrides\Content\Index implementing OverridableAction is shown instead of
Folio's dashboard (the list, form and other screens look for no override; change those with
type customizations and screens, see Extensions):
namespace App\Folio\Overrides\Content; use Preflow\Folio\Override\OverridableAction; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; final class Index implements OverridableAction { public function handle(ServerRequestInterface $request): ResponseInterface { // your custom dashboard } }
Admin styling
The admin ships a single stylesheet served by Folio itself at
{prefix}/_assets/admin.css (no build step, no asset publishing required). The
<link> URL is content-hash versioned via the folio_admin_css_url Twig
global, so it caches immutably and busts on change.
Inter and JetBrains Mono are self-hosted (since 0.75): Folio serves the font files from
{prefix}/_assets/ and loads them with @font-face in admin.css. Nothing is fetched from a font
CDN, and a strict font-src 'self' policy is enough. Both fonts are under the SIL Open Font
License 1.1, and the licence texts ship beside them in assets/fonts/.
Dark mode is built in. Every colour is one token in the :root block, written
as light-dark(light, dark), with an emerald accent and the sign-in's green-black
"ink" (since 0.73). The default follows the operating system (prefers-color-scheme).
The Theme row in the user menu (the button at the foot of the sidebar) lets the
user override it, persisted in localStorage and applied by a no-flash inline
script. Extension CSS should read the tokens (var(--c-surface),
var(--c-text-muted), var(--c-accent) and so on) instead of colour literals,
so it follows both themes.
Notices and alerts:
| Markup | Tone |
|---|---|
<div class="folio-notice" role="status"> |
Info (blue), the default since 0.73: something to know. |
<div class="folio-notice is-warning" role="status">, or <div class="folio-notice" role="alert"> |
Warning (amber): something that needs attention. role="alert" also makes screen readers announce it at once, so use it only for what has just gone wrong. |
<div class="folio-alert folio-alert-error" role="alert"> |
Error (red): something failed. |
<div class="folio-alert folio-alert-success" role="status"> |
Done (emerald). |
Before 0.73 every .folio-notice was amber. A notice that should still look
like a warning needs is-warning.
Extension templates that extend @folio/admin/_layout.twig can use:
{% block subtitle %}…{% endblock %}: one muted line under the page title, saying what the page is for. It replaces the help banners Folio used before 0.73. An empty block prints nothing.{% set shell = 'rail' %}: the 56px icon rail instead of the sidebar, for editor-like screens that need the width. It goes before the first block. The rail has the same nav items, and its expand button brings the sidebar back for the session.folio_site: a global withname(fromapp.name) andenv(the environment, ornullin production). The sidebar's site switcher shows it.
Every template is overridable via the @folio namespace (drop replacements in
resources/folio/). Because the admin renders through action-mode controllers,
the stylesheet link and the small theme scripts are plain markup in
_layout.twig rather than going through the asset collector. They already carry
nonce="{{ csp_nonce() }}", so the admin works under Preflow's default strict
CSP. If you override _layout.twig, keep the nonces and the
<meta name="csp-nonce"> tag (the rich-text editor reads it).
Type tiles and families
Since 0.75 each type and block has a tile: its icon on the tint of its family, a
Preflow\Data\TypeFamily (see the preflow/data README).
A type declares it at the top of its model file (for a model class, in its admin file):
{ "key": "company", "label": "Companies", "icon": "ui-users", "family": "reference", "fields": { … } }
"family" |
For | Tint |
|---|---|---|
page |
pages and posts (the default for a type with a "frontend") |
--c-type-page (blue) |
reference |
lookup data: categories, companies, tags | --c-type-reference (violet) |
asset |
files and media | --c-type-asset (amber) |
member |
accounts (the default for a user type) | --c-type-member (lime) |
shop |
products, orders | --c-type-shop (rose) |
live |
tools that act at once: jobs, a queue | the accent (emerald) |
Without a "family" (and without a frontend or users) the tile is neutral. Any other value stops
the boot with Type "company": "family" must be one of page, reference, asset, member, shop, live.
A block takes it from its attribute, #[Block('quote', family: TypeFamily::Reference)]
(preflow/blocks), and an extension's sidebar item from AdminNav::add(…, family: TypeFamily::Live).
The tiles show in list headers and rows, the dashboard's review queue, block cards, the block picker
and relation rows; the sidebar's items (folio_nav()) carry the family too, for a layout of your own. Each tint is a token pair in admin.css, --c-type-{family}
and --c-type-{family}-soft, written as light-dark() like every colour; .folio-type-icon[data-family]
is the only rule that maps a family to them, so no template names a colour. live uses the emerald
accent, which also marks actions (the primary button, focus, ticked rows): a live tile reads as
"acts at once". In your own templates, draw a tile with the macro:
{% import '@folio/admin/partials/_type_icon.twig' as types %}
{{ types.type_icon(listing.family, listing.glyph) }} {# 24px; types.type_icon(family, icon, 28) for a header #}
The ink layer (since 0.76)
Folio's darkest surface, the sign-in's green-black with its glow, is the ink layer: the dashboard band, the brand tile, empty states and the 404 / forbidden cards. It looks the same in both themes. Use it sparingly: at most one ink place per screen, never inside a form or a table. The classes, for a screen of your own:
| Class | What |
|---|---|
.folio-ink |
the surface: ink background with its glow, light text, rounded |
.folio-ink-card |
a card on it: text at the bottom left, marks behind it on the right |
.folio-ink-title |
the card's title |
.folio-ink-line |
the living line: mono text in ink's light green; end it with <span class="folio-caret" aria-hidden="true"></span> |
.folio-ink-action (on a .btn) |
the primary action on ink (--ink-accent) |
.folio-ink-error, .folio-ink-glyph, .folio-ink-muted |
the 404 / forbidden card: one large mark glyph, a muted message |
.folio-ink-marks |
the marks layer (folio_ink_marks() draws it) |
.folio-ink-drift |
the marks drift slowly (stopped under reduced motion) |
.mc-1 … .mc-6 |
the six mark hues (--mark-1 … --mark-6) |
folio_ink_marks(seed, count = 6, area = {}, aspect = 1) draws the editing marks
(caret, pilcrow, brace, …: the mark-* symbols of icons.svg) as one aria-hidden <svg>,
placed from a fixed seed so a screen looks the same on every request. A seed that is not a
number is a screen's fixed name (its crc32 is the seed). area is the box the marks stay in,
as fractions of the surface ({x: [0.5, 1.0], y: [0, 1]}: the right half, away from the text);
aspect is the surface's width / height, so the marks spread evenly on screen; count is at
most 60. In PHP, Preflow\Folio\View\InkMarks::place() gives the same placement as data.
<section class="folio-ink folio-ink-card"> {{ folio_ink_marks('shop-empty', 6, {x: [0.5, 1.0], y: [0, 1]}, 3) }} <p class="folio-ink-title">No orders yet</p> <p class="folio-ink-line">orders arrive here<span class="folio-caret" aria-hidden="true"></span></p> <a class="btn folio-ink-action" href="/folio/shop/settings">Set up the shop</a> </section>
An empty list of your own can use the partial instead:
{% include '@folio/admin/partials/_empty.twig' with {screen: 'shop', title: '…', line: '…', action: {label: '…', url: '…'}} only %}.
Styling extension screens
Screens inside the admin shell get the control styling for free: plain input, select,
textarea and button elements already look like Folio's. Three reusable pieces cover the
common layouts:
- Checkbox chips for a small multi-select (about a dozen options at most):
{% include '@folio/admin/partials/checkbox-chips.twig' with {name: 'types', options: types, selected: chosen, label: 'Types', help: 'Optional hint'} only %}.optionsmaps value to label,selectedis a list of values,labelnames the group for assistive tech (it is visually hidden),helpis optional. The boxes post asname[]; with nothing checked no key is posted, so your handler decides what that means. .folio-toolbaris a wrapping row for filters and buttons above a list. Inside it,.folio-toolbar-fieldsis a<form>that fills the free space and.folio-toolbar-fieldpairs an inline<label>with its control. On narrow screens the row wraps into full-width controls..folio-table-formgoes on a table that holds form controls in its cells: cells get even padding and the controls fill them. Use.folio-cell-checkon a narrow checkbox or radio column.
Model classes as types
A PHP model marked #[Entity(type: 'job')] (see preflow/data, "Model classes as types") is the
type job. Folio edits it like a JSON type: lists, search, filters, forms, drafts, publishing,
version-checked saves, relations, media, translations and page cache tracking, from the one class
definition.
A class appears in Folio only when config/models/job.json exists. That admin-only file holds
presentation: label, icon, section, list columns, filters, sort, publishing, and per field
readonly, hidden, placeholder, and so on. It may not set schema (table, storage,
id_field, matrixable, views, or a field's type, validate, translatable, unique,
index, length, searchable, transform, default, relation, options, accept, time,
blocks, fields, multiple): such a file is refused with an error naming it. A class without
the file is never listed, and "extends": true patch files do not apply to class keys. Folio
creates the tables (job, job__published, job__revisions) as for any type.
App code reads and writes the working copy through the DataManager (find, query, save).
A save that changes a published record shows it as "Changed", like an edit in the admin. To read
what is published, as public pages do, use PublishedModels:
$job = $published->find(Job::class, $id); // ?Job, read-only $jobs = $published->query(Job::class)->where('state', 'open')->get();
The objects are read-only; saving one throws. In an admin or share preview it returns the working
copy, as LiveContent does for JSON types; so it does for a type with publishing off (it has no
published copies: the record is what the site shows). It refuses a class that is not a registered
type.
Type options
Besides fields, a type JSON can carry two optional blocks, plus the flags singleton
and publishing (see below) and its sidebar place.
A type's key cannot be a path Folio uses under its prefix (login, logout,
_assets, _uploads, _blocks, media, extensions, blocks, preview, upload,
assets); such a key is an error naming the type.
frontend: public rendering
"frontend": { "route": "/blog/{slug}", "home": "home", "template": "folio/article.twig", "published": { "field": "status", "value": "published" } }
route:{field}matches one path segment,{...field}matches the rest of the path. The parameter names are record fields used for the lookup. Since 0.52 their values may only hold lowercasea-z, digits and single hyphens (ueber-uns; a{...field}also single slashes between parts):- a save or publish through Folio with an umlaut, a capital, a space or anything else is refused
with the field's
validation.slugmessage, in every language; - the editor converts what is typed as the field is left and before sending (
Über unsbecomesueber-uns; text with no a-z form, such as another script, stays as typed so the message explains it), and machine translation writes a translated address the same way (Slug::clean()), leaving it out when nothing is left; - route parameters should be text fields (
string,text): the editor converts only those, so aselectwhose option keys aren't valid, a date with a time or a decimal number can never be saved; - not checked: a
readonlyfield (a value a hook sets must be valid itself), and writes that don't go through Folio's validation (an app's own$dm->saveType(),Transfer\Importerrestoring an archive).
- a save or publish through Folio with an umlaut, a capital, a space or anything else is refused
with the field's
home(optional): the value/resolves to. Only one type may declare it. It must be a valid address part too (home,start-page); anything else stops the boot with aFrontendConfigException.template(optional): a userland Twig path. When it is absent, Folio uses@folio/frontend/{type}.twig, then@folio/frontend/page.twig. Templates receiverecord,rendered(field → safe HTML),type,preview,locale,alternatesandquery(the request's query parameters).published: which field and value make a record public. The default isstatus/published. It only applies when publishing is off for the type.permission(optional): only signed-in users with this permission see the type's pages, e.g."members.access". It must be declared (by an extension's#[DeclaresPermission]or app code); an undeclared name throws in debug and refuses everyone otherwise. Staff with*pass too. Allowed pages, redirects and refusal 404s are sent withCache-Control: private, no-store.folio_list()andfolio_find()(andCollections) return nothing for the type to anyone the gate refuses.login(optional, withpermission): where refused visitors go, aslogin?next={the page's URL}. A path on this site, or one per locale:{"en": "/tools/login", "de": "/de/tools/login"}. Without it they get a 404.
"frontend": { "route": "/tools/r/{slug}", "permission": "members.access", "login": "/tools/login" }
The gate needs the signed-in user. With session.paths (config/auth.php), a session starts on
those paths and resumes elsewhere only when the visitor brings its cookie: include the route's
prefix there (/tools, and /de/tools for prefixed locales) so a member who signs in there
has a session for it. The session cookie's path
(session.path) must cover the prefix as well. Access is granted when any signed-in user,
staff or member guard, has the permission. A share-preview link does not
open the gate: it stays a staff tool, and the reviewer must pass the gate as well. The
admin live preview is unaffected. The gate fails closed: without auth configured nobody is
signed in, so nobody passes it.
Any template can read other content too:
folio_get('site'): a singleton's values.folio_list('app'): a collection's records, in the type's list order (orderableposition orsort).folio_find('force', id): one record, or null.
All three return live content only: published snapshots of publishing types, so drafts
never reach the site. In the admin preview they return working copies. Values are
localized to the page's locale. App code gets the same lists from
Preflow\Folio\Content\Collections.
Rich text arrives sanitized wherever a template reads it: in rendered, in blocks
(nested data-only blocks too), and from folio_get, folio_list and folio_find. That
holds however the value was stored (admin save, import, app code), so |raw is safe for
richtext fields. Other fields stay plain text and are escaped as usual.
Routes are tried most specific first. Two types claiming the same route, or two types
declaring home, stop the app at boot with a message naming both. Without any frontend
block, a page type is served at /{slug} exactly as before. The live preview is
available for every type that has a route.
list: the admin list
"list": { "columns": ["title", "author", "status"], "filters": ["status", "author", "published_at"], "bulk": ["status"], "sort": "-title", "orderable": false }
columns: the fields shown. The default is the first three string/number/select/boolean/date fields that are not hidden. Arelationfield can be listed too: its cell shows the linked records' labels.filters: fields offered under "+ Filter":select,boolean,date(a from/until range) andrelationfields. Active filters show as removable chips. A relation filter lists the target's records as a select, or searches them remotely when there are more than 200. Other field types and unknown names are ignored.bulk:selectandbooleanfields (not translatable, not read-only) that the bulk bar can set on the ticked rows ("Set Status"). Needs the type's edit permission.sort: the default order. A-prefix means descending. Withoutorderable, ties and unsorted lists end in the record id, so pages stay stable.orderable: true: Move up / Move down in each row's "…" menu, which store a manual position in the record's_orderkey. New records go to the end.
Every list has a search box (all words must match, each in any text field or, one level deep,
in the label and text fields of a related record; a field with "searchable": false is left
out, and so is a hidden field unless it says "searchable": true), sortable headers for the plain columns (not relations), highlighted matches and paging
by 50. The view lives in the URL, so it can be bookmarked and shared:
| Parameter | Meaning |
|---|---|
q |
Search words (at most 200 characters). |
f[field] |
A filter from list.filters: a value, f[field][from] / f[field][to] for dates (YYYY-MM-DD), f[field][] ids for relations. |
status |
A publishing type's state: published, changed (changes not live) or draft (since 0.75). Other types ignore it. |
sort |
A shown column, - prefix for descending. |
page |
1-based page number, clamped to a sane range. |
_partial=1 |
Only the results region, which the live search swaps in. |
Unknown fields, malformed values and out-of-range pages are dropped, never an error.
The list screen (since 0.75). The header shows the type's tile (its family tint and icon)
and how many records the type has; lists use the whole content width. The toolbar holds the search,
for a publishing type the status links All · Published · Changes not live · Drafts (each counted
over the whole type; they keep the search, filters and sort), "+ Filter" (a popover; active
filters are chips under the toolbar; its Reset is a link to the view without filters) and a row-height toggle (Compact 44px / Comfortable 60px,
kept per browser in localStorage['folio.density']). A frontend type's rows show their tile and
their address in the default language (mono); the record its route serves at / (frontend.home)
is marked "The home page · slug “home”". Number columns are mono and right-aligned. The language
column is the language indicator: every language of the type, complete, partial or missing, with
a dot for outdated or not checked translations (those link to ?unchecked=1).
Each row opens its record; everything else is in its "…" menu: Edit, View on site (a live record
with an address), Copy preview link, Duplicate, Move up / down (orderable lists) and the type's
header record actions (#[RecordAction] without toolbar: false), then after a divider the danger
items Unpublish and Delete… (delete plan and "Send its old links to…" as before). Each item shows only for those who may use it.
Duplicate posts to {prefix}/{type}/{id}/duplicate (the type's create permission) and opens
the copy's editor: a draft whose title (the list's first column) reads “… (copy)” in each language (folio.list.copy_title; a copy of a copy counts on: “(copy 2)”), with a free address (RecordWriter::duplicate(); a site that binds its
own RecordTypes should implement RecordAddressFields too, see
preflow/publishing). It is never offered, and the route
refuses it, for a singleton or a user type (the copy would carry the account's password and
sign-in). Copy preview link asks the share route for JSON (Accept: application/json:
{url, message, days}) and writes the link to the clipboard. Ticked rows bring up the bulk bar,
which floats under the table.
Keyboard shortcuts (? lists them): / search, n new, j / k next and previous row, Enter
or e open it, x tick it, . its menu, d duplicate it. They never fire while typing in a text
field (a tick box or a button does not count) or with Ctrl, Alt or ⌘ held. An admin script adds its own through
Folio.shortcuts.add({key, label, run, mod, typing}) (mod: ⌘ / Ctrl + key; typing: also inside
fields; run returns false when it did nothing).
Overriding @folio/admin/list.twig: the page extends the layout and includes
@folio/admin/_list_results.twig for the swappable region. In a row, row.cells[column] is
the cell as plain text (escape it) and row.marked[column] is the same text as already
escaped HTML with the search matches in <mark> (print it with |raw). Use list.headers
for the sort links, list.chips, list.clear, bulk (null without any bulk action: no tick
column) and export_url. With the tick column the cell positions shift by one.
Since 0.75 a row also carries languages (a list of {code, name, state}, state a
LocaleState, for the languages() macro in partials/_languages.twig; before, a list of badges
{text, state, href}), home (the home page's meta line or null), address and menu
({view_url, unpublish_url, unpublish_addresses, share_url}); the page has family, glyph,
type_total, column_kinds (field => number, relation or text), frontend, may
({create, edit, delete}) and row_menu ({duplicate, actions}). The bulk bar now follows the
table (admin.js marks it is-floating; is-overlay, its name before, stays an alias selector in
admin.css until 0.76). row.extra holds the cells of extension #[ListColumn]s, in their order after the type's
own columns (headings in extra_columns): each is plain text (escape it), a badge
{label, tone} or a link {label, href} (see Type customizations). Extension columns are
neither in the Excel export nor sortable. A site that overrides _list_results.twig must check
cell.href before treating an iterable cell as a badge.
At most 500 rows can be ticked for one bulk action or export (RecordDeleter::MAX_IDS): a
request with more ids[] is refused with a 422 and changes nothing.
The relation picker's search endpoint (GET {prefix}/_relation/{type}?q=&label=&order=&meta=&page=)
only uses a label / order / meta field that a relation to that type declares as its labelField /
orderBy / metaField, and never a hidden field; it pages in the declared order, then by id.
Deleting: onDelete and the summary
A relation field can say what deleting its target does to the records that point at it:
"author": { "type": "relation", "relation": { "to": "author", "onDelete": "restrict" } }
detach(default): the link is removed from the pointing records.cascade: the pointing records are deleted too (recursively).restrict: the target can't be deleted while records point at it.
Deleting, single or bulk, goes through a delete plan, record by record on Folio's own save and
delete paths (so #[BeforeDelete], #[BeforeSave] and the after hooks run for cascaded and
detached records). Before the confirmation the admin asks POST {prefix}/{type}/_delete-plan
and shows the summary: "This can't be undone." plus a line per type, such as "Also deletes 3
posts." or "2 posts lose a link."; for a restricted plan it shows the refusal ("Can't delete: 2
posts still point here.") instead. The summary is only information: the delete computes its own
plan. If any hook refuses, nothing is deleted. SQL writes run in one transaction per
connection; JSON-file writes can't be rolled back. When the records to delete (cascades
included) have public addresses, the summary also counts them (addresses) and the admin asks
where their old links go (see Redirects).
Bulk actions
The bulk bar appears when a row can be ticked: set fields from list.bulk, extension actions
and delete (each only for those who may use it).
use Preflow\Folio\Extension\BulkAction; #[FolioType('note')] final class NoteTweaks { #[BulkAction('archive', 'Archive', 'notes.archive', confirm: 'Archive the selected notes?')] public function archive(array $records, ServerRequestInterface $request): ?ResponseInterface { foreach ($records as $record) { /* ... */ } return null; } }
#[BulkAction(name, label, permission, confirm: null)] on a #[FolioType] method, signature
fn(list<DynamicRecord> $records, ServerRequestInterface $request): ?ResponseInterface. It is
posted to {prefix}/{type}/_bulk/{name} with the ticked ids[]. It needs the permission and the
type's edit permission; the records are the ticked ones of this type that exist, in the order
posted. Returning null redirects back to the same list view with "{label}: done (N)"; a
response (a download, say) is sent as is; anything else is an error. The names delete,
export and set-… are Folio's own. Bulk delete needs the type's delete permission and is
all or nothing.
Excel export
With openspout/openspout (^4) installed (composer require openspout/openspout), lists get
an Export button: GET {prefix}/{type}/_export downloads an .xlsx of the whole filtered
and sorted list (not just the page), streamed in chunks of 500, or of the ticked rows when
ids[] are given. Columns are the list's own; numbers and dates are typed cells, text is never
written as a formula. Since 0.75 the export also narrows as the list does: status (a publishing
type's state) and unchecked=1 apply to it. Without OpenSpout there is no button and the URL is a 404.
Upgrading to 0.29 — notes for site owners
- Lists without
list.sortororderablenow end in record-id order (on SQLite this replaces insertion order). Addlist.sortwhere the order matters. - The default
onDeleteisdetach: deleting a record now rewrites every record pointing at it (types with publishing get unpublished working-copy changes, and#[BeforeSave]/#[AfterSave]hooks run). DeclareonDeleteexplicitly where that matters. - The list markup gained a tick column (
td.folio-bulk-cell, an emptyth) and sortable headers: site tests that read list columns by position need updating. storage/cache/data(the JSON query cache) must be writable and git-ignored.- Mark secret fields of your own types (
hidden)"searchable": falsetoo; hidden fields are no longer searched by default, and Folio's ownpassword_hash/auth_epochfields say so. - A field with
"hidden": truelisted inlist.filtersnow makes the site fail at boot (FrontendConfigException), as one inlist.columnsdoes: remove it there or unhide the field.
Sidebar
{ "key": "site", "singleton": true, "section": "settings", "order": 10, "icon": "ui-settings" }
section: the sidebar section, a lowercase name (a-z,0-9,_,-, starting with a letter). The default iscontent(extension settings types:settings); asectionin an extension model wins over that.order: an integer; lower comes first within the section. The default is 100.icon: aui-*icon of Folio's sprite (for exampleui-file,ui-settings,ui-users,ui-key). The default isui-settingsfor singletons,ui-fileotherwise.nav: falseleaves the type out of the sidebar; its admin pages stay reachable.
The sections, in this order: the dashboard, Pages (pages), Content
(content), Library (library), Settings (settings). Any other name sits
between Content and Library (several, in the order first used), labelled from the name (member_area becomes "Member area").
Sections the user can see nothing in are left out. A wrong type for any of these keys is
an error naming the type.
Which sections start closed is set in config/folio.php; everyone can open or close a
section, and the sidebar remembers it per person. The section of the current screen is
always open, and a closed section shows the sum of its items' badges. This is the default:
return [ 'nav' => ['collapsed' => ['library', 'settings']], // sections closed until a person opens them; [] opens all ];
A value that is not a list of section names is ignored (every section starts open).
Field types
string, text ("monospace": true gives a code-style editor), number, boolean,
date ("time": true for date and time), select ("options" as a map or a list;
"display": "radio"), richtext, asset, relation and matrix.
How the editor shows them (since 0.75, the LibFields board; assets/fields.js runs the controls):
string: a route parameter of the record's address (slug) has the static path before it as a mono prefix (/jobs/) and a copy button that copies the whole address (app.url's host, the language prefix, the route). A web address ("format": "url", or aurlrule in"validate") gets a button that opens it in a new tab (only anhttp(s)://value).text: with a maximum ("length", or amax:Nrule; the smaller wins) a counter "58 / 160" stands beside the label; it turns amber near the limit (90 %), where screen readers hear it.number: a stepper (− value unit +) in mono, tabular figures."unit": "m³"(a short text, 1 to 12 characters, one line) stands after the value; − and + step by"step"and stay within themin:N/max:N/between:a,brules.date: a calendar icon, the value in mono, a clear button, and a hint under it: "In 2 days" in the admin language, with the app's timezone for a date and time ("In 2 days · Europe/Zurich").boolean: a checkbox;"display": "switch"shows a switch, for a setting that acts at once.select: up to 4 options of a choice that is required (or has a"default") show as a segmented radiogroup (real radios). An optional choice without a default stays a select, which keeps its "—" for no choice."display": "cards"shows radio cards with a description each:"options"is then{"301": {"label": "Moved for good", "help": "Search engines move the ranking over."}}(each text a string or a map per language; the help translates under{group}.option_helps.{type}.{field}.{value})."display": "select"keeps a select at any size.relation: see Blocks, matrix or relation?.asset: each chosen file is a card (a 16:10 thumbnail with the focal point, the name, kind and size, "No alt text yet" for an image without one); an empty field is a dashed drop zone. A file dropped anywhere on the field, or chosen with its Upload button, is uploaded to the media library (its upload route, itsassets.uploadpermission and CSRF token) and chosen; a file the field's"accept"refuses is named in a field error instead. Without script, Upload is the plain file input and the file is stored on save. WithAccept: application/jsonthe upload route (POST {prefix}/assets) answers{items, errors}(200, or 422 with what it refused): each item is a card{id, thumb, label, meta, warn, focal}(Preflow\Folio\Media\AssetCard).matrix: a table with a header row, rows with a grip (drag, or focus it and press ↑ / ↓) and a trash button, and "+ Add row".env: the variable's name and "set" or "not set"; never its value.
An invalid "unit" or "display" (or "display": "cards" without {label, help} options) stops
the boot with a FrontendConfigException naming the type and the field.
number takes "step": 0.5 (any positive number) or "step": "any" (since 0.46). The input gets that
step, and with a number the server checks that the value is numeric and a multiple of the step
(2.3 is refused for 0.5); "any" checks only numeric. Both checks judge a filled value only, so
an optional number field can be saved empty; with "validate": ["required"] an empty value gets the
required message alone. Without the option the browser takes whole numbers only and the server checks
nothing (as before). Any other value is a configuration error, and so is a step with more than 15
significant digits ("0.1234567890123456"): the check gets the step as a float, which holds no
more exactly, so it would compare against a different step. Both throw a LogicException. A float
step is read at 15 significant digits (0.1 + 0.2 is 0.3).
Any field also takes:
-
"hidden": true: not shown in the edit form or the default list columns. A save keeps the stored value; a posted value is ignored. -
"readonly": true: shown as its label and display value, without an input: a select its option label, a relation its record labels, a date formatted for the admin, rich text its sanitised markup, "—" when empty. Since 0.75 the editor lists these under System fields in its side panel, with the record's id (the create drawer keeps them in the form). A save keeps the stored value; a posted value is ignored. A read-only field is not validated on save (not itsvalidaterules, its type's rules orrequired): the editor can't change it, so checking it would only block the save. -
"default": <value>: the value a new-record form starts with (a singleton's form before its first save, too). A boolean"default": trueshows checked on the create form. Since 0.27 this holds for every type, so a site whose models declare defaults now sees them prefilled on create forms (before, a create form started empty). -
"unique": true(or"unique": ["scope_field", ...], unique within those fields),"index": trueand"length": n(default 191; the column length and the character limit of a unique or indexed string, select or single relation): a duplicate value is refused on the field with "already taken", in the form, in autosave and in a draft save, also when two saves race and the database's unique index refuses the second. Empty values are never duplicates and are stored asNULL. A publishing type's__publishedsnapshot gets a plain index, not a unique one. Autosave and draft saves skip the other rules but not what the storage refuses: a taken value or one longer than its column is answered in the save state ("Not saved — Slug: …") and nothing is saved. Restoring a revision or discarding changes whose value another record has taken meanwhile is refused with an error message on the editor, and the working copy stays as it was. Seepreflow/data"Unique fields and indexes".
hidden and readonly apply on create too: such a field starts empty unless a #[BeforeSave] hook sets it.
Where the editor puts a field (since 0.75; Folio-only keys, like "hidden"):
"panel": "side": the field moves from the form column into a Details card in the editor's side panel (after the extension panels), for settings that are not the content: a position, a flag, a date. It is still part of the record form: saved, autosaved and previewed with it."main"(the default) keeps it in the form column."width": "half": the field takes half the form column; two half fields in a row share it (Name and Url, Category and Company), and wrap under each other where the column is narrow."full"is the default.
Any other value of either key stops the boot with a FrontendConfigException naming the type and
the field. The fields the type's frontend address is built from (its route's parameters, such as
slug) always sit in the side panel's Address section.
The admin display (read-only fields and list cells) is the field type's renderAdmin() when
it implements Preflow\Folio\Field\DisplaysInAdmin, otherwise its frontend output. A custom
field type can implement it to show a friendlier value in the admin than it stores. The
frontend output is never changed by it.
A date shows in the admin as folio_date() writes it in the interface language: "Oct 22, 2026"
(German "22.10.2026", French "22/10/2026"), or "Oct 22, 2026 23:15" with "time": true. Only
Y-m-d, Y-m-d\TH:i and ISO 8601 with an offset (shown in the app's timezone) are parsed; any
other value is shown escaped as it is stored, never as an error.
A field type says how its value is stored in a SQL column by implementing
Preflow\Folio\Field\HasColumnKind (columnKind(array $config): Preflow\Data\Schema\ColumnKind:
Text, Short, Number, Boolean or Date); Folio registers it with the data package. The
shipped types do (number is a number, boolean yes/no, date an ISO text, string, select,
env and a single relation are short when unique or indexed, otherwise text); a type without it
is stored as text.
A field type's own rules (e.g. select's allowed values) are enforced in addition to
validate. rules() returns rule names or ValidationRule objects; an object needn't be
Stringable (since 0.66: Folio keeps each name once and each object once, without casting). To add
or replace a type, list the classes in config/folio.php:
return ['field_types' => [App\Folio\ColorFieldType::class]];
From a package (since 0.66). A package adds its field types by being installed:
list them in its composer.json, and they are registered like config/folio.php's (built by the
container; SearchableText, HasColumnKind and the field kinds apply). Blocks are listed in
extra.preflow.blocks (preflow/blocks):
"extra": { "folio": { "field_types": ["Acme\\Links\\LinkFieldType"] }, "preflow": { "blocks": ["Acme\\Links\\Cta"] } }
The order is built-ins, then packages' types, then config/folio.php's: a later type with a key
already taken replaces the earlier one, so the app's config wins. A listed class that is no field
type stops the boot naming the package. A type a provider registers itself later
($container->get(FieldTypeRegistry::class)->register(…) in boot()) gets its field kinds too.
A field type's assets() names the .css and .js files its editor needs; Folio loads
them only on forms that render the field, from {prefix}/_assets/{file}. A package
serves its own files there by adding them in its provider's boot():
$container->get(Preflow\Folio\Http\AdminAssets::class)->add('color.css', __DIR__ . '/../assets/color.css')
(an absolute path; a name already taken is an error).
Blocks
Blocks are their own package, preflow/blocks (Preflow\Blocks, since
0.70): writing a block type, data-only blocks, discovery (app/Components, app/Blocks,
extra.preflow.blocks, config/blocks.php) and rendering with blocks() are described there.
Folio adds the blocks field type and its editor, and answers the blocks' questions: they render
in Folio's content locale (the page's, or the editing locale in a preview), their rich text is
sanitized on the way to the template, and in the live preview each block's root element carries
data-folio-block="<uid>" and data-folio-label.
- Labels in the admin's language:
label,groupanddescriptionof#[Block]andlabel/helpof#[BlockField]may be translation keys ('site.blocks.hero.label', delivered inlang/{en,de,fr}/site.php); plain text is shown as written. - Removed in 0.71: the 0.69 names (before preflow/blocks) are gone:
Preflow\Folio\Block\*(nowPreflow\Blocks\*),folio_blocks()(nowblocks()),config/folio.php'blocks'(nowconfig/blocks.php),extra.folio.blocks(nowextra.preflow.blocks) and the cache keyfolio.blocks(nowblocks).
On a content type: "sections": {"type": "blocks", "allowed": ["hero", "text"], "min": 1, "max": 10}.
The editor saves at most config/blocks.php 'max_depth' levels of blocks in all (default 4).
Storage: an ordered list inside the record, so nothing extra to migrate.
[{"_uid": "a1b2c3d4e5f60718", "_block": "hero", "headline": {"en": "…", "de": "…"}, "links": [ … ]}]
- Language: structure (which blocks, their order) is shared across languages; translatable leaves hold one value per language.
- Removed classes: a block whose class was removed is kept untouched, shown in the editor as "Unknown block" (removable), and skipped on the website.
Editing:
- "Sections · 7 blocks" over the cards, with Collapse all / Expand all.
- Collapsible cards (since 0.75: grip, the block's tile, label and a one-line summary, the "…" menu
and the chevron; an open card has an accent border). The tile is tinted by the block's family,
#[Block(family: TypeFamily::Page)](preflow/blocks), with itsicon. The "…" menu holds Move up, Move down, Duplicate and Remove. Duplicate is server-rendered: the copy gets fresh ids throughout and keeps other languages and uploaded files. An invalid card shows its first error in the summary's place; an unknown or too deeply nested one is a dashed card with a warning. - Add block opens a popover (since 0.75) with a search ("Add a block — type to filter 14") and the groups in two columns: typing filters by label and description, Enter adds the first match, the arrows move, Escape closes. Without script the groups are listed in place. A field that allows one block type has its one "Add …" button. Each loads a server-rendered card (htmx, served from the admin itself).
- In the live preview the card that is open or has the focus stays outlined with its label until another card is selected.
- Inputs are named by block id (
sections[<uid>][headline]), and each card carries its own order input, so reordering never renumbers anything. - Errors show on the card, with a summary that links to it.
- Uploads work inside blocks.
Rendering in Folio's templates:
{{ blocks(record.sections, preview) }}renders each block through its component, in order and in the page's language. Templates must passpreviewfor the click-to-edit markers to appear in the live preview.{{ folio_block(block) }}renders one.{{ folio_editable('headline') }}in a block template marks an element for click-to-edit (only while a preview renders).- For a
richtextblock field,{{ body|raw }}is the intended output (sanitized).
Blocks, matrix or relation?
- blocks: content this record owns, edited in place (page sections).
- matrix: an ordered mix of references to standalone, reusable records.
- relation: references to records of one type (
"relation": {"to": "author", "multiple": true}). Since 0.75 the editor shows the linked records as rows: a grip when several can be linked (drag, or ↑ / ↓ on it), the target's initials on its family's tint, the label ("labelField") and a meta line under it ("metaField": "<field>", e.g. an e-mail; never a hidden field), open in the drawer, and remove. Under them a search row with its results and "Show more": up to 200 target records travel with the field and are filtered as you type ("orderBy": "<field>"sorts them, default: stored order); above 200 it searchesGET {prefix}/_relation/{type}?q=…&page=…({items: [{id, label, meta?}], more}). A multiple relation postsname[]in the rows' order; a single one replaces its record. Without script the linked records still post unchanged, but cannot be changed. SeeonDeletefor what deleting a target does.
Singletons
"singleton": true makes a type hold exactly one record, such as site settings:
- Admin: its nav entry opens the edit form straight away. There is no list, "new" or delete, and the first save creates the record (id = the type key).
- Templates:
{% set site = folio_get('site') %}returns its values in the page's language, ornullbefore the first save, loaded once per request. In PHP, injectPreflow\Folio\Content\Singletons.
Drafts and publishing
Publishing is its own package, preflow/publishing (Preflow\Publishing,
since 0.71): the Publisher, LiveContent, PublishedModels, revisions, scheduling, share
previews, config/publishing.php and the commands publish and publish:scheduled are described
there. Folio registers it and binds its own rules: which types publish (below), the validation a
publish runs, the languages (complete_languages, machine marks), the review index, redirects and
the page-cache tags of the addresses a record leaves and takes.
- Removed in 0.72: the names of before 0.71:
Preflow\Folio\Publishing\*andPreflow\Folio\Content\LiveContent(nowPreflow\Publishing\*),Preflow\Folio\Content\RecordDeleter(nowPreflow\Publishing\RecordDeleter; the editors' texts of a plan or a refusal are inDeleteSummary),folio:publishandfolio:publish-scheduled(nowpublishandpublish:scheduled) andconfig/folio.php'publishing'(nowconfig/publishing.php).
Types with a frontend block and singletons publish by default. Set
"publishing": true|false in the type JSON to override that.
How it works:
- The admin edits a working copy.
- The public site reads a published snapshot, stored in
{table}__publishedwith the same id. folio_get(), the frontend routes and per-language visibility (_locales) all come from the snapshot. The live preview shows the working copy.
States are derived by comparing the two copies, and shown in the list and editor header:
- Draft: nothing published yet.
- Published: the snapshot matches the working copy.
- Changed: there are unpublished edits.
The editor (since 0.75): the topbar holds a back button, the title with its state badges (Published, Changes not live, Draft), the content language (a dot per language: filled when complete, a warning ring when partial, a subtle ring when missing), Form / Split / Preview, the save state ("Draft saved 19:37", "Unsaved changes", "Saving…"), Save draft and the Publish split button. Its chevron opens Publish at…: with a date set the button reads Schedule. Without the publish permission there is Save draft alone; a type without publishing has one Save; a read-only form has no buttons. The side panel (320px beside the form from 1280px wide, under it below that, a sheet behind a topbar button on a phone) shows Publishing (state, the "N changes" since the live version, a schedule), Address (the address fields; "Renaming keeps a redirect from the old address." with redirects on; the old addresses), Languages (how far each is, visible-in, Translate with AI on), the Search result (title, address, description, or a pointer to SEO & sharing), Revisions, System fields, the extension panels and the Details card, and at the foot the record actions, Discard changes, Unpublish and Delete. The share texts are the SEO & sharing tab next to Content.
The preview bar (Split and Preview) has the device widths, the address being previewed (host
of app.url and path; "· draft" while it differs from what is live), Copy preview link (it makes
the share link, which lands under the bar with Revoke, and copies it: "Link copied · 7 days" from the
link's real lifetime), Open (the live address in a new tab, while the record is on the site) and
Focus. A record without an address in this language yet gets the device widths and Focus only.
Both copy buttons (here and in the list's row menu) use Folio.copyLater(textPromise) from
admin.js: called inside the click, it starts the clipboard write at once with the text still on its
way (a ClipboardItem, which Safari needs), falls back to writeText, and resolves true or
false. Extensions that copy what a request returns can use it too.
Keys: ⌘S / Ctrl+S saves the draft (also while typing; the browser's save dialog does not open), ⌘↵ / Ctrl+Enter publishes (where Publish is there), Esc closes the side panel on a phone, ? lists them.
The buttons and the side panel's record controls sit outside the record's <form id="folio-record">
and name it with the HTML form attribute (form="folio-record"), so a click sends every value of
the record, the side panel's too, with the form's CSRF token; no script copies values. Controls a
field's script adds later inside the side panel get the attribute too (editor-frame.js), and every
edit of a control of the form is announced as folio:edit on it, the autosave trigger. A side field's
<button> without type="button" becomes a submit of the record form. A control that is no record
data (a search or filter box of a field) carries data-folio-no-edit (on itself or a parent): typing
in it marks nothing unsaved, saves nothing and does not render the preview again.
Upgrading to 0.75: a site that overrides form.twig (an ActionResolver override or a theme)
must give its submit buttons and any control outside the form form="folio-record", and render the
new blocks (topbar_lead, the side panel partial partials/_side_panel.twig). The floating
.folio-form-actions box and its .folio-publish-at are gone (the create drawer's buttons are
.folio-drawer-actions; .folio-form-buttons stays for other forms' button rows); the record actions of extensions moved from the topbar
to the side panel's foot, the Copy preview link button to the preview bar. Read-only fields left the
form column for the side panel's System fields. The blocks editor's cards and picker changed markup
(blocks/card.twig, blocks/field.twig): the move, duplicate and remove buttons are items of the
card's "…" menu (same data-block-* attributes), .folio-block-actions is gone, the chevron
(.folio-block-toggle, data-block-collapse) is the card's one toggle, and the picker's script is
blocks.js (loaded with any blocks field, after admin.js). The field editors changed markup too
(fields.js runs them, loaded after admin.js): a relation is rows plus a search
([data-folio-relation]; the checkbox chips, the single select and the remote chips are gone), an
asset field is cards in a .folio-asset-box (its label a .form-label span), a matrix row has a
grip and a trash button instead of Up / Down / Remove, the env field is .folio-env. The asset
picker's pick message carries meta, warn and focal too (Folio.chooseAssets passes them on).
A script that adds controls inside the side panel calls Folio.ownControls(node) (admin.js) before
it announces the change, so they name the record form at once. Editors load admin.js, then
fields.js, then editor-frame.js (the create drawer too).
FieldRenderer writes a newline after <textarea> (the one the HTML parser drops), so a value that
starts with a newline keeps it.
Editing:
-
Save draft stores half-finished work and runs no validation rules.
-
Publish validates fully, then copies the working copy to the snapshot. When validation fails, the edits stay saved as a draft and the live page is untouched.
-
Discard changes resets the working copy to what is published.
-
Unpublish removes the snapshot, so the public URL returns 404.
-
Autosave: existing records save themselves 3 seconds after the last change. Uploads are only stored on a manual save.
-
Conflicts: each save sends the
_versionit was loaded with. If someone else saved in between, the save is refused ("Someone else changed this record"). Saving again overwrites their change; reloading discards yours. The check is part of the write (DataManager::saveTypeIfVersion()), so two saves arriving at the same moment cannot both pass it. -
Revisions: every publish keeps a full copy in
{table}__revisions, with the date, the user and the fields that changed. Restore copies one into the working copy, and it then has to be published again. Keep more or fewer revisions withconfig/publishing.php:return ['revisions' => 20, 'preview_ttl' => 604800];
0ornullkeeps every revision (a missing key keeps 20). Anything but a whole number of 0 or more (an int or a string of digits) ornullstops the boot with anInvalidArgumentException: a negative number, a float, text or a bool. Each publish reads the record's revisions to number the next one, so unlimited history makes that read grow with every publish.
Scheduled publishing: "Publish at" stores a date and time on the working copy. Publish what is due from cron:
*/5 * * * * cd /path/to/app && php preflow publish:scheduled
The command holds a lock, so overlapping runs do nothing. A record that fails validation
stays scheduled and is reported on every run. publish and publish:scheduled come with
preflow/publishing; to make Folio's own commands available, list them in config/commands.php:
return [...\Preflow\Folio\Console\FolioCommands::all()];
php preflow publish <type> [--id=<id>] [--all] publishes the drafts of a type
at once. By default it skips records that are already published, and records whose
legacy frontend.published field (e.g. status) does not say published. --all
publishes every working copy, including unfinished edits.
"Publish at" is a wall-clock time in app.timezone (config/app.php), which Preflow
applies on boot, in web requests and in the CLI alike. A time in the past is not
stored.
Upgrading an existing site: publishing switches on by itself for types with a
frontend block and for singletons. Until their records are published:
- their pages return 404;
folio_get()returnsnull.
After deploying:
- On SQL storage, run the migrations below.
- Run
php preflow publish <type>for each such type. - Or set
"publishing": falseto keep the previous behaviour.
Share preview links:
- Copy preview link creates a signed URL to the record's real page that shows the working copy to anyone holding it, without logging in.
- Links work on every language tab (since 0.46): a German tab's link opens
/de/…. - Links expire after
preview_ttlseconds (7 days by default). - Revoke all links invalidates every earlier link for that record.
- Responses carry
X-Robots-Tag: noindexandCache-Control: private, no-store. - Links are signed with
APP_KEYand are disabled while it is empty or still the default.
Referenced records: relations, matrix placements and folio_url() follow the same
rule on public pages: they show the published copy of a publishing type, and nothing
for a record that is only a draft. The admin, the live preview and share-preview links
show working copies.
Live content in your own code: read published content through
Preflow\Publishing\Publisher. ->query('article') returns a QueryBuilder over the
snapshots (or over the records themselves when the type does not publish), and
->live($type, $id) returns one record. Querying the type directly with DataManager
returns working copies, drafts included.
Saving records in your own code: Folio saves every record through RecordWriter and deletes
through RecordDeleter (preflow/publishing, from the container; see its
README). Folio keeps only the forms, the
permissions, the messages and the rendering: the writer runs the type's #[BeforeSave], the
draft save or, for a type without publishing, Folio's validation (blocks included) and the live
save (#[SaveRecord] where the type has one), the publish (validated), the list position, the
review index, the addresses and #[AfterSave]. Save through it too and your code gets the same
steps:
$writer = $container->get(\Preflow\Publishing\RecordWriter::class); $writer->save($record, new \Preflow\Publishing\SaveOptions(by: $userId, mayPublish: $access->can('article.publish'), loadedVersion: (int) $body['_version']));
A save by someone who may not publish (mayPublish: false) cancels a pending "Publish at", as
Folio's form, autosave, revision restore, bulk "Set …", "still correct" and AI translation do.
Saving working copies without the hooks: Publisher::saveDraft($record, $loadedVersion)
saves without rules as version stored + 1 and throws VersionConflict (with ->current,
the stored version) when the stored version is not $loadedVersion, also when another save
lands between the check and the write. With null it does not check and retries on a newer
version instead. A site form that saves a publishing type's record calls it with the version
its form loaded:
try { $publisher->saveDraft($record, (int) $body['_version']); } catch (VersionConflict $e) { // show the conflict; $e->current is the version to save against }
saveDraft($record, null) can still throw VersionConflict in rare cases: when others save
the record during each of its three tries, or inside your own transaction, where the retries
reread the same snapshot.
Publishing in your own code: Publisher::publish($record, $by, $loadedVersion) validates,
then writes the working copy only if it is still stored with $loadedVersion, then writes the
snapshot (on SQL storage in one transaction). A stale version throws VersionConflict before
anything is written. The working copy is written as version stored + 1 when its content
differs from the stored one (any field but the id and Folio's _ keys: an approve that sets a
status, an import), so an editor still on the old version conflicts instead of writing the old
content back; unchanged content (Folio's form: draft save, then publish; the scheduler) keeps
the version. Read the version from the record after publish(). Without the third argument
(null) the working copy is written unchecked (same version rule), as the scheduler,
publish and importers need. Folio's own form publish passes the version its
draft save just wrote. discard($type, $id, $loadedVersion) and
restore($type, $id, $revision, $loadedVersion) check the same way (they bump the version).
try { $publisher->saveDraft($record, (int) $body['_version']); $publisher->publish($record, $userId, (int) $record->get('_version')); } catch (VersionConflict $e) { // someone saved in between; nothing was published }
For a version-checked write outside publishing, use DataManager::saveTypeIfVersion() (see
preflow/data).
States in your own code: ->state($working) reads the snapshot; ->stateWith($working, $snapshot) compares with a snapshot you already have (null: nothing published) and reads
nothing. ->states($type) gives every working copy's state, ->states($type, $ids) those of
some ids, each with one snapshot query (one per 500 ids).
SQL storage:
- Create
{table}__publishedwith the type's columns plus_published_atand_published_by(TEXT). - Create
{table}__revisionswithuuid(primary key),record_id,seq(INTEGER),data,changed,created_atandcreated_by. - Add
_version(INTEGER),_publish_atand_share_token(TEXT) to the type's own table. A cleared schedule is stored as'', because the SQL drivers skip null values. - JSON-file storage needs no setup.
Asset library
The library itself (uploads, variants, private files, usage sources, media_image()) is the
preflow/media package: see its README. Folio adds the UI, installs the
package and binds its rules to the package's hooks. Files and records are stored as before:
public/media/Y/m/{id}.{ext}, the folio_assets table, storage/private-media, /media/…
and /files/{id}.
Library screen at {prefix}/assets:
- grid and list views;
- search by name, title or alt text, and filters by kind (images, videos, documents);
- multi-file upload;
- asset details on their own page at
{prefix}/assets/{id}, with the tabs Details and Edit image (the second for editable images andassets.edit). Details has alt text, title and focal point (click the image to set it), and a poster image for videos. The page takes abacklink to the library view it was opened from (search, filters, page); only a URL under{prefix}/assetsis accepted, anything else falls back to the library. Since 0.36 the request returns the full page also withHX-Request, no longer a fragment, so code that loaded the details panel with htmx must link to the page instead; - usage: the records that use the asset, including published versions that still show it. Deleting a used asset is refused (by the media core).
- replace (permission
assets.edit): the details page's File section takes a new file of the same kind; the asset keeps its id, alt text, focal point, extra fields and usage, so every record that shows it shows the new file. The current file is kept as a version; - earlier versions: listed newest first with date, size and dimensions. Open shows the
file (behind the private-file gate; staff only, see below), Restore (
assets.edit) makes it current again (the current file becomes a version), Delete (assets.delete) removes it for good. Needsversions_dir(defaultstorage/media-versions, a shared directory in deployments); see the media README, andmedia:versions:pruneto trim old versions; - extra fields: the fields declared in
config/media.phpfieldsappear on the details page under title, and the select, relation and boolean ones named infiltersare filters next to the kind filter; - previews and colours: a PDF shows its first page as thumbnail (needs Ghostscript
10.03.1 or newer, or
pdftocairo;media:previewsmakes them for existing files), and an image shows its dominant colour and palette as swatches: click a hex value or RGB to copy it (media:colors --allreads them for existing assets); - edit image (permission
assets.edit): the details page's Edit image tab is{prefix}/assets/{id}/editfor public JPEG, PNG, WebP and AVIF images. It rotates and mirrors, draws the image crop (free or with a ratio, including each box preset's), and lists every box preset (heightandfitinconfig/image.php) with its cut: Adjust sets a format crop for acoverpreset, Reset removes it. The original stays untouched; Download original is on the Details tab. The cropper is Cropper.js 1.6.2 (MIT, vendored in the package). A format crop stays inside the image crop; remove the image crop to art-direct outside it. The editing itself is the media core's (rotate(),flip(),setCrop(),setFormatCrop()); Folio only adds the screen; - notes: an image that could not get colour-accurate variants (CMYK without Imagick) shows the asset's note on the details page.
Asset fields store asset ids. The editor shows the selection with Choose, which
opens the library as a picker in the drawer, and Upload, a plain file input that adds
the file to the library on save, so it works without JavaScript. A field's accept
(image/*, video/* or .pdf,.zip) limits what an upload may be. An asset field in a
type's default template renders with media_image().
Permissions. assets.view (library and details page), assets.upload, assets.edit (details,
extra fields, replace, restore, edit image), assets.delete (delete an asset or an earlier version).
Config. Library settings (directory, widths, quality, private and versions directory,
extra fields, colour profile, …) live in
config/media.php. Folio reads two private-file settings from config/folio.php:
'assets' => [ 'private_permission' => 'folio.dashboard', // default 'private_login' => null, // optional: '/tools/login' ],
- Folio binds the media core's
PrivateFileAccess: only a signed-in user withprivate_permission(staff with*pass), or withassets.view(staff who can see the library open what it shows), gets a private file or a private preview. It fails closed like the frontend gate: without auth configured, nobody does. - Earlier versions are staff only:
{private_url}/{id}/v/{n}of any asset, public or private, needsassets.view.private_permissionnever opens them, so members see only the current file. - Anyone else gets a 403, or with
private_logina redirect tologin?next={the file's URL}(not for an earlier version: always a 403). - A site's own
PrivateFileAccessmust be bound by a provider listed after Folio's inconfig/providers.php.
For a members area (with private_url set in config/media.php):
// config/media.php 'private_url' => '/tools/files', // config/folio.php 'assets' => ['private_permission' => 'members.access', 'private_login' => '/tools/login'],
A private asset field ("private": true) only takes private assets, and a public field only
public ones; its picker lists just those, and choosing the other kind is a field error.
Without the asset library, a private field refuses uploads. Export and pull carry the private
files with their asset records (archive root private-media/); an archive from before private
assets has no such root, and importing it leaves local private files alone. The earlier files
of replaced assets (versions_dir) travel the same way, under the archive root media-versions/.
Importing such an archive into a site without a versions_dir skips those files and says so in
the output (Note: media-versions/: N earlier files skipped, …).
"pdf": { "type": "asset", "asset": { "accept": "application/pdf", "private": true } }
A refused upload in a field (wrong type, too large, not matching accept) shows as a field
error, and the record is not saved.
A content type keyed assets keeps working on the site, but its admin list is
hidden behind the library at {prefix}/assets. Rename the type to edit it.
Usage. Folio registers its record source with the media core; AssetReferences (the
scanner it uses) stays in Folio and is not deprecated.
Older uploads: paths stored before the library existed keep rendering through
{prefix}/_uploads. php preflow folio:assets:import [--dry-run] moves them into the
library:
- it rewrites every reference to them, in working copies, published versions, revisions and nested blocks;
- it lists files that are missing;
- running it again changes nothing.
Dev server: the PHP built-in server must serve public/ as its document root for
/media files:
php -S 127.0.0.1:8000 -t public public/index.php
public/index.php should return false for existing files under cli-server.
Deprecated in 0.34 (the class names removed in 0.72):
| Old | New |
|---|---|
Preflow\Folio\Assets\Asset, Variant, AssetUploadException, ImageTag, ImageWidths, VariantGenerator, Image\* |
Preflow\Media\… (Image\* under Preflow\Media\Image\) |
Preflow\Folio\Assets\AssetLibrary |
Preflow\Media\MediaLibrary |
folio_image() |
media_image() |
folio_asset_url() |
media_url() |
config/folio.php assets (library settings) |
config/media.php (private_permission and private_login stay in folio.php) |
php preflow folio:assets:variants |
php preflow media:variants |
The old class names (Preflow\Folio\Assets\*, but AssetReferences, which is Folio's) are
gone since 0.72: use the Preflow\Media names. The old template functions and command still work. config/folio.php assets is read only when
config/media.php is missing, and never merged with it.
Extensions
An extension adds screens, type customizations, models and settings to Folio without
touching Folio itself. A working one ships in
examples/folio-demo/app/Folio/Demo; the examples below are
copied from it.
Where they live. A project extension is a folder app/Folio/{Dir}/ with an
Extension.php (class App\Folio\{Dir}\Extension). A package names its extension class
in composer.json: "extra": {"folio": {"extension": "Vendor\\Pkg\\Extension"}}. Folio
carries one itself: the members admin (members, in src/Members/), there when Composer lists
preflow/members (in vendor/composer/installed.json) and its classes load; it needs the rbac
extension enabled. A project's or package's own extension named members takes its place. Until
0.38 the admin was in the members package; an installed.json that still names
Preflow\Members\Extension is ignored, and folio-extensions.cache rebuilds by itself. With the
admin enabled Folio binds Settings → Members as the members core's MembersSettings and the
member's edit page as MemberLinks (the link in the owner's mails). Whenever preflow/members is
installed it binds its content locale as PageLocale, names @folio/frontend/_site.twig as the
member pages' default layout (MembersDefaults), and it also counts published snapshots in a member's own deletion
(MemberDeleteCopies) and carries the deletion into them (pointers cleared, cascaded pages
unpublished; the snapshot gets no revision entry).
The codes admin (codes, in src/Codes/) is Folio's too, there when Composer lists preflow/codes:
Library → Codes with the type code, the codes_purpose field, redemptions, expiry filters and
the Revoke action (permissions code.view|create|edit|delete and codes.revoke). Its texts are the
translation group codes, shipped by Folio. preflow/codes no longer needs Folio: it defines the type
code itself and Folio's richer definition takes its place when the extension is enabled; the storage
alias codes is optional (fallback default). The admin creates readable codes only; codes bound to a
recipient come from Codes::issue(). See the codes README.
The members admin also locks and unlocks a member (a Locked pill over the state, sessions ended, sign-in refused
while locked) and resends the confirmation mail to an unconfirmed one: the record actions lock, unlock and
resend_confirmation (members.decide), offered in the Decision panel where they apply, with "Locked since" and
"Last sign-in" lines. Needs preflow/members 0.39.
// app/Folio/Demo/Extension.php #[FolioExtension(name: 'demo', label: 'Demo', description: 'A screen, a note customization and a setting', icon: 'ui-puzzle', version: '1.0.0')] #[DeclaresPermission('demo.view', 'See the demo screen')] #[DeclaresPermission('demo.shout', 'Shout a note')] final class Extension extends \Preflow\Folio\Extension\Extension { public function settings(): array { return [ 'greeting' => ['type' => 'string', 'label' => 'Greeting', 'default' => 'Hello from the demo extension'], ]; } }
The folder's other parts are found by their attributes: Screens/* (#[FolioScreen]),
Types/* (#[FolioType]), models/*.json (content types in the model JSON format) and
templates/ (Twig, namespace @{name}, e.g. @demo/overview.twig). A part is named
screen:{path}, type:{key}, model:{key} or settings.
prepareModel(array $schema, Container $c): array sees each of the extension's model
schemas at boot, before a site's "extends": true file is merged, for what the JSON can't
know: a storage alias that falls back to default when the site has not configured it, or
select options from the site's locales.
Enabling. Nothing runs until config/folio/extensions.json, which is committed,
lists it:
{"enabled": {"demo": {"disabled_parts": []}}}
Every part of an enabled extension is on unless listed under disabled_parts, with one
exception: a model whose JSON says "opt_in": true (and the extension's type part
type:{key} for it) stays off until the site lists it under enabled_parts, so a new
version can ship a model existing sites don't get unasked. Until then it is as if the
extension did not ship it: no type, no table, no permissions.
{"enabled": {"rbac": {"disabled_parts": [], "enabled_parts": ["model:staff"]}}}
folio:extensions:enable <name> --part=<id> and the Extensions screen write either list.
A site without that file and without app/Folio/*/Extension.php behaves exactly as
before. Misnamed classes, missing templates, duplicate routes and unknown names are
errors on boot in debug (in production they are logged and the part is skipped);
folio:extensions:check always treats them as errors.
Permissions. An extension declares each permission it uses with #[DeclaresPermission];
the name must start with {extension}., and the extension's label is its group in
preflow/roles. The permission of a #[FolioScreen] and of a #[RecordAction] is declared by
the screen or action itself (labelled with its label); repeat it with #[DeclaresPermission] only
to give it a label of its own. A #[BulkAction]'s permission is not: declare it. An undeclared permission throws in debug. While the extension or the part
is disabled its permissions are denied (and logged once), never a 500.
Management. Settings → Extensions ({prefix}/extensions, permission
folio.extensions) lists extensions, parts and installed versions. It is read-only unless
config/folio.php has 'extensions' => ['allow_admin_changes' => true] (default:
app.debug); then it toggles extensions and parts by writing extensions.json.
php preflow folio:extensions:list
php preflow folio:extensions:enable <name> [--part=<id>]
php preflow folio:extensions:disable <name> [--part=<id>]
php preflow folio:extensions:apply
php preflow folio:extensions:check
php preflow folio:extensions:uninstall <name> [--force]
disable <name>works without booting the app, so it is the recovery path for an extension that breaks boot or whose folder is gone. Disabling an extension forgets itsdisabled_parts.applyruns the Folio table sync (aspreflow migratedoes) and then, for every enabled extension,install()once per version per environment (the installed version is kept in the environment's own storage, never exported) andseed()on every run (since 0.53). Run it on every deploy.checkboots with debug forced on and exits 1 on any extension error; use it in CI.uninstall <name>runs the extension'suninstall()in this environment and forgets its installed version, so a laterapplyinstalls it afresh. It refuses an enabled extension (disable it first), one whose types a site model file extends, and one not installed here; it asks before it runs ([y/N]),--forceskips the question. Nothing else ever callsuninstall(): disabling keeps the data. A failinguninstall()keeps the installed version.
Seeding on every deploy
install() runs once per version, so it cannot bring anything a later deploy adds. For data
that must follow the code, such as the roles an extension creates, override seed() (since 0.53):
public function seed(Container $c): array { $c->get(RoleStore::class)->ensure('staff', 'shop_manager', 'Shop manager', ['shop.view', 'shop.refund']); return []; // lines to print, one each }
seed()runs on everyfolio:extensions:apply, afterinstall()when that runs. It returns the lines apply prints ([]for none); its return type is: array(an override without it is a fatal error since 0.71); a run that only seeds and returns none still says "nothing to do".- It must be safe to run again and again.
RoleStore::ensure()is: it creates a missing role and grants an existing role only the permissions the list names for the first time. - Apply resolves every enabled extension from the container on every run, so keep constructors light.
- Roles an extension or
config/roles.phpseeds come back on the next deploy if someone deleted them in Folio. To retire one, take it out of the seed list (orconfig/roles.php) first.
Screens
A screen is an admin page under the Folio prefix, in the sidebar, behind a permission.
Its methods carry #[Get] / #[Post] with paths relative to the screen. The class is
built from the container, so it can take constructor dependencies.
// app/Folio/Demo/Screens/Overview.php #[FolioScreen('demo', 'Demo overview', icon: 'ui-file', permission: 'demo.view')] final class Overview { public function __construct(private readonly DataManager $data) {} #[Get('/')] public function index(): ScreenView { return new ScreenView('@demo/overview.twig', [ 'notes' => $this->data->queryType('note')->get()->items(), ]); } }
{# app/Folio/Demo/templates/overview.twig #} {% extends '@folio/admin/_layout.twig' %} {% block title %}{{ screen.label }} — Folio{% endblock %} {% block page_title %}{{ screen.label }}{% endblock %} {% block content %} <p class="demo-greeting">{{ folio_setting('demo', 'greeting') }}</p> <p>{{ notes|length }} notes</p> {% endblock %}
FolioScreen(path, label, icon, section, order, permission, assets, nav): the permission is required.nav: false(since 0.53) leaves the screen out of the sidebar (and so its#[NavBadge]); routes, permission and assets stay, and other pages link to it. Thelabelmay be a translation key ('site.screens.queue'): the sidebar item andscreen.labelthen follow each staff member's admin language; plain text stays as written.sectionissettingsby default; a new name adds a sidebar section after the built-in ones.- A method returns a
ScreenView(template, data)(rendered in the admin layout withprefix,csrfandscreenadded to your data), a string, or any PSR-7 response. POSTs need the CSRF token (_csrf_token, available ascsrfin the template). #[NavBadge]on one public, non-static method of the screen (fn(): ?int, e.g. entries waiting for review) puts a count on its sidebar item. It runs only for a user who may see the screen, at most once per request;null,0, a non-int or a throw shows no badge. A static or second one is a boot error.- A path colliding with a type key or a Folio path (
login,logout,_assets,_uploads,media,extensions) is a boot error. - The sidebar is data:
folio_nav()in Twig,Preflow\Folio\Http\AdminNavin PHP (add()for your own items). Session flashes for the layout come fromfolio_flashes().
Screen assets
An extension ships its own CSS, JS and other files in an assets/ folder next to its
Extension.php (app/Folio/Demo/assets/, or the package's extension folder). Folio serves
them without login at {prefix}/_assets/x/{extension}/{path}, for enabled extensions only
(a disabled or unknown extension, or any other path, is a 404). The URL Folio prints carries
?v= and the first 12 hex digits of the file's hash, so a changed file gets a new URL.
#[FolioScreen('demo', 'Demo overview', permission: 'demo.view', assets: ['demo.css', '@folio/htmx.js'])] final class Overview { #[Get('/')] public function index(): ScreenView { // listed after the attribute's assets; a file named in both is included once return new ScreenView('@demo/overview.twig', [], assets: ['demo.mjs']); } }
- Where to list them:
assets:on#[FolioScreen](every page of the screen) and onScreenView(the one page). AScreenViewreturned by a#[FormPanel]method adds itsassetsto the record's edit form. Attribute entries come first, order is kept and duplicates are dropped. - Entries:
'file.js'(own extension; the path may have subfolders,'vendor/lib.js'),'@folio/htmx.js','@folio/trix'or'@folio/ai-translate'(Folio's own files; these three are all@folio/offers;trixistrix.cssplustrix.js;ai-translateisai-translate.css, the review row's styles, plusai-translate-core.js, the DOM-free helpers aswindow.FolioAiTranslateCore, for a screen with its own Translate button on the suggestion endpoints),'@other/file.css'(the file of another enabled extension). A missing file, an unknown@folio/name or a disabled extension throws anExtensionExceptionnaming the screen method or panel, so a file deleted after a deploy fails loudly instead of printing a dead tag. - htmx and modules: use
@folio/htmx.jsrather than shipping your own htmx (only it gets thehtmx-confignonce meta). ES modules must be named.mjs: a.jsfile withimportloads as a classic script and fails. - Asset picker:
Folio.chooseAssets(options, onPicked)(fromadmin.js, on every admin page) opens the asset library as a picker in the drawer, with its search and upload, like an asset field's Choose.options:multiple(defaultfalse),private(truelists and uploads private assets only,falsepublic ones only, defaultnull: no filter),url(default{prefix}/assets?picker=1&multiple=0|1[&private=0|1]).onPicked(items)gets[{id, thumb, label}](ids checked as 32 hex characters;thumbmay be empty). A single pick closes the drawer; withmultipleit calls back once per pick until the drawer closes. - Tags:
.cssbecomes<link rel="stylesheet">in the<head>;.jsbecomes<script src defer nonce>;.mjsbecomes<script type="module" nonce>. Other served types (map,svg,png,webp,woff2,json) are not added to the page: load them from your JS.@folio/htmx.jsalso adds thehtmx-configmeta Folio's own htmx pages use (nonce for htmx's indicator styles,allowScriptTagsoff). - File rules: only
css,js,mjs,map,svg,png,webp,woff2andjson, with a lower-case extension. Each path segment matches[A-Za-z0-9._-]and does not start with a dot. Anything else, a path leavingassets/, or a symlink pointing outside it is a 404. Responses carry the rightContent-TypeandX-Content-Type-Options: nosniff. - Caching:
Cache-Control: public, max-age=31536000, immutableonly when the request's?v=is the file's current hash. Any other request (no?v=, a stale one, or a lazyimport './x.mjs', CSSurl()orsourceMappingURL) getsCache-Control: no-cacheand anETag, and a matchingIf-None-Matchis answered with 304, so edits show up at once. - No PHP in
assets/: extension discovery scans every.phpfile under an extension folder,assets/included, so a PHP file there is loaded as a class candidate. Keep PHP out of that folder (it is never served either way). - Strict CSP: pages run under the nonce CSP. Folio's tags carry the nonce; do not add
inline scripts, inline styles or
on*=attributes in your templates. folio_extension_asset('demo', 'logo.svg')in Twig returns the versioned URL of a file (for an<img>or a preload link) and throws when it is not served.- Edit-form panels (a
ScreenViewfrom a#[FormPanel]) get nofolio-extension-assetsmeta: usefolio_extension_asset()there. A site that overrides the admin layout (@folio/admin/_layout.twig) must printpage_assetsitself. - Every extension screen page has
<meta name="folio-extension-assets" content="{prefix}/_assets/x/{extension}/">in its head: the base URL for scripts that load more files at run time.
// assets/demo.mjs: load Mermaid only when a diagram is on the page const base = document.querySelector('meta[name="folio-extension-assets"]').content; const nonce = document.querySelector('meta[name="csp-nonce"]')?.content; const diagrams = document.querySelectorAll('.demo-diagram'); if (diagrams.length > 0) { const { default: mermaid } = await import(base + 'vendor/mermaid/mermaid.esm.min.mjs'); mermaid.initialize({ startOnLoad: false }); let n = 0; for (const el of diagrams) { const { svg } = await mermaid.render('demo-diagram-' + n++, el.textContent); const doc = new DOMParser().parseFromString(svg, 'image/svg+xml'); // Mermaid's SVG carries a <style> element; the strict CSP needs the page nonce on it. doc.querySelectorAll('style').forEach((style) => style.setAttribute('nonce', nonce)); el.replaceChildren(document.importNode(doc.documentElement, true)); } }
Copy Mermaid's whole dist/ folder, chunks/ included, to assets/vendor/mermaid/: the ESM
entry mermaid.esm.min.mjs imports many chunk files, so shipping the entry alone 404s. (Or
list the single-file mermaid.min.js as a classic script and use the mermaid global.)
Files imported at run time are not versioned (they revalidate on every load); change the
file name when you update them if you want long caching.
Mermaid writes inline style attributes and a <style> element into its SVG, and the
admin's strict style-src (nonce, no unsafe-inline) blocks both. The example stamps the
page nonce on the <style> element; inline style attributes can stay blocked, so check
the diagram in the browser (console, CSP violations) and prefer Mermaid themes that style
through the <style> element.
editor_assets (the template variable behind a field type's assets()) is internal to
Folio's own pages; extensions use the options above.
Dialogs
Folio asks every question through one styled dialog (a native <dialog>, rendered once by each
admin layout as partials/_dialog.twig); it never shows the browser's own confirm, alert or
prompt. Extensions use the same dialog from admin.js, on every admin page:
const answer = await window.folioConfirm({ title: 'Archive “Winter news”?', // the question (a message alone becomes the title) message: 'It leaves the list but stays searchable.', items: ['3 pages link to it'], // optional list under the message confirm: 'Archive', // the confirm button's label (default "Confirm") cancel: 'Keep it', // default "Cancel" tone: 'danger', // .btn-danger and the focus on Cancel; else primary, focus on confirm extra: 'Archive and next', // an optional third button icon: 'ui-trash', // an id from Folio's icons.svg (default ui-info, ui-warn for danger) }); // 'confirm' | 'cancel' | 'extra' await window.folioAlert({ title: 'Still in use', message: '…', items: ['2 jobs'] }); // one OK button
Escape and a click on the backdrop cancel; the focus returns to where it was. A second question
while one is open cancels the first. Without a layout's markup (a site that overrides the layout)
admin.js builds the dialog itself.
data-confirm="…"on a form (or on one of its submit buttons, such as the list's bulk bar) asks before it is sent. On confirm the same button is submitted again (form.requestSubmit(button)), so itsname/value, its busy state and the form's CSRF token go along; without script the form simply submits. The confirm button says what the clicked button says (ordata-confirm-label);data-confirm-tone="danger"(or a danger button:.btn-danger,.is-danger,data-tone="danger") makes it a danger dialog.hx-confirm(anddata-confirmon an htmx element) asks through the same dialog: Folio answers htmx'shtmx:confirmevent and sends the request withissueRequest(true).- Delete forms with
data-delete-planshow what the delete also does as the dialog's list; a plan that may not go ahead tells why ("Still in use", with what still points here).
Leaving with unsaved changes. A link, a breadcrumb, the sidebar, a row menu or a shortcut that
leaves an editor with unsaved changes asks Leave without saving / Stay / Save and leave
(where the form has a Save button: it saves with _folio_next, and a successful save goes to the
link; a refused one shows the errors). An autosaving editor does not ask: its pending edits are
saved at once and the page leaves when they are; only a save that fails asks, with the reason.
⌘/Ctrl/Shift/Alt and middle clicks, target="_blank", download, #hash links on the same page
and other sites are not held. Closing the tab, reloading, typing an address and back/forward keep
the browser's own question (no page can style it). Folio.navigate(href) goes to a page through the
same guard; Folio.unsaved.isDirty(), .dirtyForms(), .clear(form), .onLeave(fn) and
.pending() are the hooks behind it. Leaving without saving a side panel form still saves the
record's own pending edits first (since 0.75).
The guarded forms are the editor's (.folio-form form, the side panel's, an asset's Details) and
any form that carries data-folio-guard (the redirect form, the drawer's alt texts). A
drawer is a page of its own in a frame, and removing the frame asks nothing: so Escape in the
drawer, and the page behind's Close button, Escape and a click beside the drawer, go through
Folio.leaveDrawer() in the drawer's page. While a guarded form there holds unsaved changes, its
leave dialog asks, and only Leave without saving closes the drawer (since 0.76).
A confirmed action in an autosaving editor (a data-confirm form such as Unpublish) saves the
pending edits first, then goes; if that save fails, the dialog's go-ahead names the action
("Unpublish without saving"). A form that throws the edits away carries data-folio-discards
(Folio's Delete and Discard changes do): it saves nothing first, and the queued autosave is dropped
(since 0.75).
Unsaved count and Cancel (forms without autosave, since 0.76). An editor whose type does not
autosave (no publishing, such as Roles) shows "N unsaved changes" and Cancel beside Save,
hidden while nothing is changed. admin.js takes a snapshot of the form's submittable values once
the field scripts have run: what FormData would send for the form and the controls that name it
with form="…" (the side panel's), without controls marked data-folio-no-edit and names that
start with _ (_csrf_token, _locale, _version). Folio.unsaved.count(form) is the number of
field names whose values differ from that snapshot, so a field kept in hidden inputs (rich
text, blocks, relations, picked assets) counts, and a radio group or a checkbox list (the
permissions matrix) counts once. While the form holds unsaved changes the state never hides: when
nothing is left to count it says "Unsaved changes" without a number.
Cancel calls Folio.unsaved.discard(form). When only visible controls changed, they go back to
the page as loaded in place (disabled ones too), the form's unsaved mark is cleared, a bubbling
folio:discard event is sent on the form, and fields.js's counters, date hints and steppers
follow (Folio.initFields(root)). When a hidden input changed (or the restore does not reach the
snapshot), Cancel reloads the edit page (the form's data-folio-edit-url, in the content
language) past the leave guard once: a form without autosave has no server draft, so that shows
the saved state.
A refused save (a 422 re-render with the posted values) carries data-folio-refused on the
form: it starts unsaved, so leaving asks, the state says "Not saved" (or the count once
something else changes), and Cancel reloads the edit page. The markup is form.twig's
[data-folio-unsaved-state="<form id>"] and [data-folio-discard="<form id>"]; any
non-autosaving form can carry them.
For field and extension authors: a field that changes controls from script should send a bubbling
change (or input) event for each (fields.js does so for Trix's trix-change), so the count and
the leave guard see it as a click. A field whose visible controls have derived state (locks,
previews) listens for folio:discard on its form and rebuilds that state from the restored
controls; the permissions matrix (rbac_permissions) re-applies its locks there. A field kept in
hidden inputs needs nothing: Cancel reloads the page for it.
Type customizations
A class with #[FolioType('note')] customizes the admin of an existing type (an unknown
key is a boot error). It is built from the container; its methods carry:
// app/Folio/Demo/Types/NoteTweaks.php #[FolioType('note')] final class NoteTweaks { public function __construct(private readonly DataManager $data) {} #[FormPanel('Name length')] public function nameLength(DynamicRecord $record): string { return mb_strlen((string) $record->get('name')) . ' characters'; } #[RecordAction('shout', 'Shout', 'demo.shout', icon: 'ui-check', confirm: 'Upper-case this note\'s name?')] public function shout(DynamicRecord $record): null { $record->set('name', mb_strtoupper((string) $record->get('name'))); $this->data->saveType($record, false); return null; } }
| Attribute | Method signature | Effect |
|---|---|---|
#[FormPanel(label, position: 'after')] |
fn(DynamicRecord): ScreenView|string|null |
A panel on the edit form (not on create), before or after the form's fields, or side: a card in the editor's side panel (since 0.75 the 320px column at the right from 1280px, also next to the preview; under the form below that), after Folio's own sections and before the Details card. A string is shown as escaped text, a ScreenView as a template fragment (it gets prefix, csrf, type, id and record). Returning null hides the panel (no heading, no box, and no side column or extras section on its account; an empty string still renders an empty panel). Any other position is a boot error. |
#[RecordAction(name, label, permission, icon, confirm, toolbar: true)] |
fn(DynamicRecord, ServerRequestInterface): ?ResponseInterface |
A POST button at {prefix}/{type}/{id}/_action/{name} (since 0.75 at the foot of the editor's side panel, and in the list's row menu); null redirects back to the form with "{label}: done". Needs the permission and the type's edit permission. With toolbar: false there is no button in the editor or the row menu; the action stays routed and permission-checked, for a form inside a FormPanel to post to. |
#[ListColumn(label, position: 100)] |
fn(DynamicRecord): string|Badge|Link |
A column after the type's own, ordered by position, shown as escaped text, as a link for new Link($label, $url) ($url is member/3/edit, relative to the admin prefix, or a site path like /shop/order?id=3; anything with a scheme, host, //, backslash or whitespace is no link and shows the escaped label), or as a status pill for new Badge($label, $tone) (tone '', published, draft, changed, scheduled, failed or incomplete; the label is escaped, an unknown tone is shown plain). |
#[NavBadge] |
fn(): ?int |
A count badge on the type's sidebar item (e.g. waiting members); also on a #[FolioScreen] class (see Screens), computed only while the item is visible and at most once per request; null, 0 or any non-int return shows none, a callback that throws shows no badge (the admin still renders), several add up, one per class. Keep it cheap: it runs on admin page renders. |
#[ListNotice] |
fn(): ?string |
A notice above the type's list (since 0.53), on the full page only, not on live-search refreshes. The method returns HTML that Folio prints as it is, so it escapes its own content (usually by rendering a Twig template). Use Folio's .folio-notice markup, e.g. <div class="folio-notice" role="status"><p>…</p></div>, for the spacing (a list of items inside it: <ul class="folio-notice-list">, no bullets). Since 0.73 that is the info tone (blue). Add is-warning (<div class="folio-notice is-warning" role="status">) for the amber warning tone; see Admin styling. null or '' shows nothing, and so does a throw. Several add up, grouped by type (in the order the types' customizations were registered), each type's notices in extension name order. |
#[DashboardNotice] |
fn(): ?string |
A notice above the dashboard's "Needs you" section (since 0.53), the same rules as #[ListNotice]. Only called for a user who may view the type ({type}.view); the notices are grouped by type (in the order the types' customizations were registered), each type's notices in extension name order. |
#[DashboardItem] |
fn(): ?Preflow\Folio\Dashboard\NeedsYou |
A card in the dashboard's "Needs you" section (since 0.76): new NeedsYou($title, text: …, url: …, tone: 'warning', icon: null). The title says what waits ("3 orders to ship"), the text how to go about it; the whole card links to url, a path relative to the admin prefix (order?status=open) or a site path, as Link takes it (anything else shows no card). tone is warning, info or danger (another tone throws \InvalidArgumentException when the NeedsYou is built, so the card is not shown); icon is an icons.svg id (ui-shield), null the tone's. The method must declare that return type (a boot error otherwise). Only called for a user who may view the type ({type}.view); null or a throw shows nothing. The cards follow Folio's own (changes not live, not-found addresses, translations to review) in the same order as #[DashboardNotice]: by type, in the order the types' customizations were registered, then each type's in extension name order. The section is hidden when there are none, and it loads just after the page (htmx), as does the cache line. Folio's roles extension announces new permissions this way. |
#[FieldPlaceholder(field)] |
fn(string $locale): ?string |
What the field means while it is empty, as its placeholder in the edit form in the locale being edited (e.g. a standard text from the lang files); it replaces the "falls back to" placeholder of a translatable field. The field must exist on the type (boot error), one per field. |
#[BeforeSave] |
fn(DynamicRecord): void, or fn(DynamicRecord, WriteKind $kind): void |
Before validation; may set() fields. Throw Preflow\Validation\ValidationException to refuse the save: nothing is stored, the form is shown again with its errors (422). Runs on every save through the RecordWriter (see Saving records in your own code): the form, autosave, each record of the bulk bar's "Set …", both records of a list move, "still correct" of a translation, AI translation (since 0.71), "Add to role" on the new-permissions notice, the detached records of a member's own deletion (a refusal there is logged; the deletion stands), and an app's own writer calls. A method whose second parameter is typed Preflow\Publishing\WriteKind gets the kind of write (since 0.71): Save (the form, "still correct", "Add to role", an app's own save()), Autosave, Bulk, Reorder (a list move), Translate (AI translation) or Detach (a delete takes the deleted ids out of the record), so a hook can leave some alone, e.g. skip image work on autosave. |
#[SaveRecord] |
fn(DynamicRecord $record, ?DynamicRecord $previous): void |
Writes the record itself instead of the whole-record save, for types without publishing: after #[BeforeSave] and validation, before #[AfterSave], on every save through the RecordWriter (the form's create and update, the bulk "Set …", AI translation; a list move's positions too, there without validation). $previous is the stored record before the change, null for a new one. One per type (a second is a boot error). Throw ValidationException to refuse the save (422, the form shown again). Use it when the store has rules a whole-record write would break, e.g. a counter changed at the same time. |
#[AfterSave] |
fn(DynamicRecord): void, or fn(DynamicRecord, WriteKind $kind): void |
After the record is stored, on the same saves as #[BeforeSave], with the same optional WriteKind; on a publishing type also when the publish after the saved draft fails. |
#[BeforeDelete] |
fn(DynamicRecord): void |
Throw Customization\Refused('message') to keep the record; the message is shown to the editor. |
#[AfterDelete] |
fn(DynamicRecord): void |
After the record is gone. |
A decision panel in the side column, with its own form and tabs:
#[FormPanel('Decision', position: 'side')] public function decision(DynamicRecord $record): ScreenView { return new ScreenView('@members/decision.twig'); } #[RecordAction('accept', 'Accept', 'members.decide', toolbar: false)] public function accept(DynamicRecord $record, ServerRequestInterface $request): null { /* ... */ } #[RecordAction('decline', 'Decline', 'members.decide', toolbar: false)] public function decline(DynamicRecord $record, ServerRequestInterface $request): null { /* ... */ } #[ListColumn('State')] public function state(DynamicRecord $record): Badge { return new Badge('Waiting', 'changed'); }
{# decision.twig: one form per tab panel, each posting to its own action #} <div data-folio-tabs> <div class="folio-segmented"> <button type="button" data-folio-tab="accept">Accept</button> <button type="button" data-folio-tab="decline">Decline</button> </div> <div data-folio-tabpanel="accept"> <form method="post" action="{{ prefix }}/{{ type }}/{{ id }}/_action/accept"> <input type="hidden" name="_csrf_token" value="{{ csrf }}"> <textarea name="message" rows="6"></textarea> <button type="submit" class="btn btn-primary">Accept and send</button> </form> </div> <div data-folio-tabpanel="decline"> <form method="post" action="{{ prefix }}/{{ type }}/{{ id }}/_action/decline"> <input type="hidden" name="_csrf_token" value="{{ csrf }}"> <textarea name="message" rows="6"></textarea> <button type="submit" class="btn btn-primary">Decline and send</button> </form> </div> </div>
Use one form (and one toolbar: false RecordAction) per tab, so a click on a send button
says which decision was made and only that panel's fields are posted.
A button that needs a change (since 0.75): give a panel form's submit data-folio-needs-change
and admin.js keeps it disabled until a control of that same form changes (a reset disables it again);
edits elsewhere on the page do not count. A transfer card's "Transfer" stays off until a new owner is
picked:
<form method="post" action="{{ prefix }}/{{ type }}/{{ id }}/_action/transfer"> <input type="hidden" name="_csrf_token" value="{{ csrf }}"> <select name="owner">…</select> <button type="submit" class="btn btn-secondary" data-folio-needs-change>Transfer</button> </form>
Without script the button works as a plain submit, so the action checks the posted value itself.
Tabs: <div data-folio-tabs> holds a .folio-segmented of <button data-folio-tab="key"> and
sibling <div data-folio-tabpanel="key"> panels. admin.js adds the ARIA roles, arrow keys
(Home/End too) and hides the inactive panels; the first tab is active unless one carries
data-folio-tab-active. Panels are focusable. Without JS every panel shows, stacked, and the tab buttons stay hidden.
Things to know:
- Hooks run on every save through the
RecordWriter: the form (store and update, plain and publishing types), autosave, reorder, bulk "Set …", translation and destroy (the hook table above lists them). A hook that should skip some of them asks for the kind of write, a second parameter typedWriteKind. They do not run on the publishing controller's restore and discard, which copy content the record had before. - Record actions: a click submits only the action's own form, so unsaved edits in the edit form are not saved. On a publishing type the action receives the working copy; saving it directly (as above) bypasses the Publisher's versioning, so changes appear in the next publish rather than as a revision.
- Duplicate action names on a type are a boot error. A disabled
type:{key}part has no effect.
Extending an extension's type
A site adds fields to a type an extension ships (and to its {name}_settings singleton)
without forking it: a model file in config/models/ whose key is that type and which says
"extends": true.
{
"key": "member",
"extends": true,
"fields": {
"company": { "type": "string", "label": "Company" },
"role_title": { "type": "string", "label": "Role", "after": "name" }
},
"list": { "columns": ["name", "email", "company"] }
}
fieldsare appended after the extension's;"after": "name"on a field puts it right after that field instead (the field must exist, in the extension or earlier in the file).list.columns, when given, replace the extension's columns; its otherlistoptions stay.storage,table,users,id_fieldandkeyare locked: repeating the extension's value is fine, a different one is a boot error naming the option. So is redefining a field the extension defines. Any other key (label,icon,section, alistoption other thancolumns...) is a boot error too, as the merge would drop it; each error names the file.- The patch is never a type of its own;
preflow migrate(itsfolio.tablesstep, orpublishing.tablesfor a type that publishes) adds the new columns. A patch no enabled extension type takes is a boot error that names the file and, when the extension is switched off, the command that enables it. folio:extensions:disable(and the admin toggle) refuse to disable an extension or part a patch extends, so the site never ends up unbootable. Remove the file first. If it happens anyway,preflow folio:extensions:enable <name>still works (it enables on discovery alone when the boot fails on such a patch), anddisableworks without booting.
Settings
Extension::settings() returns a form in the model JSON fields format. Folio shows it
under Settings as the singleton type {name}_settings (no publishing), labelled
"{extension label} settings" (e.g. "Members settings"), and reads it with
the declared defaults. Overriding settings() declares the settings part; discovery sees
that without instantiating the extension, so a disabled extension's constructor never runs.
An enabled one whose settings() returns no fields is an error on boot.
'greeting' => ['type' => 'string', 'label' => 'Greeting', 'default' => 'Hello from the demo extension'],
{{ folio_setting('demo', 'greeting') }}
$settings = $container->get(\Preflow\Folio\Extension\Settings\ExtensionSettings::class); $settings->get('demo', 'greeting'); // the stored value, else the default, else null $settings->get('demo', 'greeting', 'de'); // translatable fields: a locale (falls back like content) $settings->get('demo', 'greeting', 'de', exact: true); // only de's own value or default, never another locale's
- Before the first save the form and
get()return the declared defaults. - Permissions are the settings type's standard ones:
{name}_settings.viewand{name}_settings.edit. - Only
null(never saved) counts as "not set": a cleared string reads'', and an unchecked boolean readsfalseon every storage, also where SQL hands it back as''. - An unknown extension or key throws in debug and returns null in production.
- Secrets stay out of settings. A field of
'type' => 'env', 'env' => 'MAIL_PASSWORD'stores nothing and shows only "set" or "not set";get()reads the environment variable.
Cache screen
Settings → Cache ({prefix}/cache, permission folio.cache; staff with * have it) lists
every cache registered with Preflow\Core\Cache\CacheRegistry as a table row: label,
entries, size and a Clear button, plus Clear all. Folio registers folio.extensions
and the blocks cache (blocks, preflow/blocks); core registers data.json (when the JSON driver is in use) and blade
(when the Blade engine is in use). A site registers its own:
$app->container()->get(\Preflow\Core\Cache\CacheRegistry::class) ->register(new \Preflow\Core\Cache\DirectoryCache('images', 'Resized images', $app->basePath('storage/cache/images')));
An OPcache table shows the status (memory, scripts, hit rate) with Reset OPcache. OPcache
is per SAPI: the web request resets the web pool's cache; the CLI cannot see it, so the
button is a web-only action. The same registry backs cache:list and
cache:clear <key>… | --all (devtools).
With the page cache on (preflow/core's README, "Page cache"), its row is pages (clearing it
empties the cache), and a Page cache section has the two manual actions: Refresh a page
(a path such as /about, or the full address: every cached copy of it is forgotten and it is
rebuilt behind the response) and Refresh everything (every page is forgotten and the known
pages are rebuilt in the background, the rest by preflow pages:work). Pages refresh
themselves on every write; these are for changes the site can't see. They are core's
Refresher::refresh() and refreshAll(), the same as preflow pages:refresh.
Redirects
Old links keep working. Folio remembers every page's public addresses and answers an old one
with a redirect, through preflow/routing's RedirectMiddleware (see its README for when a
redirect applies and config/redirects.php). Folio supplies the store, the automatic entries,
the prompt when a page goes away and Settings → Redirects.
Renamed pages
When a record's public address changes (any field its route is built from, in any language), its old address answers with a 301 to the record's current address. That covers a publish (also the scheduler and a revision restore) and, for types without publishing, a plain save. Every old address points at the record itself, not at an address, so a page renamed twice redirects from both old addresses straight to the current one. A record that loses its address in one language only (translated slug cleared, language removed) redirects that address to its default-language address. The address a page holds now always shows the page: renaming A → B → A drops the entry at A, and B redirects to A.
Removed pages: "Send its old links to…"
Deleting a record that has public addresses (single, bulk, or records deleted by cascade) and unpublishing one asks, in place of the plain confirmation, where the old links go:
- Leave them as not found (the default): its addresses answer 404 as before.
- A page on the site: a search over the public pages
(
GET {prefix}/redirects/_targets?q=,{items: [{value: "type:id", label, type, url}]}). - A URL or a path:
https://…or a path on this site (/…, never//host).
The three are radio cards (the chosen one opens to its search or input; the arrow keys, Home and
End move the choice). The dialog's title names the record when the form carries
data-removal-label ("Delete “About”?", "Unpublish “About”?"; the list's and the editor's forms
do), else it is the form's own question.
The form posts the choice as _redirect_to (type:id or the URL); the server checks it again
and treats anything it can't use as no target. A bulk delete has one target for the whole
batch; records deleted by cascade get the same one.
The server also follows the target to where it really leads, so old links never end in a chain:
- a path or URL on this site (an absolute URL counts when its host is
app.url's or the request's) where a page answers is stored as that page, so the links follow it when it moves later; - one that redirects already is replaced by that redirect's final target;
- a target that leads back to the record (one of its own addresses, current or earlier), a draft or unknown record, an address that redirects nowhere, and in a delete one of the records the same delete removes all count as no target.
Choosing needs only the right to that delete or unpublish. Without JavaScript nothing is asked and the old links answer 404. A type without publishing that leaves the site by its status field shows a warning after the save instead, with a link to the record's redirects for those who may manage them.
What a removal does to the other entries that point at the record:
- With a target, they follow it. The record's own automatic entries (its earlier addresses) become "removed" entries to the target, like its current addresses. Entries from anywhere else (hand-made entries, areas, another record's) send visitors to the target too, each keeping what it is (a hand-made entry stays hand-made, an area stays an area, its status is kept). Entries an earlier removal of the record left without a target get it too.
- Without a target, the record's own addresses become "removed" entries without a target (404), and the entries of others keep pointing at the record: 404 while it is off the site, working again when it comes back, shown as broken if it was deleted.
When the record comes back to the site, its own "removed" entries point at it again and are automatic once more, whatever target the removal gave them; hand-made entries, areas and other records' entries keep the target the removal gave them.
The "Old addresses" note
A record's editor shows the addresses that send visitors to it in the sidebar, as visitors see
them (/about, /de/über-uns), at most ten and then how many more, with a link to its
redirects ({prefix}/redirects?record={type}:{id}) for those with redirects.manage. The note
is hidden when there are none.
Settings → Redirects
{prefix}/redirects, permission redirects.manage (the automatic entries need none: they follow
from publishing). Two tabs:
- Redirects: "from → to" (the old address, the target, and the language, area and target type
under it), kind (automatic, removed page, handmade), code (301 or 302), hits since created, and
a broken flag when the target page is gone or none was picked; untargeted and broken entries
first. The search finds the old or the new address (a URL, or a page's label);
?record=type:idshows the entries of one record. Add, edit (the drawer), delete (the row's "…" menu). - Not found (its tab counts the rows): addresses visitors asked for that don't exist, hit at least twice, most hit first, with → add a redirect (the form, prefilled) and Dismiss. A dismissed address comes back only when it is hit again 30 days or more after the dismissal; an address that gets a redirect or that a page takes leaves the list. The count is approximate: an address is counted at most once a minute (hits in between are dropped), a burst of new addresses is recorded at most 30 a minute, and the list keeps the 1,000 most recently hit addresses.
The form opens in the drawer (?_drawer=1; the list reloads after a save) and is a page of its
own without script. A hand-made entry has an old address, typed after the form's / (a query or
fragment is left out), with its language: on a site with locale prefixes a select offers one of the site's
languages (a new entry starts in the default one) or "Any language", and a language prefix typed
in front (/de/werkstatt) still wins; without prefixes entries are language-neutral. Posted without the select, the prefix
or else the default language decides, as before. "Send visitors to" is A page (a search over
_targets, the pick shown with its type and URL) or An address (a URL or path); both post
the one field target, and without script the address input takes type:id too. It can cover everything under it
(an area): either all to one target, or keep the rest under a new address
(/workshops-2024/basel → /workshops/basel, a URL or path target without ? or #). Hand-made
entries answer 301 (default) or 302; automatic and removed-page entries are always 301.
A typed path or URL where a page answers, in the entry's language, is stored as that page (as if
it had been picked), so the entry follows the page when it moves; not for "keep the rest" (the
rest of the path is appended to the URL), nor with a ? or # (kept as typed).
The form refuses what could never work or would loop:
- an address under a non-public path (the admin, members, private files), or that a page holds now (it would never answer; an area's root may be a page);
- an area over the whole site (
/); - an address that already has an entry (edit that one instead);
- a target starting with two slashes or backslashes in any mix (
//host,\\host), a page that is not on the site, the old address itself, or an address inside the area being saved; - chains, both ways: a target that redirects already (exactly or through an area), and an address that other entries send visitors to (point them to the final target first); a "keep the rest" area counts with everything below its target.
Storage and upgrading
Three internal tables in the default storage: folio_redirects (the entries),
folio_addresses (each record's current public address per language) and folio_not_found
(at most not_found_limit addresses, 1000 by default; the least recently hit go first; path,
language, count, first and last hit, nothing about the visitor).
Upgrading to 0.52: SQL sites run preflow migrate, which creates the three tables and
records every live page's current addresses. Until it has run, pages, 404s, publishes and
deletes behave exactly as before (the redirect pieces catch the failure and log it in debug
mode). Without that run, the first rename of a page after upgrading redirects only for
publishing types (their previous live copy still holds the old address); after it, for every
type. On a JSON-only site preflow migrate has nothing to do: addresses are recorded as records
are saved or published, so the same holds there for each record's first rename.
Records written by folio:import don't get automatic redirects for renames the import makes (the
importer writes rows directly); run preflow migrate afterwards to record their addresses.
Until preflow migrate has run on a SQL site, Settings → Redirects says so instead of showing
the lists.
Settings → SEO audit
{prefix}/seo-audit, permission seo.audit, on every site (also without SQL storage). The last report of
the SEO audit (see the SEO audit section in packages/view/README.md): when it ran and on which site, whether
it is complete or how many pages it has done, the number of findings per severity and the findings grouped by
check, most serious first. Each finding links to its page and, for content findings, to the record or library
asset to fix, for users who may open them ({type}.view / assets.view). The texts come in en, de and fr.
Since 0.74 a user who also has seo.audit.run runs the audit on this screen, through preflow/view's Audits
(the same steps and report as preflow seo:audit, which still works and is named under the buttons):
- Run audit (no report yet), Continue and Start over (an incomplete report), Run again (a
complete one, from the start) are plain POSTs to
{prefix}/seo-audit/run(fresh=1starts over). Each runs one step of 20 seconds (SeoAuditController::STEP_SECONDS; at least one page) and shows the page with the progress. Without JavaScript every Continue is one more step. - With JavaScript the progress posts itself to
{prefix}/seo-audit/step(htmx) after each step until the report is complete, then goes to the finished report. Leaving the page stops it; Continue picks it up where it stopped. A step whose request fails shows Continue. - Another run of the same site (the command, another browser) holds the audit's lock: the screen says so
and offers Try again; it does not retry by itself. A step that throws is logged with its message; the
screen says only that it failed (the details are in the server log) and offers Continue. When the next
step fails at the same place again, it also suggests running
preflow seo:auditon the server. - A step closes the session first, keeps running when the tab closes, and raises a PHP time limit below 60 seconds to 60 where it may. The audited pages render as a visitor, and the mail they queue is dropped (core's internal render scope).
The audited pages are the published site as a visitor gets it, also when the step runs inside the admin (which otherwise shows working copies): every internal render reads published snapshots with empty memos, the same isolation the page cache uses.
Operations:
- Each step closes the PHP session first (the browser's other requests don't wait), keeps running when
the client goes away (
ignore_user_abort) and raises a PHP time limit below 60 seconds to 60 whereset_time_limit()is allowed. - On LiteSpeed, a request whose client has gone may still be stopped; consider
SetEnv noabort 1(or thenoabortrewrite flag) for the admin, so a closed tab never cuts a step off before it saves. - A page too slow to render inside a web request, or a web PHP memory limit too low for the audit, needs
the command: run
preflow seo:auditon the server. The screen shows its report the same way. - The report and its lock (
storage/data/seo-audit.jsonandseo-audit.json.lock) must be writable by the web server's user, and by the account that runs the command if that is a different one (see "Lock and concurrency" inpackages/view/README.md). - Without auth on Folio (no
config/auth.php), every permission check passes: anyone who can open the admin can run the audit.
Both routes need seo.audit; the controller adds seo.audit.run (403 without it; then the screen shows no
buttons). With rbac, the new permissions are announced in Settings → Roles like any new permission: the
admin role (*) has them, other roles get seo.audit.run only when someone adds it.
User types and transfer
A type can hold the site's own users or members:
{ "key": "member", "users": "members", "storage": "sqlite", "fields": { "email": { "type": "string" } } }
"users": "<population>"marks a type as user data. It is not transferred (folio:export,folio:import,folio:pull) unless it also says"transfer": true: live users never travel to a laptop by accident.- Any other type can opt out with
"transfer": false."transfer"must be a JSON boolean; anything else ("false",0) is an error naming the type. - A type that is not transferred cannot have fields that hold files (
asset,blocks,matrix) yet: its records stay on the server, but the uploaded files would still be exported. Such a type is an error. Field types a site registers itself (config/folio.phpfield_types) are not checked; don't give one that stores files to a type that is not transferred. - Per-environment state (installed extension versions, throttle rows) is never exported.
Signing users in. Preflow\Auth\Record\RecordUserProvider is a user provider over a
user type, for a SessionGuard in config/auth.php:
'providers' => [ 'members' => [ 'class' => Preflow\Auth\Record\RecordUserProvider::class, 'type' => 'member', 'email' => 'email', 'password' => 'password_hash', 'roles' => 'roles', // the defaults ], ], 'guards' => [ 'members' => ['class' => Preflow\Auth\SessionGuard::class, 'provider' => 'members'], ],
- The class lives in preflow/auth since 0.38; Folio builds it with its type catalog. The old
names
Preflow\Folio\Users\RecordUserProviderandPreflow\Folio\Users\RecordUserare gone since 0.72: aconfig/auth.phpnaming them no longer works. - The type must exist and have
"users"set, or boot fails with a message naming it. - The Folio login upgrades an outdated password hash (an imported
$1$hash, an older cost) at sign-in, without touchingauth_epoch(Preflow\Auth\PasswordRehash, see auth's README). findByCredentials()lowercases and trims the typed email and accepts only an exact match of the stored value, and only when exactly one record has it: a database that compares loosely (MySQL collations) never lets a look-alike or duplicate pick an account. Store emails lowercase.- The record is the signed-in
RecordUser; itsrolesfield (a list or a JSON/comma string) feeds the permission resolver. - Its
auth_epochfield (provider keyepoch;"0"when the type has none) is the session epoch (Preflow\Auth\HasSessionEpoch): when it changes, the user's other sessions are signed out. - When the default guard has record users (directly or in a
ChainUserProvider), a signed-in record user has an own account page at{prefix}/_account(linked from the user menu, no permission needed; a config user has none): it changes the password only, asks for the current one, is throttled (folio.password, 10 per 15 minutes per account) and signs out the account's other sessions, remember-me logins and invite/reset links (StaffPasswords::setPassword()).StaffPasswordsalso offersrevokeLinks(),forgetDevices()andverify()for packages that manage the accounts (preflow/rbac).
SQL storage
Folio content can live in SQLite by setting "storage": "sqlite" in a type's model JSON
(a storage named in a model that is not configured in config/data.php is an error, not a
fallback to the default). Tables come from the model JSON, with no migration files:
php preflow migrate
# [publishing.tables] created table post
# [publishing.tables] created table post__published
- Folio's step (
folio.tables) creates the tables of types without publishing and Folio's own internal tables. The tables of publishing types (the type's table and its__publishedand__revisionsshadows, with Folio's system columns) come from preflow/publishing's step (publishing.tables), the asset table from preflow/media's (media.table). The id isTEXT PRIMARY KEY; every other column is nullable and typed by its field (numbers and yes/no as numbers, everything else text; unique and indexed fields get their indexes), seepreflow/data"Record tables".__revisionsstay text. A site made before 0.42 keeps its text columns until it is converted withtools/convert-typed-columns.php;preflow migratewarns about them meanwhile. - It creates and adds only: missing tables and columns are added, nothing is ever dropped, renamed or altered. Re-running it is safe.
- It runs on SQLite and MySQL (and on any storage whose PDO driver has a registered schema
grammar, see
preflow/dataGrammars::register()). - A record key without a column (a JSON field added to the model before migrating) fails
with a message naming
preflow migrate, not a raw PDO error. - JSON-stored types are left alone.
folio:extensions:applyruns the same sync. - Lists ordered by
_ordercompare numerically (orderBy(..., numeric: true)), also where the column is text.
Live content to local: folio:pull
Content is edited live; code goes through git. To work on a copy of the live content:
// config/folio.php 'remotes' => [ 'production' => ['ssh' => 'user@host', 'path' => 'www/site/current', 'port' => 22, 'php' => 'php'], ],
php preflow folio:pull production --dry-run # what would change
php preflow folio:pull production # backup, then replace local content
php preflow folio:pull production --only=data # records only (or --only=media)
- What content means: the records of every Folio type (with their published and
revision copies), the asset records, the media directory and legacy uploads. Assets in the
media bin stay on their server (their files are in its bin only): an export leaves them out
and an import or pull leaves the local ones alone.
Never code,
.env, caches, logs, sessions or other tables (user_tokens). - SSH: your SSH agent and
~/.ssh/config, with no credentials in config. Host keys are never ignored, so a changed key aborts. - Remote settings:
sshis a host alias oruser@host, never an ssh option.pathis absolute or relative to the remote home, without~.phpis a single binary path (/usr/local/bin/php84), without arguments.
- Symlinks: a symlinked media root (a Deployer
shared/link) is followed. Symlinked subdirectories inside it are skipped. - Refused on import: dotfiles (
.htaccess,.user.ini) and executable files (.php,.phtml,.sh, …) under media or uploads. - On the server: it runs
php preflow folio:export --stdout, so the remote needs this Folio version and PHP CLI. - Locally: before anything changes, a backup of the local content goes to
storage/backups/pull-YYYYmmdd-HHMMSS.tar.gz. A failed or broken remote export changes nothing. - Local-only content: records and files that exist only locally are removed, so the copy matches live.
- Records all or nothing: the records are replaced in one go (removed and replaced rows first, so a slug that has another id live never collides); if that fails, no record changes. A replaced record is written whole: on SQL, as on JSON, a column only the local models have is empty after an import or pull.
The same archive works without SSH, as a content backup and restore:
php preflow folio:export backup.tar.gz # or --stdout
php preflow folio:import backup.tar.gz [--dry-run]
There is deliberately no folio:push: it would silently overwrite live edits.
Translations
With more than one locale in config/i18n.php, fields marked "translatable": true
hold one value per language: {"en": "About", "de": "Über uns"}. Everything else is
shared across languages. With a single locale, the flag has no effect and values stay
plain.
// config/i18n.php return ['default' => 'en', 'available' => ['en', 'de'], 'fallback' => 'en', 'url_strategy' => 'prefix'];
Editing:
- The edit form has language tabs (
?locale=de). Saving one language never touches the others. - Fields shared across languages are labelled.
- An empty translation shows the default-language text as a placeholder ("Falls back to English"), and a collapsible reference shows the default-language text.
- Visible in checkboxes set the record's
_locales. The default language is always on. - The list shows a badge per language:
de · 2 missing,de · 1 outdatedor justde(see "Missing and outdated" below). The form's language tabs carry the same counts.
Website:
/and/aboutserve the default language;/de/…serves German, with translated route values (e.g./de/ueber-uns).- A page not visible in a language returns 404 there.
- Missing translated values fall back field by field: requested, then fallback, then default.
- Templates receive
localeandalternates([{locale, url}]). {{ folio_alternate_links(alternates) }}emits thehreflanglinks (includingx-default).- The absolute URLs in
alternates(href, used forhreflangand canonical links) use the host ofapp.url(config/app.phpurl) when it is set and absolute, so a request onwww.or another alias still names the one canonical host. Without it they use the request's scheme and host. {{ folio_url('page', id, 'de') }}links to a record in a language.- Referenced records (relation, matrix) render in the page's language.
Validation: required applies to the default language only. Other rules apply to
every language that has a value; errors are keyed field.de.
Older values stored before a field became translatable keep working as the default language's value. Upload fields are always shared.
Missing and outdated
Folio checks every translatable text of a record: translatable fields, the translatable fields of blocks (also nested ones) and the texts a custom field type reports.
- Missing: the default language has a text and the language has none. Every such text counts, required or not. Whitespace only is empty.
- Outdated: the translation was saved from a default text that has changed since.
Saving a translation stamps the fingerprint (a short hash of the trimmed default text) in
the record's
_translated_from({locale: {path: fingerprint}}). A translation without a fingerprint (saved before 0.45, imported) is never outdated. A single-locale app has neither. - In the admin: the list badge and the form's language tabs show the counts. On a
language's tab an outdated field is marked, with a Still correct button that stamps
the current default text without changing the translation (for a field with block texts:
"3 texts outdated", one button for all of them). That saves the working copy as a draft
(a published record becomes "changed"); nothing is published, and a stale
_versionis a conflict like any save. The route isPOST {prefix}/{type}/{id}/_translations/confirm(locale, optionalpath,_version) and needs the type's edit permission. - Publishing: outdated never blocks. With
'complete_languages' => trueinconfig/publishing.phpa record is not published while a language it is visible in (_locales) misses a text; the draft is saved and the error names the language and the texts (_translations.de). Off by default. Scheduled publishing reports such a record as failed. Machine translations nobody has checked yet (seeunchecked()) show as a note with an accept button per field, a count on the language tab and a badge in the list (it links to the list narrowed with?unchecked=1). Publishing is the check: publishing a record (form, bulk,publish, the scheduler) clears the "not checked" marks of the languages it is visible in (Visible in,_locales), in the working copy and the published copy. Languages it is not visible in keep their marks: an AI write never ticks Visible in, so German nobody has looked at stays marked when the record is published for an English fix. Clearing marks is a change of the working copy, so its_versiongoes up. The 0.47 setting'publishing' => ['checked_translations' => true]and the warning after publishing are gone; a config that still sets the key is ignored. - Review index: the records with unchecked texts are kept in a small index (the core
Preflow\I18n\Machine\ReviewIndex,storage/ai/review.json): per record its type, id, label and the number of unchecked texts per language. Folio's saves, publishes, discards, restores and deletes and the translation worker keep it current, so a dashboard can list the records without loading every record. It is a cache: writes that bypass Folio (imports, project code) leave it stale until the record's next Folio save, or untilphp preflow folio:review-indexrebuilds it from all records of the types with translatable texts. - New column:
_translated_fromis a TEXT column on SQL-backed record tables (and their published and revisions tables);preflow migrateadds it.
Custom field types. A field type that keeps locale maps inside its own value tells Folio
about them by implementing Preflow\Folio\Field\ReportsTranslatableTexts. Paths and labels
are relative to the field; Folio prefixes them with the field's name:
final class FaqFieldType implements FieldType, ReportsTranslatableTexts { // value: [['question' => ['en' => '…', 'de' => '…'], 'answer' => [...]], ...] public function translatableTexts(mixed $value, array $config, Locales $locales): array { $texts = []; foreach ((array) $value as $i => $item) { $texts[] = TranslatableText::of("{$i}.question", 'Question ' . ($i + 1), $item['question'] ?? null, $locales); } return $texts; // reported as "faq.0.question" } }
Use a stable key in the path (an id, not the position) when items can be reordered, so a fingerprint follows its item.
Taking part in AI translation. Reporting texts is only half of it. To let AI translate the
texts of your field, the field type must also implement
Preflow\Folio\Field\AcceptsTranslatedTexts, the way back: Folio hands it translated texts by
path and it returns the field value with them set. Only the field type knows how its value is
built, so only it can do that.
final class FaqFieldType implements FieldType, ReportsTranslatableTexts, AcceptsTranslatedTexts { // translatableTexts() as above. $texts is path => text, with the paths you reported ("0.question"). public function withTranslatedTexts(mixed $value, array $texts, string $locale, array $config, Locales $locales): mixed { $items = (array) $value; foreach ($texts as $path => $text) { [$i, $key] = explode('.', (string) $path, 2) + [1 => '']; if (isset($items[(int) $i][$key])) { $map = LocaleValue::map($items[(int) $i][$key], $locales); $map[$locale] = $text; $items[(int) $i][$key] = $map; // only this language changes } } return $items; } }
Set only $locale, leave every other language and every other text as it is, and ignore paths
you do not know. A field type that reports texts but does not implement AcceptsTranslatedTexts
is left out of AI translation, so no money is spent on a text that could never be stored. A
text may also carry a kind (TranslatableText::of($path, $label, $raw, $locales, 'faq-answer')),
which lets tuning target it (see "Field options" below).
SQL storage: translatable values and the record's _locales list are stored as JSON
text. A SQL-backed type needs a _locales (TEXT) column, and its translatable columns
must be wide enough for the JSON map. JSON-file storage needs no setup.
folio_alternate_links() emits absolute URLs when the request has a host, which
search engines require.
Translate with AI
With AI on (config/ai.php enabled and the provider's key, see preflow/ai), each language
tab except the default gets a Translate button, for users with the ai.translate
permission ("Use AI translation") and the type's edit permission. With AI off there is no
button, the form's markup is unchanged and the endpoints below answer 404.
On a record, Translate queues a background job. The dialog asks which texts (Missing and outdated, or All) and an optional one-off note, and shows the estimate for this record. Confirm queues a job for this record and language and says "Queued". You can leave the page.
- A cron job works the queue (
php vendor/bin/preflow translate:work, see "Background translation" in thepreflow/i18nREADME). The worker translates everything the record holds, also structured fields edited on their own screens, and saves the result as a working copy (a draft) with the texts marked "not checked". The live version is untouched. - While a job for the record is queued or running, the edit form says so, and while the record is being translated, how far it is ("120 of 410 texts"). It does not block editing: saves made meanwhile are kept. The job starts from the record as it is when it writes, leaves alone every text that changed since it read it (yours wins), and a text whose English changed meanwhile is written but shows as outdated.
- Queuing the same record and language again while a job is pending answers with the pending job, not a second one.
- A type without publishing writes live: the dialog asks you to tick "the translations go live without review", and the server refuses to queue without it.
- If the worker has not run in the last five minutes, the dialog, the status page and the dashboard say so, with the cron line to add.
- Review is publishing. Open the edit view, read the German tab, fix what needs fixing and publish. Publishing clears the "not checked" marks of the languages the record is visible in. There is no per-text accept on the record form.
Interface labels (Settings → Translations) and media alt texts still fill their inputs at once and show a review row under each text: Accept keeps the text as checked, Discard puts back what the field held, and editing the text counts as checking it. They answer in seconds, so they need no queue. On Settings → Translations, when the selected language is the source language, the screen asks you to choose a language to translate into.
The endpoints (all POST, with the CSRF token):
{prefix}/{type}/{id}/_ai/estimatewithlocale,scope(neededorall) and an optionalprefix:{units, requests, costUsd}(costUsdis null when the model has no price). Never writes.{prefix}/{type}/{id}/_ai/queuewithlocale,scope, optionalnote,prefixandconfirm_live=1:{job, state, existing, message, worker}.prefixlimits the texts to paths under it (flowgivesflowandflow.…).workeris false when the cron job has not run lately.{prefix}/translations/_ai/suggestand{prefix}/assets/{id}/_ai/suggestfill labels and alt texts and never write. ({prefix}/{type}/{id}/_ai/suggeststill answers the texts of one record, for other admin screens; the record form does not use it any more.)
Without ai.translate they answer 403. The ai.settings permission ("AI settings") is
declared too.
Waiting for review (dashboard)
With AI on, the dashboard has a Waiting for review section for users with ai.translate:
- Translation jobs that are queued, running or failed: what, since when, who queued it, the
progress, the cost so far and the failure reason. The record a job is translating right now
shows with its own progress bar ("120 of 410 texts"); the run screen shows it too. While a
job is queued or running, the section refreshes itself every 5 seconds
(
GET {prefix}?review=1with htmx: the section alone). It stops when none is, or when the active jobs have not changed for 10 minutes (their driver went away or no worker runs). - Records with unchecked texts, grouped by type: the record's label, the languages, the number of texts, and a link to the edit view on that language tab. A row shows only if the user may view that type.
- The notice about a missing worker.
The records come from the review index (see "Review index" above), so the dashboard does not load every record.
Translating a whole type
Under the same conditions the list has Translate all… in its header and Translate… in the bulk bar (the ticked records). The run screen asks for the languages, the texts (needed or all) and a note, then shows the plan: "N records, M texts, K requests, about $X". Start queues one job. The cron worker translates it one record per step, so the page can be closed.
- The run screen is a status page: done / total, the cost so far, each record's status, failed and changed-meanwhile records with their reason. Stop keeps what is done; Continue picks up the rest, also after the provider stopped the run (quota, billing), whose message is shown. While the page is open it may also drive steps itself, so a site without cron still gets work done. The worker and the page never translate the same record at once (a lease per record), so nothing is paid twice.
- The run screen (Translate all…) lists the type's unfinished runs (not started, queued,
stopped; the newest five) with Continue. When a step fails while the page is open, the
progress says "The last step didn't finish" and offers Continue in place. The state is kept
in
storage/ai/jobs/. - Each step request keeps running when the browser goes away (
ignore_user_abort) and raises a shorter PHP time limit to 300 seconds where the host allows it; the provider clients give up on a request after 120 seconds. - Publishing types get working copies, with the texts marked as not checked; nothing is published. Records whose working copy already had other changes are listed separately. An AI write never publishes, never changes a record's Visible in languages, and cancels a pending Publish at (as a save by someone who may not publish does), so machine text cannot go live on a timer without a person looking at it.
- A type without publishing writes live: Start stays disabled until "the translations go live without review" is ticked, and the server refuses a start without it.
- The results end with "Review: N records with unchecked texts", a link to the list filtered to records with unchecked texts.
The routes (all ask ai.translate and the type's edit permission; POSTs need the CSRF token):
GET|POST {prefix}/{type}/_ai/run (form; step=estimate plans the job), and
POST {prefix}/{type}/_ai/jobs/{job}/start|step|stop, which answer the progress partial for
htmx and the whole screen otherwise. A job is only reachable through the type it translates.
Jobs are stored in storage/ai/jobs/{id}.json.
Field options
A model's field can tune or leave out AI translation with an ai block in its JSON:
"body": { "type": "richtext", "translatable": true, "ai": { "tuning": "Keep the tone playful." } }, "faq": { "type": "faq", "ai": { "kinds": { "faq-answer": "Short; no full stop." } } }, "internal_code": { "type": "string", "translatable": true, "ai": { "translate": false } }
translate: falseleaves the field out of AI translation (button, whole-type runs, estimate). You can still translate it by hand. Only the model JSON sets this; the Settings screens cannot override it.tuningis an instruction for this field (a string, or a list of strings).kindsmaps a text kind to an instruction. A structured field of your own (see "Custom field types" above) can tag each text with a kind, for examplefaq-answer, and the instruction then applies to those texts only.
These are the defaults. A text saved in Settings → AI → Tuning under the key {type}.{field}
or {type}.{field}:{kind} replaces the default of that level and leaves the others alone.
Settings → AI
The AI entry in the Settings section needs the ai.settings permission. It stays visible
while enabled is true even without a key, so an admin can see why nothing works ("switched
on: yes, key: no"). It has three screens:
- Status: provider, model, whether a key is present (never the key), a Test button that sends one tiny request, and the calls, tokens and cost of this and last month per purpose and model. A model with no price shows "—", not 0.
- Tuning: the instructions per level: project, each language, each translatable type, and any field or kind level you add by key. The config default shows as the placeholder; saving a text replaces the default of that level, and saving it empty brings the default back.
- Glossary: the fixed terms with their rendering per language, a "keep as is" choice, the types they are limited to and a note.
Edits are stored in storage/ai/settings.json, apart from config/ai.php.
Labels and media
Folio's own pieces get the same button, with the same review and the ai.translate permission:
- Settings → Translations: a Translate button for the shown page of labels fills empty values as unsaved input (or every value with "all"). Review them, then Save all filled saves every filled or changed row of the page in one go (a row's own Save button still saves just that row and reloads the page). A text with markup or wrong placeholders is refused on its own: it stays in its field with the reason under it, the others are saved. While filled rows are unsaved, leaving the page asks first.
- Media: the asset panel has a button for the alt text of each language, translated from the default-language alt text. Titles are not translated, because an asset has one title for all languages.
Labels and alt texts have no "outdated" or "not checked" tracking: their stores keep no fingerprints, and both screens are reviewed in full anyway.
Trying it without a key
Name a provider of your own that returns Preflow\Ai\Testing\FakeAiClient, on your machine only
(config/ai.php 'provider' => App\Ai\FakeTranslations::class with 'enabled' => true; no key
needed). This one answers every text with "[de] " and the text:
// app/Ai/FakeTranslations.php (local only) final class FakeTranslations implements \Preflow\Ai\AiClientFactory { public function available(#[\SensitiveParameter] array $options): bool { return true; } public function create(#[\SensitiveParameter] array $options): \Preflow\Ai\AiClient { return new \Preflow\Ai\Testing\FakeAiClient([static function (\Preflow\Ai\AiRequest $r) { $items = json_decode(substr($r->prompt, (int) strrpos($r->prompt, "\n")), true); // the texts: the prompt's last line return \Preflow\Ai\Testing\FakeAiClient::json(array_map(static fn (array $i) => ['n' => $i['n'], 'text' => '[de] ' . $i['text']], $items)); }]); } }
Then open a record's German tab and click Translate. The job is queued; run
php vendor/bin/preflow translate:work by hand to work it. Reopen the tab: the AI texts show
"Machine-translated, not checked yet" until you publish the record.
Upgrading to 0.47
SQL sites: run preflow migrate after upgrading. Every save in a second language now
stores the source text next to the fingerprint (_translated_source) and the machine-made marks
(_machine_translated), also with AI off. On SQL storage these are new TEXT columns on each
record table and its published and revisions tables; until preflow migrate adds them,
publishing a record and saving a translation fail. JSON-file storage needs nothing.
Upgrading to 0.48
Background translation. See docs/superpowers/plans/2026-10-30-release-0.48-checklist.md.
- Add the cron line on every site with AI on:
* * * * * cd <app> && php vendor/bin/preflow translate:work. Without it, queued jobs wait (the run screen still works while its page is open). The command prints only when it worked records or stopped, so cron mails nothing on idle minutes. publishing.checked_translationsis ignored. Publishing clears the "not checked" marks of the languages the record is visible in; the warning after publishing is gone.- Run
php vendor/bin/preflow folio:review-indexonce to fill the review index for the records that already have unchecked texts. Publisher's constructor changed. The 8th parameter,bool $checkedTranslations, is replaced by?ReviewIndexUpdater $review. Code that builds aPublisheritself and passes that argument by position must drop the bool (or pass aReviewIndexUpdater); named argumentscheckedTranslations:fail. Folio's own wiring needs nothing.- Before adding the cron line, look at
storage/ai/jobs. Runs that 0.47 left "running" (a closed run page) are continued by the worker on its first minute, and paid for. Stop the ones you do not want first (the run screen's Stop, or set"state": "stopped"in the job's JSON file). - No new SQL columns;
preflow migrateis not needed for this release.
Admin languages
Folio's own screens, the staff mails and the admin parts of rbac, members and codes come in
English, German and French. Each staff user picks the language of the admin for themselves;
the content languages (config/i18n.php) are separate.
Choosing the language:
- The sidebar has a language switch next to the theme switch, and the account page a
Language panel. The choice is stored per user (table
folio_user_preferences; a config user under its address) and applies on every device. - Without a choice, the admin uses
config/folio.php'locale', elseapp.locale, else English when Folio has no texts in that language. - The sign-in and password pages follow the browser (
Accept-Language). - Staff mails (invitation, password reset) go out in the recipient's language, else the site's default content language.
- The languages offered are the ones Folio has texts for: its own
en,de,fr, plus anylang/{locale}/folio.phpa site adds (two-letter codes). - Public pages never change with the admin's language: the translator's locale and the overrides are put back after every admin request.
- Media's alt texts and captions follow the content language, not the admin's: Folio binds
media's
LocaleProviderto its content locale. A site that binds media'sConfigLocaleProvideritself is outside that wiring: it reads the translator's locale, which on an admin request is the admin's language.
Changing a text: the Translations screen. Settings → Translations
({prefix}/translations, permission translations.edit) lists the texts of every admin
group (Folio, rbac, members-admin, codes, the extensions' groups) and the site's model labels,
per language. A changed text replaces the delivered one in the admin for everyone using that
language; Reset brings the delivered one back.
- Plain text only:
<,>,",`and HTML entities (&#…,&name;; a plain&is fine) are refused, and texts whose English contains markup are not offered. A straight'is stored as’. - Formats (
folio.format.*) are configuration, not offered. - The texts live in the table
folio_translation_overrides(runpreflow migrateon SQL storage), are compiled tostorage/cache/folio-translations-{locale}.php(Settings → Cache lists it) and travel withfolio:export/folio:import/folio:pull. - A site's own
lang/{locale}/folio.php(orrbac-admin.phpfor the rbac admin screens,rbac-mail.phpfor the staff mails, …) changes single keys for every request, in version control; an override on the screen wins over it. - Overrides apply on admin requests only, except editable site groups (below). Staff mails sent from a console command or a queue
worker (outside an admin request) use the delivered texts and the site's
lang/files. - A site or package that sets its own
TranslationOverrideson the translator keeps it: Folio only fills an empty override layer (NoTranslationOverrides), and the Translations screen's texts then don't apply.
Editable site texts. A site's own text groups that staff should change themselves (a
mail's subject, a notice on a page) are listed in config/folio.php:
'translations' => ['editable' => ['review-mail']],
The screen offers each listed group besides the admin groups, in every language of
config/i18n.php (not the admin's languages), with the note that the texts appear on the
website and in mails. Its overrides apply on every request (Translator::alwaysOverride()):
public pages, mails in the recipient's language (t($key, $params, 'fr')) and console or
queue mails. Plain text as above, and an override keeps exactly the :placeholders of the
delivered text (the language's, else the fallback language's, else English: what visitors
and mails get today, shown as the delivered text): dropping :link or adding an unknown :foo
is refused; a colon followed by letters counts as a placeholder (:name), so a text like mailto:info@… is checked as one. The keys are those of lang/{locale}/{group}.php in every site language (only two-letter languages are offered; region locales like de-CH are not listed). An
entry that is not a group name, site (the model labels stay admin-only) or an admin group
(folio, rbac, …) is skipped with a line folio: translations.editable: … in the error
log; boot goes on, and every group not listed renders exactly as before.
Model labels. A model file's label, label_singular, a field's label and help and a
select option's label can be a plain string or a map per language:
{ "key": "app", "label": {"en": "Apps", "de": "Anwendungen", "fr": "Applications"},
"label_singular": {"en": "App", "de": "Anwendung", "fr": "Application"},
"fields": { "title": { "type": "string", "label": {"en": "Title", "de": "Titel"} } } }
Or keep the plain English string and add keys to lang/{locale}/site.php:
| Text | Key |
|---|---|
| type label / singular | site.types.{type}.label / site.types.{type}.singular |
| field label / help | site.fields.{type}.{field}.label / site.fields.{type}.{field}.help |
| select option | site.options.{type}.{field}.{value} |
| sidebar section (also a section only a screen names) | site.sections.{section} |
| extension screen label | site.screens.{name} (#[FolioScreen('queue', 'site.screens.queue')]) |
| empty list sentence | site.types.{type}.empty (optional) |
A key wins over the map, the map over the plain string. They show in the sidebar, the
dashboard, list headers, filters, chips, bulk menus, the Excel header row, the editor and the
roles screen; public pages are unchanged. label_singular gives "1 Seite" in the delete
summary; without it English guesses and other languages use the label.
A project's own extension (in app/Folio, no translations: group) uses site.* keys for its
attribute labels too: #[ListColumn('site.columns.kind')], #[FormPanel('site.panels.check')],
#[RecordAction(label: 'site.actions.go')].
Extensions. An extension that ships texts declares its group in its package's
composer.json (see preflow/i18n) and names it on
the extension: #[FolioExtension(name: 'shop', …, translations: 'shop')]. Then:
- its types' labels resolve under
shop.types.…,shop.fields.…,shop.options.…(same shapes as above), its settings type too; - attribute labels may be keys:
#[ListColumn('shop.list.state')],#[RecordAction(label: 'shop.actions.refund', confirm: 'shop.actions.refund_confirm')],#[FormPanel('shop.panels.orders')]. Any text with spaces or capitals stays as written; - its permissions show as
shop.permissions.{rest}on the roles screen (forshop.refund:shop.permissions.refund) and its name asshop.extension.label; translations:checkreports a delivered language missing one of these keys.
In templates, folio_date(value, 'date'|'datetime') and folio_number(value) format in the
admin's language (German 26.10.2026, French 26/10/2026, English Oct 26, 2026); t() reads
any key. Block labels may be keys too: #[Block(label: 'site.blocks.hero.label', group: 'site.blocks.groups.sections', description: 'site.blocks.hero.description')] and
#[BlockField(label: 'site.blocks.hero.title', help: …)] are shown in the admin's language
(block picker, cards, field labels and help, the preview's block label) from
lang/{locale}/site.php; plain text stays as written.
Status
Shipped:
- type discovery and auto-form CRUD;
- the field types above;
- the matrix field with create-in-drawer and per-placement views;
- side-by-side live preview;
- admin login;
- per-action overrides;
- multi-type frontend routing (the home record is served at
/); - the configurable list;
- content translations;
- blocks and singletons;
- drafts and publishing (autosave, conflicts, revisions, scheduled publishing, share links);
- the asset library (variants,
media_image(), picker, usage, legacy import); - click-to-edit in the live preview;
folio:pull(live content to local);- permissions on every admin action (
can(), withpreflow/rolesorpreflow/rbacfor roles); - the admin in English, German and French, chosen per user, with a Translations screen for changing texts.
The admin runs under Preflow's strict, nonce-based CSP: no inline handlers or styles.
Hand-written <script>/<style> tags in your own templates need
nonce="{{ csp_nonce() }}".