AppKit provides two ways to validate input: the Form class for explicit validation, and parameter attributes (#[MapRequestPayload], #[MapQueryString], #[MapFilter]) for automatic deserialization and validation directly in controller methods.
Extend Modufolio\Appkit\Form\Form and implement the rules() method. It returns a Symfony Validator constraint.
// src/Form/CreatePostForm.php
namespace App\Form;
use Modufolio\Appkit\Form\Form;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Constraint;
class CreatePostForm extends Form
{
protected function rules(): Constraint
{
return new Assert\Collection([
'title' => [new Assert\NotBlank(), new Assert\Length(max: 255)],
'body' => [new Assert\NotBlank()],
]);
}
}Call validate() with the raw data:
$form = new CreatePostForm();
$result = $form->validate((array) $request->getParsedBody());
if ($result->hasErrors()) {
// show errors
}validate() takes an array. getParsedBody() is null on a request without a body and may be an object for other media types, so cast it first — passing the bare null is a TypeError.
validate() always returns a ValidationResult. It is immutable.
$result->isValid(); // bool
$result->hasErrors(); // bool — opposite of isValid()
$result->errors(); // array<string, string[]> — field => [messages]
$result->first('title'); // ?string — first error message for a field
$result->messages(); // string[] — flat list of all messages
$result->violations(); // Symfony ConstraintViolationListInterface
$result->throwIfFailed(); // throws ValidationException if invalidField names in errors() have bracket notation stripped. [title] becomes title.
try {
$result->throwIfFailed();
} catch (\Modufolio\Appkit\Form\ValidationException $e) {
$e->getErrors(); // same as $result->errors()
$e->getValidationResult(); // the ValidationResult instance
}The exception handler does not know this class. Form\ValidationException extends \RuntimeException, so one that escapes a controller falls through to the built-in \RuntimeException handler and becomes a 500 "Runtime error" — the 422 mapping is registered for Symfony's ValidationFailedException only. To turn a Form result into a 422, throw that exception yourself with the result's violations:
use Symfony\Component\Validator\Exception\ValidationFailedException;
if ($result->hasErrors()) {
throw new ValidationFailedException($data, $result->violations());
}Or register Form\ValidationException with its own handler in configureExceptionHandler() — see Exception handling.
Use #[MapRequestPayload] to skip writing $form->validate() manually. AppKit deserialises the request body and validates it against the DTO's constraints in one step.
Deserialization goes through whatever your App::serializer() returns — the framework declares that method abstract, and the resolver only requires a DenormalizerInterface. The framework's own test app wires a Serializer with Symfony's ObjectNormalizer (tests/App/App.php), and the notes below assume that normalizer: how it populates your DTO depends on the property visibility you choose. A different denormalizer brings its own rules.
The simplest approach. ObjectNormalizer writes directly to public properties — no extra methods needed.
// src/Dto/CreatePostDto.php
namespace App\Dto;
use Symfony\Component\Validator\Constraints as Assert;
class CreatePostDto
{
#[Assert\NotBlank]
#[Assert\Length(max: 255)]
public string $title = '';
#[Assert\NotBlank]
public string $body = '';
}When properties are private and there is no constructor for the serializer to call, ObjectNormalizer falls back to setters. Without them, the properties stay at their default values and validation will silently pass on empty data.
Provide a setter for each property you want populated:
// src/Dto/CreatePostDto.php
namespace App\Dto;
use Symfony\Component\Validator\Constraints as Assert;
class CreatePostDto
{
#[Assert\NotBlank]
#[Assert\Length(max: 255)]
private string $title = '';
#[Assert\NotBlank]
private string $body = '';
public function setTitle(string $title): void { $this->title = $title; }
public function getTitle(): string { return $this->title; }
public function setBody(string $body): void { $this->body = $body; }
public function getBody(): string { return $this->body; }
}If your DTO has private properties and no setters, the serializer cannot populate them. The object will contain only default values and validation will run against those defaults, not the actual request data. Always use either public properties or getters/setters — not private properties alone.
use Modufolio\Appkit\Attributes\MapRequestPayload;
#[Route(path: '/api/posts', name: 'api.posts.create', methods: ['POST'])]
public function create(#[MapRequestPayload] CreatePostDto $dto): ResponseInterface
{
// If we get here, validation passed
$post = new Post();
$post->setTitle($dto->title);
$this->entityManager->persist($post);
$this->entityManager->flush();
return Response::json(['id' => $post->getId()], 201);
}Validation failures automatically return a 422 response with error details. You never need to check $result->hasErrors() when throwOnError is true (the default).
Set throwOnError: false and receive a ValidationResult as the next parameter.
public function create(
#[MapRequestPayload(throwOnError: false)] CreatePostDto $dto,
ValidationResult $result,
): ResponseInterface {
if ($result->hasErrors()) {
return Response::json(['errors' => $result->errors()], 422);
}
// $dto is fully populated even when throwOnError: false
// ...
}AppKit detects the ValidationResult type in the next parameter automatically. No extra wiring is needed.
#[MapQueryString] works the same way but reads from $request->getQueryParams() instead of the request body.
// src/Dto/SearchQuery.php
class SearchQuery
{
#[Assert\Length(max: 100)]
public ?string $q = null;
#[Assert\Range(min: 1)]
public int $page = 1;
#[Assert\Choice(choices: ['asc', 'desc'])]
public string $sort = 'desc';
}#[Route(path: '/posts', name: 'posts.index', methods: ['GET'])]
public function index(#[MapQueryString] SearchQuery $query): ResponseInterface
{
// $query->q, $query->page, $query->sort are populated from the URL
}#[MapFilter] is designed for filter objects with a fromArray() static method. The class must implement MapFilterInterface. The resolver checks that with assert(), so a class that merely has a fromArray() fails in development (zend.assertions=1) but passes unnoticed where assertions are compiled out (zend.assertions=-1, the production default) — implement the interface rather than lean on the check. AppKit calls fromArray($request->getQueryParams()) and injects the result.
// src/Filter/PostFilter.php
use Modufolio\Appkit\Resolver\MapFilterInterface;
class PostFilter implements MapFilterInterface
{
public function __construct(
public readonly ?string $status = null,
public readonly ?string $author = null,
) {}
public static function fromArray(array $params): self
{
return new self(
status: $params['status'] ?? null,
author: $params['author'] ?? null,
);
}
}#[Route(path: '/posts', name: 'posts.index', methods: ['GET'])]
public function index(#[MapFilter] PostFilter $filter): ResponseInterface
{
// $filter->status, $filter->author
}Any symfony/validator constraint works in DTOs and Form classes. Common ones:
use Symfony\Component\Validator\Constraints as Assert;
#[Assert\NotBlank]
#[Assert\NotNull]
#[Assert\Length(min: 3, max: 255)]
#[Assert\Email]
#[Assert\Url]
#[Assert\Range(min: 1, max: 100)]
#[Assert\Choice(choices: ['draft', 'published'])]
#[Assert\Regex(pattern: '/^[a-z0-9-]+$/')]
#[Assert\Count(min: 1, max: 10)]Group multiple constraints on one property:
#[Assert\NotBlank]
#[Assert\Length(min: 8)]
#[Assert\Regex(pattern: '/[A-Z]/', message: 'Must contain an uppercase letter')]
public string $password = '';Outside of controllers, inject ValidatorInterface to run validation directly.
use Symfony\Component\Validator\Validator\ValidatorInterface;
class SomeService
{
public function __construct(private readonly ValidatorInterface $validator) {}
public function process(MyDto $dto): ValidationResult
{
$violations = $this->validator->validate($dto);
return ValidationResult::fromViolations($violations);
}
}