preflow / core
Preflow framework core — kernel, container, config, middleware, error handling
Requires
- php: >=8.4
- nyholm/psr7: ^1.8
- psr/container: ^2.0
- psr/http-factory: ^1.0
- psr/http-message: ^2.0
- psr/http-server-handler: ^1.0
- psr/http-server-middleware: ^1.0
Requires (Dev)
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
- preflow/auth: <0.66
- preflow/blade: <0.60
- preflow/components: <0.60
- preflow/data: <0.66.1
- preflow/folio: <0.67
- preflow/htmx: <0.65
- preflow/i18n: <0.67
- preflow/notify: <0.74
- preflow/routing: <0.62
- preflow/twig: <0.60
- 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
- v0.13.1
- v0.13.0
- v0.12.0
- v0.11.4
- v0.11.3
- v0.11.2
- v0.11.1
- v0.11.0
- v0.10.4
- v0.10.3
- v0.10.2
- v0.10.1
- v0.10.0
- v0.9.1
- v0.9.0
- v0.8.0
- v0.7.0
- v0.6.0
- v0.5.0
- v0.4.1
- v0.4.0
- v0.3.3
- v0.3.2
- v0.3.1
- v0.3.0
- v0.2.3
- v0.2.2
- v0.2.1
- v0.2.0
- v0.1.1
- v0.1.0
This package is auto-updated.
Last update: 2026-11-14 19:29:53 UTC
README
Foundation package for the Preflow framework. Provides the DI container, config, middleware pipeline, error handling, kernel, and application bootstrap.
Installation
composer require preflow/core
Requires PHP 8.4+.
What's included
| Component | Description |
|---|---|
Container |
PSR-11 container with autowiring and attribute injection |
Config |
Dot-notation PHP array config |
MiddlewarePipeline |
PSR-15 middleware stack |
ErrorHandler |
Dev (rich HTML) / prod (the app's app/pages/_errors/, else minimal) error pages |
Kernel |
Dual-mode (Component/Action) request dispatcher |
Application |
Bootstrap entry point |
CsrfMiddleware |
CSRF check on every unsafe request (origin, session or stateless token), #[CsrfExempt], expired-session handling |
| HTTP exceptions | NotFoundHttpException, ForbiddenHttpException, UnauthorizedHttpException |
NonPublicPaths |
Path prefixes that are not public pages (admin, member area, private files), for features that treat unknown addresses as pages |
SafeReturnPath |
Whether a "next" / "return to" value is a safe path on this site under allowed prefixes |
Version (0.75) |
Installed package versions from Composer: Version::installed('preflow/folio') ("0.75.0", no "v"; null when not installed), Version::label() ("0.75.0"; "dev · 01c613d" for a VCS branch or a package the root replaces; plain "dev" for a path repository, whose reference is no commit) |
| Route contracts | Route, RouteMode, RouterInterface, ConfiguredMiddleware (route middleware with arguments) |
API
Container
// Bind an interface to a concrete class (transient) $container->bind(CacheInterface::class, RedisCache::class); // Bind as singleton (binding it again later replaces an instance already resolved) $container->singleton(LoggerInterface::class, FileLogger::class); // Or mark a class: shared from its first autowired get(), whoever asks first #[\Preflow\Core\Container\Attributes\Singleton] final class Registry {} // Register a pre-built instance $container->instance(Config::class, $config); // Resolve (autowires constructor dependencies) $service = $container->get(MyService::class); // Instantiate with parameter overrides $obj = $container->make(MyService::class, ['timeout' => 30]);
make() builds a new object of a class (an instance registered for it is not reused): the given
parameters, by name, win over autowiring, #[Config] and #[Env]; the rest is resolved as get()
would. An id bound to a class (bind(Contract::class, Impl::class)) passes them on to that class;
a factory closure gets them as its second argument, and one that takes no second parameter
throws a ContainerException rather than drop them (as does an instance registered under a
name that is no class).
Route middleware with arguments (MiddlewareWith, see preflow/routing) is built this way.
Attribute injection
use Preflow\Core\Container\Attributes\Config; use Preflow\Core\Container\Attributes\Env; final class Mailer { public function __construct( #[Config('mail.from')] private string $fromAddress, #[Config('mail.driver', default: 'smtp')] private string $driver, #[Env('MAIL_HOST')] private string $host, #[Env('MAIL_PORT', default: '587')] private string $port, ) {} }
#[Config('key')] reads from the registered Config instance using dot notation. #[Env('NAME')] reads from getenv(). Both support a default.
Config
$config = new Config([ 'app' => ['name' => 'MyApp', 'debug' => false], 'db' => ['host' => 'localhost'], ]); $config->get('app.name'); // 'MyApp' $config->get('missing', 'fallback'); // 'fallback' $config->has('db.host'); // true $config->set('app.debug', true); $config->all(); // full array
ServiceProvider
use Preflow\Core\Container\Container; use Preflow\Core\Container\ServiceProvider; final class CacheServiceProvider extends ServiceProvider { public function register(Container $container): void { $container->singleton(CacheInterface::class, RedisCache::class); } public function boot(Container $container): void { // Runs after all providers are registered $container->get(CacheInterface::class)->connect(); } } $app->registerProvider(new CacheServiceProvider());
Middleware
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; final class AuthMiddleware implements MiddlewareInterface { public function process( ServerRequestInterface $request, RequestHandlerInterface $handler, ): ResponseInterface { if (!$request->hasHeader('Authorization')) { return new Response(401); } return $handler->handle($request); } } $app->addMiddleware(new AuthMiddleware());
A request the app makes of itself (the SEO audit rendering a page) carries the attribute
preflow.internal_render; Preflow\Core\Http\InternalRender::is($request) reads it. Middleware that records
visits, counts hits or queues side effects should skip them for such a request. Since 0.54.
Every internal render (GEO's Markdown views and llms-full.txt, the SEO audit's pages, page cache rebuilds)
runs in core's anonymous render scope: the request context names the rendered page, nobody is signed in,
the session is hidden, the translator starts in the site's default locale with overrides off (since 0.75: an
audit step run from a German admin interface rendered the pages in German on a site without a locale
middleware), and the translator state and users are put back afterwards. Since 0.74 mail the page
queues with Notifier::sendLater() during it is dropped (Notifier::discardingQueued()), so the request
that flushes the queue afterwards sends only its own mail; send() still sends at once. Since 0.75 the
request's queued cookies are set aside for the render (CookieQueue::isolated()), so a remember-me token
rotated or a signed-in marker set by the request that runs an audit step still reaches its response. The audit's pages
(also a step run from Folio inside an admin's request) and rebuilds also see PHP's superglobals as an
anonymous visitor's (AnonymousGlobals).
Application bootstrap
$app = Application::create([ 'app' => [ 'name' => 'MyApp', 'debug' => (bool) getenv('APP_DEBUG'), ], ]); $app->registerProvider(new CacheServiceProvider()); $app->addMiddleware(new AuthMiddleware()); $app->setRouter($router); $app->setActionDispatcher($dispatcher); $app->setComponentRenderer($renderer); $app->boot(); $response = $app->handle($request);
boot() selects the error renderer based on app.debug, boots all providers, and wires the kernel. handle() runs the middleware pipeline and dispatches to the matched route.
Work that only some requests need. A provider can leave setup out of boot and do it on the first request that needs it:
public function boot(Container $container): void { $app = $container->get(Application::class); if ($app->runningInConsole()) { // Commands handle no request: set up once every provider booted. $app->onProvidersBooted(fn () => $this->setUpAdmin($container)); return; } $app->onRequest(function (ServerRequestInterface $request) use ($container): void { if (str_starts_with($request->getUri()->getPath(), '/admin/')) { $this->setUpAdmin($container); // make it idempotent: it runs on every such request } }); }
onRequest(\Closure $hook)calls the hook with every requesthandle()receives, before any middleware and before routing, with the raw path (locale prefix still on). Hooks run in the order added. A hook that throws fails that request like a boot error (no error page), so a setup that can fail should only mark itself done once it has succeeded. Folio sets up its whole admin this way.onProvidersBooted(\Closure $hook)calls the hook once duringboot(), after every provider'sboot()and once the user providers ofconfig/auth.phpare built, so a setup may rely on what a provider listed after its own defines inboot()(rbac'sstafftype, which a record provider needs). Hooks run in the order added; one added later runs at once (since 0.69.1).runningInConsole()is true under the CLI and phpdbg (PHP_SAPI);php -Scounts as web. Tests that stand in for web requests callsetRunningInConsole(false)beforeboot().Container::resolved(string $id)tells whether a shared instance exists already (set withinstance(), or a singleton built by an earlierget()), without building it. Tests use it to check that a request left a lazy service alone.
Admin routes added later in this way go in front of a catch-all with
RouteCollection::insertBefore($anchor, $entries) (preflow/routing).
JsonBodyMiddleware
Automatically decodes application/json request bodies into getParsedBody(). No manual json_decode needed in controllers.
use Preflow\Core\Http\Middleware\JsonBodyMiddleware; $app->addMiddleware(new JsonBodyMiddleware()); // In a controller: $data = $request->getParsedBody(); // already a decoded array
Flash messages
FlashType enum covers the four standard severity levels. Helper functions write and read flash messages via the session.
use Preflow\Core\Flash\FlashType; flashSuccess('Post saved.'); flashError('Validation failed.'); flashInfo('Changes are pending review.'); flashWarning('Disk space is low.'); // In a controller or template resolver: $flashes = getFlashes(); // ['success' => ['Post saved.'], ...]
Template helpers flash('success') and flash('error') read a single message for a given type.
Request context in page templates
Page templates rendered through the Component kernel receive a request variable with commonly needed values.
{{ request.path }} {# /blog/hello-world #}
{{ request.method }} {# GET #}
{{ request.isFragment }} {# a hypermedia client (the app's driver) asked for a fragment #}
{{ request.isHtmx }} {# a client sent it at all, history restores too (older name, any driver) #}
{{ request.query.page }} {# query string parameter #}
Hypermedia clients (HypermediaProtocol, 0.65)
Core never reads a client library's headers itself. Preflow\Core\Http\Hypermedia\HypermediaProtocol
says what core needs to know about a client (htmx, Datastar…), and HypermediaProtocols (bound in
the container, and the request attribute HypermediaProtocols::class on every request handle()
passes on) holds the app's: the app's hypermedia driver first (config/app.php hypermedia,
htmx by default; Preflow\Htmx\HypermediaDriver extends the interface), then those packages add
for their own screens (Folio's admin ships htmx). for($request) finds the client that sent a
request.
| Method | Used by |
|---|---|
isFragmentRequest($request) |
AuthMiddleware, the expired-session answer, the page cache, GEO, request.isHtmx |
isHistoryRestore($request) |
the page cache (a restore is served the full page, never stored), request.isFragment |
fragmentTarget($request) |
the component endpoint (a fragment without the wrapper) |
varyHeaders() |
the page cache key and the Vary names a stored response may use |
navigate($response, ?$url) |
AuthMiddleware (to the login), the expired-session answer (reload), MembersView::navigate() |
announceNonce($response, $nonce), nonceHeaders() |
SecurityHeadersMiddleware (htmx: HX-Nonce); the page cache never stores those headers, a 304 drops them |
pageNonce($request) |
handle(): a client that sends its page's CSP nonce back is answered with it (NonceGenerator::adopt()), so the scripts and styles the response puts into the page pass the page's policy |
The configured driver (hypermedia other than htmx) is built eagerly at the end of the boot, right
after the service providers (also on the CLI), so its constructor may only depend on services bound
by then; a driver that can't be built stops the boot.
With htmx (every built-in path) the behaviour is what it was: core adds the header-only htmx
client at the start of the boot whenever preflow/htmx is installed and hypermedia is htmx, so
an app without components or a template engine still treats htmx requests as such. An app on
another driver gets its driver's answers everywhere; a stray HX-Request header makes no
fragment for it (unless a package added htmx, as Folio does for its admin), though the default
vary_headers still keep such a request's cache key apart. An app without preflow/htmx has no
client: no request is a fragment for core (the default vary_headers keep the keys apart).
pageNonce() is untrusted client input: core takes it from a fragment request only (not a plain
request or a history restore), a base64 value of 22+ characters, for that request only (released
before after-response work), and the page cache never stores the response (refused:page-nonce).
A driver reads the page's nonce client-side (script.nonce) and never prints it into the page.
config/middleware.php
Place a config/middleware.php file in your application root and return an array of middleware class names. The kernel loads it automatically — no manual $app->addMiddleware() calls needed for global middleware.
// config/middleware.php return [ Preflow\Core\Http\Middleware\JsonBodyMiddleware::class, App\Http\Middleware\LocaleMiddleware::class, ];
Throttle
Preflow\Core\Throttle\Throttle limits attempts (a login, a form, a code guess) in fixed
windows. It is bound when config/data.php exists, stores through the DataManager in the
table preflow_throttle (created by preflow migrate), and is keyed by app.key. It stores on
the throttle storage alias when config/data.php defines one, otherwise on the default
driver.
client() throws a RuntimeException while app.key is empty or the default key: a hash
salted with a public key could be brute-forced back to the IP.
$throttle = $container->get(\Preflow\Core\Throttle\Throttle::class); $client = $throttle->client($request); // salted hash, never the IP if ($throttle->tooMany('login', $client, max: 5, windowSeconds: 600)) { // answer 429 } $throttle->hit('login', $client, windowSeconds: 600); // after a failed attempt $throttle->clear('login', $client); // after a success
- Atomic hits. Concurrent requests never lose a hit. On SQL,
hit()is one upsert (DataManager::execute()) that counts the attempt or restarts an expired window; on the JSON driver an exclusive file lock instorage/locksguards the read-modify-write. The limit itself is still a fixed window, so a burst at a window's edge can double. - Windows must be much shorter than a day. The client hash rotates at 00:00 UTC, so a window spanning midnight starts over.
- No raw IP is stored, only an HMAC of
REMOTE_ADDR(forwarded headers are ignored). IPv6 clients count by their /64 prefix, since one subscriber usually holds a whole /64. Without aREMOTE_ADDR(CLI, tests) all requests are oneunknownclient; it never throws. prune(olderThanSeconds: 172800)deletes old rows and returns how many;hit()runs it opportunistically, once per request when a new window starts.
Cookie queue
Preflow\Core\Http\CookieQueue holds Set-Cookie values queued during a request (the
remember-me cookies of preflow/auth, whose tokens use the auth storage alias, else the
default: with a JSON default and the auth tables in SQLite, set 'auth' => 'sqlite' in
config/data.php). It is bound when config/auth.php exists. The
middleware that queued them applies them to its response; the Kernel applies what is left to
an error response, which bypasses the pipeline, so a rotated cookie is never lost.
isolated(\Closure $render) (since 0.75) runs $render with the queue empty, drops what it
queued and puts the earlier values back; core's internal render scope uses it.
Auth providers from config
Any class in config/auth.php providers (other than the two built-in ones) is built from
the container with its config entry as config. It is built lazily (LazyUserProvider) and
resolved at the end of boot(), after the service providers, so it may use services they
register (e.g. Folio's type catalog). A class that is not a UserProviderInterface throws at
boot.
CSRF
CsrfMiddleware checks every POST, PUT, PATCH and DELETE, with or without a session.
GET, HEAD and OPTIONS are never checked. A form carries the token as the _csrf_token
field (csrf_field()), JavaScript as the X-CSRF-Token header (csrf_token()). Every request
that has a token (a session, or a real app.key for the stateless token) gets a
Preflow\Core\Http\Csrf\CsrfToken attribute, which packages that print {{ csrf }}
themselves (Folio, rbac, the form builder) read. Without a session and with an empty or the
built-in default app.key there is no attribute: code reading it must handle null. The
attribute is set once per request: after SessionInterface::invalidate() (a sign-in, a
sign-out) it still holds the removed token, so a page rendered in that same request takes
CsrfToken::generate($session) instead.
Layer 1: the origin. The first signal the browser sent decides:
Sec-Fetch-Site:same-originpasses;same-site,cross-siteandnoneare refused (unless theOriginis one ofcsrf.trusted_origins).- else
Origin: passes when it is the site's origin (app.url), the request's own scheme and host, or a trusted origin;nullcounts as no signal. - else
Referer: the same comparison on its origin. - no signal at all: the token decides alone.
A refused origin is refused with any token. Behind a proxy that terminates TLS, set app.url
to the public address (https://example.com): the request itself then looks like
http://…, and only app.url tells the check which origin is the site's.
Layer 2: the token. Which one applies is decided by the request:
| The request | Check |
|---|---|
| no session; or a new session without its cookie, and no cookie at all or no session token (a public page; a browser that blocks cookies) | the origin passes (no token needed: such a request comes only from the site's own pages); or, when there is no origin signal, a valid stateless token and no cookie at all (with any cookie, such as a remember-me one, it is refused) |
| a live session | the origin does not refuse and the session's token matches; on a sign-in path a public page's valid stateless token with a passing origin also passes |
| a new session with its cookie (expired, or an old cookie) | a stateless token with a passing origin (a public page posting into a session path); a session token is the expired case below |
| a new session without its cookie, but other cookies and a session token | the expired case below (the browser dropped the session cookie and kept a remember-me cookie). A browser that keeps other cookies but never sends the session cookie (a Secure session cookie on a plain-http stage) gets this answer on every form but the sign-in ones |
A session that held a token is always checked on that token, also when the request came without its cookie.
Stateless token. Pages without a session (sites with auth.session.paths) print a signed
token: HMAC-SHA256 with app.key over a 24-hour time slot, valid in the current and the
previous slot, so a page left open works for at least a day. It is not tied to the visitor (no
cookie, no address), so the page stays cacheable; it is only the fallback for browsers that
send no origin signal. With an empty app.key or the framework's built-in default key it is
never issued nor accepted (such requests are refused), and debug mode logs a warning: set
app.key.
Not checked:
- requests with an
Authorizationheader and no cookie (API clients: nothing ambient to use); /--component/(component actions carry signed tokens of their own);- routes marked
#[CsrfExempt('why')], on a controller class (all its routes) or one route method. The reason is required:
use Preflow\Core\Http\Csrf\CsrfExempt; use Preflow\Routing\Attributes\Post; use Preflow\Routing\Attributes\Route; #[Route('/hooks')] #[CsrfExempt('Stripe webhook, verified by its signature')] final class StripeWebhookController { #[Post('/stripe')] public function receive(ServerRequestInterface $request): ResponseInterface { /* … */ } }
- path prefixes in
csrf.exempt, only for routes that cannot carry the attribute (file routes inapp/pages, paths outside the router).
To find the attribute the middleware matches the route of an unsafe request (once; the Kernel dispatches that match instead of matching again). A middleware added later that rewrites the URI of a POST therefore no longer changes which route runs: the Kernel dispatches the route the CSRF check matched.
Config (config/csrf.php, optional; a missing file means the defaults):
return [ 'exempt' => [], // path prefixes without a check, added to '/--component/' 'trusted_origins' => [], // other origins allowed to post, e.g. 'https://stage.example.com' 'sign_in_paths' => [], // sign-in forms of the site; packages register their own ];
Paths in exempt and sign_in_paths are compared without the locale prefix (/de/login
is /login); sign-in paths match exactly (a trailing slash is ignored), exempt paths as
prefixes of whole segments (/hooks covers /hooks and /hooks/stripe, not /hookshot).
Failures are a 403, in one of two kinds:
ForbiddenHttpException('CSRF check failed: …'): a refused origin, no token or a wrong one on a live session, or no origin signal and no valid stateless token (or cookies with it).Preflow\Core\Http\Csrf\CsrfSessionExpiredException(extendsForbiddenHttpException): the session expired and the request has no passing origin (see below).
Expired sessions
The session existed when the form was rendered, but not any more (it carries a session token the new session cannot match). With a refused or missing origin it is the 403 above. With a passing origin:
-
Sign-in forms pass, with the new session and its fresh token: the posted password is the proof, and login CSRF is stopped by the origin check. Folio registers
{folio path}/login, members{members path}/loginand{members path}/register; a site adds its own incsrf.sign_in_paths, a package in its provider:$container->get(\Preflow\Core\Http\Csrf\CsrfSignInPaths::class)->add('/shop/login');
-
Any other form: the site's
ExpiredSessionHandleranswers first (below). Without one, or when it returnsnull, the framework's answer: the request is not applied. A form gets a 303 back to the page it came from (theRefererwhen its origin isapp.urlor the request's own, else/) with awarningflash ("Your session had expired. Please send the form again.", in en/de/fr/it, or the site'scsrf.session_expiredtranslation) and the posted fields kept as old input, so the form shows them again (Preflow\Core\Http\OldInput, read by validation'sold(), the form builder and members; never kept:_csrf_token, fields whose name containspassword, uploaded files, and nothing when the kept fields exceed 64 KB). htmx gets a 204 withHX-Refresh: true, a JSON client (Accept: application/json) a 409{"error":"session_expired"}.
Nothing runs in the visitor's name: a remember-me cookie could sign them in again, so the data waits for one more click from a page with a fresh token.
Site middleware runs inside the CSRF check, so it cannot catch these. To answer the expired
case your own way (keep a draft, send the visitor to the sign-in), bind a
Preflow\Core\Http\Csrf\ExpiredSessionHandler in a service provider. It gets the request
(with the new session as the SessionInterface attribute and its fresh CsrfToken) and
returns a response to send, or null for the framework's answer. It is called for the
expired case with a passing origin only, never for a sign-in path, a mismatch on a live
session, a missing token, safe methods or exempt routes.
use Preflow\Core\Http\Csrf\ExpiredSessionHandler; use Preflow\Core\Http\Session\SessionInterface; final class KeepDraftOnExpiry implements ExpiredSessionHandler { public function handle(ServerRequestInterface $request): ?ResponseInterface { $path = $request->getUri()->getPath(); if (!preg_match('#^/account/posts/\d+/edit$#D', $path)) { return null; // the framework's answer } // Keep the values to PREFILL the form after sign-in; never save them from here. $request->getAttribute(SessionInterface::class) ->set('draft:' . $path, (array) $request->getParsedBody()); return new Response(303, ['Location' => '/login?next=' . rawurlencode($path)]); } } // in a ServiceProvider::register() $container->singleton(ExpiredSessionHandler::class, KeepDraftOnExpiry::class);
Security rule: the request is not verified as the visitor's. Never apply posted data from
an expired-session request (no save, no state change, no mail). At most keep it to prefill the
form after the visitor signs in again, and let them submit it once more with a valid token.
Drop _csrf_token and secrets before keeping anything.
Upgrading
Unsafe requests without a session were not checked before. A public POST endpoint called by a
server (a webhook, a payment provider, a cron job over HTTP) sends no origin signal and no
token, so it is now refused: mark its route #[CsrfExempt('…')], or list a file route or a
path outside the router in csrf.exempt, before upgrading. Forms of the site keep working on
modern browsers (the origin passes); csrf_field() now prints a token on public pages too.
Set a real app.key, or browsers that send no origin headers cannot post public forms.
Browser POSTs that come from another site are refused now, also on public paths: the browser
sends Sec-Fetch-Site: cross-site (a payment provider's return URL that posts back, an OAuth or
SAML form_post callback). Mark such routes #[CsrfExempt('…')] too, or list their origin in
csrf.trusted_origins when it is a fixed one.
Caches
The application keeps a CacheRegistry in the container. The framework registers its own
caches: data.json (the JSON driver's query cache, when the JSON driver is in use) and
blade (compiled Blade templates in storage/cache/blade, when the Blade engine is in use).
Register yours in a provider so tooling can list and clear them:
use Preflow\Core\Application; use Preflow\Core\Cache\CacheRegistry; use Preflow\Core\Cache\DirectoryCache; use Preflow\Core\Cache\FileCache; final class SiteCacheProvider extends ServiceProvider { public function register(Container $container): void { $app = $container->get(Application::class); $caches = $container->get(CacheRegistry::class); // A folder of cache files; the folder and dotfiles stay, symlinks inside it are never // followed. `pattern` is an fnmatch() pattern on file names; files matching `exclude` are // neither counted nor cleared. $caches->register(new DirectoryCache('site.thumbs', 'Thumbnails', $app->basePath('storage/cache/thumbs'))); // A single file. $caches->register(new FileCache('site.sitemap', 'Sitemap', $app->basePath('storage/cache/sitemap.xml'))); } }
If you bind your own Twig engine or router with a cache path, register that same path like
this (DirectoryCache('twig', …) for compiled templates, FileCache('routes', …) for a route
table). Core's own Twig engine compiles into storage/cache/twig/{key}, where the key follows
composer's installed.json, so an install or update never serves templates compiled by the
previous packages; cache:clear empties every key. A DirectoryCache folder must be an absolute path other than /; anything else
throws an InvalidArgumentException. Subfolders that can't be read are skipped.
Keys must be unique; a duplicate throws a LogicException. Implement CacheSource for
anything else (key(), label(), stats(), clear()). OpcacheStatus reports and resets
PHP's OPcache for the current SAPI.
Page cache (config/page_cache.php)
The page cache keeps finished pages for visitors who are not signed in, under
storage/cache/pages/{release key}, and is listed and cleared as the pages cache
(preflow cache:clear pages, Folio's Cache screen). It is off until a site switches it on:
// config/page_cache.php: every key is optional, these are the defaults return [ 'enabled' => false, 'max_age' => 14400, // seconds; older pages are rebuilt behind the next visitor; at most 72000 (20 h), 0 means that 'rebuild_budget' => 2.0, // seconds of rebuilding after a response 'cache_query' => true, // cache pages with a query string (search, filters) 'ignore_query' => ['utm_*', 'fbclid', 'gclid', 'mc_cid', 'mc_eid'], 'vary_headers' => ['HX-Request', 'HX-Target', 'HX-History-Restore-Request'], // plus the hypermedia clients' own varyHeaders() 'signed_in_cookies' => [], // added to the auth marker and the remember cookies 'hosts' => [], // hosts cached besides the host of app.url 'max_query_variants' => 50, // pages with a query kept per URL path, oldest evicted first 'max_pages' => 50000, // pages kept in all, oldest evicted first 'explain' => false, // send X-Page-Cache-Skip (why a miss was not stored) 'verify' => 'first', // 'first', 'always' or 'off': render a page twice before storing it ];
-
Hosts. Only requests for the host of
app.url, or one listed inhosts, are cached. An alias domain or a forgedHostheader is never stored.app.urlneeds its scheme (https://example.com, notexample.com). Without a host fromapp.urland withouthosts, nothing is cached, and an enabled cache logs that once. -
Limits.
max_query_variantsandmax_pagesbound the disk a crawler can fill with?x=1,?x=2, … The oldest pages go first, files and index rows together. -
Releases. The release key hashes the real path of the base folder (a deploy's
currentlink resolves toreleases/N) and the mtimes of the boot manifest andinstalled.json. A new release folder,preflow optimizeor a composer install starts an empty cache. The filestorage/cache/pages/activenames the release that last used the cache: a rollback to an older release folder finds another release named there and empties its own pages first. Folders of other releases are removed after 24 hours unused, never the active one. -
Deploys. A deploy never serves a page of the release before it (it could reference assets that are gone): the new release key starts empty. The new release notes which release was active before it (
storage/cache/pages/{key}/warm-from). The first time its refresh queue is opened (the cron's firstpreflow pages:work, or a write that queues a page), the full pages without a query in the previous release's index are queued, last in line, so the pages visitors used are rebuilt within minutes. They are verified again (verify'first'): the templates changed. A rollback is warmed the same way (its old queue and pending invalidations are dropped with its pages). On a site quiet for more than 24 hours, the previous release's folder may be pruned by the new release's first stored page before the cron opens the queue; then nothing is warmed and pages are built on visit. -
In-place deploys (a
git pullin the same folder, withoutoptimizeor a composer install) keep the release key, so the pages from before the deploy stay. Runpreflow cache:clear pagesorpreflow optimizeas part of such a deploy. -
What a page depends on. While a page renders, every read through the
DataManageris noted as a tag: a record (default:object#1234), a listing (default:object, from a query or an aggregate), or the tables araw()query names (FROM,JOIN). The storage is the driver name (defaultfor the default driver, whatever it is configured as), and the table is as the driver sees it: Folio's published copies areobject__published. A raw read whose tables can't be told, such as one with aWITHclause, depends on the whole storage (default:*). -
SQL views. A raw read of a view is tagged with the view's name (
FROM active_jobsisdefault:active_jobs): the parser can't see which tables the view reads, so a write to its base table (job) does not reach those pages. Read the base tables in such a query, report the view as changed where its tables are written ($pageCache->changed('default:active_jobs')), or add the base table as a dependency of the page ($pageCache->depends('default:job')). -
What a write changes. Every write through the
DataManageris noted too. That coverssave,insert,update,delete,saveType,deleteIn, delete plans,execute(), a write sent throughraw(), Folio's publisher and Folio's import. The writes are merged, and the pages that depend on them are invalidated once, before the response goes out, or at the end of a command (a cron import). A command also flushes every 30 seconds and past 5000 pending tags, so a killed run loses little; a command still running from the release before a deploy reaches the new release's pages too:- writing a record invalidates its pages, the pages listing its table, and the whole-storage readers;
- a raw write invalidates every page of the tables it names;
- a raw write whose tables can't be told, or that names no table (unless it changes no
data, such as
SETorPRAGMA), invalidates every page.
-
Memos. A service that keeps what it read for later calls (the media library, Folio's site settings and lists) must still report the read on each hit, or a second page rendered in the same process won't depend on it:
$dm->reportRead(...)for one record, or[$value, $reads] = $dm->captureReads(fn () => ...)once and$dm->replayReads($reads)on each hit. -
Writes the data layer can't see, such as a bare
$pdo->exec()or a write through$dataManager->driver(...), must be reported:$pageCache = $container->get(\Preflow\Core\PageCache\PageCache::class); $pageCache->changed('default:member'); // the member table changed (many rows) $pageCache->changed('default:member#42'); // one member $pageCache->changed('*'); // everything
The storage name is mapped as the data layer's own tags are (
mysql:memberbecomesdefault:memberwhen mysql is the default driver).$dataManager->reportWrite('default', 'member', 42)does the same from data code. Both do nothing while the cache is off. -
Serving.
PageCacheMiddlewareruns after the locale middleware and before the session middleware. It is on whenenabledis true,pdo_sqliteis loaded andapp.debugis off. In the console it is there for rebuilds only (preflow pages:work); every other request passes through. A hit reads two small files, the release'sactivename and the page, and makes about six stats: the release key (the base folder's real path, the mtimes of the boot manifest andinstalled.json), theenabledmarker, the page's stale marker and the release's pending invalidations. No database, no SQLite, no session. Responses carryX-Page-Cache: hit,stale,missorbypass. Withexplainon, a miss that wasn't stored says why inX-Page-Cache-Skip(off by default: the reason is for audits, not for visitors). -
Sessions. Nothing is stored from a request that started the session. Without
auth.session.paths, every request starts one at boot, so a site must limit sessions to the paths that need them for anything to be cached; until it does, every miss saysX-Page-Cache-Skip: session(withexplainon). Withpaths, a session starts on those paths, and on any other path only when the visitor brings the session cookie: an anonymous visitor gets none (their pages are stored), a signed-in one resumes theirs everywhere:// config/auth.php 'session' => [ 'paths' => ['/login', '/logout', '/account', '/folio', '/--component'], // ... ],
A signed-in visitor's public pages show their account (and are never cached: the
preflow_authmarker bypasses the cache). A leftover session cookie of a visitor who signed out also resumes a session, so that visitor's misses are not stored (a hit is still served, before any session opens). The form of a cached page carries the public (stateless) CSRF token; posted on a public path where the session only resumed by its cookie, it is accepted as it is for a visitor without one (with a passing origin). A site that needs sessions for guests on public pages (a cart before sign-in) must list those paths. The skeleton ships with its sign-in, admin and component paths. -
The key is the URL as the visitor asked for it (the locale prefix included), the locale the locale middleware resolved (a cookie or
Accept-Languageon unprefixed paths), the normalised query, the values of the hypermedia clients'varyHeaders()and thevary_headers, and a forwarded scheme and host (X-Forwarded-Proto,X-Forwarded-Host), so http and https never share a copy. -
htmx headers. Only the
vary_headers(defaultHX-Request,HX-Target,HX-History-Restore-Request, kept for apps that load htmx without preflow/htmx, so a restore never shares a fragment's key; since 0.65 with the third, which htmx's own client already adds, so htmx apps keep their keys) and the hypermedia clients'varyHeaders()are in the key (a header in both counts once). A fragment handler that answers differently byHX-Trigger,HX-Trigger-Name,HX-Boosted,HX-Current-URLor a site header must add it tovary_headers(or sendVaryfor it, which keeps the response from being stored), or the fragments of one target overwrite each other. A history restore (HX-History-Restore-Request) is served the full page, but what it renders is never stored. -
Live, never cached: anything but GET and HEAD; admin, API and component paths (non-public paths); internal renders; and requests with a signed-in cookie (preflow/auth's
preflow_authmarker, every guard's remember-me cookie,signed_in_cookies) or anAuthorizationheader. A leftover session cookie alone is not one. The marker is set at sign-in for as long as the session cookie (or the remember-me cookie); a sliding session (session.lifetime> 0) can outlive it, and then that visitor gets cached hits until a page that asks for the user sets it again. No leak: a miss with their session open is not stored. -
Never stored: a render of another kind than its key (a history restore, which an app may answer with a fragment), an empty or the default
app.key(placeholders could be forged), a body that can't be read twice (a stream), a page where a component's error boundary or a Folio block that couldn't be built stood in for content, a status other than 200 (404s, redirects), a response that sets a cookie (a header or a native one), a request that started the session or where a guard resolved a user,Cache-Control: privateorno-store, aVaryon a header the key doesn't hold (Accept-Languageincluded: a page that redirects by it says so withVaryand is not cached), bodies that aren't text, JSON, XML or JavaScript, and a page something it read was written while it rendered. Code can refuse the page being rendered with$pageCache->refuse('why');$pageCache->storing()says whether it may be stored. -
Render isolation. Every render the cache may store (a visitor's miss, verify's second render, every rebuild) starts from the state a public render starts from. A package with request-scoped state that outlives a request in the same process (a content mode, memos) registers what resets it, and a store check as a second line:
$pageCache->isolate(static function () use ($content): \Closure { $restore = $content->showPublished(); // and empty the memos return $restore; // run after the render, last entered first }); $pageCache->refuseWhen('working-copy', static fn (): bool => $content->showsDrafts());
A rebuild after a Folio admin request (a publish) runs in that request's process: Folio resets its working-copy mode and its
CollectionsandSingletonsmemos this way, so drafts never reach a stored page, and refuses a page rendered with working copies anyway (refused:working-copy, a share preview). -
Stored headers are the response's own, without
Set-Cookie, the security headers (Content-Security-Policy, the clients'nonceHeaders()such asHX-Nonce,Strict-Transport-Security, which are added fresh to every hit) andDate,Content-Length,Transfer-Encoding,Connection,Keep-Alive. -
Per-request values. The CSP nonce and the CSRF token are stored as signed placeholders and filled in on each hit, so every visitor gets their own (and a form from a hit posts). A site adds its own in a provider's
boot():$container->get(\Preflow\Core\PageCache\PerRequestValues::class) ->register('form_token', fn (): string => FormToken::value());
Values shorter than 8 characters are never replaced.
-
Live parts are what differs per visitor inside an otherwise shared page: a guest's album count, "in your album" buttons, a cart count, a greeting. Mark them, and only they are rendered on every visit, on the server:
{% live with {number: object.number} %} {% include '_album_button.twig' with {marked: in_album(number)} %} {% endlive %}@live(['number' => $object->number]) @include('album-button', ['marked' => in_album($number)]) @endlive
A component class marked
#[\Preflow\Components\Live]is a live part as a whole.- The block sees only its
withvalues and the template globals, whether the cache is on or not. The values are scalars and arrays, and the same for every visitor: what identifies the part (an object number), never the personal value itself. The part reads that while it renders, from the visitor's request:$container->get(\Preflow\Core\Http\RequestContext::class)->request?->getCookieParams(). - While a page renders for the cache, a live part is not rendered. It leaves a signed
placeholder,
<!--pc:l:{recipe}:{signature}-->(the recipe is the template's logical name, or the component class, with the block and its values, as base64url JSON; the signature is an HMAC withapp.key). That is what is stored. Everytext/htmlresponse the cache answers fills the placeholders for its visitor, the miss that stored the page included. - Only declared live parts are ever filled: a
{% live %}block of a template the loader finds by name (or of a component's own template, by its class), an@livebody the Blade engine extracted (checked by its hash), a component class marked#[Live]. No file path is ever read from a recipe. A marker that doesn't verify, or names anything else, is left as an inert HTML comment, logged once per response (page cache: N live marker(s) left as comments …). - With an empty
app.key, the default one or the skeleton's example (change-this-to-a-random-32-char-string!), nothing is signed or filled: live parts render in place, and the page is not stored (app-key). - Block names starting with
__live_belong to{% live %}: a template's own{% block __live_… %}is a syntax error. - A body that is not
text/html(a JSON string with a live component in it) is never filled and, while it holds a placeholder, never stored (live-non-html). A response without aContent-Typeis HTML (PHP sendstext/htmlfor it), logged once. A template in no template folder and of no component (a string) renders its live parts in place and is not stored (refused:live:twig-template,live:blade-template). - What a live part reads is not a dependency of the page,
refuse()inside it does nothing, and a part that throws becomes<div hidden data-live-error="true"></div>(logged) while the rest of the page is served. A live component goes through its error boundary. - Many live components of one class are loaded together: a class implementing
\Preflow\Components\PrefetchesLivegets oneprefetchLive($propsList)call per page. - CSS and JS a live part adds while it is filled are not printed: keep them in the page. Live parts don't open the session (a hit has none), and see only globals registered at boot (a global a middleware adds later is missing on hits).
- The block sees only its
-
Verify. A page about to be stored is rendered once more as another anonymous visitor: another
REMOTE_ADDR(and forwarded address), no cookies. If the two differ in their body or their stored headers (anHX-Triggerwith the visitor's album count) outside their live parts and per-request values (each render's own), the page is not stored (X-Page-Cache-Skip: differs) andpage cache: {url} differs per visitor outside live partsis logged: something personal is not in a live part or registered. It never runs on a hit. Since 0.75 the second render's queued mail (Notifier::sendLater()) and queued cookies are dropped, as for an internal render: a GET page that queues a mail sends it once, not twice. Other effects it has (a data write,Mailer::send()) still happen twice.'first'(the default) verifies each key once per release: its first store, by a visitor or a rebuild. A verified key is noted in the release's index (kept when the page is forgotten, emptied bycache:clear pagesand a rollback), and is not verified again; a key that differed is verified again on its next miss. A forgotten personal value shows up on real traffic as a log line and an uncached page, never as a leak. It costs one extra render per page and release. It checks the visitor it gets: a part that differs only for visitors with a cookie (an album) is caught when such a visitor makes the first store.'always'verifies every store: each stored miss costs two renders. For tests and staging; preflow/testing'sWebApplicationsets it.'off'never verifies.trueandfalsemean'always'and'off'.- The second render sees the other visitor in the PSR request,
RequestContextand the superglobals ($_COOKIEempty,$_SERVER['REMOTE_ADDR']another address, noHTTP_COOKIEorHTTP_X_FORWARDED_FOR,$_REQUESTwithout the cookies; all put back afterwards; a leftover$_SESSIONoutside an active session is hidden too). A render with a session active is not verified, so not stored. A site's own memo of an earlier value is the same in both renders and goes unnoticed.
-
Freshness, per dependency. What a page reads is fresh by default: a write forgets the page, so the next visitor gets the new state. A developer can loosen that for a part of a page, in the template or the component, never in settings:
<h1>{{ job.title }}</h1> {# fresh #} {% freshness 'next-visit' %} {% for other in jobs_of(job.company) %}…{% endfor %} {% endfreshness %} {% freshness 'timed' every '12h' %}{{ weather() }}{% endfreshness %}
@freshness('next-visit') … @endfreshness @freshness('timed', every: '12h') … @endfreshness
#[\Preflow\Components\Freshness(Freshness::NextVisit)] // around resolveState() and render #[\Preflow\Components\Freshness(Freshness::Timed, seconds: 43200)] $pageCache->within(Freshness::NextVisit, null, fn () => $jobs->ofCompany($id));
fresh: a write of what it read forgets the page.next-visit: a write marks the page stale. The next visitor still gets it at once, and a fresh one is built behind them.timed(every '30m','12h','2d'or seconds): writes are ignored. The page expires at its render time plus that time, but never later thanmax_age: with the default 4 h,every '12h'refreshes every 4 h. Raisemax_agefor longer times (up to 20 h).- A tag read several ways counts as the strictest (fresh, then next visit, then timed). Only
the changed tags count: a page that read "other jobs" timed and its own job fresh is still
forgotten when its job changes. Nested scopes: the innermost wins. Inside a live part the
dial does nothing (its reads are no dependencies).
'*'(a raw write whose tables are unsure,pages:refresh --all) forgets every page whatever its freshness, and queues every full page for rebuilding;cache:clear pagesempties the cache without rebuilding. - The dial applies where a read runs, not where its value is shown:
{% set others = … %}inside a next-visit scope and printed outside it is still next visit. A component's reads beforeresolveState()(its constructor) follow the scope around it. - A write while the page renders: of something it read fresh, the page is not stored; next visit only, it is stored stale; timed only, it is stored.
- Folio's frontend pages (types with publishing) depend on their record by id and on their
address (
DataTags::address(),~address:/path), not on the whole type their lookup queried. The Publisher reports the addresses a publish, unpublish or delete leaves and takes. So "other jobs" next visit works on a job's own Folio page, and publishing another record of the type leaves other detail pages alone (unless they list the type, which follows its own freshness). Every publishing route a path tries also adds "any record" of its table ({storage}:{table}__published#), found or not: a table or bulk write around the Publisher (an import, a pull) forgets every page another record of that type could claim. A single-record write around the Publisher reaches the pages of that record only. $pageCache->depends('menu')adds a dependency the data layer can't see, in the scope's freshness. Tags starting with~are exact: read and changed as they are.
-
Expiry and the maximum age. A page can say when it goes out of date, an event date or a sale ending at midnight; the earliest of several counts:
{% expires at event.startsAt %} {# a DateTimeInterface, a unix time or a date string; null: none #} {% expires in '1h' %}@expires(at: $event->startsAt) @expires(in: '1h')
$pageCache->expiresAt($date)does it from PHP. A page past its expiry, or older thanmax_age(4 h by default, at most 20 h: a cached page carries form and component tokens that last 24 h;0means those 20 h, a negative or longer value is refused), is served asstale(an expiry at or before the render is ignored: the page already shows what came after it) and its rebuild is queued: it is built right after that response, or by the cron. AHEADrequest (an uptime monitor) is served the stale page and queues nothing. A stale fragment or search page is served once and then forgotten (their next visit renders them). -
Rebuilding. A write forgets the pages that read it fresh, and queues the full pages without a query among them for rebuilding (the refresh queue, a table in the release's
index.sqlite): the pages that showed the changed record or address first, then the listings. A stale hit queues its page. Fragments and search or filter pages are never queued: they are built on their next visit. One page is one queue entry, however often it is queued (an import of 300 jobs queues each affected page once).-
After the response. The request that queued something rebuilds right after its response went out (
fastcgi_finish_request/litespeed_finish_request), forrebuild_budgetseconds (2.0; checked after each page, so at least one is built;0leaves everything to the cron). Code can add its own work there with$app->afterResponse(fn () => ...). -
The cron takes what is left, every minute:
* * * * * cd /path/to/site/current && php preflow pages:work >/dev/null 2>&1
preflow pages:work [--budget=50]rebuilds for up to 50 seconds.preflow pages:refresh {url}forgets every copy of one URL (a path is taken againstapp.url) and rebuilds it;preflow pages:refresh --allforgets every page and rebuilds the known full pages. Folio's Cache screen has the same two actions. Both record an invalidation first (of the page's tags, or of everything), so a render already running can't store the old page after them. -
A rebuild is an internal anonymous GET of the page's URL through the whole pipeline (
Rebuild::request(), markedInternalRenderandPageCache::REBUILD), from clean request state each time (request context, assets, SEO meta, translator locale). It is anonymous in PHP's superglobals too, as verify's second render is (AnonymousGlobals): after a response they are still those of the request that wrote, so during the rebuild$_COOKIE,$_POSTand$_FILESare empty,$_GET/$_REQUESTare the page's query,$_SERVERhas noHTTP_COOKIEor forwarded address andREMOTE_ADDR127.0.0.1, and$_SESSIONis unset; all are put back afterwards. With a PHP session open, nothing is rebuilt. Mail a page queues withsendLater()during a rebuild is dropped (since 0.74), not sent with the response that queued the rebuild. -
It carries the key parts the page was stored under: the locale (as
Accept-Language),X-Forwarded-Proto/X-Forwarded-Hostand the vary header values, so it is stored under the key visitors read; one that gives another key is not rendered (logged). The stock locale middleware resolves a cookie visitor's language the same way fromAccept-Language; a site middleware that chooses the language from a cookie of its own must also understandAccept-Language(and set thelocalerequest attribute), or its pages are only built on visit. -
A page a visitor already rendered again is skipped. A rebuild that is not stored (a 404 or redirect, a cookie set, a refusal, another key, an error) forgets the page: a stale copy is never kept past its rebuild, and its next visitor renders it. One that a write refused while it rendered stays queued for a later pass, and is dropped after 3 such failures in a row.
-
Never a loop: each page is built at most once per pass, and what a rebuild itself writes forgets pages as any write does but queues nothing in that pass. A page whose render writes what it read (a view counter) is therefore not cached, as before. The work after a response is one pass per request; it never schedules more.
-
Claim with lease: each process takes a queue entry in a write transaction and holds it for 5 minutes, so the after-response work and the cron never build one URL at once, and an entry of a process that died is taken again later.
-
-
Failed invalidations. An invalidation that can't reach the index (SQLite busy, the file unusable) is never dropped: it is kept in
storage/cache/pages/{key}/pending-invalidationsand applied by the next request or command that reaches the index. While one is pending, no page of the release is served from the cache or stored (one stat per hit), so an index that stays unreachable turns the cache off rather than serving pages nothing can refresh. If even that file can't be written, the release's pages are removed. -
Switching it off and on.
storage/cache/pages/enabledmarks a cache that has been emptied since it was switched on. While the cache is off, the first write through the data layer removes the marker; the first request after switching it on again finds it missing and clears the cache before serving anything.
Boot manifest (preflow optimize)
preflow optimize (preflow/devtools) writes bootstrap/cache/boot.php: what boot otherwise
reads and scans on every request although it only changes on deploy. It holds:
- the result of every
config/*.php(read throughApplication::configFile()); - the package discovery data (
InstalledPackages, i.e.vendor/composer/installed.json); - the catalogs packages remember: components, controllers, translation groups, and Folio's extensions, extension types, model listings, frontend routes, blocks and interface languages;
- the compiled public routes, per set (
Router::compiled(), see preflow/routing): the app's own (app: file pages and controllers), the media core's (media), the members pages (members,members.landing) and Folio's frontend catch-all (folio.frontend). With a manifest no route pattern is compiled per request. Folio's admin routes are not in it: they are compiled on the first request under the admin prefix in a process, as without a manifest. A set that can't be exported (middleware given as closures or objects) is compiled live, every request; so is a stored set whose rows the installed routing code can't rebuild.
A boot uses it only when all of these hold; otherwise it boots live, exactly as without one:
- the request is not in the console (
runningInConsole()): CLI commands and cron jobs never read it, so they always see the current files; app.debugis off (APP_DEBUG=0), decided live fromconfig/app.phpas this request loads it:APP_DEBUG=1set by the web server, or'debug' => 1edited on the server, bypasses the manifest at once.optimizewrites none in debug mode;.env,config/folio/extensions.jsonandvendor/composer/installed.jsonare unchanged (each is stat-ed on every request), and every variable.envsets has the value it had when the manifest was written. Config files read env values when they load, so the manifest holds those values: an edited.envapplies on the next request (live boots until the nextoptimize).
Environment variables set only by the web server (SetEnv, the FPM pool, the hosting
panel) and not named in .env are not watched: after changing one, run preflow optimize
again. optimize --check runs in the CLI and compares the CLI's environment, so it can't see a
variable that the web server sets to a different value than the shell: --check says "current"
while every web request boots live (correct, only slower). To confirm a site really uses its
manifest, check from a web request (timing, or a temporary debug header), not only with --check.
Secrets. A config file that reads a secret (getenv('DB_PASS'), app.key) puts its value
in the manifest, so a secret that only the web server or the deploy shell sets now also lands on
disk. The file gets the .env's permission bits (0640 without a .env), lives under the release
in bootstrap/cache/, outside public/; keep it there and don't commit it (see below).
OPcache with opcache.validate_timestamps=0. Running optimize again in place (same
release path) doesn't reach the web server's OPcache, which keeps serving the old compiled
manifest until it is reset: reset OPcache afterwards (Folio → Settings → Cache → Reset OPcache,
or your deploy's reset). A deploy to a new release path is not affected.
Toggling an extension (Folio's Settings → Extensions, folio:extensions:*) rewrites
extensions.json and removes the manifest: the change applies on the next request, and the
site boots live until the next deploy or preflow optimize. Stored settings are never in it. Everything else in it
(config files, code, model files, installed.json) is trusted until the next
preflow optimize: run it on every deploy, after composer install and migrations /
folio:extensions:apply, before the release goes live. preflow optimize --check lists the
sources that changed since (exit 1), each once, and preflow cache:clear removes the manifest
(cache boot), as does "Clear all" on Folio's Cache screen: the site stays correct and boots live
until the next deploy or preflow optimize. A failed optimize (the build or the write) removes
it too, so the manifest of the code before is never left in place. The file holds config values, secrets included:
it gets the .env's file permissions, is per release and must not be committed. Add this to the site's .gitignore:
/bootstrap/cache/
A package keeps its own deploy-time data in the manifest with
$app->bootManifest()->remember('vendor.key', fn () => …, [$sourcePath]): the closure runs
live without a manifest and while one is built; with a manifest its stored value comes back.
The value must be exportable (scalars, arrays, enums) and depend only on code, config files
and files that change on deploy, never on stored data or the request.
$manifest->whenBuilding(fn (Application $app) => …) resolves a lazy service while the
manifest is built, so its catalog is in it.
A package's routes go in as a set of their own:
$router->getCollection()->addMany($router->compiled('vendor.name', fn () => [...entries]))
($router is the Preflow\Routing\Router bound as RouterInterface); the same rules apply.
A page or controller added on the server without a deploy is not routed until the next
preflow optimize (optimize --check lists app/pages as changed).
HTTP exceptions
Throw these anywhere in the request lifecycle — ErrorHandler converts them to the correct status code automatically.
use Preflow\Core\Exceptions\NotFoundHttpException; use Preflow\Core\Exceptions\ForbiddenHttpException; use Preflow\Core\Exceptions\UnauthorizedHttpException; throw new NotFoundHttpException('Page not found'); throw new ForbiddenHttpException(); throw new UnauthorizedHttpException('Login required');
Your own error pages
A live site answers errors with a plain built-in page. Put templates in app/pages/_errors/ to
answer with your own pages instead, rendered through the app's engine like any file page, so they
extend your layout (since 0.66):
app/pages/_errors/404.twig the page for a 404 (any status: 403.twig, 500.twig, 503.twig, …)
app/pages/_errors/error.twig any status without a page of its own (optional)
{% extends "_layout.twig" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}<h1>{{ status }}: {{ title }}</h1><p>{{ message }}</p><p><a href="/">Home</a></p>{% endblock %}
- Variables:
status(404),title("Not Found"),message(what a visitor may read: the status text for 403, 404, 405 and any exception that is not anHttpException; anHttpException's own message otherwise), andrequestandrouteas file pages have them. The extension is the engine's (404.blade.phpwith Blade). The leading_keeps the folder out of the routes. - Development (
APP_DEBUGon) keeps the detailed debug page; internal renders (the SEO audit) get your page. API paths and clients that ask for JSON keep the JSON answer. - A status without a page (and no
errorpage) gets the built-in page. A page that fails to render gets the built-in page too, never a blank one; the reason goes to the log. - An
HttpException's headers (Retry-Afteron a 503) are kept. - Error pages are never stored by the page cache, so every 404 (bots' probes too) renders your layout. Keep the 404 page light.
Non-public paths
Preflow\Core\Http\NonPublicPaths is a list of path prefixes that are not public pages. The
application keeps one in the container, holding /--component from the start; packages add
theirs in their provider (Folio its admin prefix, members its path, media its private_url):
use Preflow\Core\Http\NonPublicPaths; if ($container->bound(NonPublicPaths::class)) { $container->get(NonPublicPaths::class)->add('/api'); }
A prefix matches whole segments: /folio covers /folio and /folio/x, not /folios. '' and
/ are ignored (they would cover the whole site). matches($path) and all() read the list.
Redirects and the not-found list (preflow/routing) never answer or record a 404 under these
paths. Add the prefix of any area of your own that is not a page (an API, a webhook endpoint).
Safe return paths
Preflow\Core\Http\SafeReturnPath::check($next, $allowedPrefixes) (since 0.72) judges a
"next" or "return to" value from the request before a redirect goes there. It returns $next
unchanged when it is a path on this site under one of the prefixes, else null:
use Preflow\Core\Http\SafeReturnPath; $to = SafeReturnPath::check($request->getQueryParams()['next'] ?? null, ['/employer']) ?? '/employer';
- A prefix matches whole segments:
/employerallows/employer,/employer/jobsand/employer?tab=x, not/employerx;/allows every path. A prefix that does not start with/('',employer, a URL) throws anInvalidArgumentException. - Refused: a URL (
https://…,//evil.example), a backslash (\evil), a dot segment (/a/../b), a double slash, a control, space or non-ASCII character, and each of these percent-encoded (/%2F%2Fevil.example,%252e%252e), decoded down to the fixed point. - A query string is kept as given. A
#is allowed only inside the query string, so a fragment right after the path (/employer#top) is refused.
Members' SafeNext builds on it with the member pages and next.allow, in every locale.
Pages from controllers
A file page (app/pages/*.twig) answers GET with status 200. Use a controller page when you
need a 404 with your layout, a POST that shows a page, or a choice of template at run time.
Inject Preflow\View\PageResponder and call render($template, $context, $status, $headers):
the template gets the same request and route variables as a file page (your context keys
win), collected CSS/JS is injected, and the answer is text/html; charset=UTF-8.
#[Route('/jobs')] final class JobPageController { public function __construct(private readonly PageResponder $pages, private readonly DataManager $dm) {} #[Get('/{id}')] public function show(ServerRequestInterface $request): ResponseInterface { $job = $this->dm->find(Job::class, $request->getAttribute('id')); return $job === null ? $this->pages->render('_views/not-found.twig', status: 404) : $this->pages->render('_views/job.twig', ['job' => $job]); } }
route is empty in a controller page; pass 'route' => (object) [...] if the template needs it.
Anonymous GET pages from controllers go through the page cache like file pages do.
JSON APIs (config/api.php)
A controller action that returns an array or a JsonSerializable answers JSON; no response
object needed. Preflow\Core\Http\Json builds the rest: Json::response($body, $status, $headers),
Json::data($data, $meta) (the {"data": …, "meta": …} envelope; meta only when given),
Json::noContent() (204) and Json::FLAGS (the encoding flags: unescaped slashes and unicode,
zero fractions kept, exceptions on failure). Every JSON response, errors included, is
Content-Type: application/json.
use App\Models\Job; // your model, with public $uuid and $title use Preflow\Auth\Http\TokenAuthMiddleware; use Preflow\Core\Http\Json; use Preflow\Data\DataManager; use Preflow\Routing\Attributes\{Get, Middleware, Post, Route}; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; #[Route('/api/jobs')] final class JobController { public function __construct(private readonly DataManager $dm) {} #[Get('/')] public function index(): ResponseInterface { $jobs = $this->dm->query(Job::class)->get() ->map(fn (Job $j) => ['id' => $j->uuid, 'title' => $j->title]) ->items(); return Json::data($jobs, ['page' => 1]); } #[Post('/')] #[Middleware(TokenAuthMiddleware::class)] public function create(ServerRequestInterface $request): ResponseInterface { $job = new Job(); $job->title = (string) ($request->getParsedBody()['title'] ?? ''); // A failed validation throws ValidationException: the client gets the 422 shape below. $this->dm->save($job); return Json::data(['id' => $job->uuid], status: 201); } }
config/api.php (all keys optional; without the file the defaults apply and CORS is off):
return [ 'prefixes' => ['/api'], // path prefixes (whole segments) that are the API 'cors' => [ 'origins' => ['https://app.example.com'], // empty (default) = CORS off; ['*'] = any 'methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], 'headers' => ['Authorization', 'Content-Type', 'Accept'], 'expose' => [], // response headers a browser may read 'max_age' => 600, // seconds a preflight answer may be cached 'credentials' => false, // cannot be true together with origins ['*'] ], ];
curl -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"title":""}' https://site.test/api/jobs
# 422 {"message":"The given data was invalid.","errors":{"title":["..."]}}
Get a token with preflow auth:token <email-or-id> (preflow/devtools, see
preflow/auth's README).
Request bodies. A malformed JSON body answers 400 on every path, instead of arriving as an empty body.
Errors. For a path under an API prefix, or a request whose Accept asks for JSON without
text/html, an error answers {"message": "<text>"} instead of the HTML error page. A 422 adds
"errors": {"<field>": ["<message>", …]}; in debug mode "debug": {"exception", "message", "file", "line"} is added. An HttpException's own message is shown (it is written for the
client); any other exception shows Server Error, never its message outside debug. An
HttpException can carry $headers (Allow, WWW-Authenticate), which go on the response.
CORS. With cors.origins set, CorsMiddleware runs outermost: it answers preflight
(OPTIONS with Access-Control-Request-Method) for API paths without reaching the router, and
adds its headers to every API response, error responses included. Every API response also gets Vary: Origin, so a shared cache keeps the origins apart.
The page cache never stores API paths (they are non-public paths, and every api.prefixes
entry is registered as one). Caching API GETs is on the roadmap; it needs its own design, because
bearer requests must never get or store a shared copy.
Upgrading to 0.62. A site without config/api.php changes in five visible ways:
- A wrong method on an existing path (not a catch-all) answers 405 with
Allowinstead of 404. - An uncaught
ValidationExceptionanswers 422 instead of 500 (HTML pages too). - A malformed JSON request body answers 400; it used to be ignored.
- A request with
Accept: application/json(withouttext/html) gets a JSON error body instead of the HTML error page. A 404 that a site middleware catches (NotFoundHttpException, a branded not-found page) stays the site's HTML. /apiis the default API prefix: paths under it are non-public (page cache, redirects, not-found list, SEO and GEO skip them) and answer errors as JSON; set'prefixes' => []inconfig/api.phpto opt out.
A site middleware that catches NotFoundHttpException does not see 405s: they render the framework's
error page. HTML error pages, CSRF and sessions behave as before for everything else, and so does the
page cache outside /api.