Application · Adapter
Validation
Validate named application input with portable rules, then optionally apply that contract at a Symfony HTTP boundary.
Validation turns named input and declarative rules into application data without requiring an HTTP framework. Keep rule selection and validation in a use case or input boundary; compose the optional Symfony subscriber only where controller metadata is the right boundary.
Ownership. ValidationService, its data, validators, and the #[Validation] attribute are
Application code. SymfonyValidationSubscriber, JSON request parsing, exception presentation, and
Symfony service configuration are Adapter concerns. Consumers own their input source, authorization,
error response shape, and any business invariant that needs a Domain value object rather than an
input rule.
Dependencies. Portable validation requires PHP 8.5+ and this package. The Symfony controller
subscriber is optional and needs symfony/http-kernel plus symfony/event-dispatcher.
Install.
Start with portable application validation.
use Fight\Common\Application\Validation\Exception\ValidationException;
use Fight\Common\Application\Validation\ValidationService;
$validation = new ValidationService();
try {
$data = $validation->validate(
['email' => 'customer@example.com'],
[['field' => 'email', 'label' => 'Email', 'rules' => 'required|email']],
);
$email = $data->get('email');
} catch (ValidationException $failure) {
$errors = $failure->getErrors();
}
Failed rules collect errors by field and throw Fight\Common\Application\Validation\Exception\ValidationException.
An unsupported rule, a malformed rule definition, or input with non-string keys throws
DomainException; validation does not sanitize data, authorize the caller, or turn primitives into
Domain values. With Symfony, the Adapter reads query parameters for safe methods and request
parameters otherwise, then lets the same exception propagate before the controller action runs.
Reference¶
Validation is a portable Application service for named input and declarative rules. The optional
Symfony subscriber is Adapter composition: it reads controller metadata, selects HTTP input, and
delegates to the same ValidationService used outside Symfony. Passing rules does not sanitize or
authorize input; it only establishes that the declared rules passed.
Table of Contents¶
- Overview
- Wiring Up the Subscriber
- The
#[Validation]Attribute - Defining Rules
- Custom Error Messages
- Handling Validation Errors
- Complete Example
Overview¶
The optional Symfony HTTP composition is built from three cooperating pieces:
Request
└─► kernel.controller event
└─► SymfonyValidationSubscriber
└─► reads #[Validation] attribute from the controller method
└─► ValidationService::validate()
├─► passes → controller action executes (declared rules passed)
└─► fails → ValidationException thrown (action never executes)
#[Validation] attribute — Application metadata attached to a method; declares the fields to validate and the rules each field must satisfy.
SymfonyValidationSubscriber — an Adapter that 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 HTTP methods Symfony considers safe.
- 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 — a portable Application service that 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.
Sequential reuse and failure cleanup¶
Contracts: fight-common.behavior.validation-coordinator-reuse and
fight-common.behavior.validation-service-reuse.
ValidationCoordinator and ValidationService support sequential reuse. Queued validators belong to one
invocation, not the lifetime of the service. Register custom validators again when they are needed for a later
call. Each coordinator call creates a fresh context and clears its queue on success, ordinary validation failure,
or an unexpected throwable. It returns the existing passed/failed ValidationResult; the service returns
ApplicationData or raises the existing structured ValidationException for an ordinary failed result.
An unexpected validator Exception or PHP Error propagates unchanged, including object identity, integer code
and previous cause. Remaining validators are not executed to drain the queue. A later call with unrelated input
and empty or new rules does not run old validators or inherit their errors. The service also discards queued
custom validators and any partially registered rules when input validation, rule parsing or registration fails,
without replacing the supplied coordinator. Preparation rejection behavior is unchanged.
ValidationCoordinator::resetValidators(): void explicitly discards queued validators without executing them;
it is safe to call on an empty queue, and new validators may be registered afterward. This additive callable
operation lets the service clean up preparation failures on its existing coordinator. Exceptional state retention
was a defect, not a promised retry mechanism; supported existing signatures, normal results and exception families
are retained. No new runtime or dependency requirement is introduced.
These guarantees apply to calls that enter validation, not PHP argument-type rejection before method entry. They do not promise concurrent or reentrant use, reset consumer-owned validator internals, or roll back a custom validator's side effects. Validation is neither sanitization nor authorization. Cleanup does not make exception messages or diagnostic context safe for public HTTP presentation.
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\Http\Symfony\EventSubscriber\SymfonyValidationSubscriber: ~
If you are not using autowiring, provide the dependency explicitly:
services:
Fight\Common\Application\Validation\ValidationService: ~
Fight\Common\Adapter\Http\Symfony\EventSubscriber\SymfonyValidationSubscriber:
arguments:
- '@Fight\Common\Application\Validation\ValidationService'
tags:
- { name: kernel.event_subscriber }
For JSON API endpoints, also wrap the kernel with Fight\Common\Adapter\Middleware\Symfony\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 |
Stored attribute metadata; the supplied validation service and Symfony subscriber do not read it. |
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).
This structured exception and the Domain validation exception retain their constructors, cause and catchability;
consumer subclasses may still use the Application exception's late-static fromErrors() factory. They are not
subtypes of the opt-in Application failure vocabulary (docs/application-failures.md). Use InvalidInput only when a
consumer deliberately reports generic unusable input without a field map; use RuleViolation for an explicit
business-rule rejection. Neither replaces field validation or makes diagnostic errors public-safe.
The existing global exception subscriber converts exceptions into a JSendResponse. This keeps the controller action free of try/catch boilerplate, but the legacy subscriber can expose arbitrary diagnostic messages and validation context. It is not a production-safe redaction boundary. For JSON requests, select the explicit safe Symfony exception composition instead of the legacy exception subscriber: the validation subscriber remains unchanged, but the thrown exception yields a fixed safe 400 unless a consumer deliberately projects public field data. The opt-in safe PSR-15 path is separate. In Laravel, the explicit safe native handler also fixes Common structured validation at 400 without raw fields; native Laravel validation retains its own 422 and requires deliberate public field projection. The legacy Laravel ErrorController still returns raw validation errors and must not be mistaken for that safe path. The next section describes existing unsafe Symfony wiring, not the new safe path.
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\Http\Symfony\Controller\ErrorController: ~
Fight\Common\Adapter\Http\Symfony\EventSubscriber\SymfonyExceptionSubscriber: ~
If you are not using autoconfigure, tag the subscriber manually:
services:
Fight\Common\Adapter\Http\Symfony\Controller\ErrorController: ~
Fight\Common\Adapter\Http\Symfony\EventSubscriber\SymfonyExceptionSubscriber:
arguments:
- '@Fight\Common\Adapter\Http\Symfony\Controller\ErrorController'
tags:
- { name: kernel.event_subscriber }
Once wired, any ValidationException thrown during a request (including from SymfonyValidationSubscriber) produces a raw structured JSON fail response; validator text may contain private data. This legacy behavior remains compatible for the minor, not safe for production exposure. Use the safe composition above for new JSON error handling; HTML is delegated to the framework and has no Common redaction guarantee.
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.