Контролери

Контролер — клас, який обробляє HTTP-запит після middleware і повертає Psr\Http\Message\ResponseInterface. У Concept Core немає обов’язкового базового класу: це звичайний PHP-клас із публічними методами, прив’язаними до маршрутів.

Див. також: маршрутизація, життєвий цикл → контролер, middleware, route interceptors.

Прив’язка до маршруту

Callable маршруту — масив [Клас::class, 'метод']:

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 бере контролер із DI-контейнера; залежності конструктора підставляються автоматично (автовайринг). Метод action викликає RouteStrategy.

Де розміщувати

Типові варіанти (шляхи налаштовуються через paths.php):

  • ЗастосунокApp/Controllers/ → namespace Concept\App\Controllers
  • КомпонентComponents/{Name}/Controllers/Concept\Components\{Name}\Controllers, маршрути в routes.php компонента

Детальніше про дерева каталогів і namespaces — у структурі каталогів. Один контролер — одна зона відповідальності (кабінет, адмін, публічна вітрина). Складну логіку виносьте в Services/.

Мінімальний приклад

<?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');
    }
}

Шаблон @frontend/index — логічне ім’я Twig (префікс компонента/модуля). Детальніше — у розділі Views.

Повний приклад

UserController компонента AuthAdmin — типовий CRUD: список, форми, FormRequest, flash + redirect після POST, окремий 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: від маршруту до action

Після middleware League Route викликає RouteStrategy::invokeRouteCallable(). Порядок усередині стратегії:

  1. Route interceptors — глобальні перевірки (ACL) до handler
  2. prepareRequest() — сегменти URL → атрибути PSR-7; оновлення ServerRequestInterface у контейнері
  3. getCallable() — резолв контролера з DI (constructor injection)
  4. resolveArguments() — FormRequest, ServerRequestInterface, route params, defaults
  5. invokeArgs() — виклик методу action

Телеметрія HTTP_ROUTE_CALLABLE_INVOKED охоплює крок 5. Детальніше — життєвий цикл.

Автовайринг і конструктор

Під час bootstrap створюється League Container із делегатом ReflectionContainer: якщо клас не зареєстрований явно, контейнер аналізує конструктор і рекурсивно створює залежності за type-hint.

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

Для маршруту [UserController::class, 'index'] League Route робить приблизно $container->get(UserController::class) — один екземпляр контролера на запит (shared за замовчуванням для того самого класу в межах одного HTTP-запиту).

Що передавати в конструктор

Оголошуйте залежності явно — через типи параметрів. Контролер «просить» те, що йому потрібно:

Тип залежності Приклад Коментар
Контракти ядра ResponseFactoryInterface, ConfigInterface Реєстрація в service providers Concept Core
Сервіси застосунку / компонента UserAuthService, AuthService Бізнес-логіка; краще залежність від інтерфейсу, якщо є
Моделі, репозиторії UserModel, ProjectModel Допустимо в MVC-стилі; для складних доменів — обгортка-сервіс
Інфраструктура HTTP/сесії FlashBagInterface Для flash-повідомлень після redirect
public function __construct(
    private readonly ResponseFactoryInterface $response,
    private readonly ViewResponseFactoryInterface $viewResponse,
    private readonly UserAuthService $auth,
    private readonly FlashBagInterface $flashBag,
    private readonly ProjectModel $projectModel,
) {}

Якщо клас не резолвиться (немає binding, циклічна залежність) — помилка контейнера на етапі запиту. Власні реалізації реєструйте в ServiceProvider компонента або застосунку (детальніше — Контейнер сервісів, Service providers).

Не використовуйте контейнер як service locator

У контейнері зареєстровано ContainerInterface, але не інжектуйте його в контролер і не викликайте $this->container->get(...) у методах action. Це приховує реальні залежності, ускладнює тести й рефакторинг.

Погано (service locator)

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

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

Добре (явні залежності)

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

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

Що краще не передавати в конструктор

  • ContainerInterface, League\Container\Container — антипатерн service locator (див. вище)
  • Router — для URL є UrlGeneratorInterface / redirectByName()
  • Суперглобали ($_GET, $_POST) — лише через FormRequest або PSR-7

Контейнер у App::getContainer() — для bootstrap, CLI і service providers. У контролерах, middleware і сервісах домену покладайтеся на constructor injection конкретних типів.

Відповіді

Кожен action повертає ResponseInterface. Дві основні фабрики:

Інтерфейс Коли використовувати
ViewResponseFactoryInterface HTML: create('@cabinet/index', ['user' => $user])
ResponseFactoryInterface JSON, redirect, back()
// Redirect за іменем маршруту
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 після POST
$this->flashBag->set('success', 'Збережено');
return $this->response->redirectByName('admin.users');

Повний опис — Відповіді; Twig-шаблони — Views.

Аргументи методу

RouteStrategy заповнює параметри action у такому пріоритеті:

  1. FormRequest — створення з контейнера, валідація до входу в метод
  2. ServerRequestInterface — поточний PSR-7 запит
  3. Параметри URL — ім’я параметра = ім’я в {id:number}, з приведенням типів через Caster
  4. Значення за замовчуванням у сигнатурі PHP
public function show(int $id): ResponseInterface { /* ... */ }

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

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

Сервіси (UserModel, ResponseFactoryInterface тощо) — через конструктор (автовайринг), не через ContainerInterface::get(). У параметри методу — лише FormRequest, ServerRequestInterface і сегменти URL.

FormRequest

Type-hint нащадка FormRequest у action — RouteStrategy створює екземпляр з контейнера і викликає validate() до входу в метод. При помилці — ValidationException, яку перехоплює HandleValidationExceptionMiddleware (JSON 422 або 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');
}

Повний опис правил, DTO, except/only — у розділі Запити та валідація.

Параметри маршруту

Сегмент {id:number} потрапляє в метод як int $id (якщо імена збігаються). Той самий id доступний і як атрибут запиту: $request->getAttribute('id').

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

Route interceptors

Глобальні ACL-перевірки за setName() маршруту виконуються в RouteStrategy до autowire action — через класи з routes.interceptors. Типова відмова — throw винятку; redirect або JSON 403 — у middleware, що обгортає handler.

Детальні приклади — Route interceptors.

Авторизація

Розділіть рівні перевірки:

  • Middleware на групі — «чи залогінений», redirect на login (приклади)
  • Route interceptors — ACL за named route (детальніше)
  • Контролер — бізнес-обмеження (ресурс належить поточному користувачу)
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]);
}

Рекомендовані патерни

  • Тонкий контролер — orchestration: запит → сервіс → відповідь
  • Один action — одна відповідальністьshowLogin, login, не «універсальний» метод
  • POST/PUT/PATCH/DELETE — FormRequest + redirect (PRG), не повторний рендер форми
  • Іменовані маршрутиredirectByName() замість жорстких URL

Closure та invokable

Маршрут може вказувати на closure або клас з __invoke. RouteStrategy autowire для них обмежений: FormRequest, ServerRequestInterface, сегменти URL і значення за замовчуванням. Довільні сервіси (UserModel тощо) у параметрах closure не резолвляться — для DI використовуйте класи контролерів.

Діагностика

php bin/console.php route:list