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

ClassRole
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

  1. The action declares type MyRequest $request (a FormRequest subclass).
  2. RouteStrategy first runs route interceptors, then puts URL segments into request attributes.
  3. For a FormRequest parameter, the container creates an instance and calls validate() before entering the method.
  4. If rules fail — ValidationException with errors() and full all() → middleware returns 422 (JSON) or redirect back with flash.
  5. If all is well — the controller method runs; data is read via validated() or toDto().

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 $exceptLoginRequest (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 from validated() result (for example password_confirmation after the same:password rule)
  • $only — whitelist keys in validated() (rare; usually keys from rules() are enough)
  • _csrf_token is excluded globally from validated(), 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 mapping
  • null or omitted — only validated() works; toDto() returns null

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/json or X-Requested-With: XMLHttpRequest) — 422, body jsonError() with errors field
  • HTML form — flash error, SessionKey::VALIDATION_ERRORS (_validation_errors), SessionKey::VALIDATION_DATA (_validation_data), redirect back()

Flash "old input" receives full all() (query + body), not just validated(). ShareViewDataMiddleware maps flash to 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 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 (ValidationServiceProviderConfigKey::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 $except do not end up in the DTO
  • Fields in $only restrict what ends up in validated() 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 / bool when needed
  • allowSuperfluousKeys() — extra keys in the array do not break mapping (convenient for forms)
  • Transformers: custom logic for complex types via caster.transformers config key
  • Mapper cache: storage/cache/valinor (faster repeated mapping)

Useful Valinor documentation sections:

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

  1. Create Requests/MyActionRequest.php with rules() and optionally aliases().
  2. If needed — Dto/MyActionDto.php and protected ?string $dtoClass = ....
  3. Add type hint in controller: public function store(MyActionRequest $request).
  4. Ensure global HandleValidationExceptionMiddleware is attached in routes.
  5. In Twig use errors / old to 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.