Validation
Melodic provides attribute-based validation that lets you declare rules directly on your DTO properties using PHP 8 attributes. The Validator reads these attributes at runtime, checks each public property, and returns a structured ValidationResult.
Overview
Validation in Melodic follows a declarative pattern. You attach rule attributes to your DTO class properties, then pass the object (or a raw array) to the Validator. Each rule attribute has a validate() method and a message property. If validation fails, you get back a result containing field-keyed error arrays.
DTO with Attributes → Validator::validate() → ValidationResult (isValid + errors)
Built-in Rules
All rules are PHP attributes in the Melodic\Validation\Rules namespace. Each rule targets properties (Attribute::TARGET_PROPERTY) and provides a default error message that you can override.
| Attribute | Parameters | Default Message | Description |
|---|---|---|---|
Required |
?string $message |
This field is required | Rejects null, empty/whitespace strings (after trim), and empty arrays ([]) |
Email |
?string $message |
Must be a valid email address | Validates using FILTER_VALIDATE_EMAIL |
MinLength |
int $min, ?string $message |
Must be at least {min} characters | Checks mb_strlen() for multibyte safety |
MaxLength |
int $max, ?string $message |
Must be no more than {max} characters | Checks mb_strlen() for multibyte safety |
Min |
int|float $min, ?string $message |
Must be at least {min} | Numeric minimum (uses is_numeric()) |
Max |
int|float $max, ?string $message |
Must be no more than {max} | Numeric maximum (uses is_numeric()) |
Pattern |
string $regex, ?string $message |
Must match the pattern {regex} | Validates against a regular expression via preg_match() |
In |
array $values, ?string $message |
Must be one of: {values} | Strict in_array() check against allowed values |
Nullable by default (since 3.0). Every format rule — Email, MinLength, MaxLength, Min, Max, Pattern, and In — passes when the value is null or absent. Only #[Required] rejects a missing value. This means an optional field with a format rule (for example #[Min(18)] public ?int $age = null;) is valid when omitted and only checked once a value is supplied. To make a field mandatory and format-checked, add #[Required] alongside the format rule.
#[In] uses strict comparison. The In rule compares with ===, so the value must match the type of the allowed entries. During automatic model binding, the incoming wire value is coerced to the property's declared type before validation runs — so #[In([1, 2])] on an int property accepts "1" sent by a client, because "1" is coerced to 1 first. On an untyped or mixed property no coercion happens, so "1" !== 1 and the check fails. Type the property, or list values of the matching type.
Creating a DTO with Rules
Define a plain class with public properties and attach validation attributes. The Validator inspects all public properties via reflection.
<?php
use Melodic\Validation\Rules\Required;
use Melodic\Validation\Rules\Email;
use Melodic\Validation\Rules\MinLength;
use Melodic\Validation\Rules\MaxLength;
use Melodic\Validation\Rules\Min;
use Melodic\Validation\Rules\In;
class CreateUserRequest
{
#[Required]
#[MinLength(2)]
#[MaxLength(50)]
public string $name = '';
#[Required]
#[Email]
public string $email = '';
#[Required]
#[MinLength(8, message: 'Password must be at least 8 characters')]
public string $password = '';
#[Min(18)]
public ?int $age = null;
#[In(['admin', 'editor', 'viewer'])]
public string $role = 'viewer';
}
Custom messages. Every rule attribute accepts an optional $message parameter. Pass your own string to override the default.
Validator API
The Melodic\Validation\Validator class provides two methods for validating data.
validate(object $dto): ValidationResult
Validates a populated DTO object. The validator iterates over all public properties, reads their attributes, and calls each attribute's validate() method with the current property value. Uninitialized properties are treated as null.
<?php
use Melodic\Validation\Validator;
$dto = new CreateUserRequest();
$dto->name = 'Jo';
$dto->email = 'invalid';
$dto->password = '123';
$validator = new Validator();
$result = $validator->validate($dto);
if (!$result->isValid) {
// $result->errors is an array keyed by field name
// e.g. ['email' => ['Must be a valid email address'],
// 'password' => ['Password must be at least 8 characters']]
}
validateArray(array $data, string $dtoClass): ValidationResult
Validates a raw associative array against a DTO class without instantiating it. This is useful when working directly with request body data. The validator reflects on the DTO class properties, then checks the corresponding array keys against each property's attribute rules.
<?php
$data = [
'name' => 'Alice',
'email' => 'alice@example.com',
'password' => 'short',
];
$result = $validator->validateArray($data, CreateUserRequest::class);
if (!$result->isValid) {
// Handle errors
}
Missing keys. If a key is missing from the array, the value is treated as null. A missing field is reported as an error only when it carries #[Required]; format rules pass for a null value (see Nullable by default above).
ValidationResult
The Melodic\Validation\ValidationResult class is a readonly value object returned by both validation methods.
| Property / Method | Type | Description |
|---|---|---|
isValid |
bool |
Whether all rules passed |
errors |
array<string, string[]> |
Field-keyed error messages (empty when valid) |
ValidationResult::success() |
ValidationResult |
Static factory: creates a valid result with no errors |
ValidationResult::failure(array $errors) |
ValidationResult |
Static factory: creates an invalid result with the given errors |
The errors array structure maps each field name to an array of error message strings:
// Example errors array
[
'email' => ['Must be a valid email address'],
'password' => ['Password must be at least 8 characters'],
'role' => ['Must be one of: admin, editor, viewer'],
]
ValidationException
The Melodic\Validation\ValidationException extends RuntimeException and carries the full ValidationResult. Throw it from your service layer to signal validation failures that can be caught by the error handler or your controller.
<?php
use Melodic\Validation\ValidationException;
$result = $validator->validate($dto);
if (!$result->isValid) {
throw new ValidationException($result);
}
// The exception carries the result:
// $exception->result->errors
// $exception->getMessage() returns 'Validation failed'
Default message. The ValidationException constructor defaults to 'Validation failed'. You can pass a custom message as the second argument if needed: new ValidationException($result, 'User data is invalid').
Automatic Model Binding
When a controller action has a parameter typed as a Melodic\Data\Model subclass, the framework automatically hydrates it from the request body and validates it. If validation fails, a 400 JSON response with the errors array is returned before the controller is called.
<?php
use Melodic\Data\Model;
use Melodic\Validation\Rules\Required;
use Melodic\Validation\Rules\Email;
use Melodic\Validation\Rules\MaxLength;
class CreateUserRequest extends Model
{
#[Required]
#[MaxLength(50)]
public string $username = '';
#[Required]
#[Email]
public string $email = '';
}
<?php
use Melodic\Controller\ApiController;
use Melodic\Http\JsonResponse;
class UserApiController extends ApiController
{
public function __construct(
private readonly UserService $userService,
) {}
public function store(CreateUserRequest $request): JsonResponse
{
// $request is already hydrated and validated
$id = $this->userService->create($request->username, $request->email);
return $this->created(['id' => $id], "/api/users/{$id}");
}
public function update(string $id, UpdateUserRequest $request): JsonResponse
{
// Route params (like $id) and model params work together
$this->userService->update($id, $request);
return $this->noContent();
}
}
If the request body fails validation, the framework returns a 400 response like:
{
"username": ["This field is required"],
"email": ["Must be a valid email address"]
}
How it works. The RoutingMiddleware uses ReflectionMethod to inspect action parameters. Route params (strings from the URL like $id) are matched by name first. Parameters typed as a concrete Model subclass are hydrated via Model::fromArray($request->body()), then validated using the Validator resolved from the DI container. If validation fails, the controller action is never called.
Mass Assignment
Model::fromArray() binds every matching public property from the input, and toUpdateArray() carries every provided field into your UPDATE statement. If you reuse a full entity model as a request body, a client can over-post fields you never intended to accept — a hidden role or isActive field in the JSON body would be bound and persisted.
There are two defenses, in order of preference:
- One input DTO per endpoint. Define a dedicated request model per endpoint (
CreateUserRequest,UpdateUserProfileRequest) that contains only the fields that endpoint accepts. Never reuse a persistence/entity model as a controller action parameter. #[Guarded]as defense in depth. When an entity model has to double as an input model, mark the sensitive properties with theMelodic\Data\Guardedattribute. A guarded property is never bound fromfromArray()input and never appears intoUpdateArray()output — it can only be set programmatically.
<?php
use Melodic\Data\Guarded;
use Melodic\Data\Model;
class UserModel extends Model
{
public string $username = '';
#[Guarded]
public string $role = 'user'; // wire input silently ignored
#[Guarded]
public bool $isActive = true; // set it in code, never from the request body
}
What #[Guarded] does and does not affect. A guarded property is skipped on the way in (fromArray()) and excluded from toUpdateArray(), so untrusted input can never reach it. It still appears in toArray() and toPascalArray() output, so guarded values you set in code are still serialized in responses and available for INSERT parameter arrays.
Prefer dedicated request DTOs. #[Guarded] is a safety net, not a substitute for per-endpoint request models. Reach for it when an existing entity model has to serve double duty; otherwise a purpose-built request DTO makes the accepted fields explicit.
Manual Validation in a Controller
You can still validate manually when you need more control over the response or validation logic.
<?php
use Melodic\Controller\ApiController;
use Melodic\Http\Request;
use Melodic\Http\Response;
use Melodic\Validation\Validator;
class UserController extends ApiController
{
public function __construct(
private readonly UserService $userService,
private readonly Validator $validator,
) {}
public function store(Request $request): Response
{
$data = $request->body();
$result = $this->validator->validateArray($data, CreateUserRequest::class);
if (!$result->isValid) {
return $this->json(['errors' => $result->errors], 422);
}
$user = $this->userService->create($data);
return $this->created(['user' => $user->toArray()]);
}
}
Alternatively, validate in the service layer and throw a ValidationException:
<?php
class UserService extends Service
{
public function __construct(
DbContextInterface $context,
private readonly Validator $validator,
) {
parent::__construct($context);
}
public function create(array $data): User
{
$result = $this->validator->validateArray($data, CreateUserRequest::class);
if (!$result->isValid) {
throw new ValidationException($result);
}
return (new CreateUserCommand(
name: $data['name'],
email: $data['email'],
password: $data['password'],
))->execute($this->context);
}
}
Where to validate. Validate in the controller when you want direct control over the HTTP response. Validate in the service when you want consistent enforcement regardless of the caller.