Route Resolution and Project Policy
When multiple sources declare routes (plugins via module.json and projects via proj.json), the compiler resolves them following a predictable priority: project routes override plugin routes by default. A project can also disable specific plugin routes without forking the plugin.
Compilation Order
Routes are compiled in three phases:
- Read all module manifests — every plugin's
module.jsonroutes[]andgroups[] - Compile plugin routes — with the project's disable/allow policy applied
- Compile project routes —
proj.jsonroutes[]/groups[]andKernel::withRoutes()
A project route declaring the same METHOD /path (and domain, if grouped) overrides a plugin route. The plugin route is removed and replaced.
Project Override
Override a plugin route by redeclaring it with a different handler:
Plugin (module.json):
{
"routes": [
{ "method": "GET", "path": "/register", "handler": "Auth\\RegisterController@show", "name": "auth.register" }
]
}Project (proj.json):
{
"routes": [
{ "method": "GET", "path": "/register", "handler": "Shop\\RegisterController@show" }
]
}The project route replaces the plugin route. The route INHERITS the plugin's name (auth.register) unless the project declares its own.
This is the intended way to customize a plugin page without forking it.
Route Disable Policy
Disable a plugin route WITHOUT replacing it, using routePolicy.disable in proj.json or Kernel::withRoutePolicy():
{
"routePolicy": {
"disable": [
"GET /register", // one route: method + path
"GET@api.example.com /admin", // one route in a domain group
"auth.identity" // a module domain: every route this module solves()
]
}
}Each disable spec is either:
METHOD /path— one exact route (e.g.,GET /register)METHOD@domain /path— one route in a domain group (e.g.,GET@api.example.com /admin)module.domain— every route a plugin solves (e.g.,auth.identitydisables all auth routes)
A spec that matches no plugin route fails at boot with a descriptive message (preventing silent typos).
Programmatically:
$kernel = Kernel::configure()
->withRoutePolicy(['GET /register', 'auth.identity'])
->build();Route Allow Policy
Complement to disable: declare ONLY the routes you want to expose. When an allowlist is present, a plugin route must match a spec or it is dropped:
{
"routePolicy": {
"only": [
"GET /dashboard",
"GET /profile",
"invoicing.*" // all routes the invoicing module solves()
]
}
}An empty allowlist means "allow everything" — treating it as "allow nothing" would silently break projects on upgrade. Unmatched specs do NOT fail the boot (unlike disable), so a shared configuration that names routes from an optional plugin is normal.
Programmatically:
$kernel = Kernel::configure()
->withRouteAllowPolicy(['GET /dashboard', 'invoicing.*'])
->build();Disable and allow compose: allow a module's whole domain, then disable the handful of routes you do not want.
Disable Then Replace
Disable a plugin route and optionally declare your own:
{
"routePolicy": {
"disable": ["GET /login"]
},
"routes": [
{ "method": "GET", "path": "/login", "handler": "Custom\\LoginController@show" }
]
}Because disable runs BEFORE project routes are compiled, the freed GET /login key is available for the project's own declaration without a duplicate-route conflict.
Programmatic Policy
Use Kernel::withRoutePolicy() instead of proj.json:
$kernel = Kernel::configure()
->withRoutePolicy(['GET /register', 'auth.identity'])
->build();Plugin Route Requirements
A route can declare requires[] to opt specific routes into plugins without loading them app-wide:
Plugin (module.json):
{
"routes": [
{ "method": "GET", "path": "/dashboard", "handler": "...", "requires": ["view.rendering"] }
]
}Project (proj.json):
{
"routes": [
{ "method": "GET", "path": "/custom", "handler": "...", "requires": ["tenancy.routing"] }
]
}When a request matches a route, its requires[] domains are seeded into the dependency graph, and only those plugins load (plus their transitive requires). Routes without requires[] load ZERO extra plugins — they resolve under the synthetic __project__ scope.
An unknown domain in requires[] fails at boot.
Multiple Plugins, Same Route
Two plugins cannot declare the same route. The compiler fails at boot:
Duplicate route [GET /register] declared by [Auth\\Provider] and [SignUp\\Provider].This is not a coincidence — it is a guard. A silent "last-one-wins" would hide plugin conflicts from a deploy until the moment they matter.
The project resolves ties by disabling one and declaring its own.
Route Name Uniqueness
Route names are a flat, application-wide namespace. Two routes cannot claim the same name, even on different domains:
{
"groups": [
{ "domain": "a.example.com", "routes": [{ "name": "home", ... }] },
{ "domain": "b.example.com", "routes": [{ "name": "home", ... }] } // ✗ boot failure
]
}Use a group name prefix to disambiguate:
{
"groups": [
{ "domain": "a.example.com", "name": "a.", "routes": [{ "name": "home", ... }] },
{ "domain": "b.example.com", "name": "b.", "routes": [{ "name": "home", ... }] }
]
}Names: a.home, b.home
A project override inherits the plugin route's name unless it declares its own, so overridden routes keep their old names accessible.
Compiled Manifest
After compilation, ManifestWriter creates three files:
| File | Purpose | Size |
|---|---|---|
route-manifest.php | Flat map: "METHOD /path" → entry. Public API for tools and RouteCatalog. | Full |
route-index.php | Matcher-ready index: static table, dynamic buckets by first segment, precompiled regex per route. | Full (precompiled) |
route-names.php | Name → {path, method, domain}. UrlGenerator reads this to build URLs without loading the full table. | Small |
Both derived files are optional at runtime: every consumer falls back to deriving from route-manifest.php if the index or names file is missing (e.g., an old deploy). The boot compiler writes all three.
Matching at Request Time
RouteMatcher uses the compiled index. It handles:
- Static routes first (literal paths, exact O(1) lookup)
- Dynamic routes next (parameterized paths, regex scan)
- Domain groups when present (most specific first)
- Trailing slash behavior (strict / ignore / redirect)
- HEAD fallback (HEAD → GET when no HEAD route)
See Route Basics for environment variable control over these behaviors.
Boot Failures
The compiler fails at boot on:
- Duplicate route key — two plugins claim the same
METHOD /path(orMETHOD@domain /path) - Duplicate route name — two routes claim the same name (including across domain groups)
- Duplicate disable spec matching — never happens; a spec that matches nothing fails
- Unknown domain in requires[] — route requires a domain no module solves
- Unknown domain in a group — grouped route's host is not registered in
proj.jsondomains[](when the list exists) - Unknown type in parameter —
{id:badtype}is not in the type table - Duplicate capture name —
/{a}/{a}produces an invalid regex - Invalid capture name —
{2fa}becomes empty after sanitization - Handler missing '@' — must be exactly one
- Handler class/method does not exist — when
ROUTE_VERIFY_HANDLERS=true
Caching (BOOT_CACHE)
When BOOT_CACHE=1, the compiler checks a stamp and skips recompilation if nothing read changed:
BOOT_CACHE=1 php app/public/index.phpThe stamp invalidates on:
- Changed
module.json(any module) - Changed
proj.json(any key the builder reads) - Changed
config/*.phpfiles - Added/removed config file
- Changed
bootstrap/app.php(the builder inputs) - Missing compiled manifests
This saves ~2ms per request on FPM (86× faster than without cache). Clear the cache by deleting var/cache/manifests/.
Static Content
The route manifest includes compiled metadata for each route:
path— the path template (/posts/{id})method— HTTP method (GET,POST, etc.)handler— class@methodregex— precompiled anchored regex (for dynamic routes)params— parameter list with types (for URL generation and matching)filters— filter aliases to applyrequires— module domains to seed into the graphfaces— face restriction (if any)domain— host group ('' for ungrouped)solves— module domain or__project__for project routesname— route name (if any)overrides— the module class that was overridden (if applicable)graph_key— cachebusting key for the dependency graph
Next Steps
- Route Basics — route declaration reference
- Route Disable Policy — in this section
- Route Cookbook — practical override and policy examples
Source
- CompileRouteManifestStage.php — compilation order and policy application
- RoutePolicy.php — disable and allow policy logic
- RouteMatcher.php — request-time matching algorithm