This Request for Comments proposes a new component targeting validation that
will eventually replace zend-validator, but initially complement it in order to
provide a stronger architecture, simplified maintenance, and more consistent
usage.
The component is yet to be named. When naming, we will be careful to ensure
there is no confusion between the names (e.g., we will not name it
zend-validation
, as this is too close in naming to zend-validator
, which
could lead to issues being raised in the wrong locations).
Why?
The zend-validator component has a number of short-comings that are due to past
architectural decisions. These include:
-
Soft dependencies on a number of other components, such as zend-cache,
zend-session, zend-http, zend-uri, and more. Most of these are used by one or
a subset of validators, but you need to pay attention to the Composer
suggestions to know when and why you would need to install them. This makes
working with zend-validator out-of-the-box difficult. -
Stateful validators can lead to hard-to-debug issues. If the same instance of
a validator is used in multiple validator chains (e.g., within an input
filter), the value and messages retrieved from the validator will be based on
when it was executed. -
Difficult to understand constructors. Most validator constructors only take an
array of options at this point, which requires that you know what option
keys and their related values are expected. Because options may also be set
after instantiation, you often cannot know if your validator is configured
correctly until you execute it — which is generally too late. -
Duplicated and verbose type-checking logic. Because the component was written
before scalar type hints were added to PHP, and because most validator
configuration was occuring via an options array, we ended up with a lot of
conditionals to assert the validity of options values.
None of these short-comings are enough to address on their own, necessarily;
they are pain points, definitely, but nothing to break backwards compatibility
for.
Considering them as a whole, they indicate some very real maintenance and design
issues that if we address, could make usage easier.
Goals
-
Provide a migration path. zend-valdiator is used standalone and with
zend-inputfilter in many, many places; we do not want to leave users with no
path to upgrade. All of the following goals must address this point. -
Upgrade to PHP 7.1. In particular, as we adapt existing validators to the new
component, this will allow us to remove a ton of code that was doing type
checking. -
Extract component-specific validators to their own packages. This will mean
additional packages that contain validators specific to a given component.
Doing so ensures the new component has no hard or soft dependencies on these
other components, nor do the components themselves have dependencies on the
new component. -
Remove unnecessary dependencies, or dependencies that may be inlined; examples
include:- zend-filter (used in the Digits validator); since we use the defaults for
that filter, we can inline the logic. - zend-config (only used in tests)
- zend-filter (used in the Digits validator); since we use the defaults for
-
Stateless validation. All validators will return a validation result, which
may be queried to determine outcome (“was validation successful?”), as well as
to retrieve validation failure messages. As part of this approach, validators
will no longer receive an array of options, but instead specific, typed
constructor arguments. This will allow re-use of validators across multiple
validation chains, and also reduce maintenance of individual validators.Additionally, this will extract the presentation of validation failure
messages from the validators. Presentation logic currently handled includes
value obfuscation, message translation, and message truncation; these can be
moved into result decorators or helpers.
PHP 7.1 Upgrade
The PHP 7.1 upgrade allows a number of things:
- Return type hints for most methods.
- Scalar type hints for method arguments and return values.
Additionally, this means we can start using things such as the splat operator
with a new Callback
validator implementation.
To make full use of typehints, we also need to write the constructors of each
validator such that they accept concrete arguments instead of an options array.
This will require that we create factories for each validator that accepts
options, and register those with the component’s plugin manager. Because
zend-servicemanager allows passing options when creating a service, we can write
these similar to the following:
use Psr\Container\ContainerInterface;
use RuntimeException;
class BetweenFactory
{
public function __invoke(ContainerInterface $container, $serviceName, array $options = null)
{
$options = $options ?: [];
$min = $options['min'] ?? 0;
$max = $options['max'] ?? PHP_INT_MAX;
$inclusive = $options['inclusive'] ?? false;
return new Between($min, $max, $inclusive);
}
}
Using this approach, the options remain the same; the only difference is how the
plugin manager uses them to create new validator instances. This solves the 80%
case for migration; as direct instantiation is the minority use case.
Extracting Components and Dependency Reduction
We can reduce dependencies within the new component dramatically, making it
easier to use standalone, and bringing clarity to which validators have
additional requirements.
First, there are a number of components that are currently included in the
zend-validator composer.json
that are never used, or only used in tests:
- zend-cache (never used)
- zend-config (used in tests; other solutions exist)
Second, there are components used by zend-validator, but where the functionality
could likely be inlined in order to reduce a dependency. These include:
- zend-filter: the
Digits
validator uses aDigits
filter with default
values; this can be inlined. - zend-http: used by the
bin/update_hostname_validator.php
script; we could
use curl instead. - zend-i18n: currently, this is to allow injecting validators with translators,
if the translator service is available. Since translation will move to result
objects, and those will likely move to a new component, this can be removed.
Third, there are a number of components included by zend-validator because
specific validators make use of them:
- zend-db: used by the
NoRecordExists
andRecordExists
validators - zend-math: used by the
Csrf
validator - zend-session: used by the
Csrf
validator - zend-uri: used by the
Uri
andSitemap\Loc
validators
For the first two sets of dependencies, we can omit them and adapt our code so
they are no longer necessary.
For the third set of dependencies, we have two options:
- Split each set of validators into a separate package; e.g., zend-db-validator.
This package then bridges the two components, and developers would install
that package if they want those validators. - Write the validator functionality such that it no longer has dependencies.
The first option (splitting to a separate component) will be used for the
zend-db validators. This will also be used for the Csrf
validator; while we
can replace its usage of Zend\Math\Rand::getBytes()
with random_bytes()
, it
still maintains a hard dependency on zend-session, and, as such, should be in a
separate package (likely zend-validator-csrf or zend-csrf-validator).
The second option will be used for the Uri
and Sitemap\Loc
validators (the
latter will be rewritten to use a Uri
validator internally).
The composer.json
package will suggest any extracted packages.
Stateless Validators
The following illustrates the problem in the current architecture:
$validator = new Between(['min' => 1, 'max' => 10, 'inclusive' => true]);
$chainOne = new ValidatorChain();
$chainOne->addValidator($validator);
$chainTwo = new ValidatorChain();
$chainTwo->addValidator($validator);
$chainTwo->isValid(11);
$chainOne->isValid(0);
$value = $validator->getValue();
$messages = $validator->getMessages();
What values are expected? The answer is: it depends on the order in which
the two validator chains are executed. In this case, the value will be 0
, and
we will have messages related to that; if we reversed the order in which the two
chains are executed, we’ll get different values.
This may seem unlikely, but consider:
- Validating a form, where you might have a collection of items that have the
same validation. If you use the same chains between them, and multiple
collections fail validation, the messages will not match the given collection. - zend-inputfilter uses validator chains internally, and can suffer from the
same problem.
Additionally, all validators currently define public setter methods that allow
you to change the various constraints the validator uses during validation. As
such, if you were to share an instance, but change a constraint to suit a
particular validator chain, all places that instance is used will have the
changed constraint — which likely is not desired.
These problem forced us to not share validator instances returned by the plugin
manager, but can still be encountered if you register instances manually.
The solution is to make validators stateless. By this, I mean specifically:
-
Values required to allow the validator to work cannot be changed after
instantiation. As such, these can and should be typed, and directly
injected in the constructor. This goes hand-in-hand with the proposed updates
to PHP 7.1. -
Validation should return a result of validation. Users would query this
result instance to determine if validation was successful, and pull any
validation failure messages from it. It would also compose the value, to allow
access to that by a result consumer.
As such, I propose the following:
- A final
ValidationFailureMessage
class defining a value object to contain a
message template and any variables that might be substituted into the
template. - A
Result
interface defining methods for querying validation status and
retrieving an array ofValidationFailureMessage
instances; this would
represent a single validation result. - A final
ValidatorResult
class implementing theResult
interface. This
would be the only implementation shipped. - Adding a
Validator
interface that defines a single method,
validate($value, $context = null) : ValidatorResult
.
We would also provide a helper class for rendering ValidationFailureMessage
instances. This would simply interpolate variables into the message templates
and spit them out. One or more separate packages could then provide helpers that
provide features such as message translation, message truncation, and
value obfuscation.
Each validator would be updated to implement the new interface. A new
AbstractValidator
implementation would be stripped of all message presentation
logic, storing only message templates and message variables to include in
ValidationFailureMessage
instances it returns in a ValidatorResult
.
The new ValidatorChain
implementation would implement Validator
, and only
accept Validator
instances. During validation, it would create a
ValidatorResult
that contains the final $isValid
(based on all elements in
the chain), and, if invalid, an aggregate of all ValidationFailureMessage
instances from all validators that failed.
All interfaces and proposed classes are listed in their entierty below in the
Appendix “Interfaces, Traits, and Classes”.
Compatibility concerns
The proposed package will initially depend on zend-validator v2, and provide
the proposed interface, classes, and traits only, with one or two existing
validators re-written to demonstrate how to write validators under the new
architecture.
We will ship a trait that adapts a given zend-validator ValidatorInterface
implementation so that it can also work as a Validator
implementation; a
suggested implementation, LegacyValidator
, is provided in the Appendix
"Interfaces, Traits, and Classes". This will allow users to use existing
validators, but then gradually rewrite them to work under the new architecture.
But what about…?
Logging
When logging, you often do not need validation failure message strings, but
rather their codes and the values that belong to each.
ValidationFailureMessage
, for this reason, composes a code related to the
message, as well as the associated values (which are often used to form the
final message for views as well). A logger might then serialize these values
using JSON:
$logger->warn(json_encode([
'code' => $message->getCode(),
'context' => $message->getVariables(),
]));
Translation
Many developers have indicated that translation is a perk of the current
zend-validator implementation, and something that should be turn-key.
We feel this can be addressed easily with helper classes that accept either a
result or the array of validation failure message instances. As an example:
use Zend\I18n\Translator\TranslatorInterface;
use Zend\YetToBeNamed\Result;
use Zend\YetToBeNamed\ValidationFailureMessage;
class ResultMessageTranslator
{
private $textDomain;
private $translator;
public function __construct(TranslatorInterface $translator, string $textDomain)
{
$this->translator = $translator;
$this->textDomain = $textDomain;
}
public function __invoke(Result $result) : array
{
if ($result->isValid()) {
return [];
}
$messages = [];
foreach ($result->getMessages() as $message) {
$messages[] = $this->prepareMessage($message);
}
return $messages;
}
private function prepareMessage(ValidationFailureMessage $message)
{
$translated = $this->translator->translate($message->getTemplate(), $this->textDomain);
foreach ($message->getVariables() as $key => $substitution) {
$pattern = sprintf('%%%s%%', $key);
$translated = str_replace($pattern, $substition, $translated);
}
return $translated;
}
}
We will likely provide a separate package that does exactly this, which would
make this then a turn-key solution for developers.
Appendix
Interfaces, Traits, and Classes
Below are the proposed interfaces, traits, and classes for the new component.
Class: ValidationFailureMessage
final class ValidationFailureMessage
{
/** @var string */
private $code;
/** @var string */
private $template;
/** @var array */
private $variables;
public function __construct(string $code, string $template, array $variables = [])
{
$this->code = $code;
$this->template = $template;
$this->variables = $variables;
}
public function getCode() : string
{
return $this->code;
}
public function getTemplate() : string
{
return $this->template;
}
public function getVariables() : array
{
return $this->variables;
}
}
Interface: Result
interface Result
{
public function isValid() : bool;
/**
* @return ValidationFailureMessage[]
*/
public function getMessages() : array;
/**
* @return mixed
*/
public function getValue();
}
Class: ValidatorResult
final class ValidatorResult implements Result
{
/** @var bool */
private $isValid;
/** ValidationFailureMessage[] */
private $messages;
/** @var mixed */
private $value;
public function __construct(bool $isValid, $value, array $messages = [])
{
$this->isValid = $isValid;
$this->value = $value;
array_walk($messages, function ($message) {
if (! $message instanceof ValidationFailureMessage) {
throw new InvalidArgumentException(sprintf(
'All validation failure messages must be of type %s; received %s',
ValidationFailureMessage::class,
is_object($message) ? get_class($message) : gettype($message)
));
}
})
$this->messages = $messages;
}
public function isValid() : bool
{
return $this->isValid;
}
/**
* @return ValidationFailureMessage[]
*/
public function getMessages() : array
{
return $this->messages;
}
/**
* @return mixed
*/
public function getValue()
{
return $this->value;
}
}
Interface: Validator
interface Validator
{
public function validate($value, $context = null) : ValidatorResult;
}
Trait: LegacyValidator
trait LegacyValidator
{
public function validate($value, $context = null) : ValidatorResult
{
if ($this->isValid($value, $context)) {
return new ValidatorResult(true, $value);
}
$messageVariables = array_merge(
$this->abstractOptions['messageVariables'],
['value' => $value]
);
$messages = [];
foreach (array_keys($this->abstractOptions['messages']) as $messageKey) {
$template = $this->abstractOptions['messageTemplates'][$messageKey];
$messages[] = new ValidationFailureMessage($template, $messageVariables);
}
return new ValidatorResult(false, $value, $messages);
}
}
Usage would be something like:
class CustomValidator extends AbstractValidator implements
Validator,
ValidatorInterface
{
use LegacyValidator;
/* ... */
}
Existing Proposals
I have reviewed the following proposals which predate this one. Each has some
unique ideas; none offers a complete roadmap, however.
“Road to ZF3”
- URL: https://github.com/zendframework/zend-validator/issues/1
- Author: @bakura10
Suggests the following:
- Moving component-specific validators (e.g., Barcode, Db, File, etc.) to their
specific components and deprecating them within zend-validator. - Stateless validators: validators return a result.
ValidationResult
- URL: https://github.com/bakura10/zend-validator/pull/1
- Author: @bakura10
Implements a ValidatorResultInterface
, and modifies ValidatorInterface
to
define validate()
(instead of isValid()
; the method now takes an optional
$context = null
argument.
Validator result interface proposal
- URL: https://github.com/zendframework/zend-validator/pull/24
- Author: @Maks3w
This proposal simply proposes new interfaces, without implementation:
- Defines a
ResultInterface
, withisValid()
,isNotValid()
,
getErrorCodes()
, andgetMessages()
methods. - Defines a
TranslatableMessageInterface
, withgetMessageTemplate()
,
getMessageVariables()
,getTranslationDomain()
, and__toString()
methods.
v3 proposal
Extends the previous proposal, and adds PHP 7.1 support. The specific extensions
include:
- A final
Message
value object, composing a message key, message template, and
message variables. - A final
Result
value object, composing the results of validation, and an
array ofMessage
instances. - An update to
ValidatorInterface
to renameisValid()
tovalidate()
, and
have it return aResult
.
Refactor to stateless validators
- URL: https://github.com/zendframework/zend-validator/pull/181
- Author: @weierophinney
Independent proposal based on online discussions. Includes:
-
Result
andResultAggregate
interfaces, with default implementations, as
well as examples demonstrating decoration. -
Validator
interface. - Updates to
AbstractValidator
andValidatorChain