Respect\Validation is a PHP validation library that makes complex validations simple, with many validators out of the box.
It's built for engineers who validate real data and might need to display meaningful messages to end-users without hassle. It was first released over a decade ago, and has since grown to over 35 million installs on Packagist, nearly 6k stars on GitHub, and hundreds of public packages depending on it.
Validation 3.0 lands this month β and it's a big one.
I've been the main maintainer of Validation for a long time, and after a lot of thought and several conversations with users and dear engineer friends, I've refactored the whole library and rebuilt the engine from the ground up.
Validation still has the simplicity of making complex validations easier to end users, but now features consistent naming, better separation of concerns, higher customization capabilities, modern PHP features, and a developer experience that just feels right.
In this post:
- Accurate errors for nested validators
- Result composition
- Prefixed shortcuts
- PHP attributes
- No need for exceptions
- Paths in error messages
- Helpful stack traces
- Use your own exception
- Use your own templates
- Attaching templates to the chain
- Placeholder pipes
- Naming validators
Accurate errors for nested validators
Previous versions of Validation had a fundamental limitation: when a validator passed, nothing happened β and when it failed, it threw an exception. That sounds reasonable until you consider validators that wrap other validators, like Not.
Here's the problem. In previous versions, when you wrote:
// Before v3 π€
v::not(v::not(v::intType()))->assert('not an integer');
// β These rules must not pass for "not an integer"
Cryptic. Which rules? Why? The library couldn't produce a coherent message because the inner not(intType()) passed (since 'not an integer' isn't an integer), but the outer Not had no idea why it passed β only that it did.
Years ago I read Replacing Throwing Exceptions with Notification in Validations by Martin Fowler, and that sparked some ideas. What if we could do something similar in Validation? What if every validator (pass or fail) would return a result object that captures what happened and why?
After playing around with different prototypes and with PHP's newest features, I came up with a result object that has everything we need, without a lot of verbosity. Now, when validators compose, they can inspect each other's results and produce messages that actually make sense:
// v3 π
v::not(v::not(v::intType()))->assert('not an integer');
// β "not an integer" must be an integer
No matter how deep the nesting, Validation now knows exactly what happened and tells you clearly.
This change also unlocked capabilities that simply weren't possible before β like result composition.
Result composition
I always found it a pity that we couldn't compose validators and their messages. I wrote lots of customizations on top of Validation in Assertion, but there were quite some limitations.
In v3, validators can finally wrap others and combine their results into a single, coherent message. The outer validator provides context (what was extracted), and the inner validator provides the validation (what was expected).
v::all(v::intType())->assert(['1', '2', '3']);
// β Every item in `["1", "2", "3"]` must be an integer
v::length(v::greaterThan(3))->assert('abc');
// β The length of "abc" must be greater than 3
v::min(v::positive())->assert([3, 1, 0, -5]);
// β The minimum of `[3, 1, 0, -5]` must be a positive number
v::max(v::positive())->assert([-1, -2, -3]);
// β The maximum of `[-1, -2, -3]` must be a positive number
v::size('MB', v::not(v::greaterThan(5)))->assert('path/to/file.zip');
// β The size in megabytes of "path/to/file.zip" must not be greater than 5
v::dateTimeDiff('years', v::greaterThan(18))->assert('2025');
// β The number of years between now and "2025" must be greater than 18
Prefixed shortcuts
In a distant past, Validation had a not prefix as part of the Validator class, but we removed it. Many years later, when I wrote Assertion, I added several prefixes to make it easier to use validators that wrap other validators. I decided to bring them to Validation too.
v::allEmoji()->assert($input); // all items must be emojis
v::keyEmail('email')->assert($input); // key 'email' must be valid email
v::propertyPositive('age')->assert($input); // property 'age' must be positive
v::lengthBetween(5, 10)->assert($input); // length between 5 and 10
v::maxLessThan(100)->assert($input); // max value less than 100
v::minGreaterThan(0)->assert($input); // min value greater than 0
v::nullOrEmail()->assert($input); // null or valid email
v::undefOrPositive()->assert($input); // undefined or positive number
PHP attributes
PHP attributes have been available for years, and I always found it a shame we didn't support validating object properties with them. Symfony Validator has been supporting annotations/attributes for a very long time, but it doesn't have as many validators out-of-the-box as we have. Now, we can use any validator as an attribute.
use Respect\Validation\Rules\{Email, Between, GreaterThan, Length};
class User
{
public function __construct(
#[Email]
public string $email,
#[Between(18, 120)]
public int $age,
#[Length(new GreaterThan(1))]
public string $name,
) {
}
}
// Validate everything at once
v::attributes()->assert($user);
No need for exceptions
Sometimes you just want to validate data and handle the result manually. In previous versions, the only way to do that was wrapping the validation in a try block. In v3, you can skip the exception entirely:
$result = v::numericVal()->positive()->between(1, 255)->validate($input);
if (!$result->isValid()) {
echo $result;
}
The validate() method returns a ResultQuery object that has the following methods to output messages:
| Method | Description |
|---|---|
getMessage() |
Returns the first message from the deepest failed result in chain. |
getFullMessage() |
Returns the full message including all failed results. |
getMessages() |
Returns an array of all messages from failed results. |
The ResultQuery also has methods to query nested results. If you're validating a nested array or object properties:
$mysqlUserResult = $result->findByPath('mysql.user');
if ($mysqlUserResult !== null) {
echo $mysqlUserResult;
}
The findByPath() returns either a ResultQuery or null, and you can also use findByName() and findById().
Paths in error messages
Because previous versions of Validation didn't have result objects, it was impossible to trace paths of nested structures. Consider the following chain:
$validator = v::init()
->key(
'mysql',
v::init()
->key('host', v::stringType())
->key('user', v::stringType())
->key('password', v::stringType())
->key('schema', v::stringType()),
)
->key(
'postgresql',
v::init()
->key('host', v::stringType())
->key('user', v::stringType())
->key('password', v::stringType())
->key('schema', v::stringType()),
);
In previous versions of Validation, this is what would happen:
// Before v3 π€
$validator->assert($input);
// β host must be a string
Was it host from mysql or postgresql? You had no idea which one failed.
In v3, because every validator returns a result object, we can trace the path of nested structures and display much more helpful messages.
// v3 π
$validator->assert($input);
// β `.mysql.host` must be a string
Not only do you have the full path of the nested structure, but it's also clear that .mysql.host is a path, not a name.
Helpful stack traces
When an exception is thrown, PHP reports where it was created, not where it was caused. In most validation libraries (including older versions of Validation) that means stack traces point deep inside library internals. You end up hunting through the trace to find your actual code.
v3 fixes this. If v::intType()->assert($input) fails in example.php line 11, your exception looks like this:
Respect\Validation\Exceptions\ValidationException: "string" must be an integer in /opt/example.php:11
Stack trace:
#0 /opt/example.php(11): Respect\Validation\Validator->assert(1.0)
#1 {main}
Your file. Your line. Your problem to fix β not ours to hide.
I picked up this technique while digging into Pest's source code. Kudos to Pest's engineers!
Use your own exception
Even with a helpful stack trace, at times we just want to use our own exceptions. The trouble with older versions of Validation (and some other libraries) is that when a validation fails, you have to wrap it in a try/catch or manually check the result so you can throw your own exception.
I implemented this in Assertion years ago. It was an awesome idea that Jefersson Nathan suggested while I was working on it. In v3, you can do that right when you're using the assert() method:
v::email()->assert($input, new DomainException('Invalid email'));
But sometimes, you might want to reuse the message from Validation. For cases like that, you can just pass a callable to assert(). This idea came from seeing how openapi-psr7-validator uses Validation. It made a lot of sense that people might want their own exceptions without having to rebuild the message.
v::email()->assert(
$input,Β
fn(ValidationException $e) => new DomainException($e->getMessage()),
);
The callable receives the exception that Validation would have thrown. You can do whatever you want in there (log it, wrap it, enrich it), but it needs to return an exception.
Use your own templates
Sometimes, you don't need your own exception, but you need a specific message. You can pass your template to assert() just as a string, and Validation will throw ValidationException with it as the message.
v::email()->assert($input, 'This is my template');
// β This is my template
When you have multiple validators in a chain, you can define one template for each validator:
v::intVal()->positive()->lessThan(100)->assert($input, [
'intVal' => 'Must be an integer',
'positive' => 'Must be positive',
'lessThan' => 'Must be under 100',
]);
When using validators that handle structures (like Key and Property), you can define the template by the path of the validator:
// Target nested structures by path
v::key('name', v::stringType())
->key('age', v::intVal())
->assert($input, [
'__root__' => 'Please check your user data',
'name' => 'Please provide a valid name',
'age' => 'Age must be a number',
]);
The __root__ key targets the root validator. In this case, that's an AllOf that wraps the chain.
Passing templates to assert() is best for when you don't want to reuse the templates. When you do, you'll want to keep the templates in the chain.
Attaching templates to the chain
In previous versions of Validation, you could use the setTemplate method in the chain to define your template.
v::email()->setTemplate('This is my template')->assert($input);
// β This is my template
There are a couple of issues with setTemplate:
- It can be confusing when you have nested validators in the chain
- It's not as explicit as I would have liked it to be
- It added some overhead to the
Validatorclass that was already quite complex
Because of the new engine (result composition), I created a validator called Templated that overwrites the template of the result of a wrapped validator.
v::templated('This is my template', v::email())->assert($input);
// β This is my template
This makes the validation more explicit and less confusing. The Templated validator also allows you to pass parameters to your template, so you can inject your own placeholders.
v::templated(
'Every {{user_type}} user needs a valid email',
v::email(),
['user_type' => $userType],
)->assert('not an email');
// β Every "admin" user needs a valid email
Placeholder pipes
Validation uses Stringifier to convert values into strings for templates. By default, strings get double quotes around them. With placeholder pipes, you can customise how values are rendered. Just add a pipe (|) followed by the modifier name.
raw: removes quotes, useful for field names or labels that shouldn't look like string values:
v::templated(
'The {{field|raw}} field is required',
v::notEmpty(),
['field' => 'email'],
)->assert('');
// β The email field is required
// (instead of: The "email" field is required)
quote: uses backticks, great for patterns or code-like values:
v::templated(
'Product SKU must follow the {{pattern|quote}} format',
v::regex('/^[A-Z]{3}-\d{4}$/'),
['pattern' => 'XXX-0000'],
)->assert('invalid-sku');
// β Product SKU must follow the `XXX-0000` format
trans: translates the value when using a translator:
v::templated(
'Le champ {{field|trans}} est invalide',
v::email(),
['field' => 'email_address'], // key in your translation file
)->assert('not-an-email');
// β Le champ "adresse e-mail" est invalide
listOr and listAnd: formats arrays as readable lists:
v::templated(
'Status must be {{haystack|listOr}}',
v::in(['active', 'pending', 'archived']),
)->assert('deleted');
// β Status must be "active", "pending", or "archived"
v::templated(
'User must have {{roles|listAnd}} roles to perform this action',
v::callback(fn(User $user) => $user->hasRoles(['admin', 'editor'])),
['roles' => ['admin', 'editor']],
)->assert($user);
// β User must have "admin" and "editor" roles to perform this action
Naming validators
The same reasoning applies to naming. In previous versions, you could use setName to give a validator a custom name in messages:
v::email()->setName('Your email')->assert($input);
// β Your email must be a valid email
This had the same issues as setTemplate: confusing with nested validators, not explicit, and added overhead to the Validator class.
In v3, there's a Named validator that wraps another validator and gives it a name:
v::named('Your email', v::email())->assert($input);
// β Your email must be a valid email
Same pattern as Templated, same benefits. And because they're just validators, they work as PHP attributes too:
class User
{
public function __construct(
#[Named('Your email', new Email())]
public string $email,
#[Templated('You must be at least {{age}} years old', new GreaterThanOrEqual(18))]
public int $age,
) {
}
}
Closing thoughts
This is the biggest release in Validation's history! The new engine, the result objects, the composition capabilities, the customization options... it's all been a long time coming.
This post focused on the most exciting user-facing changes, but there's a lot more to this version:
- Ability to customize a lot of standard behavior
- Improved validation messages
- Renamed validators for better semantic meaning
- More validators that are only possible due to the new engine
I'm incredibly grateful to everyone who contributed ideas, feedback, and code over the years! I can't wait to get feedback on the new version!
Top comments (0)