A pure PHP validation library for input validation. No framework dependencies, no string parsing, no magic.
composer require signalforge/validationRequirements: PHP 8.2+
use Signalforge\Validation\Validator;
$validator = new Validator([
'email' => ['required', 'email'],
'name' => ['required', 'string', ['min', 2], ['max', 100]],
'age' => ['nullable', 'integer', ['between', 18, 120]],
]);
$result = $validator->validate([
'email' => 'user@example.com',
'name' => 'John Doe',
'age' => 25,
]);
if ($result->valid()) {
$data = $result->validated(); // Only validated fields
} else {
$errors = $result->errors(); // Validation errors with message keys
}- No string parsing — Rules are always arrays, never pipe-separated strings
- No database validation — No
unique,existsrules. Use database constraints. - Keys, not messages — Validator returns message keys for i18n, not hardcoded strings
- Fail fast — Invalid rule definitions throw during setup, not during validation
- Stateless — Validators are pure functions with no side effects
$rules = [
'email' => ['required', 'email'],
'name' => ['required', 'string'],
];$rules = [
'name' => ['required', 'string', ['min', 2], ['max', 100]],
'age' => ['nullable', 'integer', ['between', 18, 120]],
'role' => ['required', ['in', ['admin', 'user', 'guest']]],
];$rules = [
'tags' => ['required', 'array', ['min', 1], ['max', 10]],
'tags.*' => ['required', 'string', ['max', 50]],
'items' => ['required', 'array'],
'items.*.name' => ['required', 'string'],
'items.*.quantity' => ['required', 'integer', ['min', 1]],
'items.*.options.*' => ['string'],
];Apply rules based on other field values:
$rules = [
'type' => ['required', ['in', ['personal', 'business']]],
// Required only when type is 'business'
'company_name' => [
['when', ['type', '=', 'business'], [
'required',
'string',
['max', 200],
]],
],
// With else branch
'document_type' => [
'required',
['when', ['category', '=', 'legal'],
[['in', ['contract', 'nda', 'agreement']]], // then
[['in', ['report', 'memo', 'note']]], // else
],
],
];Apply rules based on the current field's value:
$rules = [
'bio' => [
'nullable',
'string',
// Only validate min length if field has a value
['when', ['@filled'], [
['min', 10],
]],
// Apply different max based on length
['when', ['@length', '>=', 256], [
['max', 65535],
]],
],
];Combine multiple conditions with and/or:
$rules = [
'vat_number' => [
['when', ['and', ['type', '=', 'business'], ['country', 'in', ['HR', 'SI', 'AT']]], [
'required',
'vat_eu',
]],
],
'admin_code' => [
['when', ['or', ['role', '=', 'admin'], ['role', '=', 'superadmin']], [
'required',
'string',
]],
],
];$result = $validator->validate($data);
// Check if validation passed
$result->valid(); // bool
$result->failed(); // bool
// Get validated data (only if valid)
$result->validated(); // array
// Get all errors
$result->errors();
// [
// 'email' => [
// ['key' => 'validation.required', 'params' => ['field' => 'email']],
// ],
// 'name' => [
// ['key' => 'validation.min.string', 'params' => ['field' => 'name', 'min' => 2, 'actual' => 1]],
// ],
// ]
// Check specific field
$result->hasError('email'); // bool
$result->errorsFor('email'); // array
// Convert to array
$result->toArray();
// ['valid' => false, 'errors' => [...], 'validated' => [...]]The validator returns message keys, not formatted messages. Use MessageFormatter for i18n:
use Signalforge\Validation\MessageFormatter;
// Load from directory
$formatter = MessageFormatter::fromDirectory(__DIR__ . '/messages');
// Or define inline
$formatter = new MessageFormatter([
'en' => [
'validation.required' => 'The {field} field is required.',
'validation.min.string' => 'The {field} must be at least {min} characters.',
'validation.email' => 'The {field} must be a valid email address.',
],
'hr' => [
'validation.required' => 'Polje {field} je obavezno.',
'validation.min.string' => 'Polje {field} mora imati najmanje {min} znakova.',
'validation.email' => 'Polje {field} mora biti valjana email adresa.',
],
]);
// Format errors
$messages = $formatter->format($result, 'en');
// ['email' => ['The email field is required.']]
$messages = $formatter->format($result, 'hr');
// ['email' => ['Polje email je obavezno.']]| Rule | Description |
|---|---|
required |
Field must be present and not empty |
nullable |
Field can be null (stops further validation if null) |
filled |
If present, must not be empty |
present |
Field must exist in input (can be empty) |
| Rule | Description |
|---|---|
string |
Must be a string |
integer |
Must be an integer |
numeric |
Must be numeric (int, float, or numeric string) |
boolean |
Must be boolean or boolean-like (1, 0, 'true', 'false', 'yes', 'no') |
array |
Must be an array |
Works differently based on type (string length, numeric value, array count):
| Rule | Description |
|---|---|
['min', n] |
Minimum length/value/count |
['max', n] |
Maximum length/value/count |
['between', min, max] |
Between min and max |
| Rule | Description |
|---|---|
['regex', pattern] |
Must match regex pattern |
['not_regex', pattern] |
Must not match regex pattern |
alpha |
Only alphabetic characters (Unicode-aware) |
alpha_num |
Only alphanumeric characters |
alpha_dash |
Alphanumeric, dashes, underscores |
lowercase |
Must be lowercase |
uppercase |
Must be uppercase |
['starts_with', prefix] |
Must start with prefix |
['ends_with', suffix] |
Must end with suffix |
['contains', substring] |
Must contain substring |
| Rule | Description |
|---|---|
['gt', field] |
Greater than other field's value |
['gte', field] |
Greater than or equal to other field |
['lt', field] |
Less than other field's value |
['lte', field] |
Less than or equal to other field |
| Rule | Description |
|---|---|
distinct |
All items must be unique |
| Rule | Description |
|---|---|
email |
Valid email address |
url |
Valid URL |
['url', ['http', 'https']] |
URL with specific schemes |
ip |
Valid IP address (v4 or v6) |
['ip', 'v4'] |
Valid IPv4 address |
['ip', 'v6'] |
Valid IPv6 address |
uuid |
Valid UUID |
['uuid', 4] |
Valid UUID v4 |
json |
Valid JSON string |
| Rule | Description |
|---|---|
date |
Valid date string |
['date_format', format] |
Date matching PHP format |
['after', date] |
Date after given date |
['before', date] |
Date before given date |
['after_or_equal', date] |
Date after or equal to given date |
['before_or_equal', date] |
Date before or equal to given date |
| Rule | Description |
|---|---|
['in', [...values]] |
Value must be in list |
['not_in', [...values]] |
Value must not be in list |
['same', field] |
Must match other field's value |
['different', field] |
Must differ from other field |
confirmed |
Field {name}_confirmation must match |
| Rule | Description |
|---|---|
oib |
Valid Croatian OIB (11-digit, MOD 11-10 checksum) |
['phone', 'HR'] |
Valid Croatian phone number |
| Rule | Description |
|---|---|
iban |
Valid IBAN (any country) |
['iban', 'HR'] |
Valid IBAN for specific country |
vat_eu |
Valid EU VAT number |
['vat', 'HR'] |
Valid VAT for specific country |
['phone', 'SI'] |
Valid phone for Slovenia |
['phone', 'AT'] |
Valid phone for Austria |
['phone', 'DE'] |
Valid phone for Germany |
| Rule | Description |
|---|---|
postal_code |
Valid postal code (generic) |
['postal_code', 'GB'] |
Valid UK postal code (e.g., "SW1A 1AA") |
['postal_code', 'DE'] |
Valid German postal code (5 digits) |
['postal_code', 'FR'] |
Valid French postal code (5 digits) |
['postal_code', 'ES'] |
Valid Spanish postal code (5 digits) |
['postal_code', 'DK'] |
Valid Danish postal code (4 digits) |
['postal_code', 'NL'] |
Valid Dutch postal code (e.g., "1012 AB") |
['postal_code', 'AT'] |
Valid Austrian postal code (4 digits) |
['postal_code', 'BE'] |
Valid Belgian postal code (4 digits) |
['postal_code', 'CH'] |
Valid Swiss postal code (4 digits) |
['postal_code', 'IT'] |
Valid Italian postal code (5 digits) |
['postal_code', 'PL'] |
Valid Polish postal code (e.g., "00-001") |
['postal_code', 'SE'] |
Valid Swedish postal code (e.g., "111 22") |
['postal_code', 'NO'] |
Valid Norwegian postal code (4 digits) |
['postal_code', 'FI'] |
Valid Finnish postal code (5 digits) |
['postal_code', 'IE'] |
Valid Irish Eircode (e.g., "D02 X285") |
['postal_code', 'PT'] |
Valid Portuguese postal code (e.g., "1000-001") |
['postal_code', 'US'] |
Valid US ZIP code (e.g., "10001" or "10001-1234") |
['postal_code', 'CA'] |
Valid Canadian postal code (e.g., "K1A 0B1") |
country_code |
Valid ISO 3166-1 alpha-2 country code |
['country_code', 'alpha2'] |
Valid ISO 3166-1 alpha-2 code (e.g., "DE") |
['country_code', 'alpha3'] |
Valid ISO 3166-1 alpha-3 code (e.g., "DEU") |
Supported postal code countries: GB, UK, DE, FR, ES, DK, NL, AT, BE, CH, IT, PL, PT, SE, NO, FI, IE, CZ, HU, HR, SI, SK, RO, BG, GR, US, CA
Reference the current field being validated:
['@length', '>=', 256] // String length comparison
['@length', '<', 10]
['@value', '=', 'special'] // Exact value comparison
['@value', '!=', 'default']
['@matches', '/^\d+$/'] // Regex match
['@type', 'string'] // PHP type check
['@type', 'array']
['@empty'] // null, '', or []
['@filled'] // Not emptyReference other fields in the input:
['field', '=', 'value']
['field', '!=', 'value']
['field', '>', 100]
['field', '>=', 100]
['field', '<', 100]
['field', '<=', 100]
['field', 'in', ['a', 'b', 'c']]
['field', 'not_in', ['x', 'y']]
['field', 'filled'] // Other field is not empty
['field', 'empty'] // Other field is empty['and', [...condition1], [...condition2]]
['or', [...condition1], [...condition2]]$result = Validator::make($data, $rules)->validate($data);The validator returns standardized message keys:
validation.{rule} # Simple rules: validation.required
validation.{rule}.{type} # Type-specific: validation.min.string
validation.{rule}.{variant} # Variants: validation.ip.v4
Common message keys:
validation.requiredvalidation.emailvalidation.min.string,validation.min.numeric,validation.min.arrayvalidation.max.string,validation.max.numeric,validation.max.arrayvalidation.between.string,validation.between.numeric,validation.between.arrayvalidation.invalidation.samevalidation.confirmedvalidation.oibvalidation.ibanvalidation.vat_eu
Rule names must:
- Start with a lowercase letter
- Contain only lowercase letters, numbers, and underscores
- Be at most 1024 characters
// Valid
'required', 'email', 'min', 'phone_hr', 'vat_eu'
// Invalid (throws InvalidRuleException)
'Required', '123rule', 'rule-name', 'rule.name'Implement RuleInterface:
use Signalforge\Validation\Rules\AbstractRule;
use Signalforge\Validation\ValidationError;
final class Slug extends AbstractRule
{
public function name(): string
{
return 'slug';
}
public function validate(
mixed $value,
array $params,
array $data,
string $field,
): ?ValidationError {
if (!is_string($value) || !preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $value)) {
return $this->error($field);
}
return null;
}
}Register the rule:
$validator = new Validator($rules);
$validator->registerRule('slug', Slug::class);$validator = new Validator([
'username' => ['required', 'string', ['min', 3], ['max', 20], 'alpha_dash', 'lowercase'],
'email' => ['required', 'email'],
'password' => ['required', 'string', ['min', 8], 'confirmed'],
'age' => ['nullable', 'integer', ['between', 13, 120]],
'terms' => ['required', 'boolean'],
]);$validator = new Validator([
'customer' => ['required', 'array'],
'customer.name' => ['required', 'string', ['max', 100]],
'customer.email' => ['required', 'email'],
'customer.phone' => ['nullable', ['phone', 'HR']],
'shipping_address' => ['required', 'array'],
'shipping_address.street' => ['required', 'string'],
'shipping_address.city' => ['required', 'string'],
'shipping_address.postal_code' => ['required', 'string'],
'shipping_address.country' => ['required', ['in', ['HR', 'SI', 'AT', 'DE']]],
'items' => ['required', 'array', ['min', 1]],
'items.*.product_id' => ['required', 'integer'],
'items.*.quantity' => ['required', 'integer', ['min', 1], ['max', 100]],
'items.*.notes' => ['nullable', 'string', ['max', 500]],
'payment' => ['required', 'array'],
'payment.method' => ['required', ['in', ['card', 'bank_transfer', 'cash']]],
'payment.iban' => [
['when', ['payment.method', '=', 'bank_transfer'], [
'required',
['iban', 'HR'],
]],
],
]);$validator = new Validator([
'type' => ['required', ['in', ['personal', 'business']]],
'oib' => ['required', 'oib'],
'company_name' => [
['when', ['type', '=', 'business'], [
'required',
'string',
['max', 200],
]],
],
'vat_number' => [
['when', ['type', '=', 'business'], [
'required',
'vat_eu',
]],
],
'iban' => ['required', ['iban', 'HR']],
'phone' => ['required', ['phone', 'HR']],
]);$validator = new Validator([
'address' => ['required', 'array'],
'address.street' => ['required', 'string', ['min', 5], ['max', 200]],
'address.city' => ['required', 'string', ['min', 2], ['max', 100]],
'address.country' => ['required', 'country_code'],
// Conditional postal code validation based on country
'address.postal_code' => [
'required',
'string',
// UK postal codes
['when', ['address.country', '=', 'GB'], [
['postal_code', 'GB'],
]],
// German postal codes
['when', ['address.country', '=', 'DE'], [
['postal_code', 'DE'],
]],
// French postal codes
['when', ['address.country', '=', 'FR'], [
['postal_code', 'FR'],
]],
// Spanish postal codes
['when', ['address.country', '=', 'ES'], [
['postal_code', 'ES'],
]],
// Danish postal codes
['when', ['address.country', '=', 'DK'], [
['postal_code', 'DK'],
]],
// Dutch postal codes
['when', ['address.country', '=', 'NL'], [
['postal_code', 'NL'],
]],
],
]);
// Example valid data
$result = $validator->validate([
'address' => [
'street' => '10 Downing Street',
'city' => 'London',
'country' => 'GB',
'postal_code' => 'SW1A 2AA',
],
]);$validator = new Validator([
'sender' => ['required', 'array'],
'sender.name' => ['required', 'string', ['max', 100]],
'sender.street' => ['required', 'string'],
'sender.postal_code' => ['required', ['postal_code', 'DE']],
'sender.city' => ['required', 'string'],
'sender.country' => ['required', ['country_code', 'alpha2']],
'recipient' => ['required', 'array'],
'recipient.name' => ['required', 'string', ['max', 100]],
'recipient.street' => ['required', 'string'],
'recipient.postal_code' => ['required', 'string'],
'recipient.city' => ['required', 'string'],
'recipient.country' => ['required', ['in', ['GB', 'FR', 'ES', 'NL', 'DK', 'DE', 'AT', 'BE']]],
'package' => ['required', 'array'],
'package.weight' => ['required', 'numeric', ['min', 0.1], ['max', 30]],
'package.dimensions' => ['required', 'array'],
'package.dimensions.length' => ['required', 'integer', ['min', 1], ['max', 150]],
'package.dimensions.width' => ['required', 'integer', ['min', 1], ['max', 150]],
'package.dimensions.height' => ['required', 'integer', ['min', 1], ['max', 150]],
]);# Run tests
composer test
# Run static analysis
composer analyseMIT