Looking to hire Laravel developers? Try LaraJobs

laravel-addressable maintained by christianrey

Description
Polymorphic postal addresses for Eloquent models, with purpose roles and an optional cost-aware Google enrichment layer.
Author
Last update
2026/05/25 10:45 (dev-master)
License
Links
Downloads
3

Comments
comments powered by Disqus

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 + an Addressable trait, 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) and admin2_name (second-level). Which level means "province" depends on the country. In Spain, admin1_name is the autonomous community ("Galicia") and admin2_name is 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.