Skip to content

Commit 0282afe

Browse files
authored
Merge pull request #253 from gsteel/v3/migration-guide
Initial V3 Migration Guide
2 parents c8c7f96 + 1b4a054 commit 0282afe

File tree

6 files changed

+1107
-32
lines changed

6 files changed

+1107
-32
lines changed
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# Composing `final` Validators
2+
3+
In version 3.0, nearly all validators have been marked as `final`.
4+
5+
This document aims to provide guidance on composing validators to achieve the same results as inheritance may have.
6+
7+
Consider the following custom validator. It ensures the value given is a valid email address and that it is an email address from `gmail.com`
8+
9+
```php
10+
namespace My;
11+
12+
use Laminas\Validator\EmailAddress;
13+
14+
class GMailOnly extends EmailAddress
15+
{
16+
public const NOT_GMAIL = 'notGmail';
17+
18+
protected $messageTemplates = [
19+
self::INVALID => "Invalid type given. String expected",
20+
self::INVALID_FORMAT => "The input is not a valid email address. Use the basic format local-part@hostname",
21+
self::INVALID_HOSTNAME => "'%hostname%' is not a valid hostname for the email address",
22+
self::INVALID_MX_RECORD => "'%hostname%' does not appear to have any valid MX or A records for the email address",
23+
self::INVALID_SEGMENT => "'%hostname%' is not in a routable network segment. The email address should not be resolved from public network",
24+
self::DOT_ATOM => "'%localPart%' can not be matched against dot-atom format",
25+
self::QUOTED_STRING => "'%localPart%' can not be matched against quoted-string format",
26+
self::INVALID_LOCAL_PART => "'%localPart%' is not a valid local part for the email address",
27+
self::LENGTH_EXCEEDED => "The input exceeds the allowed length",
28+
// And the one new constant introduced:
29+
self::NOT_GMAIL => 'Please use a gmail address',
30+
];
31+
32+
public function isValid(mixed $value) : bool
33+
{
34+
if (! parent::isValid($value)) {
35+
return false;
36+
}
37+
38+
if (! preg_match('/@gmail\.com$/', $value)) {
39+
$this->error(self::NOT_GMAIL);
40+
41+
return false;
42+
}
43+
44+
return true;
45+
}
46+
}
47+
```
48+
49+
A better approach could be to use a validator chain:
50+
51+
```php
52+
use Laminas\Validator\EmailAddress;
53+
use Laminas\Validator\Regex;
54+
use Laminas\Validator\ValidatorChain;
55+
56+
$chain = new ValidatorChain();
57+
$chain->attachByName(EmailAddress::class);
58+
$chain->attachByName(Regex::class, [
59+
'pattern' => '/@gmail\.com$/',
60+
'messages' => [
61+
Regex::NOT_MATCH => 'Please use a gmail.com address',
62+
],
63+
]);
64+
```
65+
66+
Or, to compose the email validator into a concrete class:
67+
68+
```php
69+
namespace My;
70+
71+
use Laminas\Validator\AbstractValidator;
72+
use Laminas\Validator\EmailAddress;
73+
74+
final class GMailOnly extends AbstractValidator
75+
{
76+
public const NOT_GMAIL = 'notGmail';
77+
public const INVALID = 'invalid';
78+
79+
protected $messageTemplates = [
80+
self::INVALID => 'Please provide a valid email address',
81+
self::NOT_GMAIL => 'Please use a gmail address',
82+
];
83+
84+
public function __construct(
85+
private readonly EmailAddress $emailValidator
86+
) {
87+
}
88+
89+
public function isValid(mixed $value) : bool
90+
{
91+
if (! $this->emailValidator->isValid($value)) {
92+
$this->error(self::INVALID);
93+
94+
return false;
95+
}
96+
97+
if (strtoupper($value) !== $value) {
98+
$this->error(self::NOT_GMAIL);
99+
100+
return false;
101+
}
102+
103+
return true;
104+
}
105+
}
106+
```
107+
108+
In the latter case you would need to define factory for your validator which for this contrived example would seem like overkill, but for more real-world use cases a factory is likely employed already:
109+
110+
```php
111+
use Laminas\Validator\EmailAddress;
112+
use Laminas\Validator\ValidatorPluginManager;
113+
use Psr\Container\ContainerInterface;
114+
115+
final class GMailOnlyFactory {
116+
public function __invoke(ContainerInterface $container, string $name, array $options = []): GMailOnly
117+
{
118+
$pluginManager = $container->get(ValidatorPluginManager::class);
119+
120+
return new GmailOnly(
121+
$pluginManager->build(EmailAddress::class, $options),
122+
);
123+
}
124+
}
125+
```
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
# Refactoring Legacy Validators
2+
3+
This document is intended to show an example of refactoring custom validators to remove runtime mutation of options and move option determination to the constructor of your validator.
4+
5+
## The Old Validator
6+
7+
The following custom validator is our starting point which relies on now removed methods and behaviour from the `AbstractValidator` in the 2.x series of releases.
8+
9+
```php
10+
namespace My;
11+
12+
use Laminas\Validator\AbstractValidator;
13+
14+
class MuppetNameValidator extends AbstractValidator {
15+
public const KNOWN_MUPPETS = [
16+
'Kermit',
17+
'Miss Piggy',
18+
'Fozzie Bear',
19+
'Gonzo the Great',
20+
'Scooter',
21+
'Animal',
22+
'Beaker',
23+
];
24+
25+
public const ERR_NOT_STRING = 'notString';
26+
public const ERR_NOT_ALLOWED = 'notAllowed';
27+
28+
protected array $messageTemplates = [
29+
self::ERR_NOT_STRING => 'Please provide a string value',
30+
self::ERR_NOT_ALLOWED => '"%value%" is not an allowed muppet name',
31+
];
32+
33+
public function setAllowedMuppets(array $muppets): void {
34+
$this->options['allowed_muppets'] = [];
35+
foreach ($muppets as $muppet) {
36+
$this->addMuppet($muppet);
37+
}
38+
}
39+
40+
public function addMuppet(string $muppet): void
41+
{
42+
$this->options['allowed_muppets'][] = $muppet;
43+
}
44+
45+
public function setCaseSensitive(bool $caseSensitive): void
46+
{
47+
$this->options['case_sensitive'] = $caseSensitive;
48+
}
49+
50+
public function isValid(mixed $value): bool {
51+
if (! is_string($value)) {
52+
$this->error(self::ERR_NOT_STRING);
53+
54+
return false;
55+
}
56+
57+
$list = $this->options['allowed_muppets'];
58+
if (! $this->options['case_sensitive']) {
59+
$list = array_map('strtolower', $list);
60+
$value = strtolower($value);
61+
}
62+
63+
if (! in_array($value, $list, true)) {
64+
$this->error(self::ERR_NOT_ALLOWED);
65+
66+
return false;
67+
}
68+
69+
return true;
70+
}
71+
}
72+
```
73+
74+
Given an array of options such as `['allowed_muppets' => ['Miss Piggy'], 'caseSensitive' => false]`, previously, the `AbstractValidator` would have "magically" called the setter methods `setAllowedMuppets` and `setCaseSensitive`. The same would be true if you provided these options to the removed `AbstractValidator::setOptions()` method.
75+
76+
Additionally, with the class above, there is nothing to stop you from creating the validator in an invalid state with:
77+
78+
```php
79+
$validator = new MuppetNameValidator();
80+
$validator->isValid('Kermit');
81+
// false, because the list of allowed muppets has not been initialised
82+
```
83+
84+
## The Refactored Validator
85+
86+
```php
87+
final readonly class MuppetNameValidator extends AbstractValidator {
88+
public const KNOWN_MUPPETS = [
89+
'Kermit',
90+
'Miss Piggy',
91+
'Fozzie Bear',
92+
'Gonzo the Great',
93+
'Scooter',
94+
'Animal',
95+
'Beaker',
96+
];
97+
98+
public const ERR_NOT_STRING = 'notString';
99+
public const ERR_NOT_ALLOWED = 'notAllowed';
100+
101+
protected array $messageTemplates = [
102+
self::ERR_NOT_STRING => 'Please provide a string value',
103+
self::ERR_NOT_ALLOWED => '"%value%" is not an allowed muppet name',
104+
];
105+
106+
private array $allowed;
107+
private bool $caseSensitive;
108+
109+
/**
110+
* @param array{
111+
* allowed_muppets: list<non-empty-string>,
112+
* case_sensitive: bool,
113+
* } $options
114+
*/
115+
public function __construct(array $options)
116+
{
117+
$this->allowed = $options['allowed_muppets'] ?? self::KNOWN_MUPPETS;
118+
$this->caseSensitive = $options['case_sensitive'] ?? true;
119+
120+
// Pass options such as the translator, overridden error messages, etc
121+
// to the parent AbstractValidator
122+
parent::__construct($options);
123+
}
124+
125+
public function isValid(mixed $value): bool {
126+
if (! is_string($value)) {
127+
$this->error(self::ERR_NOT_STRING);
128+
129+
return false;
130+
}
131+
132+
$list = $this->allowed;
133+
if (! $this->caseSensitive) {
134+
$list = array_map('strtolower', $list);
135+
$value = strtolower($value);
136+
}
137+
138+
if (! in_array($value, $list, true)) {
139+
$this->error(self::ERR_NOT_ALLOWED);
140+
141+
return false;
142+
}
143+
144+
return true;
145+
}
146+
}
147+
```
148+
149+
With the refactored validator, our options are clearly and obviously declared as class properties, and cannot be changed once they have been set.
150+
151+
There are fewer methods to test; In your test case you can easily set up data providers with varying options to thoroughly test that your validator behaves in the expected way.

0 commit comments

Comments
 (0)