commerceguys/addressing
A PHP 8+ addressing library powered by CLDR and Google's address data. It provides address formats for 200+ countries, subdivisions, translations, HTML/plain-text formatting, validation via symfony/validator, and zones.
Install the library alongside the optional validator integration:
composer require commerceguys/addressing symfony/validator
Nothing is auto-discovered. The fastest productive move is to bind the three repositories as singletons in a service provider — they parse large JSON/PHP data files and memoize results internally, so you do not want a new instance per request call:
// app/Providers/AddressingServiceProvider.php
use CommerceGuys\Addressing\AddressFormat\AddressFormatRepository;
use CommerceGuys\Addressing\Country\CountryRepository;
use CommerceGuys\Addressing\Subdivision\SubdivisionRepository;
public function register(): void
{
$this->app->singleton(CountryRepository::class);
$this->app->singleton(AddressFormatRepository::class);
$this->app->singleton(SubdivisionRepository::class);
}
First use case to build: a country <select> plus an address form whose fields are driven by AddressFormat. Look first at:
src/Address.php — the default immutable value object you will map to Eloquent.src/AddressFormat/AddressFormat.php — getUsedFields(), getRequiredFields(), getFieldOrder(), getUppercaseFields(), getPostalCodePattern(), getSubdivisionDataFields(), plus the localized labels (getAdministrativeAreaType(), getLocalityType()).src/Formatter/DefaultFormatter.php and PostalLabelFormatter.php — for displaying/printing.src/Validator/Constraints/AddressFormatConstraint.php — the actual validation logic.A 20-line smoke test in tinker:
$formats = app(AddressFormatRepository::class);
$format = $formats->get('JP'); // field order, required fields, labels
$countries = app(CountryRepository::class)->getList('ja'); // code => localized name
Persist the address as a single castable value object. The library is deliberately storage-agnostic (Address is immutable, PSR-7-style with* mutators). Mirror the Doctrine-embeddable advice with an Eloquent custom cast:
class AddressCast implements CastsAttributes
{
public function get($model, $key, $value, $attributes): ?Address
{
$data = json_decode($value ?? '', true);
if (! $data) return null;
$a = new Address();
foreach (['countryCode', 'administrativeArea', 'locality', 'dependentLocality',
'postalCode', 'sortingCode', 'addressLine1', 'addressLine2', 'addressLine3',
'organization', 'givenName', 'additionalName', 'familyName'] as $f) {
if (! empty($data[$f])) $a = $a->{'with' . ucfirst($f)}($data[$f]);
}
return $a;
}
public function set($model, $key, $value, $attributes): array
{
return [$key => json_encode($value ? [
'countryCode' => $value->getCountryCode(),
'addressLine1' => $value->getAddressLine1(),
// ...etc
] : null)];
}
}
Store the JSON column (addresses.address or separate country_code/postal_code columns if you need to index/sort them for zone lookups). Keep subdivision codes (CA, CE), not names — names change with locale and CLDR updates.
Drive the form from the format, not from hardcoded assumptions. In a Blade partial or Livewire component:
$format = app(AddressFormatRepository::class)->get($countryCode);
$used = $format->getUsedFields(); // ['addressLine1','locality','administrativeArea','postalCode',...]
$required = $format->getRequiredFields();
$order = $format->getFieldOrder(); // e.g. ['addressLine1','postalCode','locality','administrativeArea']
Label the locality field with $format->getLocalityType() (“Town”, “Municipality”, “City”) and the administrative area with $format->getAdministrativeAreaType() (“State”, “Province”, “Prefecture”). Hide fields not in $used. This single mechanism is why the package is worth using — Japanese, Brazilian, and Irish addresses all render correctly from the same template.
Cascading subdivision selects. Expose one AJAX endpoint:
Route::get('/subdivisions/{country}/{parent?}', function (SubdivisionRepository $repo, string $country, ?string $parent = null) {
$parents = $parent ? [$country, $parent] : [$country];
return collect($repo->getAll($parents))
->map(fn ($s) => ['code' => $s->getCode(), 'name' => $s->getName(), 'has_children' => $s->hasChildren()]);
});
Check has_children to decide whether to render a third select (Administrative Area → Locality → Dependent Locality, max three levels).
Validation: two constraints, in order. Wrap it in a custom Laravel rule:
class ValidAddressFormat implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$validator = Validation::createValidator();
if ($validator->validate($value->getCountryCode(), new CountryConstraint())->count()) {
$fail('Unknown country.'); return;
}
foreach ($validator->validate($value, new AddressFormatConstraint()) as $violation) {
$fail($violation->getMessage());
}
}
}
AddressFormatConstraint also verifies that fields not used by the country format are empty — so a stray administrativeArea=CA submitted with countryCode=DE fails rather than silently persisting. That is a feature; lean on it.
Formatting output. Use DefaultFormatter for UI (it always appends the localized country name and emits semantic <span> classes) and PostalLabelFormatter for invoices, packing slips, and shipping labels — it uppercases exactly the fields the destination format requires for automated sorting and adds the bilingual country name for international mail:
$label = (new PostalLabelFormatter($formats, $countries, $subdivisions, ['locale' => app()->getLocale()]))
->format($address, ['origin_country' => config('shop.origin_country')]);
Zones for shipping and tax. Zone::match($address) handles country + subdivision + postal code inclusion/exclusion, including ranges (6691, 6991:6993) and regexes. Persist zones (e.g. as JSON in shipping_zones) and match on cart/checkout to select shipping rates or VAT rules. This replaces a lot of bespoke “does this postcode belong to…” code.
Address is immutable. $address->withLocality('X') returns a new object; forgetting to reassign is the most common first bug. Consider a small mutable wrapper or an AddressBuilder from your form data.new CountryRepository() inside a loop or a Blade loop; resolve from the container.CountryRepository::getList($locale) only knows the locales shipped with CLDR (~150); en is the safe fallback. Set the locale per request rather than at boot, and be aware country names in dropdowns change when the app language changes — never store the name, only the code.administrativeArea that fails validation. Reset the whole address subform (or only allowed fields) when country_code changes.AddressFormatConstraint requires a valid countryCode; without the CountryConstraint pre-check you get confusing downstream messages for empty/garbage countries.getPostalCodePattern() is a raw regex (\d{5}(-\d{4})? style). Expose a human hint like “12345” derived from the pattern (\d → 0), and do not reimplement the regex yourself — subdivision-level overrides exist (some countries differ by state).subdivision_data_fields includes administrativeArea.getSubdivisionDepth() is deprecated in favor of getSubdivisionDataFields() (e.g. ['administrativeArea']). Custom AddressFormat definitions using subdivision_depth are still converted automatically, but new code should read the new method.PostalLabelFormatter requires origin_country (constructor or format() options) and throws otherwise. Put it in config/shop.php rather than passing it ad hoc.{!! !!} blindly if you override the wrapper; the formatter escapes field values but lets you pass wrapper element/attributes — validate any attribute values that come from user input.symfony/validator range; if your app already constrains Symfony packages, run composer why symfony/validator before upgrading. The validator integration is a separate dependency — the core library works without it.US/CA/Mountain View/94043, JP/沖縄県/…, BR/CE/…) and assert both formatting and AddressFormatConstraint results. This catches CLDR data updates that change labels or add subdivisions without you noticing.AddressFormatRepository and override get(), then rebind it — the formatter, validator, and your form builder all consume the same interface.How can I help you explore Laravel packages today?