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.