byte5/addressable
Composer 安装命令:
composer require byte5/addressable
包简介
Addresses package for Laravel
README 文档
README
A small Laravel package for attaching schema.org-aligned postal addresses to any Eloquent model via a polymorphic relationship.
Requirements
- PHP 8.2+
- Laravel 12 or 13
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
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.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
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_uuidif 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_names.addresses is the table used to store addresses (default
addresses). Both the migration and the Address model read it.
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
typea 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
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
$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 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
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
The columns map to schema.org/PostalAddress:
| Column | schema.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
$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 <script type="application/ld+json">
tag. Latitude/longitude are intentionally excluded, since a schema.org
PostalAddress has no geo property (those belong on a Place.geo).
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.
| Rule | Checks | Needs 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 provider | yes — see 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
Countries::list() returns an ISO 3166-1 alpha-2 code => name map, localised and
ordered via commerceguys/addressing — ready to drop into a <select>. 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)
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
// 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
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
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 onValidatesAddresses, not the coreAddressLookupcontract — so a custom driver only implements it if its provider supports validation.
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.
| Event | Fires when | Payload |
|---|---|---|
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()); });
AddressSuggestionsRequestedfires on every keystroke-drivensuggest()call — debounce or sample in high-traffic listeners.
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:
- user types →
AddressLookup::suggest($query)→ render theSuggestion[] - user picks one →
AddressLookup::details($placeId)→PlaceDetails - fill your form fields from the result via shared state — no events needed.
PlaceDetailsexposesstreet,postal,city,region,country,latitude,longitude, andtoArray()matches theAddresscolumns.
The snippets below are reference starting points to copy and adapt, not shipped components.
Livewire
use Byte5\Addressable\App\Facades\AddressLookup; use Livewire\Component; class AddressForm extends Component { public string $query = ''; /** @var array<int, array{placeId: string, description: string}> */ 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 --}} <div> <input type="text" wire:model.live.debounce.300ms="query" placeholder="Search address…"> @if ($suggestions) <ul> @foreach ($suggestions as $suggestion) <li> <button type="button" wire:click="select('{{ $suggestion['placeId'] }}')"> {{ $suggestion['description'] }} </button> </li> @endforeach </ul> @endif <input type="text" wire:model="street" placeholder="Street"> <input type="text" wire:model="postal" placeholder="Postal code"> <input type="text" wire:model="city" placeholder="City"> <input type="text" wire:model="region" placeholder="Region"> <input type="text" wire:model="country" placeholder="Country"> </div>
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)); });
<div x-data="addressLookup()"> <input type="text" x-model="query" @input.debounce.300ms="search()" placeholder="Search address…"> <ul x-show="suggestions.length"> <template x-for="s in suggestions" :key="s.placeId"> <li><button type="button" @click="select(s.placeId)" x-text="s.description"></button></li> </template> </ul> <input type="text" x-model="form.street" placeholder="Street"> <input type="text" x-model="form.postal" placeholder="Postal code"> <input type="text" x-model="form.city" placeholder="City"> <input type="text" x-model="form.region" placeholder="Region"> <input type="text" x-model="form.country" placeholder="Country"> </div> <script> function addressLookup() { return { query: '', suggestions: [], form: { street: '', postal: '', city: '', region: '', country: '' }, async search() { if (this.query.length < 3) { this.suggestions = []; return; } this.suggestions = await (await fetch(`/address/suggest?q=${encodeURIComponent(this.query)}`)).json(); }, async select(placeId) { const details = await (await fetch(`/address/details/${placeId}`)).json(); if (details) this.form = { street: details.street, postal: details.postal, city: details.city, region: details.region, country: details.country, }; this.suggestions = []; }, }; } </script>
Testing
composer test # Pest test suite composer stan # PHPStan static analysis
统计信息
- 总下载量: 0
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 1
- 点击次数: 3
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-06-19