mysaaspackage / validation
Attribute-based validation returning violations as a value
Requires
- php: >=8.1
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.19
- phpunit/phpunit: ^10
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.
nullis valid for format asserts (Email,Uuid,Date, ...). Presence is the job ofNotNull/NotEmpty, which run with high priority. This is what makes optional fields natural:#[Email] public ?string $email = nullaccepts 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, notmin_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; onnullit 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')].
Customis imperative-only. There is a 22nd assert,Custom(code, message), which is deliberately not registered in the engine: using it as an attribute throwsNo 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