Problem
Your routes are growing past a few closures. You want each route to dispatch to a class method, and you want the controller's dependencies (page repository, logger, mailer, your own services) to be injected through the constructor rather than yanked out of the container inside every method.
Recipe
The shape works with the hand-written router from the Build a Module tutorial, the FastRoute wrap, and any third-party router that lets you register a closure as the handler. The router itself stays ignorant of the container; the closure does the wiring.
Write the controller as a normal class with its dependencies in the constructor:
namespace Acme\Site\Api;
use Bigins\ScriptorSimpleRouter\Request;
use Bigins\ScriptorSimpleRouter\Response;
use Psr\Log\LoggerInterface;
use Scriptor\Boot\Frontend\PageRepository;
final class UserController
{
public function __construct(
private readonly PageRepository $pages,
private readonly LoggerInterface $logger,
) {}
public function show(Request $req): Response
{
$id = (int) $req->param('id');
$this->logger->info('User detail requested for {id}', ['id' => $id]);
return Response::json([
'id' => $id,
'name' => 'Ada Lovelace',
]);
}
}
No App::container() calls inside the methods. Every dependency
is visible at the top of the file.
Register the route with a closure that builds the controller and forwards the call:
// themes/<your-theme>/routes.php
use Acme\Site\Api\UserController;
use Bigins\ScriptorSimpleRouter\Request;
use Psr\Log\LoggerInterface;
use Scriptor\Boot\App;
use Scriptor\Boot\Frontend\PageRepository;
$router = \Bigins\ScriptorSimpleRouter\Router::instance();
$router->get('/api/users/{id}', static function (Request $req): \Bigins\ScriptorSimpleRouter\Response {
$container = App::container();
return (new UserController(
$container->get(PageRepository::class),
$container->get(LoggerInterface::class),
))->show($req);
});
Five lines of glue per route. The container resolution happens inside the closure, so it runs only when the route matches; routes that never match never construct their controllers.
The pattern works because every Scriptor service the controller
needs is bound into the container by default
(PageRepository, LoggerInterface, SessionStore,
EventDispatcherInterface, your own bindings from boot.php).
Anything you register with the container becomes injectable the
same way.
Variants
Factory shared across several routes for the same controller
When one controller owns three routes, the glue duplicates. Lift
the factory into a single static closure:
$userController = static fn (): UserController => new UserController(
App::container()->get(PageRepository::class),
App::container()->get(LoggerInterface::class),
);
$router->get('/api/users', static fn (Request $r) => $userController()->index($r));
$router->get('/api/users/{id}', static fn (Request $r) => $userController()->show($r));
$router->post('/api/users', static fn (Request $r) => $userController()->create($r));
The factory is still per-request (each $userController() call
constructs a fresh instance); the expression is shared.
League Container reflection autowiring
If you switch the container to League's reflection-based autowiring
(documented under "Auto wiring"),
the factory closure collapses to a single get() call:
$router->get('/api/users/{id}', static fn (Request $r) =>
App::container()->get(UserController::class)->show($r),
);
This trades explicit wiring for less boilerplate. Both shapes are common; the factory-closure version stays useful when a controller takes constructor arguments the container cannot infer (scalar config values, request-scoped state).
See also
- Auth middleware on a custom route: how to gate a controller behind a login check before it constructs
- Wrap FastRoute or league/route in a plugin: works identically with FastRoute's signature, the controller factory does not care which router invokes it
App: the locator the factory closures reach for; the only legitimate use ofApp::container()outside a theme's_ext.phpPageRepositoryandFileLogger: two of the services this recipe injects- Build a Module: Theme Integration:
the chapter that establishes the
routes.php-next-to-_ext.phpconvention these factories live in