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):
- Application —
App/Controllers/→ namespaceConcept\App\Controllers - Component —
Components/{Name}/Controllers/→Concept\Components\{Name}\Controllers, routes in the componentroutes.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:
- Route interceptors — global checks (ACL) before the handler
- prepareRequest() — URL segments → PSR-7 attributes; update
ServerRequestInterfacein the container - getCallable() — resolve controller from DI (constructor injection)
- resolveArguments() — FormRequest,
ServerRequestInterface, route params, defaults - 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 useUrlGeneratorInterface/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:
- FormRequest — created from the container, validated before entering the method
- ServerRequestInterface — current PSR-7 request
- URL parameters — parameter name = name in
{id:number}, with type casting viaCaster - 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 responsibility —
showLogin,login, not a "universal" method - POST/PUT/PATCH/DELETE — FormRequest + redirect (PRG), not re-rendering the form
- Named routes —
redirectByName()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