mysaaspackage/validation

Attribute-based validation returning violations as a value

Maintainers

Package info

github.com/MySaasPackage/Validation

pkg:composer/mysaaspackage/validation

Transparency log

Statistics

Installs: 91

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.0.2 2026-08-23 18:44 UTC

This package is auto-updated.

Last update: 2026-08-23 18:55:37 UTC


README

Attribute-based validation for PHP objects. You declare constraints as native PHP attributes on public properties, run the object through the Validator, and get the errors back as a value — a ValidationResult — never as an exception thrown for business flow. That makes it a natural fit for architectures where a use case returns Output|ValidationResult|... unions instead of throwing.

$result = (new Validator())->validate($input);

if ($result->hasErrors()) {
    return $result; // hand it to your caller / HTTP layer as data
}

Installation

composer require mysaaspackage/validation

Quickstart

Declare a DTO with public nullable properties and stack the asserts you need:

<?php

declare(strict_types=1);

use MySaasPackage\Validation\Assert\Email;
use MySaasPackage\Validation\Assert\MinLength;
use MySaasPackage\Validation\Assert\NotEmpty;
use MySaasPackage\Validation\Assert\NotNull;
use MySaasPackage\Validation\Assert\Uuid;
use MySaasPackage\Validation\Validator;

class CreateUserInput
{
    #[NotNull]
    #[Uuid]
    public ?string $organizationUuid = null;

    #[NotEmpty]
    #[MinLength(3)]
    public ?string $name = null;

    #[NotEmpty]
    #[Email]
    public ?string $email = null;
}

$input = new CreateUserInput();
$input->name = 'Jo';
$input->email = 'not-an-email';

$result = (new Validator())->validate($input);

$result->hasErrors(); // true

$result->toArray();
// [
//     'organizationUuid' => ['code' => 'not_null', 'message' => 'This field is required'],
//     'name' => ['code' => 'min_length', 'message' => 'This value must be at least 3 characters long'],
//     'email' => ['code' => 'email', 'message' => 'This field must be a valid email address'],
// ]

Things worth knowing:

  • Stacked asserts are combined with AND — every assert on a property must pass.
  • null is valid for format asserts (Email, Uuid, Date, ...). Presence is the job of NotNull / NotEmpty, which run with high priority. This is what makes optional fields natural: #[Email] public ?string $email = null accepts the absent value and validates the format only when a value is present.
  • One violation per property. Asserts run in ascending priority order and each failing assert overwrites the property's slot, so the highest-priority failing assert wins (e.g. an empty value reports not_empty, not min_length).
  • #[Valid] recurses: on an object property it validates the nested object; on an array property it validates each object element (non-object elements are skipped) and accumulates violations per array key; on null it is skipped; on a scalar it throws — that is a programmer error, not an input error.

Built-in asserts

All 21 asserts below are attribute-driven: put them on a property and the engine picks the registered validator.

Attribute Arguments Code Valid when
NotNull not_null value is not null
NotEmpty not_empty not null, not an empty/whitespace string, not an empty array
IsTrue is_true value is exactly true
Email email valid email address (or null)
Phone phone E.164-like phone number, e.g. +14155552671 (or null)
Uuid uuid UUID string (or null)
Url url valid URL (or null)
HexColor hex_color #rgb / #rrggbb string
TimeZone timezone valid IANA timezone identifier (or null)
Date date Y-m-d string (or null)
DateTime date_time ATOM (Y-m-d\TH:i:sP) string (or null)
Min value min_value numeric value >= value (or null)
Max value max_value numeric value <= value (or null)
MinLength value min_length string with at least value characters (or null)
MaxLength value max_length string with at most value characters (or null)
MaxCount value max_count array with at most value items (or null)
Enum enum, only invalid backed enum value; only restricts the allowed subset (or null)
ArrayOfEnum enum array_of_enum array of backed enum values (or null / empty)
ArrayOfUuid array_of_uuid array of UUID strings (or null / empty)
ArrayOf type array_of array of instances of type (or null)
Valid valid recurses into nested object / array of objects

Every assert also accepts code and message named arguments to override the defaults, e.g. #[NotEmpty(message: 'Name is required')].

Custom is imperative-only. There is a 22nd assert, Custom(code, message), which is deliberately not registered in the engine: using it as an attribute throws No validator registered for assert .... It exists to carry ad-hoc violations added with $result->addViolation() — see below.

Custom rules

Declarative: a custom assert + a runtime validator

Create an attribute extending Assert and a validator implementing Validatable, then register the pair through the engine's $runtimeValidators constructor argument:

<?php

declare(strict_types=1);

use Attribute;
use MySaasPackage\Validation\Assert\Assert;

#[Attribute]
class Slug extends Assert
{
    public function __construct(
        public string $code = 'slug',
        public string $message = 'This field must be a valid slug',
    ) {
    }

    public function getCode(): string
    {
        return $this->code;
    }

    public function getMessage(): string
    {
        return $this->message;
    }
}
<?php

declare(strict_types=1);

use InvalidArgumentException;
use MySaasPackage\Validation\Assert\Assert;
use MySaasPackage\Validation\Validatable;

class SlugValidator implements Validatable
{
    protected Slug $assert;

    public function setAssert(Assert $assert): void
    {
        if (!$assert instanceof Slug) {
            throw new InvalidArgumentException('Assert must be an instance of Slug');
        }

        $this->assert = $assert;
    }

    public function getAssert(): Slug
    {
        return $this->assert;
    }

    public function validate(mixed $value): bool
    {
        if (null === $value) {
            return true; // keep presence checks in NotNull/NotEmpty
        }

        return is_string($value) && (bool) preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $value);
    }
}
use MySaasPackage\Validation\Validator;

$validator = new Validator([
    Slug::class => [Validator::LOW_PRIORITY, new SlugValidator()],
]);

Priorities are Validator::LOW_PRIORITY (1), Validator::MEDIUM_PRIORITY (2) and Validator::HIGH_PRIORITY (3); higher priorities run later and win the property's violation slot. Runtime entries are merged over the defaults, so you can also override a built-in (e.g. swap the Email validator for a stricter one) by registering its assert class.

This split works well in hexagonal setups: the assert attribute is plain data and can live in your domain next to the input DTOs, while the validator implementation and its registration in the engine live in the infrastructure wiring (e.g. the $runtimeValidators array built by your DI container).

If an assert attribute is placed on a property without a registered validator, the engine throws an InvalidArgumentException (No validator registered for assert {class}) instead of failing obscurely.

Imperative: Custom + addViolation for cross-field rules

Rules that need more than one property — totals, date ranges, uniqueness — do not fit an attribute on a single property. Run the declarative validation first, then add violations imperatively:

use MySaasPackage\Validation\Assert\Custom;
use MySaasPackage\Validation\Validator;

$result = (new Validator())->validate($input);

if ($input->startsAt !== null && $input->endsAt !== null && $input->endsAt < $input->startsAt) {
    $result->addViolation('endsAt', new Custom('invalid_period', 'The end date must be after the start date'));
}

if ($result->hasErrors()) {
    return $result;
}

The toArray() format

toArray() maps property names to ['code' => ..., 'message' => ...] pairs and is recursive for #[Valid] nesting:

// flat violation
['email' => ['code' => 'email', 'message' => 'This field must be a valid email address']]

// #[Valid] on a nested object
['address' => ['street' => ['code' => 'not_null', 'message' => 'This field is required']]]

// #[Valid] on an array of objects — keyed by the array index that failed
['items' => [2 => ['street' => ['code' => 'not_null', 'message' => 'This field is required']]]]

License

MIT