laravel-addressable maintained by christianrey
laravel-addressable
Polymorphic postal addresses for Eloquent models — purpose roles (main / shipping / tax), a hybrid international schema, local-first geographic resolution from open data, and an optional, cost-aware Google enrichment layer.
- 🏠 Addresses for any model — a polymorphic
Address+ anAddressabletrait, with main / shipping / tax roles enforced per owner. - 🗺️ Local-first geo — resolve country, region/province and postal code from your own tables (open GeoNames data). No API calls, no per-lookup cost.
- 💸 Optional Google enrichment — autocomplete, geocoding and address validation, opt-in per tier, with cost shown before any billable call and the API key kept server-side.
- 🧰 Headless — backend + artisan CLI. Bring your own frontend.
Requires: PHP 8.2+ · Laravel 11, 12 or 13.
Install
composer require christianrey/laravel-addressable
php artisan vendor:publish --tag="addressable-migrations"
php artisan migrate
Publish the config if you want to tweak table names, tiers, pricing or routes:
php artisan vendor:publish --tag="addressable-config"
Addresses & roles
Add the trait to any model:
use ChristianRey\Addressable\Concerns\Addressable;
class Customer extends Model
{
use Addressable;
}
use ChristianRey\Addressable\Data\AddressData;
$customer->addAddress(
new AddressData(
line1: 'C/ Boiro 1',
locality: 'Boiro',
adminArea: 'A Coruña',
postalCode: '15930',
countryCode: 'ES',
),
roles: ['main', 'shipping'],
);
$customer->mainAddress(); // ?Address
$customer->shippingAddress(); // ?Address
$customer->taxAddress(); // ?Address
$customer->setMainAddress($address);
Each owner holds at most one main, one shipping and one tax address; a single address may carry several roles at once. Assigning a role atomically demotes the previous holder. The schema is hybrid: typed, queryable columns (country_code, postal_code, locality, admin_area, latitude, longitude, …) plus a components JSON blob and a formatted_address.
Local-first geographic data
Resolve country, region/province and postal code from package-owned tables — no API calls, no per-lookup cost. Data comes from open GeoNames dumps (CC-BY 4.0), imported on demand.
It is not Spain-only
The engine works for any country GeoNames covers — you import the ones you need by ISO 3166-1 code:
php artisan addressable:import-geo --country=ES --country=PT --country=FR --country=DE
Each --country=XX downloads and imports that country's regions and postal codes into your local tables (idempotent — safe to re-run). Nothing is bundled in the package; a fresh install starts with empty geo tables until you run this.
Resolving
use ChristianRey\Addressable\Geo\GeoResolver;
$resolver = app(GeoResolver::class);
$resolver->country('ES'); // Country (name, currency_code, calling_code)
$resolver->subdivisions('ES'); // Collection of top-level regions
$pc = $resolver->postalCode('ES', '15930');
$pc->place_name; // "Boiro"
$pc->admin1_name; // "Galicia" (top-level region)
$pc->admin2_name; // "A Coruña" (province / second-level region)
$pc->latitude; // 42.6483
$pc->longitude; // -8.8857
Coverage & caveats (honest)
- Countries & regions exist for virtually every country. Postal codes are available for the ~100 countries GeoNames provides them for (most of Europe, North America, and many others) — coverage and granularity vary by country.
- Region names are stored generically as
admin1_name(top-level) andadmin2_name(second-level). Which level means "province" depends on the country. In Spain,admin1_nameis the autonomous community ("Galicia") andadmin2_nameis the province ("A Coruña"); elsewhere the meaningful level differs. The package stores both; you interpret them for your locale. - Postal → region linking uses the names from the postal data directly (not the GeoNames admin codes, which don't reliably match across countries), so the region/province is correct even where code-based joins fail.
Geographic data is provided by GeoNames under the Creative Commons Attribution 4.0 license.
Optional Google enrichment
Disabled by default (the null driver refuses enrichment calls). Enable the driver and only the tiers you want:
ADDRESSABLE_GEOCODER=google
ADDRESSABLE_GOOGLE_KEY=your-key
// config/addressable.php
'geocoding' => [
'google' => [
'tiers' => [
'autocomplete' => true,
'geocoding' => true,
'address_validation' => false,
],
],
],
Enable the matching Google Cloud APIs: Places API (New) (autocomplete), Geocoding API (geocode), Address Validation API (validate).
use ChristianRey\Addressable\Geocoding\Contracts\Geocoder;
$geocoder = app(Geocoder::class);
$geocoder->autocomplete('Gran Vía, Madrid', sessionToken: $token); // list<AutocompleteSuggestion>
$geocoder->geocode('Puerta del Sol, Madrid'); // ?AddressData
$geocoder->validate($addressData); // AddressValidationResult
A disabled tier (or a missing key) throws a typed exception — TierDisabledException, MissingApiKeyException, QuotaExceededException or MalformedResponseException — so failures are never silent. Set quota caps in Google Cloud Console to keep testing within the free tier.
CLI
# Interactively choose tiers; prints the config + .env to apply (never rewrites files).
php artisan addressable:configure
# Fire a real request through the configured driver. Billable tiers require --confirm
# and the per-call cost is shown first.
php artisan addressable:test "Puerta del Sol, Madrid" --type=geocode --confirm
php artisan addressable:test "Calle de Alcalá 1, 28014 Madrid" --type=validate --confirm
# Compare Google's published prices against the stored reference values (best-effort,
# falls back to the official URL if the page can't be read).
php artisan addressable:check-google-pricing
Reference prices live in config/addressable.php under geocoding.pricing — editable, with an updated_at per SKU. They are a reference, not a live quote; always confirm against Google's pricing page.
Server-side proxy routes (optional)
Disabled by default. When enabled, your frontend calls these endpoints and the Google API key stays on the server — it is never sent to the browser.
ADDRESSABLE_ROUTES=true
// config/addressable.php
'routes' => [
'enabled' => env('ADDRESSABLE_ROUTES', false),
'prefix' => 'addressable',
'middleware' => ['throttle:60,1'], // add 'auth' etc. as needed
],
| Method & path | Body | Returns |
|---|---|---|
POST /addressable/autocomplete |
{ "input": "…", "sessionToken"? } |
{ "suggestions": [{ "placeId", "text" }] } |
POST /addressable/geocode |
{ "address": "…" } |
{ "result": { …AddressData } | null } |
POST /addressable/validate |
{ "line1": "…", "line2"?, "country_code"? } |
{ "complete": bool, "address": { …AddressData } } |
A disabled tier returns 409; quota 429; an unconfigured key 503; any upstream error 502 — all with generic bodies. Google's raw response is never forwarded to the client.
Testing
composer test # or: vendor/bin/pest
The Google driver is faked in tests — the suite never makes real network calls.
License
MIT. See LICENSE. Geographic data © GeoNames, CC-BY 4.0.