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 Inpasses 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:

  1. 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.
  2. #[Guarded] as defense in depth. When an entity model has to double as an input model, mark the sensitive properties with the Melodic\Data\Guarded attribute. A guarded property is never bound from fromArray() input and never appears in toUpdateArray() 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.