Routing
Preflow supports two routing styles that work side by side: file-based routing for pages and attribute-based routing for controllers. File routes resolve to component mode (templates); attribute routes resolve to action mode (controller methods).
File-Based Routing
Templates in app/pages/ map to URLs by their file path. No configuration needed — drop a .twig file and it becomes a route.
Conventions
| File | URL Pattern |
|---|---|
index.twig |
/ (or parent directory path) |
about.twig |
/about |
blog/index.twig |
/blog |
blog/[slug].twig |
/blog/{slug} |
docs/[...path].twig |
/docs/{path} (catches nested paths) |
_layout.twig |
Excluded (underscore prefix) |
Dynamic Segments
Use brackets for dynamic URL segments:
app/pages/
blog/
[slug].twig → /blog/hello-world
users/
[id].twig → /users/42
The parameter is available in the template as a variable:
<h1>{{ slug }}</h1>
Catch-All Segments
A [...param] file matches any depth of nested paths:
app/pages/
docs/
[...path].twig → /docs/getting-started
→ /docs/guides/routing
→ /docs/a/b/c/d
The full matched path is available as the path variable.
Layout Files
Files prefixed with _ are excluded from routing. Use _layout.twig as a base layout:
{# app/pages/_layout.twig #}
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>{% block title %}App{% endblock %}</title>
{{ head() }}
</head>
<body>
{% block content %}{% endblock %}
{{ assets() }}
</body>
</html>
Page templates extend the layout:
{# app/pages/about.twig #}
{% extends '_layout.twig' %}
{% block title %}About{% endblock %}
{% block content %}
<h1>About Us</h1>
{% endblock %}
Attribute-Based Routing
Controllers use PHP attributes for route definitions. The class gets a #[Route] prefix; methods get HTTP method attributes.
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)]
public function update(): ResponseInterface { /* ... */ }
#[Delete('/{id}')]
public function destroy(): ResponseInterface { /* ... */ }
}
Method paths append to the class prefix: #[Get('/{id}')] on #[Route('/api/posts')] becomes /api/posts/{id}. A method path of '/' resolves to the prefix alone.
Route Middleware
#[Middleware] is repeatable on both class and method. Method middleware stacks on top of class middleware:
#[Route('/admin')]
#[Middleware(AuthMiddleware::class)] // applied to all methods
final class AdminController
{
#[Get('/')]
public function dashboard(): ResponseInterface { /* ... */ }
#[Delete('/users/{id}')]
#[Middleware(AdminOnlyMiddleware::class)] // stacked on AuthMiddleware
public function deleteUser(): ResponseInterface { /* ... */ }
}
Route Priority
When multiple routes could match a request, RouteMatcher uses three priority passes:
- Static -- exact string match, no parameters
- Dynamic -- routes with
{param}segments, matched by regex - Catch-all -- routes with
{...param}, matches across slashes
The first match wins. If nothing matches, a NotFoundHttpException is thrown.
Route Caching
In production, compile routes to a PHP file to skip directory scanning:
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 -- OPcache-friendly with no eval.
Listing Routes
Use the CLI to see all registered routes:
php preflow routes:list