Requests and Validation
Incoming HTTP data (query, form body, JSON) is validated through
FormRequest classes. After successful validation you work with the
validated() array or, optionally, a typed DTO
built by the Valinor library.
The validator is Rakit Validation (adapter in Concept Core). See also: controllers → method arguments, HandleValidationExceptionMiddleware.
Core classes
| Class | Role |
|---|---|
Concept\Core\Http\Requests\FormRequest |
rules(), validate(), validated(), toDto() |
RouteStrategy |
Resolves FormRequest from DI, calls validate() before action |
ValidationException |
Errors + all() for flash "old input" |
HandleValidationExceptionMiddleware |
422 JSON or flash + back() |
RequestFormat |
expectsJson() — chooses error response format |
How it works
- The action declares type
MyRequest $request(aFormRequestsubclass). RouteStrategyfirst runs route interceptors, then puts URL segments into request attributes.- For a FormRequest parameter, the container creates an instance and calls
validate()before entering the method. - If rules fail —
ValidationExceptionwitherrors()and fullall()→ middleware returns 422 (JSON) or redirect back with flash. - If all is well — the controller method runs; data is read via
validated()ortoDto().
With and without DTO
Both approaches are supported. A DTO is not required.
| Approach | When convenient | In the controller |
|---|---|---|
| With DTO | Typed contract, passing to a service | $dto = $request->toDto(); |
| Without DTO |
Direct insert / update to DB with validated() array
|
->create($request->validated()) |
Example with 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'],
];
}
}
// Controller
/** @var CreateProjectDto $data */
$data = $request->toDto();
$this->projectModel->newQuery()->create([
ProjectModel::FIELD_TITLE => $data->title,
// ...
]);
Example without DTO
UpdateProjectStatusRequest — without $dtoClass.
The validated() array is passed directly to create() / update()
(via a normalizer if needed):
class UpdateProjectStatusRequest extends FormRequest
{
public function rules(): array
{
return [
'status' => ['required', 'in:' . implode(',', array_column(ProjectStatus::cases(), 'value'))],
];
}
}
// Single key — DB update
$project->update([
ProjectModel::FIELD_STATUS => $request->validated()['status'],
]);
// Multiple fields — create() with the whole array (after except / rules)
$this->postModel->newQuery()->create(
$this->normalizePayload($request->validated())
);
In the controller you can call validated() even if $dtoClass is set
in FormRequest — the DTO is then simply optional (toDto() is not used).
Creating a FormRequest
Place the class in Requests/ of a component or application
(for example Components/UserCabinet/Requests/LoginRequest.php).
Extend Concept\Core\Http\Requests\FormRequest and implement
rules(). Container registration is not required — autowiring applies.
<?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;
/** Excluded from validated() / DTO after validation */
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'],
];
}
}
Minimal login request without $except — LoginRequest
(only email and password, DTO LoginDto with optional remember).
rules() rules
Two formats (as in Rakit):
- string:
'name' => 'required|min:3' - array:
'email' => ['required', 'email', 'unique:users,email']
Standard Rakit rules are available (required, email, min,
max, same, in, nullable, boolean, etc.).
(see custom rules).
aliases() aliases
Human-readable field names in error messages ("Project title" instead of "title").
Optional method — defaults to an empty array. Merged with global aliases from
resources/lang/validator/{locale}.php (ValidationTranslationsLoader).
messages() messages
Override texts for specific rules in a given FormRequest
(key — Rakit rule name, for example 'required' => 'Fill in :attribute').
Global translations — in the same resources/lang/validator/ files.
$except and $only
-
$except— exclude fields fromvalidated()result (for examplepassword_confirmationafter thesame:passwordrule) -
$only— whitelist keys invalidated()(rare; usually keys fromrules()are enough) -
_csrf_tokenis excluded globally fromvalidated(), even if it appears in the body
validated() returns only keys present in rules()
(intersection with valid data), minus except and global exclusions.
Call validated() only after validate()
(in the controller validation has already run; otherwise — ValidationLogicException).
Where data comes from: all()
all() merges query string and parsed body
(form fields or JSON after ParseJsonBodyMiddleware).
This data goes to the validator. For JSON APIs, attach the appropriate middleware on the route group.
$dtoClass property
protected ?string $dtoClass = MyDto::class;— enable DTO mappingnullor omitted — onlyvalidated()works;toDto()returnsnull
PHPDoc @extends FormRequest<LoginDto> helps IDE/static analysis;
for requests without DTO — @extends FormRequest<null> or no generic.
Route parameters
URL segments ({id:number}) are usually passed as a separate action argument:
update(UpdateUserRequest $request, int $id).
In a FormRequest subclass, protected getRouteParam('id') is available —
if you need to inject id into rules (for example unique:users,email,{id}).
Logging
When log.validation_data = true in config/log.php
(ConfigKey::LOG_VALIDATION_DATA), FormRequest logs input data in all()
and the result of validate() — see
Configuration → log.php.
The logger masks sensitive fields via DataMaskerInterface.
Usually disabled in production.
Usage in a controller
Declare FormRequest in the method signature — validation runs automatically:
public function store(StoreUserRequest $request): ResponseInterface
{
// Option 1: validated() array
$this->userModel->newQuery()->create(
$this->normalizeUserPayload($request->validated())
);
return $this->response->redirectByName('admin.users');
}
public function store(CreateProjectRequest $request): ResponseInterface
{
// Option 2: DTO
/** @var CreateProjectDto $data */
$data = $request->toDto();
$this->projectModel->newQuery()->create([/* fields from $data */]);
return $this->response->redirectByName('cabinet.projects');
}
Call toDto() after successful validation (in the controller this is already guaranteed).
Check for null if $dtoClass is not set or Caster is unavailable.
Validation errors
On failed validation, HandleValidationExceptionMiddleware:
-
JSON / API — if
RequestFormat::expectsJson()(Accept: application/jsonorX-Requested-With: XMLHttpRequest) —422, bodyjsonError()witherrorsfield -
HTML form — flash
error,SessionKey::VALIDATION_ERRORS(_validation_errors),SessionKey::VALIDATION_DATA(_validation_data), redirectback()
Flash "old input" receives full all() (query + body), not just
validated(). ShareViewDataMiddleware maps flash to 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 mapping errors
If toDto() cannot build an object (type mismatch after validation),
ValidationCastException is thrown (wrapper around Valinor MappingError).
This is not the same as rule errors — handled by the general error handler, not validation middleware.
Therefore FormRequest rules must align with DTO types (for example boolean for bool $remember).
Custom validation rules
Registration in 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,
],
],
];
A rule class implements RuleInterface, extends Concept\Core\Services\Validator\Rule.
For example:
'email' => ['unique:users,email'] in StoreUserRequest,
'email' => ['exists:users,email'] in ForgotPasswordRequest.
For update except id: 'unique:users,email,' . $this->getRouteParam('id') . ',id'
in rules() (third parameter — record id to ignore).
DTO (Data Transfer Object)
A DTO is an immutable object with readonly properties for already validated data.
Base class Concept\Core\Dto\Dto implements toArray() by casting the object to an array.
How to create a 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,
) {}
}
- Property names match keys in
validated()(snake_case in forms → snake_case in DTO) - Constructor types must match what the validator returns + Valinor casting
- Optional fields — default in constructor or
?type - Fields in
$exceptdo not end up in the DTO - Fields in
$onlyrestrict what ends up invalidated()and DTO
Valinor and Caster
Mapping is performed by Concept\Core\Services\Caster\Caster based on
Valinor
(cuyz/valinor in core dependencies; a possible internal fork with the same
CuyZ\Valinor API — then the same documentation applies).
Describes mapping arrays to objects, strict types, nested structures — applicable to Concept Core DTOs.
allowScalarValueCasting()— string"1"→int/boolwhen neededallowSuperfluousKeys()— extra keys in the array do not break mapping (convenient for forms)- Transformers: custom logic for complex types via
caster.transformersconfig key - Mapper cache:
storage/cache/valinor(faster repeated mapping)
Useful Valinor documentation sections:
- How to map input data
- Handle invalid input
- Feature flags (casting, superfluous keys)
What toDto() does
// Simplified
if ($this->dtoClass === null || !$this->caster) {
return null;
}
return $this->caster->cast($this->validated(), $this->dtoClass);
First Rakit checks rules, then Valinor builds the structure.
Business logic validation (email uniqueness in DB) — in rules();
type coercion — in DTO/Valinor.
Checklist
- Create
Requests/MyActionRequest.phpwithrules()and optionallyaliases(). - If needed —
Dto/MyActionDto.phpandprotected ?string $dtoClass = .... - Add type hint in controller:
public function store(MyActionRequest $request). - Ensure global
HandleValidationExceptionMiddlewareis attached in routes. - In Twig use
errors/oldto re-display the form.
FormRequest is responsible for request input data. Authorization ("can this user do this") —
in middleware or services, not in rules(),
when session context outside form fields is required.