Validation¶
The validation system provides declarative, attribute-driven input validation for Symfony controller actions. Rules are declared directly on the controller method; the framework intercepts the request before the action body executes and throws a ValidationException if any rule fails. If the action body is reached, the input is guaranteed to be clean.
Table of Contents¶
- Overview
- Wiring Up the Subscriber
- The
#[Validation]Attribute - Defining Rules
- Custom Error Messages
- Handling Validation Errors
- Complete Example
Overview¶
The system is built from three cooperating pieces:
Request
└─► kernel.controller event
└─► SymfonyValidationSubscriber
└─► reads #[Validation] attribute from the controller method
└─► ValidationService::validate()
├─► passes → controller action executes (input is clean)
└─► fails → ValidationException thrown (action never executes)
#[Validation] attribute — attached to a controller method; declares the fields to validate and the rules each field must satisfy.
SymfonyValidationSubscriber — subscribes to kernel.controller. For every dispatched request it reflects on the resolved controller method, reads any #[Validation] attributes, and runs validation before the action body is entered. Input is drawn from:
- Query string ($request->query->all()) for safe HTTP methods (GET, HEAD, OPTIONS).
- Request body ($request->request->all()) for state-changing methods (POST, PUT, PATCH, DELETE).
For JSON APIs, register JsonRequestMiddleware so that the JSON body is parsed into $request->request before the subscriber runs.
ValidationService — orchestrates field-level validation using the parsed rules. On success it returns an ApplicationData object (unused in the attribute flow). On failure it throws Fight\Common\Application\Validation\Exception\ValidationException.
Wiring Up the Subscriber¶
Register SymfonyValidationSubscriber as a Symfony service. Because it implements EventSubscriberInterface, Symfony's dependency-injection system will automatically tag and wire it when autoconfigure: true is enabled (the default in modern Symfony skeletons).
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
Fight\Common\Application\Validation\ValidationService: ~
Fight\Common\Adapter\EventSubscriber\SymfonyValidationSubscriber: ~
If you are not using autowiring, provide the dependency explicitly:
services:
Fight\Common\Application\Validation\ValidationService: ~
Fight\Common\Adapter\EventSubscriber\SymfonyValidationSubscriber:
arguments:
- '@Fight\Common\Application\Validation\ValidationService'
tags:
- { name: kernel.event_subscriber }
For JSON API endpoints, also wrap the kernel with JsonRequestMiddleware so that application/json request bodies are decoded into $request->request before the subscriber inspects them:
// public/index.php (or wherever you build the kernel)
$kernel = new JsonRequestMiddleware($kernel);
The #[Validation] Attribute¶
The attribute is scoped to methods (Attribute::TARGET_METHOD). Apply it to any controller action that requires validated input.
use Fight\Common\Application\Attribute\Validation;
#[Validation(rules: [...])]
public function store(Request $request): JsonResponse { ... }
Properties¶
| Property | Type | Default | Description |
|---|---|---|---|
rules |
array |
[] |
Array of rule-definition arrays. Each element declares one field's validation rules. See Defining Rules. |
formName |
?string |
null |
Reserved for future front-end form integration. Has no effect on validation behavior. |
Defining Rules¶
Each element of the rules array is an associative array describing one field:
[
'field' => 'email', // (required) input key to validate
'label' => 'Email', // (required) human-readable field name used in error messages
'rules' => 'required|email', // (required) pipe-delimited rule string
'errors' => [ // (optional) custom error message overrides
'email' => '%s is not a valid address',
],
]
Rule String Format¶
Rules are joined with |. Arguments are placed in square brackets immediately after the rule name:
required|alpha_num_dash|min_length[3]|max_length[32]
range_number[0.5, 99.9]
in_list[admin,editor,viewer]
match[/^[a-z0-9-]+$/]
Multiple arguments are comma-separated inside the brackets. Whitespace around commas is stripped.
The match rule is handled specially: the regex pattern may itself contain [ and ] without interfering with the parser.
Rule Reference¶
Presence¶
| Rule | Arguments | Description |
|---|---|---|
required |
— | Field key must be present in the input. Does not check the value; use not_empty or not_blank to additionally enforce a non-empty value. |
Null & Empty¶
| Rule | Arguments | Description |
|---|---|---|
null |
— | Value must be null. |
not_null |
— | Value must not be null. |
empty |
— | Value must satisfy PHP's empty() — covers null, false, 0, "", "0", []. |
not_empty |
— | Value must not satisfy PHP's empty(). |
blank |
— | Value must be blank: an empty string or a string containing only whitespace. |
not_blank |
— | Value must not be blank. |
Boolean¶
| Rule | Arguments | Description |
|---|---|---|
true |
— | Value must be exactly true (strict). |
truthy |
— | Value must evaluate to true (!!value). |
false |
— | Value must be exactly false (strict). |
falsy |
— | Value must evaluate to false (!value). |
Type¶
| Rule | Arguments | Description |
|---|---|---|
scalar |
— | Value must be a PHP scalar: int, float, string, or bool. |
not_scalar |
— | Value must not be a PHP scalar (e.g., an array or object). |
type |
type |
Value must be an instance of or a primitive of the given type (e.g., string, int, App\Dto\Foo). |
list_of |
type |
Value must be a traversable where every element is of the given type. |
numeric |
— | Value must be numeric (satisfies PHP's is_numeric()). |
natural_number |
— | Value must be a positive integer (greater than zero). |
whole_number |
— | Value must be a non-negative integer (zero or greater). |
String Format¶
| Rule | Arguments | Description |
|---|---|---|
alpha |
— | Only alphabetic characters ([a-zA-Z]). |
alpha_dash |
— | Only alphabetic characters, hyphens (-), or underscores (_). |
alpha_num |
— | Only alphanumeric characters ([a-zA-Z0-9]). |
alpha_num_dash |
— | Only alphanumeric characters, hyphens, or underscores. |
digits |
— | Only digit characters ([0-9]). |
email |
— | Valid email address. |
uuid |
— | Valid UUID (any version). |
uri |
— | Valid URI. |
urn |
— | Valid URN. |
ip_address |
— | Valid IP address (v4 or v6). |
ip_v4_address |
— | Valid IPv4 address. |
ip_v6_address |
— | Valid IPv6 address. |
timezone |
— | Valid PHP timezone identifier (e.g., America/New_York, UTC). |
json |
— | Valid JSON-formatted string. |
String Content¶
| Rule | Arguments | Description |
|---|---|---|
contains |
search |
Value must contain the given substring. |
starts_with |
search |
Value must begin with the given substring. |
ends_with |
search |
Value must end with the given substring. |
match |
/pattern/ |
Value must match the given regular expression. |
String Length¶
| Rule | Arguments | Description |
|---|---|---|
exact_length |
n |
String must be exactly n characters. |
min_length |
n |
String must be at least n characters. |
max_length |
n |
String must be at most n characters. |
range_length |
min, max |
String length must be between min and max characters (inclusive). |
Numbers¶
| Rule | Arguments | Description |
|---|---|---|
exact_number |
n |
Value must equal n (integer or float). |
min_number |
n |
Value must be greater than or equal to n. |
max_number |
n |
Value must be less than or equal to n. |
range_number |
min, max |
Value must be between min and max (inclusive). Accepts integers and floats. |
Arrays & Collections¶
| Rule | Arguments | Description |
|---|---|---|
exact_count |
n |
Collection must contain exactly n items. |
min_count |
n |
Collection must contain at least n items. |
max_count |
n |
Collection must contain at most n items. |
range_count |
min, max |
Collection item count must be between min and max (inclusive). |
in_list |
a, b, ... |
Value must be one of the listed values. |
key_isset |
key |
Value must be an array that has the given key set (value may be null). |
key_not_empty |
key |
Value must be an array that has the given key set to a non-empty value. |
Date & Time¶
Arguments use PHP date-format characters. The format string is validated at parse time; an invalid format throws a DomainException.
| Rule | Arguments | Description |
|---|---|---|
date |
format |
Value must be a valid date matching the given PHP date format (e.g., Y-m-d). |
time |
format |
Value must be a valid time matching the given PHP time format (e.g., H:i:s). |
date_time |
format |
Value must be a valid date/time matching the given PHP date/time format (e.g., Y-m-d H:i:s). |
Cross-Field Comparison¶
These rules compare the field's value against the value of a second field in the same input. The second field is identified by its input key, not its label. If either field is absent from the input, the rule passes (use required on both fields if presence is mandatory).
| Rule | Arguments | Description |
|---|---|---|
equals |
field |
Value must be loosely equal (==) to the value of the given field. |
not_equals |
field |
Value must not be loosely equal (!=) to the value of the given field. |
same |
field |
Value must be strictly identical (===) to the value of the given field. |
not_same |
field |
Value must not be strictly identical (!==) to the value of the given field. |
Custom Error Messages¶
Every rule has a default error message that is produced automatically from the field label and any rule arguments. To override any of them, add an errors key whose value maps rule names to custom format strings.
The format string follows PHP sprintf conventions. The first %s is always replaced with the field label. Any subsequent placeholders correspond to the rule's arguments in order.
[
'field' => 'password',
'label' => 'Password',
'rules' => 'required|min_length[12]|max_length[128]|match[/[A-Z]/]',
'errors' => [
'min_length' => '%s must be at least %d characters.',
'max_length' => '%s cannot exceed %d characters.',
'match' => '%s must contain at least one uppercase letter.',
],
]
For rules without arguments (e.g., required, email), the format string receives only the label:
Rules whose messages are not overridden continue to use the system default. Only the specific rule names listed in errors are replaced.
Handling Validation Errors¶
When validation fails, SymfonyValidationSubscriber allows the ValidationException thrown by ValidationService to propagate up the Symfony event chain. The controller action is never entered.
Fight\Common\Application\Validation\Exception\ValidationException carries a structured error map:
// $exception->getErrors() returns:
[
'email' => ['Email must be a valid email address'],
'username' => ['Username must be at least 3 characters', 'Username may only contain alphanumeric characters, hyphens, or underscores'],
]
Each key is a field name; each value is an array of one or more error message strings for that field (multiple rules can fail independently on the same field — all errors are collected).
The recommended way to handle this exception is a global exception subscriber that converts exceptions into a standardized JSendResponse. This keeps the error-handling logic in one place and the controller action stays free of try/catch boilerplate. The next section shows how to wire this up.
Global Exception Subscriber¶
SymfonyExceptionSubscriber listens for KernelEvents::EXCEPTION and delegates to ErrorController, which produces a JSendResponse:
Request
└─► controller action
└─► ValidationException thrown
└─► kernel.exception event
└─► SymfonyExceptionSubscriber::onKernelException()
└─► ErrorController::handle()
├─► ValidationException → JSendResponse::fail() (400)
├─► HttpExceptionInterface → JSendResponse::error() (with status code)
└─► generic Throwable → JSendResponse::error() (500)
Exception-to-Response Mapping¶
| Exception | Response | Status Code |
|---|---|---|
ValidationException |
JSendResponse::fail($errors) |
400 |
NotFoundHttpException |
JSendResponse::error($message) |
404 |
AccessDeniedHttpException |
JSendResponse::error($message) |
403 |
Any other HttpExceptionInterface |
JSendResponse::error($message) |
As reported |
Generic Throwable |
JSendResponse::error($message) |
500 |
Wiring Up the Subscriber¶
With autowiring and autoconfigure enabled:
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
Fight\Common\Adapter\HttpKernel\ErrorController: ~
Fight\Common\Adapter\EventSubscriber\SymfonyExceptionSubscriber: ~
If you are not using autoconfigure, tag the subscriber manually:
services:
Fight\Common\Adapter\HttpKernel\ErrorController: ~
Fight\Common\Adapter\EventSubscriber\SymfonyExceptionSubscriber:
arguments:
- '@Fight\Common\Adapter\HttpKernel\ErrorController'
tags:
- { name: kernel.event_subscriber }
Once wired, any ValidationException thrown during a request (including from SymfonyValidationSubscriber) automatically produces a structured JSON fail response instead of a 500 error page.
Complete Example¶
The following controller creates a user account. Three fields are validated: email, username, and role. The username field overrides one default error message.
<?php
declare(strict_types=1);
namespace App\Controller;
use Fight\Common\Application\Attribute\Validation;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
class UserController extends AbstractController
{
#[Route('/users', methods: ['POST'])]
#[Validation(rules: [
[
'field' => 'email',
'label' => 'Email',
'rules' => 'required|email',
],
[
'field' => 'username',
'label' => 'Username',
'rules' => 'required|alpha_num_dash|min_length[3]|max_length[32]',
'errors' => [
'min_length' => '%s must be at least 3 characters long.',
],
],
[
'field' => 'role',
'label' => 'Role',
'rules' => 'required|in_list[admin,editor,viewer]',
],
])]
public function create(Request $request): JsonResponse
{
// If execution reaches here, validation has already passed.
// All three fields are guaranteed to be present and satisfy their rules.
$data = $request->request->all();
// $data['email'] — valid email address
// $data['username'] — 3–32 alphanumeric/hyphen/underscore characters
// $data['role'] — one of: admin, editor, viewer
// ... create the user and return a response
return new JsonResponse(['status' => 'success', 'data' => null], 201);
}
}
If any rule fails — for example, the submitted role is 'superuser' — the subscriber throws a ValidationException before create() is entered, with getErrors() returning:
The controller body never runs.