Skip to content

Commit 03ddfdc

Browse files
committed
Add postal_code validator extension
1 parent 5edbf10 commit 03ddfdc

File tree

6 files changed

+217
-39
lines changed

6 files changed

+217
-39
lines changed

README.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Worldwide postal code validation for Laravel, based on Google's Address Data Ser
2222
- [Usage](#usage)
2323
- [Available rules](#available-rules)
2424
- [Adding an error message](#adding-an-error-message)
25+
- [String rules](#string-rules)
2526
- [Changelog](#changelog)
2627
- [License](#license)
2728

@@ -93,10 +94,10 @@ $request->validate([
9394
9495
### Adding an error message
9596

96-
To add a meaningful error message, add the following line to `resources/lang/{your language}/validation.php`:
97+
To add a meaningful error message, add the following line to `lang/{your language}/validation.php`:
9798

9899
```php
99-
'postal_code' => 'Your message here',
100+
'postal_code' => 'Your message here.',
100101
```
101102

102103
The following placeholders will be automatically filled for you:
@@ -109,6 +110,19 @@ The following placeholders will be automatically filled for you:
109110

110111
*The `:countries` and `:examples` placeholders may be empty if no valid countries are passed.
111112

113+
### String rules
114+
115+
All rules provided by this package can also be used as strings. This is especially useful when validating arrays, for
116+
example:
117+
118+
```php
119+
$request->validate([
120+
'addresses.*' => 'array:country,postal_code',
121+
'addresses.*.country' => '...',
122+
'addresses.*.postal_code' => 'postal_code:addresses.*.country',
123+
]);
124+
```
125+
112126
## Changelog
113127

114128
Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently.

composer.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"require": {
2020
"php": ">=8.1",
2121
"illuminate/support": "^10.0 || ^11.0 || ^12.0",
22+
"illuminate/translation": "^10.0 || ^11.0 || ^12.0",
2223
"illuminate/validation": "^10.0 || ^11.0 || ^12.0"
2324
},
2425
"require-dev": {
@@ -50,5 +51,12 @@
5051
"phpstan/extension-installer": true
5152
},
5253
"sort-packages": true
54+
},
55+
"extra": {
56+
"laravel": {
57+
"providers": [
58+
"\\Axlon\\PostalCodeValidation\\Support\\PostalCodeServiceProvider"
59+
]
60+
}
5361
}
5462
}

src/Rules/PostalCode.php

Lines changed: 55 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ final class PostalCode implements ValidationRule, DataAwareRule
2222
*/
2323
private array $data = [];
2424

25+
/**
26+
* The resolved parameters.
27+
*
28+
* @var list<non-empty-string|false>|null
29+
*/
30+
private ?array $resolvedParameters = null;
31+
2532
/**
2633
* Create a new validation rule.
2734
*
@@ -63,24 +70,54 @@ public static function of(array|string $countries): self
6370
}
6471

6572
/**
66-
* Resolve the rule parameters.
73+
* Get the replacements for the validation message.
74+
*
75+
* @return array<string, string>
76+
*/
77+
public function replacements(): array
78+
{
79+
$countries = $examples = [];
80+
81+
foreach ($this->resolvedParameters() as $parameter) {
82+
if ($parameter === false) {
83+
continue;
84+
}
85+
86+
$countries[] = $parameter;
87+
$regex = $this->regexes->get($parameter);
88+
89+
if ($regex !== null) {
90+
$examples[] = $regex->example();
91+
}
92+
}
93+
94+
return [
95+
'countries' => implode(', ', $countries),
96+
'examples' => implode(', ', $examples),
97+
];
98+
}
99+
100+
/**
101+
* Get the resolved parameters.
67102
*
68103
* @return list<non-empty-string|false>
69104
*/
70-
private function resolveParameters(): array
105+
private function resolvedParameters(): array
71106
{
72-
$parameters = [];
73-
74-
foreach ($this->parameters as $parameter) {
75-
if (self::isCountryCode($parameter)) {
76-
$parameters[] = $parameter;
77-
} elseif (Arr::has($this->data, $parameter)) {
78-
$other = Arr::get($this->data, $parameter);
79-
$parameters[] = self::isCountryCode($other) ? $other : false;
107+
if ($this->resolvedParameters === null) {
108+
$this->resolvedParameters = [];
109+
110+
foreach ($this->parameters as $parameter) {
111+
if (self::isCountryCode($parameter)) {
112+
$this->resolvedParameters[] = $parameter;
113+
} elseif (Arr::has($this->data, $parameter)) {
114+
$other = Arr::get($this->data, $parameter);
115+
$this->resolvedParameters[] = self::isCountryCode($other) ? $other : false;
116+
}
80117
}
81118
}
82119

83-
return $parameters;
120+
return $this->resolvedParameters;
84121
}
85122

86123
/**
@@ -92,6 +129,7 @@ private function resolveParameters(): array
92129
public function setData(array $data): self
93130
{
94131
$this->data = $data;
132+
$this->resolvedParameters = null;
95133

96134
return $this;
97135
}
@@ -110,35 +148,22 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
110148
return;
111149
}
112150

113-
$examples = $countryCodes = [];
114-
$parameters = $this->resolveParameters();
115-
116-
if ($parameters === [] && is_string($value)) {
117-
return;
118-
}
119-
120151
if (is_string($value)) {
152+
if (($parameters = $this->resolvedParameters()) === []) {
153+
return;
154+
}
155+
121156
foreach ($parameters as $parameter) {
122157
if ($parameter === false) {
123158
continue;
124159
}
125160

126-
$regex = $this->regexes->get($parameter);
127-
128-
if ($regex?->test($value) === true) {
161+
if ($this->regexes->get($parameter)?->test($value) === true) {
129162
return;
130163
}
131-
132-
if ($regex !== null) {
133-
$countryCodes[] = $parameter;
134-
$examples[] = $regex->example();
135-
}
136164
}
137165
}
138166

139-
$fail('validation.postal_code')->translate([
140-
'countries' => implode(', ', $countryCodes),
141-
'examples' => implode(', ', $examples),
142-
]);
167+
$fail('validation.postal_code')->translate($this->replacements());
143168
}
144169
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Axlon\PostalCodeValidation\Support;
6+
7+
use Axlon\PostalCodeValidation\Rules\PostalCode;
8+
use Illuminate\Support\ServiceProvider;
9+
use Illuminate\Translation\PotentiallyTranslatedString;
10+
use Illuminate\Validation\Factory;
11+
use Illuminate\Validation\InvokableValidationRule;
12+
use Illuminate\Validation\Validator;
13+
14+
final class PostalCodeServiceProvider extends ServiceProvider
15+
{
16+
/**
17+
* Register validation services.
18+
*
19+
* @return void
20+
*/
21+
public function register(): void
22+
{
23+
$this->callAfterResolving('validator', static function (Factory $validator) {
24+
$validator->extendDependent('postal_code', self::validatePostalCode(...));
25+
$validator->replacer('postal_code', self::replacePostalCode(...));
26+
});
27+
}
28+
29+
/**
30+
* Replace all place-holders for the postal_code rule.
31+
*
32+
* @param string $message
33+
* @param string $attribute
34+
* @param string $rule
35+
* @param array<string> $parameters
36+
* @param \Illuminate\Validation\Validator $validator
37+
* @return string
38+
*/
39+
private static function replacePostalCode(
40+
string $message,
41+
string $attribute,
42+
string $rule,
43+
array $parameters,
44+
Validator $validator,
45+
): string {
46+
$data = $validator->getData();
47+
$replacements = PostalCode::of($parameters)->setData($data)->replacements();
48+
$translator = $validator->getTranslator();
49+
50+
return (new PotentiallyTranslatedString($message, $translator))
51+
->translate($replacements)
52+
->toString();
53+
}
54+
55+
/**
56+
* Validate that the given value is a valid postal code.
57+
*
58+
* @param string $attribute
59+
* @param mixed $value
60+
* @param array<string> $parameters
61+
* @param \Illuminate\Validation\Validator $validator
62+
* @return bool
63+
*/
64+
private static function validatePostalCode(
65+
string $attribute,
66+
mixed $value,
67+
array $parameters,
68+
Validator $validator,
69+
): bool {
70+
$rule = InvokableValidationRule::make(PostalCode::of($parameters));
71+
$rule->setValidator($validator);
72+
73+
return $rule->passes($attribute, $value);
74+
}
75+
}

tests/Rules/PostalCodeTest.php

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ public function testItFailsWhenCountryCodeFieldIsNotValid(): void
9292
['value' => $this->makeRule('other')],
9393
);
9494

95-
$this->regexes->expects($this->once())->method('get')->with('AA')->willReturn(null);
95+
$this->regexes->expects($this->exactly(2))->method('get')->with('AA')->willReturn(null);
9696

9797
self::assertFalse($validator->passes());
9898
}
@@ -104,7 +104,7 @@ public function testItFailsWhenCountryCodeIsNotValid(): void
104104
['value' => $this->makeRule('AA')],
105105
);
106106

107-
$this->regexes->expects($this->once())->method('get')->with('AA')->willReturn(null);
107+
$this->regexes->expects($this->exactly(2))->method('get')->with('AA')->willReturn(null);
108108

109109
self::assertFalse($validator->passes());
110110
}
@@ -116,7 +116,7 @@ public function testItFailsWhenValueDoesNotMatchCountryCode(): void
116116
['value' => $this->makeRule('AA')],
117117
);
118118

119-
$this->regexes->expects($this->once())->method('get')->with('AA')->willReturn(new MatchNothing());
119+
$this->regexes->expects($this->exactly(2))->method('get')->with('AA')->willReturn(new MatchNothing());
120120

121121
self::assertFalse($validator->passes());
122122
}
@@ -128,7 +128,7 @@ public function testItFailsWhenValueDoesNotMatchCountryCodeField(): void
128128
['value' => $this->makeRule('other')],
129129
);
130130

131-
$this->regexes->expects($this->once())->method('get')->with('AA')->willReturn(new MatchNothing());
131+
$this->regexes->expects($this->exactly(2))->method('get')->with('AA')->willReturn(new MatchNothing());
132132

133133
self::assertFalse($validator->passes());
134134
}
@@ -140,7 +140,7 @@ public function testItFailsWhenValueIsNotAString(): void
140140
['value' => $this->makeRule('AA')],
141141
);
142142

143-
$this->regexes->expects($this->never())->method('get');
143+
$this->regexes->expects($this->once())->method('get')->with('AA')->willReturn(new MatchNothing());
144144

145145
self::assertFalse($validator->passes());
146146
}
@@ -242,7 +242,7 @@ public function testItReplacesCountriesInErrorMessage(): void
242242
['value' => $this->makeRule('AA')],
243243
);
244244

245-
$this->regexes->expects($this->once())->method('get')->with('AA')->willReturn(new MatchNothing());
245+
$this->regexes->expects($this->exactly(2))->method('get')->with('AA')->willReturn(new MatchNothing());
246246

247247
self::assertTrue($validator->fails());
248248
self::assertSame(['Value should be a valid AA postal code.'], $validator->errors()->get('value'));
@@ -263,7 +263,7 @@ public function testItReplacesExamplesInErrorMessage(): void
263263
$regex->expects($this->once())->method('test')->willReturn(false);
264264
$regex->expects($this->once())->method('example')->willReturn('00000');
265265

266-
$this->regexes->expects($this->once())->method('get')->with('AA')->willReturn($regex);
266+
$this->regexes->expects($this->exactly(2))->method('get')->with('AA')->willReturn($regex);
267267

268268
self::assertTrue($validator->fails());
269269
self::assertSame(['Value should be a valid postal code such as 00000.'], $validator->errors()->get('value'));
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Support;
6+
7+
use Axlon\PostalCodeValidation\Regex\MatchCountry;
8+
use Axlon\PostalCodeValidation\Regex\RegexCollection;
9+
use Axlon\PostalCodeValidation\Rules\PostalCode;
10+
use Axlon\PostalCodeValidation\Support\PostalCodeServiceProvider;
11+
use Illuminate\Support\Facades\Lang;
12+
use Illuminate\Support\Facades\Validator;
13+
use Orchestra\Testbench\TestCase;
14+
use PHPUnit\Framework\Attributes\CoversClass;
15+
use PHPUnit\Framework\Attributes\UsesClass;
16+
17+
#[CoversClass(PostalCodeServiceProvider::class)]
18+
#[UsesClass(MatchCountry::class)]
19+
#[UsesClass(PostalCode::class)]
20+
#[UsesClass(RegexCollection::class)]
21+
final class PostalCodeServiceProviderTest extends TestCase
22+
{
23+
protected function getPackageProviders($app): array
24+
{
25+
return [
26+
PostalCodeServiceProvider::class,
27+
];
28+
}
29+
30+
public function testItValidatesPostalCodeForCountryCode(): void
31+
{
32+
Lang::addLines([
33+
'validation.postal_code' => ':Attribute should be a valid postal code such as :examples.',
34+
], 'en');
35+
36+
$validator = Validator::make(['value' => '1234'], ['value' => 'postal_code:NL']);
37+
38+
self::assertFalse($validator->passes());
39+
self::assertSame(['Value should be a valid postal code such as 1234 AB.'], $validator->messages()->all());
40+
}
41+
42+
public function testItValidatesPostalCodeForCountryCodeField(): void
43+
{
44+
Lang::addLines([
45+
'validation.postal_code' => ':Attribute should be a valid :countries postal code.',
46+
], 'en');
47+
48+
$validator = Validator::make(
49+
['value' => '1234', 'other' => 'NL'],
50+
['value' => 'postal_code:other'],
51+
);
52+
53+
self::assertFalse($validator->passes());
54+
self::assertSame(['Value should be a valid NL postal code.'], $validator->messages()->all());
55+
}
56+
}

0 commit comments

Comments
 (0)