Controllers

A controller is a class that handles an HTTP request after middleware and returns Psr\Http\Message\ResponseInterface. Concept Core has no mandatory base class: it is a plain PHP class with public methods bound to routes.

See also: routing, request lifecycle → controller, middleware, route interceptors.

Binding to a route

The route callable is an array [Class::class, 'method']:

use Concept\App\Controllers\IndexController;

$router->get('/', [IndexController::class, 'index'])->setName('home');
$router->get('/projects/{id:number}', [CabinetProjectController::class, 'show'])
    ->setName('cabinet.project.show');

League Route resolves the controller from the DI container; constructor dependencies are injected automatically (autowiring). The action method is invoked by RouteStrategy.

Where to place them

Typical options (paths configured via paths.php):

  • ApplicationApp/Controllers/ → namespace Concept\App\Controllers
  • ComponentComponents/{Name}/Controllers/Concept\Components\{Name}\Controllers, routes in the component routes.php

More on directory trees and namespaces — in directory structure. One controller — one area of responsibility (cabinet, admin, public site). Move complex logic to Services/.

Minimal example

<?php declare(strict_types=1);

namespace Concept\App\Controllers;

use Concept\Core\Services\View\Contracts\ViewResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;

class IndexController
{
    public function __construct(
        private readonly ViewResponseFactoryInterface $viewResponse,
    ) {}

    public function index(): ResponseInterface
    {
        return $this->viewResponse->create('@frontend/index');
    }
}

Template @frontend/index is a logical Twig name (component/module prefix). More details — in the Views section.

Full example

UserController from the AuthAdmin component — typical CRUD: list, forms, FormRequest, flash + redirect after POST, separate JSON endpoint:

namespace Concept\Components\AuthAdmin\Controllers;

class UserController
{
    public function __construct(
        private readonly ResponseFactoryInterface $response,
        private readonly ViewResponseFactoryInterface $viewResponse,
        private readonly ConfigInterface $config,
        private readonly AuthService $authService,
        private readonly FlashBagInterface $flashBag,
        private readonly UserModel $userModel,
    ) {}

    public function store(StoreUserRequest $request): ResponseInterface
    {
        $this->userModel
            ->newQuery()
            ->create($this->normalizeUserPayload($request->validated()));

        $this->flashBag->addSuccess(, 'User successfully created');

        return $this->response->redirectByName('admin.users');
    }

    public function updatePassword(UpdateUserPasswordRequest $request, int $id): ResponseInterface
    {
        // ...
        $data = $request->toDto();
        $user->update([UserModel::FIELD_PASSWORD => $data->password]);
        // ...
    }

    public function generateTokenApi(): ResponseInterface
    {
        return $this->response->json(['token' => bin2hex(random_bytes(32))]);
    }
}

RouteStrategy: from route to action

After middleware, League Route calls RouteStrategy::invokeRouteCallable(). Order inside the strategy:

  1. Route interceptors — global checks (ACL) before the handler
  2. prepareRequest() — URL segments → PSR-7 attributes; update ServerRequestInterface in the container
  3. getCallable() — resolve controller from DI (constructor injection)
  4. resolveArguments() — FormRequest, ServerRequestInterface, route params, defaults
  5. invokeArgs() — call the action method

Telemetry HTTP_ROUTE_CALLABLE_INVOKED covers step 5. More details — request lifecycle.

Autowiring and constructor

During bootstrap, a League Container is created with a ReflectionContainer delegate: if a class is not registered explicitly, the container analyzes the constructor and recursively creates dependencies by type hint.

// Concept\Core\App::__construct()
$this->container->delegate(new ReflectionContainer(cacheResolutions: true));

For route [UserController::class, 'index'], League Route roughly does $container->get(UserController::class) — one controller instance per request (shared by default for the same class within one HTTP request).

What to inject in the constructor

Declare dependencies explicitly — via parameter types. The controller "asks" for what it needs:

Dependency type Example Notes
Core contracts ResponseFactoryInterface, ConfigInterface Registered in Concept Core service providers
Application / component services UserAuthService, AuthService Business logic; prefer interface dependency when available
Models, repositories UserModel, ProjectModel Acceptable in MVC style; for complex domains — use a service wrapper
HTTP / session infrastructure FlashBagInterface For flash messages after redirect
public function __construct(
    private readonly ResponseFactoryInterface $response,
    private readonly ViewResponseFactoryInterface $viewResponse,
    private readonly UserAuthService $auth,
    private readonly FlashBagInterface $flashBag,
    private readonly ProjectModel $projectModel,
) {}

If a class cannot be resolved (no binding, circular dependency) — container error at request time. Register custom implementations in a component or application ServiceProvider (more — Service container, Service providers).

Do not use the container as a service locator

ContainerInterface is registered in the container, but do not inject it into controllers and do not call $this->container->get(...) in action methods. This hides real dependencies, complicates tests, and makes refactoring harder.

Bad (service locator)

public function __construct(
    private readonly ContainerInterface $container,
) {}

public function index(): ResponseInterface
{
    $auth = $this->container->get(UserAuthService::class);
    // ...
}

Good (explicit dependencies)

public function __construct(
    private readonly UserAuthService $auth,
    private readonly ViewResponseFactoryInterface $viewResponse,
) {}

public function index(): ResponseInterface
{
    $user = $this->auth->user();
    // ...
}

What not to inject in the constructor

  • ContainerInterface, League\Container\Container — service locator anti-pattern (see above)
  • Router — for URLs use UrlGeneratorInterface / redirectByName()
  • Superglobals ($_GET, $_POST) — only via FormRequest or PSR-7

The container in App::getContainer() is for bootstrap, CLI, and service providers. In controllers, middleware, and domain services rely on constructor injection of concrete types.

Responses

Every action returns ResponseInterface. Two main factories:

Interface When to use
ViewResponseFactoryInterface HTML: create('@cabinet/index', ['user' => $user])
ResponseFactoryInterface JSON, redirect, back()
// Redirect by route name
return $this->response->redirectByName('cabinet.login');

// JSON (API) — json(), jsonSuccess(), jsonError()
return $this->response->json(['token' => $token]);
return $this->response->jsonSuccess(['id' => $id]);

// Flash + redirect after POST
$this->flashBag->set('success', 'Saved');
return $this->response->redirectByName('admin.users');

Full description — Responses; Twig templates — Views.

Method arguments

RouteStrategy fills action parameters in this priority:

  1. FormRequest — created from the container, validated before entering the method
  2. ServerRequestInterface — current PSR-7 request
  3. URL parameters — parameter name = name in {id:number}, with type casting via Caster
  4. Default values in the PHP signature
public function show(int $id): ResponseInterface { /* ... */ }

public function update(UpdateUserRequest $request, int $id): ResponseInterface { /* ... */ }

public function handle(ServerRequestInterface $request): ResponseInterface { /* ... */ }

Services (UserModel, ResponseFactoryInterface, etc.) — via constructor (autowiring), not via ContainerInterface::get(). Method parameters — only FormRequest, ServerRequestInterface, and URL segments.

FormRequest

A type-hinted FormRequest subclass in the action — RouteStrategy creates an instance from the container and calls validate() before entering the method. On failure — ValidationException, caught by HandleValidationExceptionMiddleware (JSON 422 or flash + back()).

class StoreUserRequest extends FormRequest
{
    protected ?string $dtoClass = StoreUserDto::class;

    protected array $except = ['password_confirmation'];

    public function rules(): array
    {
        return [
            'email' => ['required', 'email', 'unique:users,email'],
            'password' => ['required', 'min:8'],
            'password_confirmation' => ['required', 'same:password'],
        ];
    }
}

public function store(StoreUserRequest $request): ResponseInterface
{
    $this->userModel->newQuery()->create($request->validated());
    return $this->response->redirectByName('admin.users');
}

Full description of rules, DTO, except/only — in Requests and validation.

Route parameters

Segment {id:number} arrives in the method as int $id (when names match). The same id is also available as a request attribute: $request->getAttribute('id').

$router->get('/edit/{id:number}', [UserController::class, 'edit'])
    ->setName('admin.user.edit');

Route interceptors

Global ACL checks by route setName() run in RouteStrategy before autowiring the action — via classes from routes.interceptors. Typical failure — throw an exception; redirect or JSON 403 — in middleware wrapping the handler.

More examples — Route interceptors.

Authorization

Separate levels of checks:

  • Middleware on a group — "is logged in", redirect to login (examples)
  • Route interceptors — ACL by named route (more details)
  • Controller — business constraints (resource belongs to current user)
public function show(int $id): ResponseInterface
{
    $user = $this->auth->user();
    if (!$user instanceof UserModel) {
        return $this->response->redirectByName('cabinet.login');
    }

    $project = $this->findUserProject($user, $id);
    if ($project === null) {
        $this->flashBag->addError(, 'Order not found.');
        return $this->response->redirectByName('cabinet.projects');
    }

    return $this->viewResponse->create('@projects/cabinet/show', ['project' => $project]);
}

Recommended patterns

  • Thin controller — orchestration: request → service → response
  • One action — one responsibilityshowLogin, login, not a "universal" method
  • POST/PUT/PATCH/DELETE — FormRequest + redirect (PRG), not re-rendering the form
  • Named routesredirectByName() instead of hard-coded URLs

Closure and invokable

A route can point to a closure or a class with __invoke. RouteStrategy autowiring for them is limited: FormRequest, ServerRequestInterface, URL segments, and default values. Arbitrary services (UserModel, etc.) in closure parameters are not resolved — use controller classes for DI.

Diagnostics

php bin/console.php route:list