Запити та валідація

Вхідні дані 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() — вибір формату відповіді при помилці

Як це працює

  1. У action оголошується тип MyRequest $request (нащадок FormRequest).
  2. RouteStrategy спочатку запускає route interceptors, потім кладе сегменти URL у атрибути запиту.
  3. Для параметра FormRequest контейнер створює екземпляр і викликає validate() до входу в метод.
  4. Якщо правила не виконані — ValidationException з errors() і повним all() → middleware повертає 422 (JSON) або redirect назад із flash.
  5. Якщо все гаразд — виконується метод контролера; дані читаються через 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-запит без $exceptLoginRequest (лише 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; — увімкнути мапінг у DTO
  • null або відсутність — працює лише 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), redirect back()

У flash «старий ввід» потрапляє повний all() (query + body), не лише validated(). ShareViewDataMiddleware мапить flash у Twig: ViewKey::ERRORSerrors, ViewKey::OLD_INPUTold.

<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 (ValidationServiceProviderConfigKey::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:

Що робить toDto()

// Спрощено
if ($this->dtoClass === null || !$this->caster) {
    return null;
}
return $this->caster->cast($this->validated(), $this->dtoClass);

Спочатку Rakit перевіряє правила, потім Valinor збирає структуру. Валідація бізнес-логіки (унікальність email у БД) — у rules(); приведення типів — у DTO/Valinor.

Чеклист

  1. Створити Requests/MyActionRequest.php з rules() і за потреби aliases().
  2. За потреби — Dto/MyActionDto.php і protected ?string $dtoClass = ....
  3. Додати type-hint у контролер: public function store(MyActionRequest $request).
  4. Переконатися, що глобальний HandleValidationExceptionMiddleware підключений у маршрутах.
  5. У Twig використовувати errors / old для повторного показу форми.

FormRequest відповідає за вхідні дані запиту. Авторизація («чи може цей user») — у middleware або сервісах, не в rules(), якщо для цього потрібен контекст сесії поза полями форми.