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