preflow / routing
Preflow routing — file-based and attribute-based hybrid router
Requires
- php: >=8.4
- preflow/core: ^0.72 || @dev
Requires (Dev)
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
- preflow/core: <0.72
Replaces
None
- dev-main
- v0.77.1
- v0.77.0
- 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-15 09:43:08 UTC
README
Hybrid file-based + attribute-based router for Preflow. Implements RouterInterface from preflow/core.
Installation
composer require preflow/routing
Requires PHP 8.4+.
What's included
| Component | Description |
|---|---|
FileRouteScanner |
Maps app/pages/ directory structure to Component routes |
AttributeRouteScanner |
Scans PHP classes for #[Route], #[Get], #[Post], etc. |
Router |
Combines both scanners, implements RouterInterface |
RouteMatcher |
Matches requests with priority: static > dynamic > catch-all |
RouteCompiler |
Caches the route collection to a PHP file for production |
MiddlewareWith |
A route middleware with constructor arguments (guard: 'employers') |
Redirect\RedirectMiddleware |
Answers unfound public pages from a redirect store and notes the rest in a not-found log |
File-based routing
Files in the pages directory map directly to URLs. The scanner handles three file conventions:
| File | URL pattern |
|---|---|
index.twig |
/ (or parent directory path) |
about.twig |
/about |
[slug].twig |
/{slug} — dynamic segment |
[...path].twig |
/{path} — catch-all (matches slashes too) |
_layout.twig |
excluded (underscore prefix) |
_views/, blog/_partials/ |
excluded with everything inside (any depth) |
Example structure:
app/pages/
index.twig → GET /
about.twig → GET /about
blog/
index.twig → GET /blog
[slug].twig → GET /blog/{slug}
docs/
[...path].twig → GET /docs/{path} (catches /docs/a/b/c)
_layout.twig → (ignored)
File routes resolve to RouteMode::Component — the handler value is the relative template path (e.g. blog/[slug].twig).
Attribute-based routing
Controllers use #[Route] on the class for a path prefix, then HTTP method attributes on methods. These resolve to RouteMode::Action with the handler ClassName@methodName.
use Preflow\Routing\Attributes\Delete; use Preflow\Routing\Attributes\Get; use Preflow\Routing\Attributes\Middleware; use Preflow\Routing\Attributes\Post; use Preflow\Routing\Attributes\Put; use Preflow\Routing\Attributes\Route; #[Route('/api/posts')] #[Middleware(ApiAuthMiddleware::class)] final class PostController { #[Get('/')] public function index(): ResponseInterface { /* ... */ } #[Get('/{id}')] public function show(): ResponseInterface { /* ... */ } #[Post('/')] public function create(): ResponseInterface { /* ... */ } #[Put('/{id}')] #[Middleware(OwnerMiddleware::class)] // stacks on top of class middleware public function update(): ResponseInterface { /* ... */ } #[Delete('/{id}')] public function destroy(): ResponseInterface { /* ... */ } }
Method paths are appended to the class prefix: #[Get('/{id}')] on #[Route('/api/posts')] becomes /api/posts/{id}. A method path of '/' resolves to the prefix alone.
#[Middleware] is repeatable on both class and method. Method middleware merges on top of class middleware.
Middleware with arguments
A class name gets the container's instance of that middleware. For one built with other
constructor arguments, give MiddlewareWith (since 0.72) in #[Middleware] or in
#[Route(middleware: [...])], next to plain class names:
use Preflow\Auth\Http\AuthMiddleware; use Preflow\Routing\MiddlewareWith; #[Route('/employer', middleware: [new MiddlewareWith(AuthMiddleware::class, guard: 'employers')])] final class EmployerController { #[Get('/jobs')] #[Middleware(AuditMiddleware::class)] // plain class names and MiddlewareWith mix freely public function jobs(): ResponseInterface { /* ... */ } } #[Route('/employer-login')] final class EmployerLoginController { #[Get('/')] #[Middleware(new MiddlewareWith(GuestMiddleware::class, redirectTo: '/employer', guard: 'employers'))] public function show(): ResponseInterface { /* ... */ } }
The route gets a new instance from the container (Container::make($class, $args)): the named
arguments win, the other parameters are autowired. Arguments are given by name, must be
constructor parameters and match their types strictly (guard: 5 is refused, not turned into
"5"). The route cache and the boot manifest keep them, so they must be values var_export()
writes (strings, numbers, booleans, arrays, enums). An unknown class, a class that is no
middleware, or an unknown, positional, wrongly typed or unwritable argument fails when the routes
are scanned. A middleware the container builds with a factory ($container->bind(X::class, fn (Container $c) => new X(...))) gets the arguments only when the factory takes them as its second
parameter (fn (Container $c, array $args)); otherwise the request fails with a
ContainerException instead of running the middleware without them. Core's kernel builds anything that implements
Preflow\Core\Routing\ConfiguredMiddleware; MiddlewareWith is routing's.
Catch-all params in attribute routes
Append ... inside the braces to create a catch-all segment that matches slashes. Useful for nested path parameters in controllers.
#[Route('/files')] final class FileController { #[Get('/{path...}')] public function show(ServerRequestInterface $request): ResponseInterface { $path = $request->getAttribute('path'); // e.g. 'images/2026/photo.jpg' // ... } }
{path...} compiles to a .+ regex, matching one or more characters including /.
Router setup
use Preflow\Routing\Router; $router = new Router( pagesDir: __DIR__ . '/app/pages', // file-based routes (optional) controllers: [PostController::class], // attribute-based routes (optional) cachePath: __DIR__ . '/storage/routes.php', // null = no cache ); // Pass to Application $app->setRouter($router);
Either pagesDir or controllers (or both) can be omitted. When cachePath is set and the cache file exists, the scanner is bypassed entirely.
Route cache (production)
use Preflow\Routing\RouteCompiler; $compiler = new RouteCompiler(); // Generate cache $compiler->compile($router->getCollection(), __DIR__ . '/storage/routes.php'); // Invalidate $compiler->clear(__DIR__ . '/storage/routes.php');
The compiled file is a plain PHP return statement — no eval, OPcache-friendly.
On a Preflow application you don't need this: preflow optimize compiles the routes into the
boot manifest (see preflow/core, "Boot manifest"). Core passes the Router a store:, and every
route set goes through Router::compiled():
// In a provider's boot(): compiled once, then served from the manifest. $router->getCollection()->addMany($router->compiled('vendor.name', fn (): array => [ new RouteEntry(pattern: '/feed', handler: FeedController::class . '@show', method: 'GET', mode: RouteMode::Action), ], [$sourcePath]));
The app's own routes (file pages and controllers) are the set app. Without a store (no
manifest, debug mode, the console) compiled() just runs the closure. The entries must depend
only on code, config files and files that change on deploy; a set whose middleware holds
closures or objects is not stored and is compiled live. Router::liveSets() lists the sets the
router compiled live (with a current manifest: none). RouteEntry::toArray()/fromArray() are
the stored form; a stored set whose rows this version can't rebuild (a key missing, an unknown
mode) is compiled live instead.
Routes registered after the collection was built (Folio's admin routes, set up on the first
admin request) must still match before a catch-all: add them with
$router->getCollection()->insertBefore($anchor, $entries), which puts them in front of the
first entry equal (==) to $anchor, or appends them when there is none.
Matching priority
For a given HTTP method, RouteMatcher tests routes in three passes:
- Static — exact string match, no parameters
- Dynamic — has
{param}segments, matched by regex - Catch-all — has
{...param}, matches across slashes
The first match wins. Throws NotFoundHttpException if nothing matches.
Wrong method: 405
When no route matches the method but the path exists for other methods, the router throws
MethodNotAllowedHttpException (a 405) and the response carries Allow with those methods
(GET also lists HEAD; OPTIONS handling stays with CORS). A catch-all route does not count:
a wrong method on a path that only a catch-all serves is still a 404. Before 0.62 every such
request was a 404.
Redirects and the not-found list
When a public page is not found, RedirectMiddleware looks the address up in a redirect store
and answers with a redirect; when no entry matches, it notes the address in a not-found log.
The mechanism lives here; where entries are kept is up to a store. Folio supplies one (renamed
and removed pages redirect automatically, Settings → Redirects lists them, see
preflow/folio); a site without Folio binds its own.
When it acts
- Only a thrown
Preflow\Core\Exceptions\NotFoundHttpExceptiononGETorHEAD: a router miss, a controller's own 404, Folio finding no record. A 404 response (Folio's gated pages) passes through untouched, and every other method keeps its 404. - Never under a non-public path (
Preflow\Core\Http\NonPublicPathsinpreflow/core: the admin, the member area, private files, the component endpoint). - A page that answers always wins: the middleware only runs after the app found nothing.
- Lookup order: the exact entry for the path (the request's language first, then an any-language entry), else the area entry with the longest path above it.
- Paths are compared after
LocaleMiddlewarestripped the locale prefix, together with the request'slocale(the site's default when there is none), in one canonical form (RedirectPath::normalize(): repeated slashes collapsed, no trailing slash, each segment encoded once, so/über-unsand/%C3%BCber-unsare the same address).
The answer
- The entry's status: 301 (permanent) or 302 (temporary), with
Cache-Control: public, max-age=86400, so a browser asks again after a day (a page renamed back must not loop). - The original query string is kept (
/old?ref=x→/new?ref=x). - A record target is resolved when the redirect is answered, through
RedirectTargets, in the old address's language (its default-language URL when it has none in that language). A renamed page's old addresses all point at the record, so renaming twice never chains. - An area entry either sends everything below it to one target (
AreaMode::One) or keeps the rest of the path under a new prefix (AreaMode::Prefix:/workshops-2024/basel→/workshops/basel). - A target that is gone (a deleted record, no target picked), equal to the visited address, or
a protocol-relative URL (
//host) keeps the 404. Store and log failures (a table not migrated yet) are logged in debug mode and keep the 404; they never turn it into a 500.
The not-found list
An unanswered address is passed to NotFoundLog::record($path, $locale, $at): the path without
query string, the language and the time, nothing about the visitor (no IP address, user agent
or referrer). Scanner noise is left out by NotFoundFilter: WordPress and admin-tool probes
(/wp-…, /xmlrpc, /phpmyadmin, /cgi-bin…), dotfiles (/.env, /.git) and script, backup
and archive extensions (.php, .sql, .bak, .zip…).
config/redirects.php
The skeleton ships it with every option commented out; without the file the defaults apply:
return [ 'not_found' => true, // record unanswered addresses; redirects work either way 'not_found_limit' => 1000, // how many addresses the store keeps (least recently hit go first) 'ignore' => ['/legacy/', '.json'], // more noise: a path prefix, or an extension starting with '.' ];
RedirectConfig::fromFile() reads it. Keeping the limit is the log's job (Folio's does).
A store without Folio
The application adds the middleware (after the site's own middleware) when a provider has bound
Preflow\Routing\Redirect\RedirectStore. RedirectTargets (record targets) and NotFoundLog
are optional:
use Preflow\Routing\Redirect\AreaMode; use Preflow\Routing\Redirect\Redirect; use Preflow\Routing\Redirect\RedirectPath; use Preflow\Routing\Redirect\RedirectStore; final class SiteRedirects implements RedirectStore { /** @var array<string, string> old path => new URL */ private const MAP = ['/old-page' => '/new-page', '/shop' => 'https://shop.example.org']; public function exact(string $path, string $locale): ?Redirect { foreach (self::MAP as $from => $to) { if (RedirectPath::normalize($from) === $path) { return new Redirect(Redirect::id('exact', '', $path), $path, '', false, AreaMode::One, null, null, $to, 301); } } return null; } public function areas(string $locale): array { return []; // Redirect objects with area: true } public function hit(Redirect $redirect, \DateTimeImmutable $at): void {} } // In a service provider's register(): $container->bind(\Preflow\Routing\Redirect\RedirectStore::class, static fn () => new SiteRedirects());
exact($path, $locale)andareas($locale)get the normalized path; return entries of that locale and any-language ones (locale'').- A
Redirecthas a fixedtargetUrl(absolutehttps://…or a path on this site), or atargetTypeandtargetIdthat a boundRedirectTargets::url($type, $id, $locale)turns into a URL; with neither it is broken and answers 404. hit()counts a served redirect; it may do nothing.NotFoundLog::record()gets every unanswered, non-noise address; bind it to keep a list.