Validator.php
PHP
Path: src/Validator/Validator.php
<?php
namespace mini\Validator;
use Closure;
use Stringable;
/**
* Composable validation builder
*
* Build reusable validators that can be composed for complex validation scenarios.
* Inspired by JSON Schema, validators are permissive by default and rules are added
* incrementally.
*
* ## Basic Usage
*
* ```php
* // Simple field validator with fluent API
* $emailValidator = (new Validator())->required()->email();
*
* if ($error = $emailValidator->isInvalid($_POST['email'])) {
* echo "Error: $error";
* }
* ```
*
* ## Entity Validation
*
* ```php
* class User {
* public static function validator(): Validator {
* return (new Validator())
* ->forProperty('username',
* (new Validator())->isString()->required()->minLength(3))
* ->forProperty('email',
* (new Validator())->required()->email())
* ->forProperty('age',
* (new Validator())->isInt()->minVal(18));
* }
* }
*
* // Direct field validation
* if ($error = User::validator()->username->isInvalid($_POST['username'])) {
* echo "Username error: $error";
* }
*
* // Full entity validation
* $errors = User::validator()->validate($_POST);
* if ($errors) {
* // ['username' => Translatable, 'email' => Translatable, ...]
* }
* ```
*
* ## Composition & Reusability
*
* ```php
* // Define reusable validators
* $emailValidator = (new Validator())->required()->email();
* $passwordValidator = (new Validator())->required()->minLength(8);
*
* // Compose into entity validator
* $userValidator = (new Validator())
* ->forProperty('email', $emailValidator)
* ->forProperty('password', $passwordValidator);
*
* // Partial validation (returns clone)
* $profileValidator = User::validator()->withoutFields(['password']);
* ```
*
* ## Design Philosophy
*
* - **Permissive by default**: Empty validator accepts any value
* - **Non-smart**: Doesn't prevent contradictory rules (e.g., `isInt()->isString()`)
* - **Fail fast**: Returns first error for field validation
* - **Nullable aware**: Optional fields skip validation when empty
* - **Lazy translation**: Error messages (Translatable) aren't converted until displayed
*
* @package mini\Util
*/
class Validator implements \JsonSerializable
{
private array $rules = []; // JSON Schema rules: ['type' => 'string', 'minLength' => 3, 'items' => $validator, ...]
private array $propertyValidators = [];
private array $patternPropertyValidators = [];
private ?Validator $additionalPropertiesValidator = null;
private bool $allowAdditionalProperties = true;
private bool $isRequired = false;
private string|Stringable|null $requiredMessage = null;
/**
* Magic property access for field validators
*
* @param string $property
* @return Validator|null
*/
public function __get(string $property): ?Validator
{
return $this->propertyValidators[$property] ?? null;
}
/**
* Invokable: $validator($value)
*
* @param mixed $value
* @return ?ValidationError
*/
public function __invoke(mixed $value): ?ValidationError
{
return $this->isInvalid($value);
}
/**
* Check if value is invalid
*
* Returns null if valid, or ValidationError if invalid. The ValidationError is:
* - Stringable: echo $error outputs the message
* - ArrayAccess: $error['field'] accesses property errors
* - IteratorAggregate: foreach ($error as $field => $fieldError)
* - JsonSerializable: json_encode($error) for API responses
*
* @param mixed $value Value to validate
* @param mixed $context Parent context (containing object/array) for custom validators
* @return ?ValidationError Null if valid, ValidationError if invalid
*/
public function isInvalid(mixed $value, mixed $context = null): ?ValidationError
{
// Check required first
// Note: For validators with property rules or array item rules, [] is not "empty" -
// it needs validation (some properties/items may be required, or minItems may apply).
$hasStructureRules = !empty($this->propertyValidators)
|| !empty($this->patternPropertyValidators)
|| isset($this->rules['items'])
|| isset($this->rules['minItems'])
|| isset($this->rules['maxItems']);
$isEmpty = $value === null || $value === '' || ($value === [] && !$hasStructureRules);
if ($this->isRequired && $isEmpty) {
return new ValidationError($this->requiredMessage ?? \mini\t("This field is required."));
}
// If not required and empty, skip all other rules
if (!$this->isRequired && $isEmpty) {
return null;
}
// Run rules first (type, minProperties, maxProperties, etc.)
foreach ($this->rules as $keyword => $ruleValue) {
$error = $this->validateRule($keyword, $ruleValue, $value, $context);
if ($error !== null) {
return new ValidationError($error);
}
}
// For object/array validation, check properties after rules pass
if ((!empty($this->propertyValidators) || !empty($this->patternPropertyValidators) || $this->additionalPropertiesValidator !== null || !$this->allowAdditionalProperties) && $this->isObjectLike($value)) {
$errors = [];
$validatedProps = [];
// Validate defined properties
foreach ($this->propertyValidators as $property => $validator) {
$validatedProps[$property] = true;
$propValue = is_array($value) ? ($value[$property] ?? null)
: ($value->$property ?? null);
if ($error = $validator->isInvalid($propValue, $value)) {
$errors[$property] = $error;
}
}
// Get all actual properties in the value
$actualProps = is_array($value) ? array_keys($value) : array_keys(get_object_vars($value));
// Validate pattern properties and track which properties they match
foreach ($actualProps as $property) {
if (isset($validatedProps[$property])) {
continue; // Already validated by properties()
}
foreach ($this->patternPropertyValidators as $pattern => $validator) {
if (preg_match($pattern, $property)) {
$validatedProps[$property] = true;
$propValue = is_array($value) ? $value[$property] : $value->$property;
if ($error = $validator->isInvalid($propValue, $value)) {
$errors[$property] = $error;
}
break; // Only validate against first matching pattern
}
}
}
// Validate additional properties
foreach ($actualProps as $property) {
if (isset($validatedProps[$property])) {
continue; // Already validated
}
if (!$this->allowAdditionalProperties) {
$errors[$property] = new ValidationError(
\mini\t("Additional property '{property}' is not allowed.", ['property' => $property])
);
} elseif ($this->additionalPropertiesValidator !== null) {
$propValue = is_array($value) ? $value[$property] : $value->$property;
if ($error = $this->additionalPropertiesValidator->isInvalid($propValue, $value)) {
$errors[$property] = $error;
}
}
}
if (!empty($errors)) {
return new ValidationError(\mini\t("Validation failed."), $errors);
}
}
return null;
}
/**
* Validate a single rule
*
* @param string $keyword Rule keyword
* @param mixed $ruleValue Rule constraint value
* @param mixed $value Value to validate
* @param mixed $context Parent context (containing object/array)
* @return null|string|Stringable Null if valid, error message if invalid
*/
private function validateRule(string $keyword, mixed $ruleValue, mixed $value, mixed $context = null): null|string|Stringable
{
// Skip null values for most rules (required is handled separately)
if ($value === null && $keyword !== 'type') {
return null;
}
// Skip x-error "rule" - it's metadata, not a validation rule
if ($keyword === 'x-error') {
return null;
}
$error = match($keyword) {
// Type validation
'type' => $this->validateType($ruleValue, $value),
// String constraints (only apply to strings)
'minLength' => !is_string($value) ? null : (strlen($value) < $ruleValue ? \mini\t("Must be at least {min} characters long.", ['min' => $ruleValue]) : null),
'maxLength' => !is_string($value) ? null : (strlen($value) > $ruleValue ? \mini\t("Must be {max} characters or less.", ['max' => $ruleValue]) : null),
'pattern' => !is_string($value) ? null : (!preg_match($ruleValue, $value) ? \mini\t("Invalid format.") : null),
// Numeric constraints (only apply to int/float, not numeric strings)
'minimum' => !(is_int($value) || is_float($value)) ? null : ($value < $ruleValue ? \mini\t("Must be at least {min}.", ['min' => $ruleValue]) : null),
'maximum' => !(is_int($value) || is_float($value)) ? null : ($value > $ruleValue ? \mini\t("Must be {max} or less.", ['max' => $ruleValue]) : null),
'exclusiveMinimum' => !(is_int($value) || is_float($value)) ? null : ($value <= $ruleValue ? \mini\t("Must be greater than {min}.", ['min' => $ruleValue]) : null),
'exclusiveMaximum' => !(is_int($value) || is_float($value)) ? null : ($value >= $ruleValue ? \mini\t("Must be less than {max}.", ['max' => $ruleValue]) : null),
'multipleOf' => !(is_int($value) || is_float($value)) ? null : (fmod($value, $ruleValue) != 0 ? \mini\t("Must be a multiple of {divisor}.", ['divisor' => $ruleValue]) : null),
// Array constraints (only apply to arrays)
'minItems' => !is_array($value) ? null : (count($value) < $ruleValue ? \mini\t("Must have at least {min} items.", ['min' => $ruleValue]) : null),
'maxItems' => !is_array($value) ? null : (count($value) > $ruleValue ? \mini\t("Must have at most {max} items.", ['max' => $ruleValue]) : null),
'uniqueItems' => !is_array($value) ? null : (count($value) !== count(array_unique($value, SORT_REGULAR)) ? \mini\t("Items must be unique.") : null),
'items' => !is_array($value) ? null : $this->validateItems($ruleValue, $value),
// Object constraints (only apply to objects/associative arrays)
'minProperties' => !$this->isObjectLike($value) ? null : (count((array)$value) < $ruleValue ? \mini\t("Must have at least {min} properties.", ['min' => $ruleValue]) : null),
'maxProperties' => !$this->isObjectLike($value) ? null : (count((array)$value) > $ruleValue ? \mini\t("Must have at most {max} properties.", ['max' => $ruleValue]) : null),
// Enum/const (apply to any type)
'const' => $value !== $ruleValue ? \mini\t("Must be exactly {value}.", ['value' => $ruleValue]) : null,
'enum' => !in_array($value, $ruleValue, true) ? \mini\t("Please select a valid option.") : null,
// Format validators (only apply to strings)
'format' => !is_string($value) ? null : $this->validateFormat($ruleValue, $value),
// Combinators (apply to any type)
'anyOf' => $this->validateAnyOf($ruleValue, $value),
'allOf' => $this->validateAllOf($ruleValue, $value),
'oneOf' => $this->validateOneOf($ruleValue, $value),
'not' => $this->validateNot($ruleValue, $value),
// Complex validators
'additionalItems' => !is_array($value) ? null : $this->validateAdditionalItems($ruleValue, $value),
'minContains' => !is_array($value) ? null : $this->validateMinContains($ruleValue, $value),
'maxContains' => !is_array($value) ? null : $this->validateMaxContains($ruleValue, $value),
'dependentRequired' => !is_array($value) ? null : $this->validateDependentRequired($ruleValue, $value),
// Custom validators (closures - apply to any type)
default => str_starts_with($keyword, 'custom:') ? $this->validateCustom($ruleValue, $value, $context) : null
};
// If validation failed and there's a custom error message, use it
if ($error !== null && isset($this->rules['x-error'][$keyword])) {
return $this->rules['x-error'][$keyword];
}
return $error;
}
/**
* Validate entire structure (alias for isInvalid)
*
* @param mixed $value
* @return ?ValidationError
*/
public function validate(mixed $value): ?ValidationError
{
return $this->isInvalid($value);
}
// ========================================================================
// Required (special handling)
// ========================================================================
/**
* Mark field as required
*
* @param string|Stringable|null $message Custom error message
* @return static
*/
public function required(string|Stringable|null $message = null): static
{
$clone = clone $this;
$clone->isRequired = true;
if ($message !== null) {
$clone->requiredMessage = $message;
}
return $clone;
}
// ========================================================================
// Immutability Helper
// ========================================================================
/**
* Set a rule immutably (clones before mutation)
*
* @param string $key Rule keyword
* @param mixed $value Rule value
* @param string|Stringable|null $message Custom error message
* @return static New instance with rule set
*/
private function setRule(string $key, mixed $value, string|Stringable|null $message = null): static
{
$clone = clone $this;
$clone->rules[$key] = $value;
if ($message !== null) {
if (!isset($clone->rules['x-error'])) {
$clone->rules['x-error'] = [];
}
$clone->rules['x-error'][$key] = (string)$message;
}
return $clone;
}
// ========================================================================
// Type Validators
// ========================================================================
/**
* Set JSON Schema type(s)
*
* @param string|array $types Single type or array of types: 'string', 'integer', 'number', 'boolean', 'array', 'object', 'null'
* @return static
*/
public function type(string|array $types): static
{
// Normalize to single string or array
$typeArray = is_array($types) ? $types : [$types];
$normalized = count($typeArray) === 1 ? $typeArray[0] : $typeArray;
return $this->setRule('type', $normalized);
}
// ========================================================================
// Format Validators (JSON Schema: format keyword)
// ========================================================================
/**
* Validate string format (JSON Schema: format)
*
* Supported formats:
* - email: Email address
* - uri: URL/URI
* - date-time: ISO 8601 date-time
* - date: ISO 8601 date (YYYY-MM-DD)
* - time: ISO 8601 time (HH:MM:SS)
* - ipv4: IPv4 address
* - ipv6: IPv6 address
* - uuid: UUID
* - slug: URL-safe string (not JSON Schema standard)
*
* @param string $format Format to validate
* @return static
*/
public function format(string $format, string|Stringable|null $message = null): static
{
return $this->setRule('format', $format, $message);
}
// ========================================================================
// Constraint Validators
// ========================================================================
/**
* Validate minimum string length
*
* @param int $min Minimum length
* @return static
*/
public function minLength(int $min, string|Stringable|null $message = null): static
{
return $this->setRule('minLength', $min, $message);
}
/**
* Validate maximum string length
*
* @param int $max Maximum length
* @param string|Stringable|null $message Custom error message
* @return static
*/
public function maxLength(int $max, string|Stringable|null $message = null): static
{
return $this->setRule('maxLength', $max, $message);
}
/**
* Validate exact value match (JSON Schema: const)
*
* @param mixed $value Exact value required
* @param string|Stringable|null $message Custom error message
* @return static
*/
public function const(mixed $value): static
{
return $this->setRule('const', $value);
}
/**
* Validate value is in allowed list (JSON Schema: enum)
*
* @param array $allowed Allowed values
* @param string|Stringable|null $message Custom error message
* @return static
*/
public function enum(array $allowed): static
{
return $this->setRule('enum', $allowed);
}
/**
* Validate minimum value (inclusive) - JSON Schema: minimum
*
* @param int|float $min Minimum value
* @param string|Stringable|null $message Custom error message
* @return static
*/
public function minimum(int|float $min, string|Stringable|null $message = null): static
{
return $this->setRule('minimum', $min, $message);
}
/**
* Validate maximum value (inclusive) - JSON Schema: maximum
*
* @param int|float $max Maximum value
* @param string|Stringable|null $message Custom error message
* @return static
*/
public function maximum(int|float $max, string|Stringable|null $message = null): static
{
return $this->setRule('maximum', $max, $message);
}
/**
* Validate minimum value (exclusive) - JSON Schema: exclusiveMinimum
*
* @param int|float $min Minimum value (exclusive)
* @param string|Stringable|null $message Custom error message
* @return static
*/
public function exclusiveMinimum(int|float $min, string|Stringable|null $message = null): static
{
return $this->setRule('exclusiveMinimum', $min, $message);
}
/**
* Validate maximum value (exclusive) - JSON Schema: exclusiveMaximum
*
* @param int|float $max Maximum value (exclusive)
* @param string|Stringable|null $message Custom error message
* @return static
*/
public function exclusiveMaximum(int|float $max, string|Stringable|null $message = null): static
{
return $this->setRule('exclusiveMaximum', $max, $message);
}
/**
* Validate number is a multiple of value (JSON Schema: multipleOf)
*
* @param int|float $divisor Number must be divisible by this value
* @param string|Stringable|null $message Custom error message
* @return static
*/
public function multipleOf(int|float $divisor, string|Stringable|null $message = null): static
{
return $this->setRule('multipleOf', $divisor, $message);
}
/**
* Validate minimum array length (JSON Schema: minItems)
*
* @param int $min Minimum number of items
* @param string|Stringable|null $message Custom error message
* @return static
*/
public function minItems(int $min, string|Stringable|null $message = null): static
{
return $this->setRule('minItems', $min, $message);
}
/**
* Validate maximum array length (JSON Schema: maxItems)
*
* @param int $max Maximum number of items
* @param string|Stringable|null $message Custom error message
* @return static
*/
public function maxItems(int $max, string|Stringable|null $message = null): static
{
return $this->setRule('maxItems', $max, $message);
}
/**
* Validate minimum number of object properties (JSON Schema: minProperties)
*
* @param int $min Minimum number of properties
* @param string|Stringable|null $message Custom error message
* @return static
*/
public function minProperties(int $min, string|Stringable|null $message = null): static
{
return $this->setRule('minProperties', $min, $message);
}
/**
* Validate maximum number of object properties (JSON Schema: maxProperties)
*
* @param int $max Maximum number of properties
* @param string|Stringable|null $message Custom error message
* @return static
*/
public function maxProperties(int $max, string|Stringable|null $message = null): static
{
return $this->setRule('maxProperties', $max, $message);
}
/**
* Validate dependent required properties (JSON Schema: dependentRequired)
*
* When a property exists, require other properties to also exist.
*
* @param string $property The property that triggers the requirement
* @param array $requiredProperties Properties required when $property exists
* @param string|Stringable|null $message Custom error message
* @return static
*/
public function dependentRequired(string $property, array $requiredProperties): static
{
$current = $this->rules['dependentRequired'] ?? [];
$current[$property] = $requiredProperties;
return $this->setRule('dependentRequired', $current);
}
/**
* Validate array has unique items (JSON Schema: uniqueItems)
*
* @param string|Stringable|null $message Custom error message
* @return static
*/
public function uniqueItems(): static
{
return $this->setRule('uniqueItems', true);
}
/**
* Validate all array items against a schema (JSON Schema: items)
*
* All items in the array must pass the provided validator.
*
* @param Validator|array<Validator> $validator Validator for all items, or array of validators for tuple validation
* @return static
*/
public function items(Validator|array $validator): static
{
return $this->setRule('items', $validator);
}
/**
* Validate additional items beyond tuple schema (JSON Schema: additionalItems)
*
* When items() is an array (tuple validation), this validates items beyond
* the defined positions.
*
* @param Validator|bool $validator Validator for additional items, or false to disallow
* @param string|Stringable|null $message Custom error message
* @return static
*/
public function additionalItems(Validator|bool $validator): static
{
return $this->setRule('additionalItems', $validator);
}
/**
* Validate minimum number of items matching a schema (JSON Schema: minContains)
*
* Requires at least $min items in the array to pass the validator.
*
* @param int $min Minimum number of matching items
* @param Validator $validator Validator that items must match
* @param string|Stringable|null $message Custom error message
* @return static
*/
public function minContains(int $min, Validator $validator): static
{
return $this->setRule('minContains', ['min' => $min, 'validator' => $validator]);
}
/**
* Validate maximum number of items matching a schema (JSON Schema: maxContains)
*
* Requires at most $max items in the array to pass the validator.
*
* @param int $max Maximum number of matching items
* @param Validator $validator Validator that items must match
* @param string|Stringable|null $message Custom error message
* @return static
*/
public function maxContains(int $max, Validator $validator): static
{
return $this->setRule('maxContains', ['max' => $max, 'validator' => $validator]);
}
/**
* Validate value matches regex pattern
*
* @param string $pattern Regular expression pattern
* @param string|Stringable|null $message Custom error message
* @return static
*/
public function pattern(string $pattern, string|Stringable|null $message = null): static
{
return $this->setRule('pattern', $pattern, $message);
}
// ========================================================================
// Combinators (JSON Schema: anyOf, allOf, oneOf, not)
// ========================================================================
/**
* Validate against any of the provided validators (JSON Schema: anyOf)
*
* The value is valid if it passes at least one of the validators.
*
* @param array<Validator> $validators Array of Validator instances
* @param string|Stringable|null $message Custom error message
* @return static
*/
public function anyOf(array $validators): static
{
return $this->setRule('anyOf', $validators);
}
/**
* Validate against all of the provided validators (JSON Schema: allOf)
*
* The value is valid only if it passes all validators.
*
* @param array<Validator> $validators Array of Validator instances
* @param string|Stringable|null $message Custom error message
* @return static
*/
public function allOf(array $validators): static
{
return $this->setRule('allOf', $validators);
}
/**
* Validate against exactly one of the provided validators (JSON Schema: oneOf)
*
* The value is valid only if it passes exactly one validator (not zero, not multiple).
*
* @param array<Validator> $validators Array of Validator instances
* @param string|Stringable|null $message Custom error message
* @return static
*/
public function oneOf(array $validators): static
{
return $this->setRule('oneOf', $validators);
}
/**
* Validate that value does NOT match validator (JSON Schema: not)
*
* The value is valid only if it fails the provided validator.
*
* @param Validator $validator Validator instance
* @param string|Stringable|null $message Custom error message
* @return static
*/
public function not(Validator $validator): static
{
return $this->setRule('not', $validator);
}
// ========================================================================
// Custom Validator (NOT exportable to JSON Schema)
// ========================================================================
/**
* Custom validation callback (for server-side validation only)
*
* Use this for PHP-specific validations that cannot be exported to JSON Schema,
* such as instance checks, filter_var validations, or any custom PHP logic.
*
* The callback should return truthy if valid, falsy if invalid.
*
* When validating object properties or array items, the callback can optionally
* accept a second parameter for the parent context (containing object/array):
*
* Examples:
* ```php
* // Simple validation (just the value)
* $validator->custom(fn($v) => $v instanceof SomeClass)
*
* // With parent context (for relational validation)
* $userValidator = (new Validator)
* ->type('object')
* ->forProperty('password_confirmation',
* (new Validator)->custom(fn($confirmation, $user) =>
* $confirmation === $user['password']
* )
* );
* ```
*
* @param Closure $callback Validation function: fn($value, $context = null) => bool
* @return static
*/
public function custom(Closure $callback): static
{
return $this->setRule('custom:' . spl_object_id($callback), $callback);
}
// ========================================================================
// Composition
// ========================================================================
/**
* Add validator for object property or array key
*
* @param string $property Property name or array key
* @param Validator $validator Validator for this property
* @return static
*/
public function forProperty(string $property, Validator $validator): static
{
$clone = clone $this;
$clone->propertyValidators[$property] = $validator;
return $clone;
}
/**
* Define validators for multiple properties (JSON Schema: properties)
*
* Bulk version of forProperty(). Validates specific named properties.
*
* @param array<string, Validator> $properties Property name => Validator map
* @return static
*/
public function properties(array $properties): static
{
$clone = clone $this;
foreach ($properties as $property => $validator) {
if (!($validator instanceof Validator)) {
throw new \InvalidArgumentException("properties() requires Validator instances");
}
$clone->propertyValidators[$property] = $validator;
}
return $clone;
}
/**
* Validate properties matching regex patterns (JSON Schema: patternProperties)
*
* Properties whose names match the pattern will be validated against the schema.
*
* @param string $pattern Regex pattern for property names
* @param Validator $validator Validator for matching properties
* @return static
*/
public function patternProperties(string $pattern, Validator $validator): static
{
$clone = clone $this;
$clone->patternPropertyValidators[$pattern] = $validator;
return $clone;
}
/**
* Validate additional properties (JSON Schema: additionalProperties)
*
* Controls validation of properties not defined in properties() or patternProperties().
*
* @param Validator|bool $validator Validator for additional properties, or false to disallow
* @return static
*/
public function additionalProperties(Validator|bool $validator): static
{
$clone = clone $this;
if ($validator === false) {
$clone->allowAdditionalProperties = false;
$clone->additionalPropertiesValidator = null;
} elseif ($validator === true) {
$clone->allowAdditionalProperties = true;
$clone->additionalPropertiesValidator = null;
} else {
$clone->allowAdditionalProperties = true;
$clone->additionalPropertiesValidator = $validator;
}
return $clone;
}
/**
* Remove fields from validation
*
* Returns a clone without specified property validators.
*
* @param array $properties Property names to remove
* @return static
*/
public function withoutFields(array $properties): static
{
$clone = clone $this;
foreach ($properties as $property) {
unset($clone->propertyValidators[$property]);
}
return $clone;
}
/**
* Keep only specified fields for validation
*
* Returns a clone with only the specified property validators.
*
* @param array $fields Property names to keep
* @return static
*/
public function withFields(array $fields): static
{
$clone = clone $this;
foreach ($clone->propertyValidators as $property => $validator) {
if (!in_array($property, $fields, true)) {
unset($clone->propertyValidators[$property]);
}
}
return $clone;
}
// ========================================================================
// Validation Helper Methods
// ========================================================================
/**
* Check if a value is object-like (PHP object or associative array)
*/
private function isObjectLike(mixed $value): bool
{
// In PHP, an empty array [] is ambiguous - it could be an empty list or empty object.
// For validation purposes, we treat [] as object-like (valid for type: 'object')
// since JSON's {} maps to PHP's [] when decoded.
return is_object($value) || (is_array($value) && ($value === [] || !array_is_list($value)));
}
/**
* Validate using custom closure
*
* Calls the closure with both value and context. If the closure only accepts
* one parameter, the second parameter is simply ignored by PHP.
*/
private function validateCustom(Closure $callback, mixed $value, mixed $context): ?string
{
$result = $callback($value, $context);
return $result ? null : \mini\t("Validation failed.");
}
private function validateType(string|array $types, mixed $value): ?string
{
$typeArray = is_array($types) ? $types : [$types];
foreach ($typeArray as $type) {
$valid = match($type) {
'string' => is_string($value),
'integer' => is_int($value),
'number' => is_int($value) || is_float($value),
'boolean' => is_bool($value),
'array' => is_array($value) && array_is_list($value),
'object' => $this->isObjectLike($value),
'null' => $value === null,
default => false
};
if ($valid) {
return null;
}
}
$typeList = count($typeArray) === 1
? "a {$typeArray[0]}"
: implode(' or ', array_map(fn($t) => "a $t", $typeArray));
return \mini\t("Must be {types}", ['types' => $typeList]);
}
private function validateFormat(string $format, mixed $value): ?string
{
if ($value === null) return null;
$valid = match($format) {
'email' => filter_var($value, FILTER_VALIDATE_EMAIL) !== false,
'uri' => filter_var($value, FILTER_VALIDATE_URL) !== false,
'date-time' => (bool)preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/', $value),
'date' => (bool)preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) && (function($parts) {
return checkdate((int)$parts[1], (int)$parts[2], (int)$parts[0]);
})(explode('-', $value)),
'time' => (bool)preg_match('/^([01]\d|2[0-3]):([0-5]\d):([0-5]\d)(\.\d+)?$/', $value),
'ipv4' => filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false,
'ipv6' => filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false,
'uuid' => (bool)preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $value),
'slug' => (bool)preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $value),
default => true
};
return $valid ? null : \mini\t("Invalid {format} format.", ['format' => $format]);
}
private function validateItems(Validator|array $validator, mixed $value): ?string
{
if ($value === null) return null;
if (!is_array($value)) return \mini\t("Must be an array.");
if (is_array($validator)) {
// Tuple validation
foreach ($validator as $index => $itemValidator) {
if (!isset($value[$index])) continue;
$error = $itemValidator->isInvalid($value[$index]);
if ($error !== null) {
return \mini\t("Item at index {index} is invalid: {error}", [
'index' => $index,
'error' => $error
]);
}
}
} else {
// All items validation
foreach ($value as $index => $item) {
$error = $validator->isInvalid($item);
if ($error !== null) {
return \mini\t("Item at index {index} is invalid: {error}", [
'index' => $index,
'error' => $error
]);
}
}
}
return null;
}
private function validateAnyOf(array $validators, mixed $value): ?string
{
foreach ($validators as $validator) {
if ($validator->isInvalid($value) === null) {
return null;
}
}
return \mini\t("Must match at least one of the allowed types.");
}
private function validateAllOf(array $validators, mixed $value): ?string
{
foreach ($validators as $validator) {
$error = $validator->isInvalid($value);
if ($error !== null) {
return $error;
}
}
return null;
}
private function validateOneOf(array $validators, mixed $value): ?string
{
$passedCount = 0;
foreach ($validators as $validator) {
if ($validator->isInvalid($value) === null) {
$passedCount++;
}
}
return $passedCount === 1 ? null : \mini\t("Must match exactly one of the allowed types.");
}
private function validateNot(Validator $validator, mixed $value): ?string
{
return $validator->isInvalid($value) === null ? \mini\t("Must not match the disallowed type.") : null;
}
private function validateAdditionalItems(Validator|bool $validator, mixed $value): ?string
{
if ($value === null) return null;
if (!is_array($value)) return \mini\t("Must be an array.");
// additionalItems only applies when items is a tuple (array of validators)
$itemsRule = $this->rules['items'] ?? null;
if (!is_array($itemsRule)) {
return null; // Not a tuple schema, additionalItems doesn't apply
}
$tupleLength = count($itemsRule);
if ($validator === false) {
// Check if there are items beyond the tuple length
if (count($value) > $tupleLength) {
return \mini\t("Array must have at most {max} items.", ['max' => $tupleLength]);
}
} elseif ($validator instanceof Validator) {
// Validate items beyond tuple length
for ($i = $tupleLength; $i < count($value); $i++) {
$error = $validator->isInvalid($value[$i]);
if ($error !== null) {
return \mini\t("Additional item at index {index} is invalid: {error}", [
'index' => $i,
'error' => $error
]);
}
}
}
return null;
}
private function validateMinContains(array $rule, mixed $value): ?string
{
if ($value === null) return null;
if (!is_array($value)) return \mini\t("Must be an array.");
$min = $rule['min'];
$validator = $rule['validator'];
$matchCount = 0;
foreach ($value as $item) {
if ($validator->isInvalid($item) === null) {
$matchCount++;
}
}
return $matchCount < $min ? \mini\t("Must contain at least {min} matching items.", ['min' => $min]) : null;
}
private function validateMaxContains(array $rule, mixed $value): ?string
{
if ($value === null) return null;
if (!is_array($value)) return \mini\t("Must be an array.");
$max = $rule['max'];
$validator = $rule['validator'];
$matchCount = 0;
foreach ($value as $item) {
if ($validator->isInvalid($item) === null) {
$matchCount++;
}
}
return $matchCount > $max ? \mini\t("Must contain at most {max} matching items.", ['max' => $max]) : null;
}
private function validateDependentRequired(array $dependencies, mixed $value): ?string
{
if ($value === null) return null;
if (!is_array($value)) return \mini\t("Must be an object.");
foreach ($dependencies as $property => $requiredProperties) {
if (isset($value[$property])) {
foreach ($requiredProperties as $requiredProp) {
if (!isset($value[$requiredProp])) {
return \mini\t("Property '{prop}' requires '{required}' to be present.", [
'prop' => $property,
'required' => $requiredProp
]);
}
}
}
}
return null;
}
/**
* Export validator as JSON Schema (JsonSerializable interface)
*
* This enables automatic recursive serialization when using json_encode().
* Child Validator instances are automatically serialized recursively.
*
* @return array JSON Schema representation
*/
public function jsonSerialize(): array
{
$schema = [];
// Add all rules (excluding custom: rules which aren't JSON Schema)
foreach ($this->rules as $keyword => $value) {
if (!str_starts_with($keyword, 'custom:')) {
$schema[$keyword] = $value;
}
}
// Properties
if (!empty($this->propertyValidators)) {
$schema['properties'] = [];
$required = [];
foreach ($this->propertyValidators as $prop => $validator) {
$schema['properties'][$prop] = $validator;
if ($validator->isRequired) {
$required[] = $prop;
}
}
if (!empty($required)) {
$schema['required'] = $required;
}
}
// Pattern properties
if (!empty($this->patternPropertyValidators)) {
$schema['patternProperties'] = [];
foreach ($this->patternPropertyValidators as $pattern => $validator) {
$schema['patternProperties'][$pattern] = $validator;
}
}
// Additional properties
if (!$this->allowAdditionalProperties) {
$schema['additionalProperties'] = false;
} elseif ($this->additionalPropertiesValidator !== null) {
$schema['additionalProperties'] = $this->additionalPropertiesValidator;
}
return $schema;
}
}