uvinum/zipcodevalidator
PHP package for validating ZIP and postal codes by country. It offers country-aware validation rules so Laravel and PHP applications can check address formats against supported regional formats, useful for checkout, registration, and other address forms.
Install and pin
composer require uvinum/zipcodevalidator
The last tagged release is from March 2017, so treat the version constraint as load-bearing: commit composer.lock, and check the require block in the package's composer.json before assuming it installs cleanly on PHP 8.x. If the constraint blocks you, prefer a fork/vendored copy over --ignore-platform-reqs so the lock file stays honest.
Where to look first
src/ for the main validator class — this is your entire public surface.First use case: guard a checkout/shipping address
Wrap the library immediately rather than calling it from controllers. A thin adapter gives you one place to swap implementations later and one place to normalise input:
namespace App\Support;
use Uvinum\ZipCodeValidator\...; // confirm exact class from src/
final class PostalCodeValidator
{
public function isValid(string $postalCode, string $countryCode): bool
{
$postalCode = $this->normalise($postalCode);
$countryCode = strtoupper(trim($countryCode));
// Countries with no postal-code system at all.
if (in_array($countryCode, ['IE', 'HK', 'AE', 'QA', 'LY'], true)) {
return $postalCode === '';
}
try {
return $this->validator->validate($postalCode, $countryCode);
} catch (\Throwable $e) {
report($e);
return true; // fail open: never block a sale on a library error
}
}
private function normalise(string $value): string
{
return strtoupper(preg_replace('/[\s\-]+/', '', trim($value)));
}
}
Bind it as a singleton in a service provider if the constructor loads the full pattern table, and use it from a FormRequest or a custom rule from there.
Custom validation rule (the pattern you'll actually use daily)
namespace App\Rules;
use App\Support\PostalCodeValidator;
use Illuminate\Contracts\Validation\Rule;
class PostalCode implements Rule
{
public function __construct(private string $countryField) {}
public function passes($attribute, $value): bool
{
$country = request()->input($this->countryField);
if (! is_string($country) || $country === '') {
return false; // let a required|country rule produce the real error
}
return app(PostalCodeValidator::class)->isValid((string) $value, $country);
}
public function message(): string
{
return 'The :attribute is not a valid postal code for the selected country.';
}
}
// FormRequest
public function rules(): array
{
return [
'country' => ['required', 'string', 'size:2', Rule::in(CountryCodes::all())],
'postal_code' => ['required', 'string', 'max:20', new PostalCode('country')],
];
}
Note the ordering: put the country rule before the postal-code rule so passes() sees a valid country, and keep postal_code nullable/sometimes for the no-postal-code countries rather than inventing an empty-string special case in the rule.
Normalise on write, validate the canonical value
Pick one canonical form (uppercase, no spaces) and store that. Validation and equality both get easier, and you avoid "sw1a 1aa" vs "SW1A1AA" creating duplicate addresses. Do the normalisation in a mutator or an address value object, not in the validator, so the rule stays pure.
Value object for domain code
final class PostalAddress
{
public function __construct(
public readonly string $country,
public readonly string $postalCode,
) {
if (! app(PostalCodeValidator::class)->isValid($postalCode, $country)) {
throw new InvalidPostalCode($postalCode, $country);
}
}
}
This keeps validation out of HTTP land and makes the check reusable in imports, seeders, and queue jobs.
Graceful degradation on the API
If you expose a "validate address" endpoint, return the library verdict as a hint, not a hard 422. Clients (mobile keyboards, autofill, carriers) produce legitimate formats the regex misses, and the data file is from 2017.
Test with a data provider, not per-country test methods
/** @dataProvider validCodes */
public function test_accepts_valid(string $zip, string $country): void
{
$this->assertTrue(app(PostalCodeValidator::class)->isValid($zip, $country));
}
public static function validCodes(): array
{
return [
['10001', 'US'], ['SW1A1AA', 'GB'], ['75008', 'FR'],
['10115', 'DE'], ['2000', 'AU'], ['01310-100', 'BR'],
];
}
Mirror it with an invalidCodes provider. These datasets are the cheapest insurance against a silent behaviour change when you fork or upgrade.
false, throw, or be silent — handle these explicitly before calling it, as in the adapter above.UK is not GB. The library keys on ISO 3166-1 alpha-2. Validate and normalise the country code upstream (Rule::in a real ISO list, or symfony/intl); never let free-text country names reach the validator.SW1A 1AA, sw1a-1aa, and SW1A1AA should all behave the same. Strip spaces/hyphens and uppercase before validating — but only after you've confirmed your stored form matches what downstream systems (labels, carriers) expect.try/catch, report() the exception, and return a pass. A broken regex should never cost you an order; a monitoring alert should tell you it's broken.preg_match() in php artisan tinker. Nine times out of ten it's a normalisation problem, not a wrong pattern.PostalCodeValidatorInterface) so migrating to commerceguys/addressing or a carrier API later is a one-line container change.How can I help you explore Laravel packages today?