Запити та валідація
Вхідні дані HTTP (query, body форми, JSON) валідуються через класи
FormRequest. Після успішної перевірки ви працюєте з масивом
validated() або, за бажанням, з типізованим DTO,
зібраним бібліотекою Valinor.
Валідатор — Rakit Validation (адаптер у Concept Core). Див. також: контролери → аргументи методу, HandleValidationExceptionMiddleware.
Класи ядра
| Клас | Роль |
|---|---|
Concept\Core\Http\Requests\FormRequest |
rules(), validate(), validated(), toDto() |
RouteStrategy |
Резолвить FormRequest з DI, викликає validate() до action |
ValidationException |
Помилки + all() для flash «старий ввід» |
HandleValidationExceptionMiddleware |
422 JSON або flash + back() |
RequestFormat |
expectsJson() — вибір формату відповіді при помилці |
Як це працює
- У action оголошується тип
MyRequest $request(нащадокFormRequest). RouteStrategyспочатку запускає route interceptors, потім кладе сегменти URL у атрибути запиту.- Для параметра FormRequest контейнер створює екземпляр і викликає
validate()до входу в метод. - Якщо правила не виконані —
ValidationExceptionзerrors()і повнимall()→ middleware повертає 422 (JSON) або redirect назад із flash. - Якщо все гаразд — виконується метод контролера; дані читаються через
validated()абоtoDto().
З DTO і без DTO
Обидва підходи підтримуються. DTO не обов’язковий.
| Підхід | Коли зручно | У контролері |
|---|---|---|
| З DTO | Типізований контракт, передача в сервіс | $dto = $request->toDto(); |
| Без DTO |
Пряма вставка / оновлення в БД масивом validated()
|
->create($request->validated()) |
Приклад з DTO
class CreateProjectRequest extends FormRequest
{
protected ?string $dtoClass = CreateProjectDto::class;
public function rules(): array
{
return [
'title' => ['required', 'min:3', 'max:255'],
'service_type' => ['required', 'in:' . implode(',', ProjectModel::SERVICE_TYPES)],
'budget' => ['required', 'max:100'],
'description' => ['required', 'min:10'],
];
}
}
// Контролер
/** @var CreateProjectDto $data */
$data = $request->toDto();
$this->projectModel->newQuery()->create([
ProjectModel::FIELD_TITLE => $data->title,
// ...
]);
Приклад без DTO
UpdateProjectStatusRequest — без $dtoClass.
Масив validated() одразу передають у create() / update()
(за потреби через нормалізатор):
class UpdateProjectStatusRequest extends FormRequest
{
public function rules(): array
{
return [
'status' => ['required', 'in:' . implode(',', array_column(ProjectStatus::cases(), 'value'))],
];
}
}
// Один ключ — оновлення в БД
$project->update([
ProjectModel::FIELD_STATUS => $request->validated()['status'],
]);
// Кілька полів — create() цілим масивом (після except / rules)
$this->postModel->newQuery()->create(
$this->normalizePayload($request->validated())
);
У контролері можна викликати validated() навіть якщо в FormRequest задано
$dtoClass — DTO тоді просто не обов’язковий (toDto() не використовують).
Створення FormRequest
Розмістіть клас у Requests/ компонента або застосунку
(наприклад Components/UserCabinet/Requests/LoginRequest.php).
Успадкуйте Concept\Core\Http\Requests\FormRequest і реалізуйте
rules(). Реєстрація в контейнері не потрібна — спрацьовує автовайринг.
<?php declare(strict_types=1);
namespace Concept\Components\AuthAdmin\Requests\Users;
use Concept\Components\AuthAdmin\Dto\User\StoreUserDto;
use Concept\Components\AuthAdmin\Enums\UserStatus;
use Concept\Core\Http\Requests\FormRequest;
/** @extends FormRequest<StoreUserDto> */
class StoreUserRequest extends FormRequest
{
protected ?string $dtoClass = StoreUserDto::class;
/** Не потрапляють у validated() / DTO після валідації */
protected array $except = [
'password_confirmation',
];
public function rules(): array
{
return [
'name' => ['required', 'min:3'],
'email' => ['required', 'email', 'unique:users,email'],
'password' => ['required', 'min:8'],
'password_confirmation' => ['required', 'min:8', 'same:password'],
'status' => ['required', 'in:' . implode(',', array_column(UserStatus::cases(), 'value'))],
'is_admin' => ['nullable', 'boolean'],
];
}
}
Мінімальний login-запит без $except — LoginRequest
(лише email і password, DTO LoginDto з optional remember).
Правила rules()
Два формати (як у Rakit):
- рядок:
'name' => 'required|min:3' - масив:
'email' => ['required', 'email', 'unique:users,email']
Доступні стандартні правила Rakit (required, email, min,
max, same, in, nullable, boolean тощо).
(див. власні правила).
Псевдоніми aliases()
Людські назви полів у повідомленнях про помилки («Project title» замість «title»).
Необов’язковий метод — за замовчуванням порожній масив. Зливаються з глобальними псевдонімами з
resources/lang/validator/{locale}.php (ValidationTranslationsLoader).
Повідомлення messages()
Перевизначення текстів окремих правил для конкретного FormRequest
(ключ — ім’я правила Rakit, наприклад 'required' => 'Заповніть :attribute').
Глобальні переклади — у тих самих файлах resources/lang/validator/.
$except і $only
-
$except— виключити поля з результатуvalidated()(наприкладpassword_confirmationпісля правилаsame:password) -
$only— білий список ключів уvalidated()(рідко; зазвичай достатньо ключів ізrules()) -
_csrf_tokenвиключається глобально зvalidated(), навіть якщо потрапив у body
validated() повертає лише ключі, присутні в rules()
(перетин із валідними даними), мінус except і глобальні виключення.
Викликати validated() можна лише після validate()
(у контролері валідація вже відбулася; інакше — ValidationLogicException).
Звідки беруться дані: all()
all() об’єднує query string і parsed body
(поля форми або JSON після ParseJsonBodyMiddleware).
Саме ці дані йдуть у валідатор. Для API з JSON підключіть відповідний middleware на групу маршрутів.
Властивість $dtoClass
protected ?string $dtoClass = MyDto::class;— увімкнути мапінг у DTOnullабо відсутність — працює лишеvalidated(),toDto()повернеnull
PHPDoc @extends FormRequest<LoginDto> допомагає IDE/static analysis;
для запитів без DTO — @extends FormRequest<null> або без generic.
Параметри маршруту
Сегменти URL ({id:number}) зазвичай передають окремим аргументом action:
update(UpdateUserRequest $request, int $id).
У підкласі FormRequest доступний захищений getRouteParam('id') —
якщо потрібно підставити id у правила (наприклад unique:users,email,{id}).
Логування
При log.validation_data = true у config/log.php
(ConfigKey::LOG_VALIDATION_DATA) FormRequest логує вхідні дані в all()
і результат validate() — див.
Конфігурація → log.php.
Логер маскує чутливі поля через DataMaskerInterface.
У production зазвичай вимкнено.
Використання в контролері
Оголосіть FormRequest у сигнатурі методу — валідація відбудеться автоматично:
public function store(StoreUserRequest $request): ResponseInterface
{
// Варіант 1: масив validated()
$this->userModel->newQuery()->create(
$this->normalizeUserPayload($request->validated())
);
return $this->response->redirectByName('admin.users');
}
public function store(CreateProjectRequest $request): ResponseInterface
{
// Варіант 2: DTO
/** @var CreateProjectDto $data */
$data = $request->toDto();
$this->projectModel->newQuery()->create([/* поля з $data */]);
return $this->response->redirectByName('cabinet.projects');
}
toDto() викликайте після успішної валідації (у методі контролера це вже гарантовано).
Перевіряйте null, якщо $dtoClass не задано або Caster недоступний.
Помилки валідації
При невдалій валідації HandleValidationExceptionMiddleware:
-
JSON / API — якщо
RequestFormat::expectsJson()(Accept: application/jsonабоX-Requested-With: XMLHttpRequest) —422, тілоjsonError()з полемerrors -
HTML-форма — flash
error,SessionKey::VALIDATION_ERRORS(_validation_errors),SessionKey::VALIDATION_DATA(_validation_data), redirectback()
У flash «старий ввід» потрапляє повний all() (query + body), не лише
validated(). ShareViewDataMiddleware мапить flash у Twig:
ViewKey::ERRORS → errors,
ViewKey::OLD_INPUT → old.
<label for="email">Email</label>
<input id="email" type="email" name="email" required
value="{{ old.email|default('') }}"
class="input {{ errors.email is defined and errors.email ? 'is-invalid' : '' }}"
aria-describedby="email-error">
{% if errors.email is defined and errors.email is not empty %}
<div class="field-error" id="email-error" role="alert">
{{ errors.email is iterable ? errors.email|join(', ') : errors.email }}
</div>
{% endif %}
Помилки мапінгу DTO
Якщо toDto() не може зібрати об’єкт (невідповідність типів після валідації),
кидається ValidationCastException (обгортка над Valinor MappingError).
Це не те саме, що помилки правил — обробляє загальний error handler, не validation middleware.
Тому правила FormRequest мають узгоджуватися з типами в DTO (наприклад boolean для bool $remember).
Власні правила валідації
Реєстрація в config/validator.php
(ValidationServiceProvider → ConfigKey::VALIDATOR_RULES):
use Concept\App\Validation\Rules\ExistsRule;
use Concept\App\Validation\Rules\UniqueRule;
return [
'validator' => [
'rules' => [
'unique' => UniqueRule::class,
'exists' => ExistsRule::class,
],
],
];
Клас правила реалізує RuleInterface, успадковує Concept\Core\Services\Validator\Rule.
Наприклад:
'email' => ['unique:users,email'] у StoreUserRequest,
'email' => ['exists:users,email'] у ForgotPasswordRequest.
Для update except id: 'unique:users,email,' . $this->getRouteParam('id') . ',id'
у rules() (третій параметр — id запису, який ігнорувати).
DTO (Data Transfer Object)
DTO — immutable об’єкт з readonly-властивостями для вже перевірених даних.
Базовий клас Concept\Core\Dto\Dto реалізує toArray() через cast об’єкта в масив.
Як створити DTO
<?php declare(strict_types=1);
namespace Concept\Components\AuthAdmin\Dto\User;
use Concept\Core\Dto\Contracts\DtoInterface;
use Concept\Core\Dto\Dto;
class StoreUserDto extends Dto implements DtoInterface
{
public function __construct(
public readonly string $name,
public readonly string $email,
public readonly string $password,
public readonly string $status,
public readonly bool $is_admin = false,
public readonly ?string $verification_token = null,
) {}
}
- Імена властивостей збігаються з ключами у
validated()(snake_case у формах → snake_case у DTO) - Типи в конструкторі мають відповідати тому, що поверне валідатор + Valinor casting
- Необов’язкові поля — default у конструкторі або
?type - Поля з
$exceptне потраплять у DTO - Поля з
$onlyобмежують те, що потрапляє уvalidated()та DTO
Valinor і Caster
Мапінг виконує Concept\Core\Services\Caster\Caster на базі
Valinor
(cuyz/valinor у залежностях core; можливий внутрішній форк з тим самим API
CuyZ\Valinor — тоді актуальна та сама документація).
Описує мапінг масивів у об’єкти, strict types, вкладені структури — це застосовно до DTO Concept Core.
allowScalarValueCasting()— рядок"1"→int/boolза потребиallowSuperfluousKeys()— зайві ключі в масиві не ламають мапінг (зручно для форм)- Трансформатори: власна логіка для складних типів через ключ конфігу
caster.transformers - Кеш мапера:
storage/cache/valinor(швидший повторний мапінг)
Корисні розділи документації Valinor:
- How to map input data
- Handle invalid input
- Feature flags (casting, superfluous keys)
Що робить toDto()
// Спрощено
if ($this->dtoClass === null || !$this->caster) {
return null;
}
return $this->caster->cast($this->validated(), $this->dtoClass);
Спочатку Rakit перевіряє правила, потім Valinor збирає структуру.
Валідація бізнес-логіки (унікальність email у БД) — у rules();
приведення типів — у DTO/Valinor.
Чеклист
- Створити
Requests/MyActionRequest.phpзrules()і за потребиaliases(). - За потреби —
Dto/MyActionDto.phpіprotected ?string $dtoClass = .... - Додати type-hint у контролер:
public function store(MyActionRequest $request). - Переконатися, що глобальний
HandleValidationExceptionMiddlewareпідключений у маршрутах. - У Twig використовувати
errors/oldдля повторного показу форми.
FormRequest відповідає за вхідні дані запиту. Авторизація («чи може цей user») —
у middleware або сервісах, не в rules(),
якщо для цього потрібен контекст сесії поза полями форми.