Refactor Router & ModuleLoader Architecture (Route Object–Centric)
Summary
Refactor Quantum’s routing subsystem to be architecturally correct, explicit, and maintainable while preserving all existing behavior and public APIs.
This refactor focuses only on routing and module-loading concerns and is intentionally scoped to avoid overlap with the broader App boot pipeline work.
The key outcome is to make Route a first-class domain object, clarify ownership boundaries between ModuleLoader, route construction, and Router, and stabilize RouteController as the global routing state holder.
Target PHP version: 7.4+
Motivation
The current routing flow has several structural issues:
Route acts as both:
- a route definition DSL, and
- a container for multiple routes
ModuleLoader:
- loads module configuration ✔
- executes route closures ❌
- mutates routing state ❌
- Routes are flattened into arrays early, losing structure and intent
- Responsibilities are implicit, making safe refactoring difficult
Despite this, the current system works and must not be broken.
This refactor preserves behavior while correcting ownership and data flow.
Hard Constraints (Must Not Change)
These constraints are non-negotiable:
RouteController must remain
- Static routing state must remain
- All router helpers must continue to work
- Route definition syntax in modules must not change
- No providers, no kernel rewrite, no breaking changes
Core Design Decisions
1. Route Represents a Single Route (Entity)
Route becomes a single-route domain object, encapsulating:
- path / pattern
- HTTP method(s)
- module
- controller / action or callback
- middlewares
- cache settings
- name
- group (if any)
- runtime match data (params, uri, pattern)
Route does not:
- store other routes
- manage groups
- execute closures
- act as a collection
2. RouteBuilder Handles Route Composition
A new internal component (name flexible, e.g. RouteBuilder) is responsible for:
- executing module route definition closures
- managing groups
- applying middlewares
- producing Route objects
- returning
Route[]
This preserves the current DSL unchanged, while moving complexity out of Route.
3. ModuleLoader Stops Touching Routes
ModuleLoader responsibilities are reduced to:
- loading module configs
- loading module dependency definitions
- loading module route closures only
It must not:
- instantiate
Route
- execute route closures
- build routes
- interact with
Router or RouteController
4. RouteController Remains the Global Routing State
RouteController remains the single global routing state holder, because:
- all router helpers depend on it
- controllers, views, middleware rely on static access
- removing it would be a breaking rewrite
Internal Change Only
Before:
RouteController::$routes // array[]
RouteController::$currentRoute // array
After:
/** @var Route[] */
RouteController::$routes;
/** @var Route|null */
RouteController::$currentRoute;
Helpers are updated internally to read from Route objects, but their APIs remain unchanged.
5. Router Matches Requests Against Route Objects
Router:
- receives
Route[] from RouteController
- performs matching
- selects a single
Route
- sets it as
RouteController::$currentRoute
Matching logic remains the same, but operates on objects instead of arrays.
Final Responsibility Map
| Component |
Responsibility |
| ModuleLoader |
Load module configs, dependencies, route closures |
| RouteBuilder |
Execute closures, build Route[] |
| Route |
Represent a single route |
| RouteController |
Store global routing state |
| Router |
Match Request → Route |
| RouteDispatcher |
Execute controller / callback |
| Helpers |
Read-only access to current Route |
Boot-Time Routing Flow (Web)
ModuleLoader loads enabled module route closures
RouteBuilder executes closures → Route[]
RouteController::setRoutes(Route[])
Router::findRoute() matches request
RouteController::setCurrentRoute(Route)
- Helpers & dispatcher read from
RouteController
What This Ticket Does NOT Do
- Does not remove static helpers
- Does not remove
RouteController
- Does not refactor App boot pipeline
- Does not introduce providers
- Does not change route DSL
- Does not rewrite middleware or dispatcher
Acceptance Criteria
- Routes are represented internally as
Route objects
ModuleLoader no longer builds or mutates routes
RouteController stores Route[] and Route
- All existing helpers continue to work
- No behavior change in routing
- No breaking public API changes
Guiding Rule
Modules define routes.
Builders construct routes.
Router matches routes.
RouteController stores routes.
This rule must hold across the routing subsystem.
What Changes in WebAppTrait::loadModules()
Before
$modulesRoutes = $moduleLoader->loadModulesRoutes();
Router::setRoutes($modulesRoutes);
After (conceptual)
$closures = $moduleLoader->loadModuleRouteClosures();
$routes = $routeBuilder->build($closures);
RouteController::setRoutes($routes);
Refactor Router & ModuleLoader Architecture (Route Object–Centric)
Summary
Refactor Quantum’s routing subsystem to be architecturally correct, explicit, and maintainable while preserving all existing behavior and public APIs.
This refactor focuses only on routing and module-loading concerns and is intentionally scoped to avoid overlap with the broader App boot pipeline work.
The key outcome is to make Route a first-class domain object, clarify ownership boundaries between
ModuleLoader, route construction, andRouter, and stabilizeRouteControlleras the global routing state holder.Target PHP version: 7.4+
Motivation
The current routing flow has several structural issues:
Routeacts as both:ModuleLoader:Despite this, the current system works and must not be broken.
This refactor preserves behavior while correcting ownership and data flow.
Hard Constraints (Must Not Change)
These constraints are non-negotiable:
RouteControllermust remainCore Design Decisions
1. Route Represents a Single Route (Entity)
Routebecomes a single-route domain object, encapsulating:Routedoes not:2. RouteBuilder Handles Route Composition
A new internal component (name flexible, e.g.
RouteBuilder) is responsible for:Route[]This preserves the current DSL unchanged, while moving complexity out of
Route.3. ModuleLoader Stops Touching Routes
ModuleLoaderresponsibilities are reduced to:It must not:
RouteRouterorRouteController4. RouteController Remains the Global Routing State
RouteControllerremains the single global routing state holder, because:Internal Change Only
Before:
After:
Helpers are updated internally to read from
Routeobjects, but their APIs remain unchanged.5. Router Matches Requests Against Route Objects
Router:Route[]fromRouteControllerRouteRouteController::$currentRouteMatching logic remains the same, but operates on objects instead of arrays.
Final Responsibility Map
Route[]Boot-Time Routing Flow (Web)
ModuleLoaderloads enabled module route closuresRouteBuilderexecutes closures →Route[]RouteController::setRoutes(Route[])Router::findRoute()matches requestRouteController::setCurrentRoute(Route)RouteControllerWhat This Ticket Does NOT Do
RouteControllerAcceptance Criteria
RouteobjectsModuleLoaderno longer builds or mutates routesRouteControllerstoresRoute[]andRouteGuiding Rule
This rule must hold across the routing subsystem.
What Changes in
WebAppTrait::loadModules()Before
After (conceptual)