PHPackages                             byte5/addressable - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Utility &amp; Helpers](/categories/utility)
4. /
5. byte5/addressable

ActiveLibrary[Utility &amp; Helpers](/categories/utility)

byte5/addressable
=================

Addresses package for Laravel

v1.0.0(today)01↑2900%MITPHPPHP ^8.2CI passing

Since Jun 19Pushed todayCompare

[ Source](https://github.com/byte5digital/addressable)[ Packagist](https://packagist.org/packages/byte5/addressable)[ RSS](/packages/byte5-addressable/feed)WikiDiscussions main Synced today

READMEChangelog (1)Dependencies (12)Versions (2)Used By (0)

Addressable
===========

[](#addressable)

A small Laravel package for attaching schema.org-aligned postal addresses to any Eloquent model via a polymorphic relationship.

Requirements
------------

[](#requirements)

- PHP 8.2+
- Laravel 12 or 13

Installation
------------

[](#installation)

Install via Composer:

```
composer require byte5/addressable
```

The service provider is auto-discovered. Publish the migration, then migrate:

```
php artisan vendor:publish --tag=byte5-addressable/migrations
php artisan migrate
```

The migration is **published rather than loaded from the package** so you can edit it before migrating — see *Owner morph key* below for UUID/ULID keys.

Publishing the config is **optional**; its defaults are merged automatically. Publish it only when you want to change a default:

```
php artisan vendor:publish --tag=byte5-addressable/config
```

Configuration
-------------

[](#configuration)

The config is merged under the `byte5-addressable` key. Publish it to change any default:

```
php artisan vendor:publish --tag=byte5-addressable/config
```

```
return [
    'models' => [
        'address' => \Byte5\Addressable\App\Models\Address::class,
    ],
    'table_names' => [
        'addresses' => 'addresses',
    ],
    'column_names' => [
        'model_morph_key' => 'addressable_id',
    ],
    'type_enum' => \Byte5\Addressable\App\Enums\AddressType::class, // or '' to disable
];
```

> Anything that affects the schema (`table_names`, `column_names`) must be set, and the migration edited if needed, **before** running it. Changing it afterwards requires a rollback and re-migrate.

### Models — swap the Address model

[](#models--swap-the-address-model)

`models.address` is the Eloquent model used for addresses. To customise it (most commonly to give addresses UUID/ULID primary keys), extend the package model, add the relevant Laravel trait, and register your class:

```
namespace App\Models;

use Byte5\Addressable\App\Models\Address as BaseAddress;
use Illuminate\Database\Eloquent\Concerns\HasUuids;

class Address extends BaseAddress
{
    use HasUuids;
}
```

```
// config/byte5-addressable.php
'models' => [
    'address' => \App\Models\Address::class,
],
```

The `HasAddresses` trait and the factory both resolve the model from this config value, so your subclass is used everywhere.

### Owner morph key — `uuid` / `ulid`

[](#owner-morph-key--uuid--ulid)

The polymorphic key that points at the **owner** model defaults to a `bigint unsigned` column named `addressable_id`. Two things control it:

- `column_names.model_morph_key` — the column **name** (rename to e.g. `addressable_uuid` if you prefer).
- The column **type** lives in the published migration. If your owner models use UUID/ULID primary keys, edit it before migrating:

    ```
    // database/migrations/..._create_addresses_table.php
    // $table->unsignedBigInteger($morphKey)->nullable();
    $table->uuid($morphKey)->nullable();   // or ->ulid($morphKey)
    ```

The Address model's **own** primary key is independent of this — it stays a `bigint` unless you swap in a model using `HasUuids`/`HasUlids` (see *Models*).

### Table name

[](#table-name)

`table_names.addresses` is the table used to store addresses (default `addresses`). Both the migration and the `Address` model read it.

### Address type enum

[](#address-type-enum)

The `type` column is cast to a string-backed enum. The package ships a default `Byte5\Addressable\App\Enums\AddressType` (`billing`, `shipping`, `home`, `work`). You can:

- **Replace it** with your own enum to match your application's address roles:

    ```
    // config/byte5-addressable.php
    'type_enum' => \App\Enums\MyAddressType::class,
    ```
- **Disable casting** and keep `type` a plain string:

    ```
    'type_enum' => '',
    ```

Your enum must be **string-backed**, and its backing values must match the values already stored in the `type` column — switching the enum does **not** migrate existing data. Rows with a `type` that isn't a valid case will throw on read.

No migration change is needed: the column stays a `string`; the enum is only the application-layer representation.

Usage
-----

[](#usage)

Add the `HasAddresses` trait to any model that should own addresses:

```
use Byte5\Addressable\App\Concerns\HasAddresses;
use Byte5\Addressable\App\Contracts\Addressable;

class User extends Model implements Addressable
{
    use HasAddresses;
}
```

You then get:

```
// All addresses (morphMany)
$user->addresses;
$user->addresses()->create([
    'street' => 'Main 10',
    'postal' => '10115',
    'city' => 'Berlin',
    'region' => 'Berlin',
    'country' => 'DE',
    'type' => 'billing', // optional role/label
]);

// The most recently attached address (morphOne)
$user->latestAddress;

// The owning model from an address (morphTo)
$address->addressable;
```

Creating addresses
------------------

[](#creating-addresses)

`$model->addAddress($data, $type)` is the standardised entry point for persisting a new address. It accepts either an `AddressData` DTO or a loose attribute array, and an optional `AddressType` (or its backing string) that overrides whatever type is already on the data:

```
use Byte5\Addressable\App\Data\AddressData;
use Byte5\Addressable\App\Enums\AddressType;

// From a typed DTO
$user->addAddress(new AddressData(
    street: 'Pariser Platz 1',
    postal: '10117',
    city:   'Berlin',
    country: 'DE',
), AddressType::Billing);

// From a loose array — internally calls AddressData::fromArray()
$user->addAddress([
    'street'  => 'Pariser Platz 1',
    'postal'  => '10117',
    'city'    => 'Berlin',
    'country' => 'DE',
    'type'    => 'billing',
]);
```

Both forms return the persisted `Address` instance.

### `AddressData` — the write DTO

[](#addressdata--the-write-dto)

`AddressData` is a readonly DTO that carries the nine address fields (`type`, `street`, `extra`, `postal`, `city`, `region`, `latitude`, `longitude`, `country`). All fields are optional (default `null`).

The lookup and schema.org DTOs provide typed bridges:

```
// From a resolved Google Place
$details = AddressLookup::details($placeId);   // PlaceDetails
$data    = $details->toAddressData(AddressType::Shipping);

// From a schema.org PostalAddress DTO
$postal = $address->toSchemaOrg();             // PostalAddress
$data   = $postal->toAddressData(AddressType::Billing);
```

Pass the resulting `AddressData` straight to `addAddress()`.

### Swapping the creation implementation

[](#swapping-the-creation-implementation)

Address creation is backed by `Byte5\Addressable\App\Contracts\CreatesAddresses`(single method: `create(Model&Addressable $owner, AddressData $data): Address`). The package binds the default `AddressCreator` service as a singleton, but you can replace it in any service provider:

```
use Byte5\Addressable\App\Contracts\CreatesAddresses;

$this->app->bind(CreatesAddresses::class, MyDedupingAddressCreator::class);
```

The package enforces **no deduplication, per-type uniqueness, or default/primary address policy** — that is intentional. Add whatever cardinality rules your application needs here.

schema.org mapping
------------------

[](#schemaorg-mapping)

The columns map to [schema.org/PostalAddress](https://schema.org/PostalAddress):

Columnschema.org`street``streetAddress``extra``extendedAddress``postal``postalCode``city``addressLocality``region``addressRegion``country``addressCountry` (ISO 3166-1 alpha-2)`latitude`, `longitude``GeoCoordinates` (on a `Place.geo`)`latitude` / `longitude` are stored as `decimal(10,8)` / `decimal(11,8)` and cast to `decimal:8`.

### Emitting a `PostalAddress`

[](#emitting-a-postaladdress)

`$address->toSchemaOrg()` returns a `PostalAddress` DTO that renders to either a PHP array or a JSON-LD string:

```
$address->toSchemaOrg()->toArray();
// [
//     '@type' => 'PostalAddress',
//     'streetAddress' => 'Pariser Platz 1',
//     'postalCode' => '10117',
//     'addressLocality' => 'Berlin',
//     'addressCountry' => 'DE',
//     // null fields omitted; no '@context'
// ]

$address->toSchemaOrg()->toJsonLd();
// {"@context":"https://schema.org","@type":"PostalAddress","streetAddress":"Pariser Platz 1",…}
```

`toArray()` is a **fragment** (`@type`, no `@context`) — nest it inside a parent entity such as `Organization`/`Person`. `toJsonLd()` is a **standalone** document (includes `@context`) — drop it straight into a ``tag. Latitude/longitude are intentionally excluded, since a schema.org `PostalAddress` has no geo property (those belong on a `Place.geo`).

Form validation rules
---------------------

[](#form-validation-rules)

Three Laravel validation rules ship for validating address input (e.g. in a `FormRequest`). All three skip empty values, so pair them with `required` / `nullable` / `string`, which own emptiness.

RuleChecksNeeds an API?`Country`a valid ISO 3166-1 alpha-2 country code (case-insensitive)no`PostalFormat`the postal code matches the format for a given country (no-op for unknown countries or those without one)no`AddressExists`the address is deliverable, via the configured validation provideryes — see [Validating an address](#validating-an-address)```
use Byte5\Addressable\App\Rules\AddressExists;
use Byte5\Addressable\App\Rules\Country;
use Byte5\Addressable\App\Rules\PostalFormat;

public function rules(): array
{
    return [
        'country' => ['required', 'string', new Country()],
        'postal'  => ['required', 'string', new PostalFormat($this->input('country'))],

        // Deliverability check: attach to ONE field. It reads the sibling
        // street/postal/city/region/country inputs and makes a single
        // (billable) provider call per validation run.
        'street'  => ['required', 'string', new AddressExists()],
    ];
}
```

The `AddressRules` facade is a small factory for the same rules:

```
use Byte5\Addressable\App\Facades\AddressRules;

AddressRules::country();           // new Country()
AddressRules::postalFormat('DE');  // new PostalFormat('DE')
AddressRules::exists();            // new AddressExists()
```

`AddressExists` reads the address from sibling fields of the validation payload, defaulting to the keys `street`, `postal`, `city`, `region`, `country`. Pass a map to point at differently-named inputs (dot notation supported):

```
new AddressExists(['postal' => 'zip_code', 'country' => 'address.country']);
```

Because it calls the validation provider, the `validation.pass_on_outage` config applies: when the provider is unreachable the rule throws by default, or passes the value through when `pass_on_outage` is `true`.

Messages live in the `byte5-addressable` translation namespace (keys `country`, `postal_format`, `address_exists`). Override them by creating `lang/vendor/byte5-addressable/{locale}/validation.php` in your app.

Country list
------------

[](#country-list)

`Countries::list()` returns an ISO 3166-1 alpha-2 `code => name` map, localised and ordered via commerceguys/addressing — ready to drop into a ``. Every key is a code the `Country` rule accepts.

```
use Byte5\Addressable\App\Facades\Countries;

Countries::list();      // ['DE' => 'Germany', 'FR' => 'France', …] in the app locale
Countries::list('de');  // ['DE' => 'Deutschland', 'FR' => 'Frankreich', …]
```

Address lookup (autocomplete + details)
---------------------------------------

[](#address-lookup-autocomplete--details)

Type-ahead address suggestions and place resolution via a pluggable provider (Google Places by default). The provider is selected in config; a future custom frontend component will call these through your own controller, keeping the API key server-side.

### Configuration

[](#configuration-1)

```
// config/byte5-addressable.php
// Autocomplete + geocoding (AddressLookup::suggest/details)
'lookup' => [
    'provider' => env('ADDRESSABLE_LOOKUP_PROVIDER', 'google'),
    'providers' => [
        'google' => [
            'key' => env('ADDRESSABLE_LOOKUP_GOOGLE_KEY'),
            'language' => env('ADDRESSABLE_LOOKUP_GOOGLE_LANGUAGE'), // Places languageCode; falls back to app locale
            'region' => env('ADDRESSABLE_LOOKUP_GOOGLE_REGION'),     // region bias (regionCode)
            'country' => env('ADDRESSABLE_LOOKUP_GOOGLE_COUNTRY'),   // single ISO 3166-1 alpha-2 code (set an array in the published config for multiple)
        ],
    ],
],

// Address validation (AddressValidator::validate + the AddressExists rule) — separate provider + key
'validation' => [
    'provider' => env('ADDRESSABLE_VALIDATION_PROVIDER', 'google'),

    // How the AddressExists rule reacts when the provider is unreachable:
    // false (default) surfaces the error; true lets the address through.
    'pass_on_outage' => env('ADDRESSABLE_VALIDATION_PASS_ON_OUTAGE', false),

    'providers' => [
        'google' => [
            'key' => env('ADDRESSABLE_VALIDATION_GOOGLE_KEY'),
        ],
    ],
],
```

Lookup and validation are independent: each has its own config-selected driver (`AddressLookupManager` / `AddressValidationManager`) and its own API key, so you can mix providers or use one key with both Google APIs enabled.

Set `ADDRESSABLE_LOOKUP_GOOGLE_KEY` in your `.env` (the project must have the Places API **(New)** enabled). See `.env.example` for every supported variable.

**Language:** when no language is given per call or in config, results default to the application locale (`app()->getLocale()`), resolved per request. A per-call `language` option or the config/env value takes precedence. Use values Google accepts as a `languageCode` (e.g. `de`, `en`, `pt-BR`).

### Usage

[](#usage-1)

```
use Byte5\Addressable\App\Facades\AddressLookup;

// 1. Suggestions as the user types
$suggestions = AddressLookup::suggest('Branden');
// => Byte5\Addressable\App\Data\Suggestion[] { placeId, description, mainText, secondaryText }

// 2. Resolve the chosen suggestion into a structured address
$details = AddressLookup::details($suggestions[0]->placeId);
// => Byte5\Addressable\App\Data\PlaceDetails (or null if not found)

// 3. Persist it — toArray() matches the Address columns
$user->addresses()->create($details->toArray());
```

Per-call overrides (and `AddressLookup::driver('google')`) are available:

```
AddressLookup::suggest('Haupt', ['country' => 'DE', 'language' => 'de']);
```

### Validating an address

[](#validating-an-address)

Check whether a structured address actually exists / is deliverable via the **`AddressValidator`** facade (backed by `AddressValidationManager`, separate from lookup). It uses Google's **Address Validation API** — a separate SKU that must be enabled in your Cloud project. The driver implements the `Byte5\Addressable\App\Contracts\ValidatesAddresses` capability:

```
use Byte5\Addressable\App\Data\AddressInput;
use Byte5\Addressable\App\Facades\AddressValidator;

$validation = AddressValidator::validate(new AddressInput(
    street: 'Pariser Platz 1',
    postal: '10117',
    city: 'Berlin',
    country: 'DE', // ISO 3166-1 alpha-2 (regionCode)
));

$validation->valid;             // normalised: deliverable / exists (every provider)
$validation->provider;          // 'google'
$validation->formattedAddress;  // standardised address
$validation->raw;               // full provider payload

// Typed Google specifics — narrow to the provider's result:
use Byte5\Addressable\App\Data\GoogleAddressValidation;

if ($validation instanceof GoogleAddressValidation) {
    $validation->granularity;              // PREMISE | SUB_PREMISE | ROUTE | LOCALITY | OTHER | ...
    $validation->complete;                 // Google addressComplete
    $validation->hasUnconfirmedComponents;
    $validation->hasInferredComponents;
    $validation->hasReplacedComponents;
}
```

The base `AddressValidation` is **provider-agnostic** (`valid`, `provider`, `formattedAddress`, `raw`); each driver maps its native verdict into `valid`. The Google driver returns a `GoogleAddressValidation` subclass that adds the typed verdict fields — through the facade you get the base type, so narrow with `instanceof` to read them (or inject the driver directly, which returns the subclass). For Google, `valid` is `true`when the address validates to `PREMISE`/`SUB_PREMISE` granularity, `addressComplete` is true, and there are no unconfirmed components.

> Validation is a separate capability: `validate()` lives on `ValidatesAddresses`, not the core `AddressLookup` contract — so a custom driver only implements it if its provider supports validation.

### Events

[](#events)

Every lookup dispatches backend events from the driver, so they fire no matter how the lookup is triggered (facade, your own endpoint, a UI component). Use them for usage/cost tracking or to record selected addresses. Nothing is dispatched when a request fails.

EventFires whenPayload`Byte5\Addressable\App\Events\AddressSuggestionsRequested`a `suggest()` request completes`provider`, `query`, `options`, `count``Byte5\Addressable\App\Events\AddressDetailsRequested`a `details()` request completes`provider`, `placeId`, `options`, `found``Byte5\Addressable\App\Events\AddressValidationRequested`a `validate()` request completes`provider`, `address`, `options`, `valid``Byte5\Addressable\App\Events\AddressResolved``details()` resolves a place`provider`, `placeId`, `details` (`PlaceDetails`)```
use Byte5\Addressable\App\Events\AddressResolved;
use Illuminate\Support\Facades\Event;

Event::listen(function (AddressResolved $event) {
    logger()->info("Resolved {$event->placeId} via {$event->provider}", $event->details->toArray());
});
```

> `AddressSuggestionsRequested` fires on every keystroke-driven `suggest()` call — debounce or sample in high-traffic listeners.

### Building an autocomplete UI (reference)

[](#building-an-autocomplete-ui-reference)

The package is **headless** — it ships no UI component and adds no frontend dependency, so you build the dropdown in whatever your app already uses (Livewire, Alpine, Vue, Inertia, …). The only integration surface is the `AddressLookup`facade; always call it **server-side** so your API key never reaches the browser.

The flow is the same in every stack:

1. user types → `AddressLookup::suggest($query)` → render the `Suggestion[]`
2. user picks one → `AddressLookup::details($placeId)` → `PlaceDetails`
3. fill your form fields from the result via shared state — no events needed. `PlaceDetails` exposes `street`, `postal`, `city`, `region`, `country`, `latitude`, `longitude`, and `toArray()` matches the `Address` columns.

The snippets below are **reference starting points to copy and adapt**, not shipped components.

#### Livewire

[](#livewire)

```
use Byte5\Addressable\App\Facades\AddressLookup;
use Livewire\Component;

class AddressForm extends Component
{
    public string $query = '';

    /** @var array */
    public array $suggestions = [];

    // Bound form fields — filled from the chosen address.
    public ?string $street = null;
    public ?string $postal = null;
    public ?string $city = null;
    public ?string $region = null;
    public ?string $country = null;

    public function updatedQuery(): void
    {
        // Map to plain arrays — Livewire only serialises primitive/array props,
        // not the Suggestion DTO.
        $this->suggestions = strlen($this->query) >= 3
            ? array_map(
                fn ($s) => ['placeId' => $s->placeId, 'description' => $s->description],
                AddressLookup::suggest($this->query),
            )
            : [];
    }

    public function select(string $placeId): void
    {
        if ($details = AddressLookup::details($placeId)) {
            $this->street  = $details->street;
            $this->postal  = $details->postal;
            $this->city    = $details->city;
            $this->region  = $details->region;
            $this->country = $details->country;
        }

        $this->suggestions = [];
    }

    public function render()
    {
        return view('livewire.address-form');
    }
}
```

```
{{-- resources/views/livewire/address-form.blade.php --}}

    @if ($suggestions)

            @foreach ($suggestions as $suggestion)

                        {{ $suggestion['description'] }}

            @endforeach

    @endif

```

#### Alpine + JSON endpoint

[](#alpine--json-endpoint)

When the UI runs in the browser (Alpine, Vue, …) it can't call the facade directly, so add a thin endpoint. **Protect it** (auth + throttle) — it spends your Google quota.

```
use Byte5\Addressable\App\Facades\AddressLookup;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::middleware(['auth', 'throttle:30,1'])->group(function () {
    Route::get('/address/suggest', fn (Request $r) => AddressLookup::suggest($r->string('q')));
    Route::get('/address/details/{placeId}', fn (string $placeId) => AddressLookup::details($placeId));
});
```

```

function addressLookup() {
    return {
        query: '',
        suggestions: [],
        form: { street: '', postal: '', city: '', region: '', country: '' },

        async search() {
            if (this.query.length
