Build a Feature: Controller, Form, Service, and Repository

The documentation is constantly evolving and updated alongside the project's progress. Since it is developed in parallel with the code, some sections may temporarily refer to earlier versions of the system-these are gradually adapted to the current state of the application. If you notice any outdated information, please report it or open an Issue / Pull Request.

Documentation

# Build a Feature: Controller, Form, Service, and Repository

This guide shows a common application structure for a form-backed feature. It separates HTTP flow, validation, business logic, and persistence. Names and constructor dependencies should match the services registered by your application.

App\Account\
├── Controller/
├── Form/
├── Repository/
└── Service/

# 1. Controller: coordinate the request

final class SignInController extends BaseController
{
    public function __construct(
        private readonly SignInService $service,
        private readonly CsrfTokenManager $csrf,
        private readonly TranslationInterface $translation,
        private readonly FlashBag $flash,
    ) {}

    public function form(): ResponseInterface
    {
        return $this->render('account/sign-in.phtml');
    }

    public function submit(): ResponseInterface
    {
        $data = (array) $this->request->getParsedBody();
        $form = new SignInForm($this->csrf, $this->translation);
        $errors = $form->validate($data);

        if ($errors !== []) {
            return $this->render('account/sign-in.phtml', ['errors' => $errors]);
        }

        $userId = $this->service->authenticate(
            (string) $data['login'],
            (string) $data['password'],
        );

        if ($userId === null) {
            $this->flash->set($this->translation->trans('auth.login.invalid'));
            return $this->redirect('account.sign-in');
        }

        $this->setSession(getenv('APP_SESSION_KEY'), $userId);
        return $this->redirect('account.home');
    }
}

Register GET and POST routes explicitly in the application route file; PHPDoc routing comments alone do not register endpoints.

# 2. Form: validate input

final class SignInForm extends Validator
{
    public function __construct(
        CsrfTokenManager $csrf,
        ?TranslationInterface $translation = null,
    ) {
        parent::__construct($translation);
        $this->registerCsrfRule($csrf);
    }

    public function validate(array $data): array
    {
        $this->rules([
            'csrf_token' => ['required', 'csrf'],
            'login' => ['required', 'string', 'min:2', 'max:60'],
            'password' => ['required', 'string', 'min:8'],
        ], $data);

        return $this->getErrors();
    }
}

Add domain-specific checks to the form or a dedicated validator where appropriate. The service must still enforce business rules that cannot be trusted to a form.

# 3. Service: perform business operations

final class SignInService
{
    public function __construct(private readonly UserRepository $users) {}

    public function authenticate(string $login, string $password): ?int
    {
        $user = $this->users->findByLoginOrEmail($login);
        if ($user === null || !password_verify($password, $user->passwordHash)) {
            return null;
        }

        return $user->id;
    }
}

Keep the controller thin and let the service own authentication/business decisions. Do not return passwords or credential hashes to templates.

# 4. Repository: persist and retrieve data

final class UserRepository extends AbstractRepository
{
    protected string $table = 'users';

    public function findByLoginOrEmail(string $login): ?User
    {
        // Query with bound parameters and map the result to a domain object.
    }
}

Use the database abstraction and parameterized queries. Keep persistence details out of templates and avoid constructing SQL from raw request values.

# Request flow

Request → Route → Controller → Form validation → Service → Repository → Database

Flash messages and translations support user feedback; a CSRF token protects state-changing forms. The exact route names, repository methods, and database schema are application decisions, not framework defaults.