Verified against Semitexa Ultimate 2026.09.19.1020
Core Validators
Semitexa ships a dependency-free, trait-based validation surface. Drop a category trait into a Payload DTO, return an error map from validate(), and the framework converts it into a 422 response automatically.
Philosophy
- Validation lives on the payload. Handlers receive validated payloads; they do not re-validate, and they do not return 422 themselves.
- Errors are aggregated, not thrown. Every category-trait validator appends to an
array<string, list<string>> $errorsaccumulator so a single request reports every problem at once. - Validators are composable. Every method shares the same signature shape, so composite rules (
anyOf,oneOf,sequentially) and conditional rules (requiredIf,validateSometimes) take callbacks that match every other validator. - No metadata graph. A validator is a method on a trait. There is no constraint registry, no annotation engine, no Symfony Validator dependency. The traits are the API.
Method convention
Every accumulator validator has the same shape:
validateXxx(array &$errors, string $field, mixed $value, ...$options): void
&$errors— the accumulator. Validators append['field' => ['message']].$field— the error key (a flat string; nested keys come fromPath::join).$value— the value being validated (typed wider where a stricter type would prevent reuse from generic callers)....$options— validator-specific (length bound, threshold, callback, …).
Setter-time validators (the legacy NotBlankValidationTrait::requireNotBlank) keep their original throw-immediately shape; both styles coexist in the same payload without conflict.
Null handling
Most validators silently accept null. That lets callers chain a validateOptional() guard without re-checking inside each rule:
if ($this->validateOptional($errors, 'website', $this->website)) {
$this->validateUrl($errors, 'website', $this->website);
}
Exceptions:
- Presence validators (
validateRequired,validateNotBlank,validateNotNull) treatnullas a meaningful absence. - Equality validators (
validateEqualTo,validateIdenticalTo) comparenulllike any other value —validateEqualTo($v, null)succeeds only when$v === null. - Conditional validators (
validateRequiredIf,validateProhibitedIf) inspect blankness explicitly.
Validation flow
- The HTTP request reaches
RouteExecutor::fillAndValidatePayload(). PayloadHydrator::hydrate()invokes setters; setter-timeValidationExceptionbecomes 422 beforevalidate()runs.- If the payload implements
ValidatablePayloadInterface, itsvalidate()runs after hydration. - A non-empty error map turns into a 422 response with envelope:
{
"errors": {
"name": ["This value should not be blank."],
"email": ["This value should be a valid email address."]
}
}
- The payload reaches the handler only when
validate()returned[].
Nested error paths
The error envelope keeps its flat array<string, list<string>> shape. Nested errors come from composing keys with Path::join:
use Semitexa\Core\Validation\Path;
Path::join('items', 0, 'sku'); // 'items[0].sku'
Path::join('address', 'country'); // 'address.country'
Path::join('tags', 1); // 'tags[1]'
CollectionValidationTrait::validateArrayOf and validateMapOf use Path::join automatically, so a per-item callback that emits an error under $field ends up at field[0], field[1], etc.
A public payload — contact form
namespace Semitexa\Modules\ValidationDemo\Application\Payload\Request;
use Semitexa\Core\Attribute\AsPublicPayload;
use Semitexa\Core\Contract\ValidatablePayloadInterface;
use Semitexa\Core\Validation\Trait\CollectionValidationTrait;
use Semitexa\Core\Validation\Trait\FormatValidationTrait;
use Semitexa\Core\Validation\Trait\PresenceValidationTrait;
use Semitexa\Core\Validation\Trait\StringValidationTrait;
use Semitexa\Core\Validation\Trait\ChoiceValidationTrait;
use Semitexa\Modules\ValidationDemo\Application\Resource\Response\ValidationAcceptedResource;
#[AsPublicPayload(
path: '/validation-demo/contact',
methods: ['POST'],
responseWith: ValidationAcceptedResource::class,
)]
final class ContactFormPayload implements ValidatablePayloadInterface
{
use PresenceValidationTrait;
use StringValidationTrait;
use FormatValidationTrait;
use ChoiceValidationTrait;
use CollectionValidationTrait;
private string $name = '';
private string $email = '';
private string $message = '';
private ?string $website = null;
private ?array $tags = null;
public function setName(string $value): void { $this->name = $value; }
public function setEmail(string $value): void { $this->email = $value; }
public function setMessage(string $value): void { $this->message = $value; }
public function setWebsite(?string $value): void { $this->website = $value; }
public function setTags(?array $value): void { $this->tags = $value; }
public function validate(): array
{
$errors = [];
$this->validateNotBlank($errors, 'name', $this->name);
$this->validateLength($errors, 'name', $this->name, min: 2, max: 100);
$this->validateNotBlank($errors, 'email', $this->email);
$this->validateRfcEmail($errors, 'email', $this->email);
$this->validateNotBlank($errors, 'message', $this->message);
$this->validateMaxLength($errors, 'message', $this->message, 2000);
if ($this->validateOptional($errors, 'website', $this->website)) {
$this->validateUrl($errors, 'website', $this->website);
}
if ($this->validateOptional($errors, 'tags', $this->tags)) {
$this->validateMaxCount($errors, 'tags', $this->tags, 10);
$this->validateArrayOf(
$errors,
'tags',
$this->tags,
static function (array &$itemErrors, string $itemField, mixed $tag): void {
if (! is_string($tag)) {
$itemErrors[$itemField] = $itemErrors[$itemField] ?? [];
$itemErrors[$itemField][] = 'This value should be a string.';
}
},
);
}
return $errors;
}
}
A non-string entry inside tags produces tags[1]: ["This value should be a string."], demonstrating nested paths through validateArrayOf.
A protected payload — profile update
use Semitexa\Authorization\Attribute\AsProtectedPayload;
use Semitexa\Core\Validation\Trait\DateTimeValidationTrait;
use Semitexa\Core\Validation\Trait\DomainValidationTrait;
#[AsProtectedPayload(path: '/validation-demo/profile', methods: ['POST'], ...)]
final class ProfileUpdatePayload implements ValidatablePayloadInterface
{
use PresenceValidationTrait;
use StringValidationTrait;
use DateTimeValidationTrait;
use DomainValidationTrait;
private string $displayName = '';
private string $locale = '';
private string $timezone = '';
private ?string $birthDate = null;
private bool $marketingOptIn = false;
// ... setters elided ...
public function validate(): array
{
$errors = [];
$this->validateNotBlank($errors, 'displayName', $this->displayName);
$this->validateLength($errors, 'displayName', $this->displayName, min: 2, max: 80);
$this->validateNotBlank($errors, 'locale', $this->locale);
$this->validateLocaleCode($errors, 'locale', $this->locale);
$this->validateNotBlank($errors, 'timezone', $this->timezone);
$this->validateTimezone($errors, 'timezone', $this->timezone);
if ($this->validateOptional($errors, 'birthDate', $this->birthDate)) {
$this->validateDate($errors, 'birthDate', $this->birthDate);
if (($errors['birthDate'] ?? []) === []) {
$this->validatePast($errors, 'birthDate', $this->birthDate);
}
}
return $errors;
}
}
#[AsProtectedPayload] makes the framework's PreHydrationAuthGate reject anonymous requests with 401 before any of this code runs. The validate() method is reached only for authenticated requests; an unauthenticated request never sees a validation error message.
A service payload — conditional + nested
use Semitexa\Authorization\Attribute\AsServicePayload;
use Semitexa\Core\Validation\Trait\ConditionalValidationTrait;
use Semitexa\Core\Validation\Trait\NumericValidationTrait;
#[AsServicePayload(path: '/validation-demo/product-sync', methods: ['POST'], ...)]
final class ProductSyncPayload implements ValidatablePayloadInterface
{
use PresenceValidationTrait;
use StringValidationTrait;
use FormatValidationTrait;
use NumericValidationTrait;
use ChoiceValidationTrait;
use CollectionValidationTrait;
use ConditionalValidationTrait;
use DomainValidationTrait;
// ... fields + setters elided ...
public function validate(): array
{
$errors = [];
$this->validateNotBlank($errors, 'sku', $this->sku);
$this->validateRegex($errors, 'sku', $this->sku, '/^[A-Z0-9-]{2,40}$/');
$this->validatePositiveOrZero($errors, 'price', $this->price);
$this->validateCurrencyCode($errors, 'currency', $this->currency);
$this->validateChoice($errors, 'type', $this->type, ['physical', 'digital']);
$isPhysical = $this->type === 'physical';
$isDigital = $this->type === 'digital';
$this->validateRequiredIf($errors, 'weight', $this->weight, $isPhysical);
$this->validateRequiredIf($errors, 'downloadUrl', $this->downloadUrl, $isDigital);
$this->validateArrayOf($errors, 'variants', $this->variants, self::variantValidator());
return $errors;
}
private static function variantValidator(): callable
{
return static function (array &$errors, string $field, mixed $variant): void {
if (! is_array($variant)) {
return;
}
// ...errors land at variants[0].sku, variants[0].price, ...
};
}
}
#[AsServicePayload] requires service-domain auth (machine token, signed webhook, mTLS); a user token is rejected at the access boundary, not by validation. Inside validate(), validateRequiredIf fires only when $isPhysical/$isDigital is true, so the conditional wiring is a single line per branch.
Composite — accept email or URL or phone
use Semitexa\Core\Validation\Trait\CompositeValidationTrait;
final class ContactMethodPayload implements ValidatablePayloadInterface
{
use PresenceValidationTrait;
use FormatValidationTrait;
use DomainValidationTrait;
use CompositeValidationTrait;
private string $contactMethod = '';
public function setContactMethod(string $value): void { $this->contactMethod = $value; }
public function validate(): array
{
$errors = [];
$this->validateNotBlank($errors, 'contactMethod', $this->contactMethod);
if (($errors['contactMethod'] ?? []) !== []) {
return $errors;
}
$this->validateAnyOf($errors, 'contactMethod', $this->contactMethod, [
fn (array &$e, string $f, mixed $v) => $this->validateRfcEmail($e, $f, is_string($v) ? $v : null),
fn (array &$e, string $f, mixed $v) => $this->validateUrl($e, $f, is_string($v) ? $v : null),
fn (array &$e, string $f, mixed $v) => $this->validateE164Phone($e, $f, is_string($v) ? $v : null),
]);
return $errors;
}
}
validateAnyOf runs each branch against an isolated scratch buffer and emits one stable field-level message on failure. Branch-specific phrasing ("valid email", "valid URL", "valid E.164") never leaks into the public envelope.
Category overview
| Category | Trait | Headline methods |
|---|---|---|
| Presence | PresenceValidationTrait |
validateRequired, validateNotBlank, validateBlank, validateNotNull, validateIsNull, validateOptional |
| Type | TypeValidationTrait |
validateString, validateInteger, validateFloat, validateNumber, validateBoolean, validateArray, validateObject, validateIterable, validateEnumCase, validateBackedEnumValue |
| String | StringValidationTrait |
validateLength, validateMinLength, validateMaxLength, validateExactLength, validateRegex, validateAlpha, validateAlphaNumeric, validateStartsWith/EndsWith/Contains/NotContains, validateLowercase/Uppercase |
| Format | FormatValidationTrait |
validateEmail (lenient), validateRfcEmail (filter_var), validateUrl, validateUuid, validateUlid, validateIp, validateHostname, validateJsonString, validateSlug |
| Numeric | NumericValidationTrait |
validatePositive, validatePositiveOrZero, validateNegative, validateNegativeOrZero, validateGreaterThan[OrEqual], validateLessThan[OrEqual], validateRange, validateDivisibleBy, validateMultipleOf |
| DateTime | DateTimeValidationTrait |
validateDate, validateDateTime, validateTime, validateBefore[OrEqual], validateAfter[OrEqual], validatePast[OrPresent], validateFuture[OrPresent] |
| Choice | ChoiceValidationTrait |
validateChoice, validateNotIn, validateCount, validateMinCount, validateMaxCount, validateExactCount, validateUnique, validateEnumChoice, validateBackedEnumChoice |
| Collection | CollectionValidationTrait |
validateArrayOf, validateListOf, validateMapOf, validateCollection, validateRequiredKeys, validateOptionalKeys, validateNoExtraKeys, validateAtLeastOneKey, validateExactlyOneKey, validateMutuallyExclusiveKeys |
| Comparison | ComparisonValidationTrait |
validateEqualTo, validateNotEqualTo, validateIdenticalTo, validateNotIdenticalTo, validateSameAsField, validateDifferentFromField |
| Conditional | ConditionalValidationTrait |
validateRequiredIf, validateProhibitedIf, validateRequiredWith, validateRequiredWithout, validateIf, validateSometimes |
| Composite | CompositeValidationTrait |
validateAll, validateAnyOf, validateOneOf, validateNoneOf, validateSequentially |
| Domain | DomainValidationTrait |
validateCountryCode, validateCurrencyCode, validateLocaleCode, validateTimezone, validateE164Phone, validateHexColor, validateBase64, validateMimeType |
The unit tests under packages/semitexa-core/tests/Unit/Validation/ are the exact behaviour contract — refer to them for the precise edge cases each method handles.
Comparison messages never leak values
Cross-field comparison messages mention only the other field name:
This value should match password.
This value should differ from old_password.
Raw rejected or expected values are never embedded — passwords, secret tokens, or any other sensitive data never appear in the envelope through these validators.
Custom validators
A custom rule is a trait method that follows the accumulator convention:
namespace App\Validation\Trait;
trait OurDomainValidationTrait
{
/** @param array<string, list<string>> $errors */
protected function validateOurInternalCode(array &$errors, string $field, ?string $value): void
{
if ($value === null) {
return;
}
if (preg_match('/^[A-Z]{3}-\d{4}$/', $value) !== 1) {
$errors[$field] = $errors[$field] ?? [];
$errors[$field][] = 'This value should be a valid internal code.';
}
}
}
use OurDomainValidationTrait; from any payload, call from validate(). No registry, no metadata. A one-off rule can also be passed directly into validateAnyOf, validateAll, or any Collection callback as a callable closure.
Deferred validators
These constraints are intentionally not in the bundled traits. Implementing them weakly under strong names would mislead callers; they remain available as future dependency-aware extensions:
| Validator | Reason for deferral |
|---|---|
| IBAN, BIC, VAT | Need country-specific check digits and tables |
| ISBN, ISSN, Luhn | Need check-digit algorithms |
| Region-aware phone | Needs giggsey/libphonenumber-for-php (an external dep) |
| File / Image | Tied to upload pipeline; out of scope of payload validation |
| PasswordStrength | Policy-dependent; better as an opt-in module |
| Per-error machine codes | Touches three error transports (HTTP, External API, GraphQL); deferred |
E.164 phone validation is implemented as a format-only check via validateE164Phone and is documented as such — it does not validate carrier or region.
Testing validation
Validation tests fall in two layers.
Unit-level — instantiate the trait via an anonymous host class, call the validator directly, assert the accumulated error map. The pattern used across packages/semitexa-core/tests/Unit/Validation/ is:
$errors = [];
$host = new class () {
use StringValidationTrait;
public function length(array &$errors, string $f, ?string $v, int $min, int $max): void
{ $this->validateLength($errors, $f, $v, $min, $max); }
};
$host->length($errors, 'name', 'ab', 3, 10);
self::assertSame(['name' => ['This value should be at least 3 characters.']], $errors);
Runtime-level — drive the actual Application::handleRequest pipeline with a synthetic POST request and assert the 422 envelope. src/modules/ValidationDemo/tests/RuntimeValidationPipelineTest.php is the canonical example: each route gets one happy-path test, one test per validation failure shape, and lifecycle tests proving validation state does not leak across requests.
Run the validation tests with the framework's test runner:
bin/semitexa test:run packages/semitexa-core/tests/Unit/Validation
bin/semitexa test:run src/modules/ValidationDemo/tests
The host-side PHPUnit binary is not the supported entry point.