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 and empty strings (after trim) |
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 |
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. This means #[Required] rules will correctly report missing fields as errors.
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.
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.