Initial release

This commit is contained in:
Fabian @ Blax Software 2026-04-14 10:20:42 +02:00
commit 615da8c4c1
26 changed files with 6761 additions and 0 deletions

9
.gitattributes vendored Normal file
View File

@ -0,0 +1,9 @@
/.vscode export-ignore
/.gitattributes export-ignore
/.gitignore export-ignore
/.phpunit.cache export-ignore
/phpunit.xml export-ignore
/pint.json export-ignore
/workbench export-ignore
/tests export-ignore
/docs export-ignore

13
.gitignore vendored Normal file
View File

@ -0,0 +1,13 @@
/vendor/
/node_modules/
composer.lock
.phpunit.cache/
.phpunit.result.cache
.php-cs-fixer.cache
.idea/
.vscode/
*.swp
*.swo
.DS_Store
Thumbs.db
workbench/

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) Blax Software <office@blax.at>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

143
README.md Normal file
View File

@ -0,0 +1,143 @@
[![Blax Software OSS](https://raw.githubusercontent.com/blax-software/laravel-workkit/master/art/oss-initiative-banner.svg)](https://github.com/blax-software)
# Laravel Addresses
[![PHP Version](https://img.shields.io/badge/php-%5E8.1-blue)](https://php.net)
[![Laravel](https://img.shields.io/badge/laravel-9.x--12.x-orange)](https://laravel.com)
Universal Laravel address management — from rural GPS coordinates to specific rooms inside skyscrapers, worldwide.
## Overview
This package provides a complete address management system for Laravel applications built on a **three-layer architecture**:
```
Address → The physical place (street, city, coordinates …)
└── AddressLink → Connects an address to a model with a purpose (User's "Office")
└── AddressAssignment → References a link from another context (Job's "pickup")
```
**Example:** A user has an office address. A job references that office as its pickup location — without duplicating the address data.
## Features
- **15 address fields** — street-level to room-level precision, with GPS coordinates (WGS-84) and altitude
- **Polymorphic links** — attach addresses to any Eloquent model
- **17 built-in link types** — Home, Office, Shipping, Billing, Warehouse and more
- **Address assignments** — reference someone else's address in another context
- **Temporal validity**`active_from` / `active_until` on every link
- **AddressService** — distance calculations (Haversine), proximity queries, duplicate detection, coordinate conversion
- **Fully configurable** — custom model classes, table names, default link type
- **Soft deletes** on addresses, cascade deletes on links and assignments
## Requirements
- PHP 8.1+
- Laravel 9, 10, 11 or 12
- `blax-software/laravel-workkit` (installed automatically)
## Installation
```bash
composer require blax-software/laravel-addresses
```
Publish and run the migrations:
```bash
php artisan vendor:publish --tag="addresses-migrations"
php artisan migrate
```
Optionally publish the config:
```bash
php artisan vendor:publish --tag="addresses-config"
```
## Quick Start
### 1. Add the trait to your model
```php
use Blax\Addresses\Traits\HasAddresses;
class User extends Model
{
use HasAddresses;
}
```
### 2. Create and attach an address
```php
use Blax\Addresses\Enums\AddressLinkType;
$link = $user->addAddress([
'street' => '350 Fifth Avenue',
'city' => 'New York',
'state' => 'NY',
'postal_code' => '10118',
'country_code' => 'US',
'latitude' => 40.748817,
'longitude' => -73.985428,
], AddressLinkType::Office);
```
### 3. Query addresses
```php
$user->addresses; // all addresses
$user->addressesOfType(AddressLinkType::Office); // only offices
$user->primaryAddress(); // primary across all types
$user->activeAddressLinks(); // only currently active links
```
### 4. Assign an address to another model
```php
use Blax\Addresses\Traits\HasAddressAssignments;
class Job extends Model
{
use HasAddressAssignments;
}
$job->assignAddressLink($link, 'pickup');
$job->assignedAddressForRole('pickup'); // → the Address model
```
### 5. Use the AddressService
```php
// Via helper
$distance = address()->distanceBetween($addressA, $addressB); // km
// Nearby addresses within 10 km
$nearby = address()->nearby(48.2082, 16.3738, 10);
// Format for display
echo address()->formatMultiline($address);
```
## Documentation
| Guide | Description |
|----------------------------------------------------------------|------------------------------------------------------|
| [Installation & Configuration](docs/installation.md) | Setup, publishing, config options |
| [Core Concepts](docs/core-concepts.md) | The three-layer architecture explained |
| [HasAddresses Trait](docs/has-addresses.md) | Full API for address-owning models |
| [HasAddressAssignments Trait](docs/has-address-assignments.md) | Full API for address-consuming models |
| [AddressService](docs/address-service.md) | Distance, proximity, formatting, conversion |
| [AddressLinkType Enum](docs/address-link-types.md) | All 17 built-in types with descriptions |
| [Customization](docs/customization.md) | Extending models, custom tables, overriding defaults |
## Testing
```bash
composer test
```
## License
MIT

86
composer.json Normal file
View File

@ -0,0 +1,86 @@
{
"name": "blax-software/laravel-addresses",
"type": "library",
"description": "Universal Laravel address management system — from rural coordinates to skyscraper rooms, worldwide.",
"keywords": [
"addresses",
"address",
"geocoding",
"coordinates",
"laravel",
"blax",
"location"
],
"homepage": "http://www.blax.at",
"license": "MIT",
"authors": [
{
"name": "Fabian Wagner",
"email": "fabian@blax.at",
"homepage": "https://www.blax.at",
"role": "Developer"
}
],
"autoload": {
"psr-4": {
"Blax\\Addresses\\": "src"
},
"files": [
"src/helpers.php"
]
},
"config": {
"sort-packages": true
},
"require": {
"php": "^8.1",
"blax-software/laravel-workkit": "*",
"illuminate/container": "^9.0|^10.0|^11.0|^12.0",
"illuminate/contracts": "^9.0|^10.0|^11.0|^12.0",
"illuminate/database": "^9.0|^10.0|^11.0|^12.0",
"illuminate/support": "^9.0|^10.0|^11.0|^12.0"
},
"require-dev": {
"laravel/framework": "*",
"laravel/pint": "^1.22",
"orchestra/testbench": "^10.4",
"phpunit/phpunit": "^12.2"
},
"extra": {
"laravel": {
"providers": [
"Blax\\Addresses\\AddressesServiceProvider"
]
}
},
"minimum-stability": "dev",
"prefer-stable": true,
"autoload-dev": {
"psr-4": {
"Blax\\Addresses\\Tests\\": "tests",
"Workbench\\App\\": "workbench/app/",
"Workbench\\Database\\Factories\\": "workbench/database/factories/"
}
},
"scripts": {
"post-autoload-dump": [
"@clear",
"@prepare"
],
"clear": "@php vendor/bin/testbench package:purge-skeleton --ansi",
"prepare": "@php vendor/bin/testbench package:discover --ansi",
"build": "@php vendor/bin/testbench workbench:build --ansi",
"serve": [
"Composer\\Config::disableProcessTimeout",
"@build",
"@php vendor/bin/testbench serve --ansi"
],
"lint": [
"@php vendor/bin/pint --ansi"
],
"test": [
"@clear",
"@php vendor/bin/phpunit"
]
}
}

48
config/addresses.php Normal file
View File

@ -0,0 +1,48 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Model Classes
|--------------------------------------------------------------------------
|
| Override these with your own model classes if you need to extend or
| customise the package models. Your custom models should extend the
| corresponding package model so that migrations and relationships
| continue to work out of the box.
|
*/
'models' => [
'address' => \Blax\Addresses\Models\Address::class,
'address_link' => \Blax\Addresses\Models\AddressLink::class,
'address_assignment' => \Blax\Addresses\Models\AddressAssignment::class,
],
/*
|--------------------------------------------------------------------------
| Table Names
|--------------------------------------------------------------------------
|
| The database table names used by the package. Change these if they
| collide with existing tables in your application.
|
*/
'table_names' => [
'addresses' => 'addresses',
'address_links' => 'address_links',
'address_assignments' => 'address_assignments',
],
/*
|--------------------------------------------------------------------------
| Default Address Link Type
|--------------------------------------------------------------------------
|
| The default AddressLinkType applied when attaching an address to a model
| without specifying a type explicitly.
|
*/
'default_link_type' => \Blax\Addresses\Enums\AddressLinkType::Other,
];

View File

@ -0,0 +1,211 @@
<?php
namespace Blax\Addresses\Migrations;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* Creates two tables:
* - `addresses` stores the physical address data (location, coordinates, etc.)
* - `address_links` polymorphic pivot linking addresses to any Eloquent model
*/
public function up(): void
{
/*
|----------------------------------------------------------------------
| addresses — the canonical address record
|----------------------------------------------------------------------
|
| Designed to support worldwide formats, from a GPS point in the
| wilderness to a specific room inside a skyscraper.
|
| Coordinate system:
| latitude / longitude → WGS-84 decimal degrees
| altitude → metres Above Mean Sea Level (AMSL)
|
*/
Schema::create(config('addresses.table_names.addresses', 'addresses'), function (Blueprint $table) {
$table->id();
// ── Street-level addressing ─────────────────────────────
// Primary street line (street name + house/building number).
$table->string('street')->nullable();
// Additional line for c/o, suite, apartment, P.O. box, etc.
$table->string('street_extra')->nullable();
// ── Building / indoor precision ─────────────────────────
// Building or complex name (e.g. "Empire State Building", "Block C").
$table->string('building')->nullable();
// Floor / level inside the building (string to allow "GF", "B2", "Mezzanine").
$table->string('floor')->nullable();
// Room, suite or unit number / name.
$table->string('room')->nullable();
// ── Postal / administrative divisions ───────────────────
// Postal / ZIP code.
$table->string('postal_code')->nullable();
// City, town, village or locality name.
$table->string('city')->nullable();
// State, province, canton, prefecture or equivalent.
$table->string('state')->nullable();
// County, district or other sub-state administrative area.
$table->string('county')->nullable();
// ISO 3166-1 alpha-2 country code (e.g. "AT", "US", "JP").
$table->string('country_code', 2)->nullable();
// ── Coordinates (WGS-84) ────────────────────────────────
// Latitude in decimal degrees (90 to +90).
$table->decimal('latitude', 10, 7)->nullable();
// Longitude in decimal degrees (180 to +180).
$table->decimal('longitude', 10, 7)->nullable();
// Altitude in metres above mean sea level (AMSL). Positive = above, negative = below.
$table->decimal('altitude', 10, 2)->nullable();
// ── Additional properties ───────────────────────────────
// Free-form notes (e.g. delivery instructions, landmark descriptions).
$table->text('notes')->nullable();
// Flexible JSON bucket for any extra data the consuming app needs
// (e.g. Plus Codes, what3words, timezone, formatted display string).
$table->json('meta')->nullable();
$table->timestamps();
$table->softDeletes();
// ── Indexes ─────────────────────────────────────────────
$table->index('country_code');
$table->index('postal_code');
$table->index('city');
$table->index(['latitude', 'longitude']);
});
/*
|----------------------------------------------------------------------
| address_links — polymorphic pivot ("addressable")
|----------------------------------------------------------------------
|
| Links an address to any Eloquent model (User, Company, Order …).
| The same address row can be linked to many models, and a single model
| can have many addresses (each with a different purpose / type).
|
| `type` enum value describing the purpose of this link
| `label` optional free-text override (handy for "Other" type)
| `is_primary` marks ONE link per type per model as the primary
| `active_from` when this link becomes effective
| `active_until` when this link expires / is superseded
| `meta` JSON bucket for developer-defined extra data
|
*/
Schema::create(config('addresses.table_names.address_links', 'address_links'), function (Blueprint $table) {
$table->id();
// ── Foreign key to the address ──────────────────────────
$table->foreignId('address_id')
->constrained(config('addresses.table_names.addresses', 'addresses'))
->cascadeOnDelete();
// ── Polymorphic owner ───────────────────────────────────
// The model this address is linked to (e.g. App\Models\User, App\Models\Company).
$table->morphs('addressable');
// ── Link semantics ──────────────────────────────────────
// The purpose of this link, drawn from AddressLinkType enum.
$table->string('type')->default('other');
// Optional human-readable label to refine or override the type
// (useful when type = "other" or when several links share the same type).
$table->string('label')->nullable();
// Whether this is the primary address for this type on the model.
$table->boolean('is_primary')->default(false);
// ── Temporal validity ───────────────────────────────────
// When this link becomes active (null = immediately).
$table->timestamp('active_from')->nullable();
// When this link ceases to be active (null = indefinitely).
$table->timestamp('active_until')->nullable();
// ── Extra data ──────────────────────────────────────────
// JSON bucket for any developer-defined data on the pivot
// (e.g. delivery window, access codes, department reference).
$table->json('meta')->nullable();
$table->timestamps();
// ── Indexes ─────────────────────────────────────────────
$table->index(['addressable_type', 'addressable_id', 'type'], 'addr_link_owner_type');
$table->index('type');
$table->index('is_primary');
});
}
/*
|----------------------------------------------------------------------
| address_assignments — reference an AddressLink from another context
|----------------------------------------------------------------------
|
| While an AddressLink says "this Address belongs to User X as their
| Office", an AddressAssignment says "Job Y uses that specific link
| for its pickup location."
|
| `role` context-specific purpose (e.g. "pickup", "delivery", "origin")
| `label` optional free-text label
| `meta` JSON bucket for developer-defined data
|
*/
Schema::create(config('addresses.table_names.address_assignments', 'address_assignments'), function (Blueprint $table) {
$table->id();
// ── Foreign key to the address link ─────────────────────
$table->foreignId('address_link_id')
->constrained(config('addresses.table_names.address_links', 'address_links'))
->cascadeOnDelete();
// ── Polymorphic consumer ────────────────────────────────
// The model this address link is assigned to (e.g. Job, Order, Event).
$table->morphs('assignable');
// ── Assignment semantics ────────────────────────────────
// Context-specific role for this assignment (e.g. "pickup", "delivery").
$table->string('role')->nullable();
// Optional human-readable label.
$table->string('label')->nullable();
// ── Extra data ──────────────────────────────────────────
$table->json('meta')->nullable();
$table->timestamps();
// ── Indexes ─────────────────────────────────────────────
$table->index(['assignable_type', 'assignable_id', 'role'], 'addr_assign_owner_role');
$table->index('role');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists(config('addresses.table_names.address_assignments', 'address_assignments'));
Schema::dropIfExists(config('addresses.table_names.address_links', 'address_links'));
Schema::dropIfExists(config('addresses.table_names.addresses', 'addresses'));
}
};

131
docs/address-link-types.md Normal file
View File

@ -0,0 +1,131 @@
# AddressLinkType Enum
`Blax\Addresses\Enums\AddressLinkType` is a PHP 8.1 backed string enum with 17 cases. It describes the purpose of an address link — **why** a particular address is attached to a model.
## Usage
```php
use Blax\Addresses\Enums\AddressLinkType;
// When adding an address
$user->addAddress(['city' => 'Vienna'], AddressLinkType::Office);
// As a string value
$user->addAddress(['city' => 'Vienna'], 'office');
// Get the human-readable label
AddressLinkType::Office->label(); // "Office"
// Access the backing value
AddressLinkType::Office->value; // "office"
// Create from a string
$type = AddressLinkType::from('office'); // AddressLinkType::Office
$type = AddressLinkType::tryFrom('unknown'); // null
// List all cases
AddressLinkType::cases(); // array of all 17 cases
```
## All Types
### Residential
| Case | Value | Label | Description |
|----------------------|-----------------------|---------------------|--------------------------------|
| `Home` | `home` | Home | Primary living / home address |
| `SecondaryResidence` | `secondary_residence` | Secondary Residence | Holiday home, second apartment |
### Business / Work
| Case | Value | Label | Description |
|----------------|----------------|--------------|-------------------------------|
| `Office` | `office` | Office | General office address |
| `Headquarters` | `headquarters` | Headquarters | Company headquarters |
| `Branch` | `branch` | Branch | Branch or satellite office |
| `Factory` | `factory` | Factory | Factory or production site |
| `Warehouse` | `warehouse` | Warehouse | Warehouse or storage facility |
### Logistics & Shipping
| Case | Value | Label | Description |
|------------|------------|----------|-------------------------------------|
| `Shipping` | `shipping` | Shipping | Shipping / delivery address |
| `Billing` | `billing` | Billing | Billing / invoicing address |
| `Return` | `return` | Return | Return / reverse-logistics address |
| `Pickup` | `pickup` | Pick-up | Pick-up point (parcel locker, shop) |
### Special Purpose
| Case | Value | Label | Description |
|-------------------|---------------------|-------------------|--------------------------------------|
| `PointOfInterest` | `point_of_interest` | Point of Interest | Landmark, monument, notable location |
| `Site` | `site` | Site | Construction or project site |
| `Temporary` | `temporary` | Temporary | Temporary / event-based address |
| `Contact` | `contact` | Contact | Correspondence address |
| `Legal` | `legal` | Legal | Registered / legal address |
### Catch-All
| Case | Value | Label | Description |
|---------|---------|-------|-------------------------------|
| `Other` | `other` | Other | Any purpose not covered above |
## Using `Other` with labels
When none of the 17 types fit, use `Other` and set a `label` on the address link for detail:
```php
$user->addAddress([
'city' => 'Munich',
], AddressLinkType::Other, [
'label' => 'Emergency Shelter',
]);
```
## Multiple addresses of the same type
A model can have multiple addresses of the same type — for example, two offices:
```php
$link1 = $user->addAddress(['city' => 'Vienna'], AddressLinkType::Office, [
'label' => 'Vienna Office',
]);
$link2 = $user->addAddress(['city' => 'Berlin'], AddressLinkType::Office, [
'label' => 'Berlin Office',
]);
// Set one as primary
$user->setPrimaryAddressLink($link1->id);
// Query
$user->addressesOfType(AddressLinkType::Office); // both
$user->primaryAddress(AddressLinkType::Office); // Vienna
```
## Filtering with query scopes
`AddressLink` provides scopes for filtering by type:
```php
use Blax\Addresses\Models\AddressLink;
// All office links across all models
AddressLink::ofType(AddressLinkType::Office)->get();
// Combined with other scopes
AddressLink::ofType(AddressLinkType::Office)->active()->primary()->get();
```
## Default type
When no type is specified, the config default is used:
```php
// config/addresses.php
'default_link_type' => AddressLinkType::Other,
// These are equivalent:
$user->addAddress(['city' => 'Vienna']);
$user->addAddress(['city' => 'Vienna'], AddressLinkType::Other);
```

328
docs/address-service.md Normal file
View File

@ -0,0 +1,328 @@
# AddressService
The `AddressService` is a singleton service providing distance calculations, proximity queries, duplicate detection, formatting and coordinate conversion.
## Accessing the Service
```php
// Via the global helper
address()->distanceBetween($a, $b);
// Via dependency injection
use Blax\Addresses\Services\AddressService;
public function __construct(private AddressService $addressService) {}
// Via the container
app(AddressService::class)->nearby($lat, $lng, 10);
```
---
## Distance Calculation
### `distanceBetween(Address $from, Address $to, string $unit = 'km'): ?float`
Calculate the great-circle distance between two addresses using the Haversine formula.
```php
$vienna = Address::create(['latitude' => 48.2082, 'longitude' => 16.3738]);
$berlin = Address::create(['latitude' => 52.5200, 'longitude' => 13.4050]);
$km = address()->distanceBetween($vienna, $berlin); // ~524.2 km
$mi = address()->distanceBetween($vienna, $berlin, 'mi'); // ~325.8 mi
```
Returns `null` if either address is missing coordinates.
### `haversine(float $lat1, float $lng1, float $lat2, float $lng2, string $unit = 'km'): float`
Calculate distance directly from coordinate pairs — no Address models needed.
```php
$distance = address()->haversine(48.2082, 16.3738, 52.5200, 13.4050); // ~524.2 km
```
### `altitudeDifference(Address $from, Address $to): ?float`
Calculate the altitude difference in metres (signed: `to from`).
```php
$valley = Address::create(['altitude' => 200.0, 'latitude' => 0, 'longitude' => 0]);
$peak = Address::create(['altitude' => 1800.0, 'latitude' => 0, 'longitude' => 0]);
address()->altitudeDifference($valley, $peak); // 1600.0
address()->altitudeDifference($peak, $valley); // -1600.0
```
Returns `null` if either address is missing altitude data.
### Constants
| Constant | Value | Description |
|-----------------------------------|--------|---------------------------------|
| `AddressService::EARTH_RADIUS_KM` | 6371.0 | Mean Earth radius in kilometres |
| `AddressService::EARTH_RADIUS_MI` | 3958.8 | Mean Earth radius in miles |
---
## Proximity Queries
### `nearby(float $latitude, float $longitude, float $radius, string $unit = 'km'): Collection`
Find all addresses within a given radius of a coordinate point. Uses a bounding-box pre-filter for performance, then refines with Haversine. Results are ordered by distance (nearest first).
```php
// All addresses within 10 km of St. Stephen's Cathedral
$nearby = address()->nearby(48.2082, 16.3738, 10);
foreach ($nearby as $address) {
echo $address->city; // "Vienna"
echo $address->distance; // 2.34 (km from centre)
}
```
Each returned `Address` has a `->distance` attribute appended with the calculated distance.
### `nearbyAddress(Address $address, float $radius, string $unit = 'km', bool $excludeSelf = true): Collection`
Convenience wrapper — find addresses near a given address.
```php
$office = Address::create([
'street' => 'Stephansplatz 1',
'city' => 'Vienna',
'latitude' => 48.2082,
'longitude' => 16.3738,
]);
// Find other addresses within 5 km
$neighbours = address()->nearbyAddress($office, 5);
// Include the reference address itself
$all = address()->nearbyAddress($office, 5, 'km', false);
```
Returns an empty collection if the address has no coordinates.
### `closest(float $latitude, float $longitude): ?Address`
Get the single closest address to a coordinate point.
```php
$nearest = address()->closest(48.2082, 16.3738);
echo $nearest->formatted; // "Stephansplatz 1, 1010, Vienna, AT"
echo $nearest->distance; // 0.12 (km)
```
Returns `null` if no addresses with coordinates exist.
---
## Bounding Box
### `boundingBox(float $latitude, float $longitude, float $radius, string $unit = 'km'): array`
Calculate a latitude/longitude bounding box around a centre point. Useful as a fast pre-filter before computing Haversine distances.
```php
$box = address()->boundingBox(48.2082, 16.3738, 10); // 10 km radius
// Returns:
// [
// 'minLat' => 48.1183...,
// 'maxLat' => 48.2981...,
// 'minLng' => 16.2395...,
// 'maxLng' => 16.5081...,
// ]
// Use in a query
Address::whereBetween('latitude', [$box['minLat'], $box['maxLat']])
->whereBetween('longitude', [$box['minLng'], $box['maxLng']])
->get();
```
---
## Duplicate Detection & Merging
### `findDuplicates(Address $address): Collection`
Find addresses that look like potential duplicates. Matches on `street`, `postal_code`, `city` and `country_code`.
```php
$address = Address::create([
'street' => 'Baker Street 221B',
'postal_code' => 'NW1 6XE',
'city' => 'London',
'country_code' => 'GB',
]);
$duplicates = address()->findDuplicates($address);
foreach ($duplicates as $dup) {
echo "Possible duplicate: #{$dup->id} — {$dup->formatted}";
}
```
### `merge(Address $target, Address $duplicate): int`
Merge a duplicate address into a target. All `AddressLink` rows pointing to the duplicate are reassigned to the target, and the duplicate is soft-deleted.
```php
$target = Address::find(1); // the one to keep
$duplicate = Address::find(2); // the one to merge away
$reassigned = address()->merge($target, $duplicate);
echo "Reassigned {$reassigned} links";
$duplicate->trashed(); // true
```
---
## Query Builders
These methods return Eloquent `Builder` instances for further chaining.
### `inCountry(string $countryCode): Builder`
```php
$austrianAddresses = address()->inCountry('AT')->get();
$austrianCount = address()->inCountry('AT')->count();
```
### `inCity(string $city, ?string $countryCode = null): Builder`
```php
$viennaAddresses = address()->inCity('Vienna')->get();
// Disambiguate: Vienna, Austria vs Vienna, Virginia
$at = address()->inCity('Vienna', 'AT')->get();
```
### `inPostalCode(string $postalCode, ?string $countryCode = null): Builder`
```php
$addresses = address()->inPostalCode('1010')->get();
$addresses = address()->inPostalCode('1010', 'AT')->get();
```
### `withCoordinates(): Builder`
Get all addresses that have latitude and longitude set.
```php
$geoAddresses = address()->withCoordinates()->get();
$count = address()->withCoordinates()->count();
```
---
## Formatting
### `format(Address $address, string $separator = ', '): string`
Build a single-line formatted string from an address.
```php
$address = Address::create([
'street' => '350 Fifth Avenue',
'building' => 'Empire State Building',
'floor' => '32',
'postal_code' => '10118',
'city' => 'New York',
'state' => 'NY',
'country_code' => 'US',
]);
echo address()->format($address);
// "350 Fifth Avenue, (Empire State Building), Floor 32, 10118, New York, NY, US"
echo address()->format($address, ' | ');
// "350 Fifth Avenue | (Empire State Building) | Floor 32 | 10118 | New York | NY | US"
```
> **Tip:** The `Address` model also has a `$address->formatted` accessor that produces the same single-line output with `", "` separator.
### `formatMultiline(Address $address): string`
Build a multi-line, postal-style formatted string.
```php
echo address()->formatMultiline($address);
// 350 Fifth Avenue
// Empire State Building, Floor 32
// 10118 New York, NY
// US
```
Line structure:
1. Street + street_extra
2. Building, floor, room
3. Postal code + city, state
4. County (if set)
5. Country code
### `formatCoordinates(Address $address): ?string`
Format coordinates as a human-readable string.
```php
$address = Address::create([
'latitude' => 48.2082,
'longitude' => 16.3738,
'altitude' => 171.0,
]);
echo address()->formatCoordinates($address);
// "48.2082000°N, 16.3738000°E (alt: 171.00m AMSL)"
```
Returns `null` if no coordinates are set.
---
## Coordinate Conversion
### `dmsToDecimal(int $degrees, int $minutes, float $seconds, string $direction): float`
Convert degrees/minutes/seconds (DMS) to decimal degrees.
```php
// 48° 12' 29.52" N
$lat = address()->dmsToDecimal(48, 12, 29.52, 'N'); // 48.2082
// 16° 22' 25.68" E
$lng = address()->dmsToDecimal(16, 22, 25.68, 'E'); // 16.3738
// Southern / Western hemispheres yield negative values
$lat = address()->dmsToDecimal(33, 51, 54.0, 'S'); // -33.865
```
### `decimalToDms(float $decimal, string $axis = 'lat'): array`
Convert decimal degrees to DMS.
```php
$dms = address()->decimalToDms(48.2082, 'lat');
// [
// 'degrees' => 48,
// 'minutes' => 12,
// 'seconds' => 29.52,
// 'direction' => 'N',
// ]
$dms = address()->decimalToDms(-73.9854, 'lng');
// [
// 'degrees' => 73,
// 'minutes' => 59,
// 'seconds' => 7.44,
// 'direction' => 'W',
// ]
```
The `$axis` parameter determines the direction letter:
- `'lat'` → N (positive) / S (negative)
- `'lng'` → E (positive) / W (negative)

172
docs/core-concepts.md Normal file
View File

@ -0,0 +1,172 @@
# Core Concepts
## The Three-Layer Architecture
Laravel Addresses uses three models that work together:
```
Address
└── AddressLink
└── AddressAssignment
```
Each layer has a distinct responsibility:
### Layer 1: Address
The **physical place**. An `Address` is a standalone record containing street data, postal information, and optional GPS coordinates. It knows nothing about who uses it or why.
```php
Address::create([
'street' => '350 Fifth Avenue',
'building' => 'Empire State Building',
'floor' => '32',
'room' => '3201',
'postal_code' => '10118',
'city' => 'New York',
'state' => 'NY',
'country_code' => 'US',
'latitude' => 40.748817,
'longitude' => -73.985428,
'altitude' => 443.0,
]);
```
All fields are nullable — an address can be as minimal as a GPS coordinate pair or as detailed as a full postal address with indoor precision.
**Available fields:**
| Field | Type | Description |
|----------------|--------|------------------------------------------------|
| `street` | string | Street name + house number |
| `street_extra` | string | c/o, suite, P.O. box |
| `building` | string | Building or complex name |
| `floor` | string | Floor/level (supports "GF", "B2", "Mezzanine") |
| `room` | string | Room, suite or unit identifier |
| `postal_code` | string | Postal / ZIP code |
| `city` | string | City, town, village |
| `state` | string | State, province, canton |
| `county` | string | County, district |
| `country_code` | string | ISO 3166-1 alpha-2 ("US", "AT", "JP") |
| `latitude` | float | WGS-84 decimal degrees (90 … +90) |
| `longitude` | float | WGS-84 decimal degrees (180 … +180) |
| `altitude` | float | Metres above mean sea level (AMSL) |
| `notes` | text | Free-form notes, delivery instructions |
| `meta` | JSON | Arbitrary extra data |
Addresses use **soft deletes** — calling `$address->delete()` sets `deleted_at` instead of removing the row.
### Layer 2: AddressLink
The **ownership pivot**. An `AddressLink` connects an `Address` to an Eloquent model (User, Company, Order …) and describes the **purpose** of that connection.
```php
$link = $user->addAddress([
'street' => 'Stephansplatz 1',
'city' => 'Vienna',
'country_code' => 'AT',
], AddressLinkType::Office, [
'label' => 'Main Office',
'is_primary' => true,
]);
// $link->type → AddressLinkType::Office
// $link->label → "Main Office"
// $link->address → the Address model
```
**Key properties:**
| Property | Type | Description |
|----------------|-----------------|-----------------------------------------------|
| `type` | AddressLinkType | The purpose (Home, Office, Shipping …) |
| `label` | string\|null | Free-text label to refine the type |
| `is_primary` | bool | Whether this is the primary link for its type |
| `active_from` | datetime\|null | When the link becomes effective |
| `active_until` | datetime\|null | When the link expires |
| `meta` | object\|null | Arbitrary JSON data |
**Important:** The same address can be linked to multiple models, and each model can have multiple addresses. A user can have a Home address, an Office address, and a Billing address — each as a separate `AddressLink`.
**Polymorphic:** Uses `addressable_type` / `addressable_id` morphs, so any Eloquent model can own addresses.
### Layer 3: AddressAssignment
The **contextual reference**. An `AddressAssignment` lets one model reference another model's address link without owning the address.
**The problem it solves:** A transport job needs a pickup and delivery address. Those addresses belong to the customer, not the job. Instead of duplicating address data, the job *assigns* the customer's existing address links.
```php
// User owns the address
$link = $user->addAddress([
'street' => 'Kärntner Straße 21',
'city' => 'Vienna',
], AddressLinkType::Office);
// Job references it as "pickup"
$job->assignAddressLink($link, 'pickup');
// Later, retrieve it
$pickupAddress = $job->assignedAddressForRole('pickup');
// → Address { street: "Kärntner Straße 21", city: "Vienna" }
```
**Key properties:**
| Property | Type | Description |
|-------------------|--------------|---------------------------------------------------|
| `address_link_id` | int | FK to the address link being referenced |
| `role` | string\|null | Context-specific purpose ("pickup", "delivery" …) |
| `label` | string\|null | Free-text label |
| `meta` | object\|null | Arbitrary JSON data |
## Cascade Behaviour
- **Deleting an Address** → all its `AddressLink` rows are cascade-deleted
- **Deleting an AddressLink** → all its `AddressAssignment` rows are cascade-deleted
- Addresses use **soft deletes**; links and assignments use **hard deletes**
## Traits
The package provides two traits to add to your Eloquent models:
| Trait | Purpose | Use on |
|-------------------------|-----------------------------------|-----------------------------------------------------|
| `HasAddresses` | Own and manage addresses | Models that **have** addresses (User, Company …) |
| `HasAddressAssignments` | Reference other models' addresses | Models that **use** addresses (Job, Order, Event …) |
A model can use both traits if it both owns and references addresses.
## The AddressService
A singleton service for operations that go beyond CRUD:
```php
// Distance between two addresses (Haversine)
address()->distanceBetween($a, $b);
// Find nearby addresses
address()->nearby($lat, $lng, $radiusKm);
// Detect duplicates
address()->findDuplicates($address);
// Format for display
address()->formatMultiline($address);
```
Access via the `address()` helper or dependency injection:
```php
use Blax\Addresses\Services\AddressService;
public function __construct(private AddressService $addressService) {}
```
See the individual documentation pages for complete API references:
- [HasAddresses Trait](has-addresses.md)
- [HasAddressAssignments Trait](has-address-assignments.md)
- [AddressService](address-service.md)
- [AddressLinkType Enum](address-link-types.md)
- [Customization](customization.md)

188
docs/customization.md Normal file
View File

@ -0,0 +1,188 @@
# Customization
## Custom Model Classes
You can extend any of the three package models with your own. This is useful for adding custom methods, relationships, accessors or validation logic.
### 1. Create your custom model
```php
// app/Models/CustomAddress.php
namespace App\Models;
use Blax\Addresses\Models\Address as BaseAddress;
class CustomAddress extends BaseAddress
{
public function getFullAddressAttribute(): string
{
return "{$this->street}, {$this->postal_code} {$this->city}, {$this->country_code}";
}
public function geocode(): self
{
// your geocoding logic
return $this;
}
}
```
### 2. Update the config
```php
// config/addresses.php
'models' => [
'address' => \App\Models\CustomAddress::class,
'address_link' => \Blax\Addresses\Models\AddressLink::class,
'address_assignment' => \Blax\Addresses\Models\AddressAssignment::class,
],
```
The package resolves all model classes through `config('addresses.models.…')`, so your custom class will be used everywhere — in relationships, service methods, and traits.
### Example: Custom AddressLink
```php
namespace App\Models;
use Blax\Addresses\Models\AddressLink as BaseAddressLink;
class CustomAddressLink extends BaseAddressLink
{
protected static function booted()
{
static::creating(function (self $link) {
// Auto-set label from type if not provided
if (! $link->label) {
$link->label = $link->type->label();
}
});
}
}
```
```php
'models' => [
'address_link' => \App\Models\CustomAddressLink::class,
],
```
---
## Custom Table Names
Change the table names if they collide with existing tables in your application:
```php
// config/addresses.php
'table_names' => [
'addresses' => 'company_addresses',
'address_links' => 'company_address_links',
'address_assignments' => 'company_address_assignments',
],
```
**Important:** Update the published migration to match these names before running `php artisan migrate`. The migration stub reads from the config, so if you publish the config first and then the migration, the table names will be picked up automatically.
---
## Custom Default Link Type
Change the default `AddressLinkType` applied when adding an address without specifying a type:
```php
// config/addresses.php
'default_link_type' => \Blax\Addresses\Enums\AddressLinkType::Home,
```
Now `$user->addAddress(['city' => 'Vienna'])` will use `Home` instead of `Other`.
---
## Using the Meta Column
All three models (`Address`, `AddressLink`, `AddressAssignment`) include a `meta` JSON column for storing arbitrary data. The column is cast to `object`.
```php
// On addresses — store extra data
$link = $user->addAddress([
'street' => 'Main Street 1',
'city' => 'Vienna',
'meta' => [
'plus_code' => '8FWR4HCJ+XX',
'what3words' => 'index.home.raft',
'timezone' => 'Europe/Vienna',
],
]);
// On address links — store context about the relationship
$link = $user->addAddress(['city' => 'Vienna'], AddressLinkType::Office, [
'meta' => [
'department' => 'Engineering',
'access_code' => '4521',
],
]);
// On address assignments — store context about the assignment
$job->assignAddressLink($link, 'delivery', [
'meta' => [
'time_window' => '09:00-12:00',
'priority' => 'express',
],
]);
// Reading meta
$address->meta->plus_code; // "8FWR4HCJ+XX"
$link->meta->department; // "Engineering"
$assignment->meta->time_window; // "09:00-12:00"
```
---
## Model Bindings
The service provider registers model bindings so that resolving a package model through the container returns the configured (possibly customised) class:
```php
// Always resolves to your custom class if configured
$address = app(\Blax\Addresses\Models\Address::class);
```
This means type-hinting the base class in dependency injection will automatically resolve to your custom model.
---
## Extending the AddressService
The `AddressService` is registered as a singleton. To add custom methods, extend it and rebind:
```php
namespace App\Services;
use Blax\Addresses\Services\AddressService;
use Blax\Addresses\Models\Address;
class CustomAddressService extends AddressService
{
public function geocode(Address $address): Address
{
// your geocoding implementation
return $address;
}
}
```
In a service provider:
```php
$this->app->singleton(
\Blax\Addresses\Services\AddressService::class,
\App\Services\CustomAddressService::class
);
```
The `address()` helper and all DI injection will now resolve your custom service.

View File

@ -0,0 +1,267 @@
# HasAddressAssignments Trait
Add the `HasAddressAssignments` trait to any Eloquent model that **references** addresses owned by other models.
```php
use Blax\Addresses\Traits\HasAddressAssignments;
class Job extends Model
{
use HasAddressAssignments;
}
class Order extends Model
{
use HasAddressAssignments;
}
```
## When to use this
Use `HasAddressAssignments` when a model needs to reference an address that it does **not** own. Instead of duplicating address data, the model creates an assignment that points to an existing `AddressLink`.
**Example:** A transport job needs a pickup address and a delivery address. Those addresses belong to customers. The job *assigns* the customers' address links to itself with context-specific roles.
```php
// Customer owns the address
$pickupLink = $customer->addAddress(['city' => 'Vienna'], AddressLinkType::Office);
$deliveryLink = $recipient->addAddress(['city' => 'Berlin'], AddressLinkType::Home);
// Job references them
$job->assignAddressLink($pickupLink, 'pickup');
$job->assignAddressLink($deliveryLink, 'delivery');
```
> A model can use **both** `HasAddresses` and `HasAddressAssignments` if it both owns and references addresses.
---
## Relationship
### `addressAssignments()`
Returns all `AddressAssignment` records for this model.
```php
$assignments = $job->addressAssignments;
foreach ($assignments as $assignment) {
echo $assignment->role; // "pickup"
echo $assignment->addressLink->type->label(); // "Office"
echo $assignment->addressLink->address->city; // "Vienna"
}
```
**Return:** `MorphMany` of `AddressAssignment`
---
## Assigning
### `assignAddressLink(AddressLink|int $addressLink, ?string $role = null, array $extra = []): AddressAssignment`
Assign an existing address link to this model.
```php
use Blax\Addresses\Enums\AddressLinkType;
$link = $user->addAddress([
'street' => 'Kärntner Straße 21',
'city' => 'Vienna',
'country_code' => 'AT',
], AddressLinkType::Office);
// Assign with a role
$assignment = $job->assignAddressLink($link, 'pickup');
// Assign with extra attributes
$assignment = $job->assignAddressLink($link, 'delivery', [
'label' => 'Customer Office Delivery',
'meta' => ['priority' => 'express', 'time_window' => '09:00-12:00'],
]);
// Assign by link ID
$assignment = $job->assignAddressLink($link->id, 'origin');
```
**Parameters:**
- `$addressLink``AddressLink` model or its ID
- `$role` — Context-specific purpose string (e.g. "pickup", "delivery", "origin", "billing")
- `$extra` — Additional attributes: `label`, `meta`
**Returns:** The created `AddressAssignment` with `addressLink.address` loaded.
---
## Removing
### `removeAddressAssignment(int $assignmentId): bool`
Remove a specific assignment by its ID.
```php
$assignment = $job->assignAddressLink($link, 'pickup');
$job->removeAddressAssignment($assignment->id); // true
```
### `removeAssignmentsForRole(string $role): int`
Remove all assignments for a specific role.
```php
// Remove all "pickup" assignments
$removed = $job->removeAssignmentsForRole('pickup'); // 1
```
**Returns:** Number of assignments removed.
### `removeAllAddressAssignments(): int`
Remove all address assignments from this model.
```php
$removed = $job->removeAllAddressAssignments(); // 3
```
**Returns:** Number of assignments removed.
---
## Querying
### `addressAssignmentForRole(string $role): ?AddressAssignment`
Get the first assignment for a specific role (eager-loads the address link and address).
```php
$assignment = $job->addressAssignmentForRole('pickup');
if ($assignment) {
echo $assignment->role; // "pickup"
echo $assignment->addressLink->address->city; // "Vienna"
}
```
### `addressAssignmentsForRole(string $role): Collection`
Get **all** assignments for a specific role. Useful when a model has multiple addresses for the same role.
```php
// A job with multiple stops
$job->assignAddressLink($linkA, 'stop');
$job->assignAddressLink($linkB, 'stop');
$job->assignAddressLink($linkC, 'stop');
$stops = $job->addressAssignmentsForRole('stop'); // Collection of 3
```
### `assignedAddressForRole(string $role): ?Address`
Convenience shortcut — returns the `Address` model directly for a role.
```php
$pickupAddress = $job->assignedAddressForRole('pickup');
echo $pickupAddress->formatted; // "Kärntner Straße 21, 1010, Vienna, AT"
```
**Returns:** `Address` or `null`.
### `assignedAddresses(): Collection`
Get all addresses assigned to this model (through their links).
```php
$addresses = $job->assignedAddresses();
foreach ($addresses as $address) {
echo $address->city;
}
```
**Returns:** `Collection` of `Address` models.
### `hasAddressAssignments(): bool`
Check whether this model has any address assignments.
```php
if ($job->hasAddressAssignments()) {
// ...
}
```
### `hasAssignmentForRole(string $role): bool`
Check whether this model has an assignment for a specific role.
```php
if (! $job->hasAssignmentForRole('delivery')) {
// prompt to assign a delivery address
}
```
---
## Cascade Behaviour
When an `AddressLink` is deleted (e.g. because the user removes their office address), all `AddressAssignment` rows referencing that link are **cascade-deleted** at the database level.
```php
// User removes their office address link
$user->removeAddressLink($officeLink->id);
// All jobs that referenced this link automatically lose their assignment
$job->hasAssignmentForRole('pickup'); // false
```
---
## Full Example
```php
use App\Models\User;
use App\Models\Job;
use Blax\Addresses\Enums\AddressLinkType;
// Setup: users own addresses
$sender = User::create(['name' => 'Alice']);
$receiver = User::create(['name' => 'Bob']);
$senderOffice = $sender->addAddress([
'street' => 'Stephansplatz 1',
'city' => 'Vienna',
'country_code' => 'AT',
], AddressLinkType::Office);
$receiverHome = $receiver->addAddress([
'street' => 'Unter den Linden 77',
'city' => 'Berlin',
'country_code' => 'DE',
], AddressLinkType::Home);
// Job references both addresses
$job = Job::create(['title' => 'Piano Transport #42']);
$job->assignAddressLink($senderOffice, 'pickup', [
'label' => "Alice's Office",
]);
$job->assignAddressLink($receiverHome, 'delivery', [
'label' => "Bob's Home",
'meta' => ['floor' => 3, 'elevator' => false],
]);
// Query
$job->assignedAddressForRole('pickup')->city; // "Vienna"
$job->assignedAddressForRole('delivery')->city; // "Berlin"
$job->assignedAddresses()->count(); // 2
$job->hasAssignmentForRole('pickup'); // true
$job->hasAssignmentForRole('billing'); // false
// Clean up a single assignment
$job->removeAssignmentsForRole('delivery');
$job->assignedAddresses()->count(); // 1
// Clean up everything
$job->removeAllAddressAssignments();
```

327
docs/has-addresses.md Normal file
View File

@ -0,0 +1,327 @@
# HasAddresses Trait
Add the `HasAddresses` trait to any Eloquent model that **owns** addresses.
```php
use Blax\Addresses\Traits\HasAddresses;
class User extends Model
{
use HasAddresses;
}
class Company extends Model
{
use HasAddresses;
}
```
## Relationships
### `addressLinks()`
Returns all `AddressLink` pivot rows for this model.
```php
$links = $user->addressLinks;
foreach ($links as $link) {
echo $link->type->label(); // "Office"
echo $link->label; // "Main Office"
echo $link->address->city; // "Vienna"
}
```
**Return:** `MorphMany` of `AddressLink`
### `addresses()`
Returns all `Address` models linked to this model (many-to-many through `address_links`). Pivot columns are included automatically.
```php
$addresses = $user->addresses;
foreach ($addresses as $address) {
echo $address->city;
echo $address->pivot->type; // "office"
echo $address->pivot->is_primary; // true/false
}
```
**Return:** `MorphToMany` of `Address` (with pivot: `id`, `type`, `label`, `is_primary`, `active_from`, `active_until`, `meta`)
---
## Adding Addresses
### `addAddress(array $attributes, AddressLinkType|string|null $type = null, array $pivot = []): AddressLink`
Creates a new `Address` record and links it to this model in one step.
```php
use Blax\Addresses\Enums\AddressLinkType;
// Minimal
$link = $user->addAddress([
'city' => 'Vienna',
'country_code' => 'AT',
]);
// With type
$link = $user->addAddress([
'street' => '350 Fifth Avenue',
'city' => 'New York',
'postal_code' => '10118',
'country_code' => 'US',
], AddressLinkType::Office);
// With type and pivot data
$link = $user->addAddress([
'street' => 'Baker Street 221B',
'city' => 'London',
'country_code' => 'GB',
], AddressLinkType::Home, [
'label' => 'Primary Residence',
'is_primary' => true,
'meta' => ['floor' => 'ground'],
]);
```
**Parameters:**
- `$attributes` — Address fields (street, city, latitude, etc.)
- `$type``AddressLinkType` enum, string value, or `null` (uses config default)
- `$pivot` — Extra link data: `label`, `is_primary`, `active_from`, `active_until`, `meta`
**Returns:** The created `AddressLink` with the `address` relation loaded.
### `linkAddress(Address|int $address, AddressLinkType|string|null $type = null, array $pivot = []): AddressLink`
Links an **existing** address to this model. Useful when the same address should be shared by multiple models.
```php
use Blax\Addresses\Models\Address;
$address = Address::create([
'street' => 'Shared Office Road 1',
'city' => 'Berlin',
'country_code' => 'DE',
]);
// Link to multiple models
$user->linkAddress($address, AddressLinkType::Office);
$company->linkAddress($address, AddressLinkType::Headquarters);
// Also accepts an address ID
$user->linkAddress($address->id, AddressLinkType::Home);
```
**Returns:** The created `AddressLink` with the `address` relation loaded.
---
## Removing Addresses
### `removeAddressLink(int $linkId): bool`
Removes a specific address link by its pivot ID. The address record is preserved.
```php
$link = $user->addAddress(['city' => 'Vienna'], AddressLinkType::Office);
$user->removeAddressLink($link->id); // true
```
### `detachAddress(Address|int $address): int`
Removes **all** links between this model and a specific address.
```php
// If the user has multiple links to the same address (e.g. Office + Billing)
$removed = $user->detachAddress($address); // 2
```
**Returns:** Number of links removed.
### `detachAllAddresses(): int`
Removes all address links from this model.
```php
$removed = $user->detachAllAddresses(); // 5
```
**Returns:** Number of links removed.
> **Note:** These methods only remove the `AddressLink` pivot rows. The `Address` records themselves are never deleted by these operations.
---
## Querying
### `addressesOfType(AddressLinkType|string $type): Collection`
Get all addresses linked with a specific type.
```php
$offices = $user->addressesOfType(AddressLinkType::Office);
$homes = $user->addressesOfType('home'); // string value also works
```
### `activeAddressLinks(): Collection`
Get all address links that are currently active (respecting `active_from` / `active_until`).
```php
$activeLinks = $user->activeAddressLinks();
foreach ($activeLinks as $link) {
echo $link->address->formatted;
}
```
A link is active when:
- `active_from` is `null` OR in the past/present **AND**
- `active_until` is `null` OR in the future
### `primaryAddress(AddressLinkType|string|null $type = null): ?Address`
Get the primary address, optionally filtered by type.
```php
// Primary address across all types
$primary = $user->primaryAddress();
// Primary office address specifically
$primaryOffice = $user->primaryAddress(AddressLinkType::Office);
```
**Returns:** `Address` or `null`.
### `hasAddresses(): bool`
Check whether this model has any address linked.
```php
if ($user->hasAddresses()) {
// ...
}
```
### `hasAddressOfType(AddressLinkType|string $type): bool`
Check whether this model has an address of a specific type.
```php
if (! $user->hasAddressOfType(AddressLinkType::Billing)) {
// prompt user to add a billing address
}
```
---
## Updating
### `setPrimaryAddressLink(int $linkId): bool`
Set an address link as the primary for its type. Automatically unsets any previous primary of the same type on this model.
```php
$officeA = $user->addAddress(['city' => 'Vienna'], AddressLinkType::Office);
$officeB = $user->addAddress(['city' => 'Berlin'], AddressLinkType::Office);
$user->setPrimaryAddressLink($officeA->id); // Vienna is primary
$user->setPrimaryAddressLink($officeB->id); // Berlin is now primary, Vienna is unset
```
**Returns:** `true` on success, `false` if the link ID was not found.
---
## Temporal Validity
Address links support time-based activation via `active_from` and `active_until`:
```php
$link = $user->addAddress([
'street' => 'Summer Cottage Lane 3',
'city' => 'Hallstatt',
], AddressLinkType::SecondaryResidence, [
'active_from' => '2025-06-01',
'active_until' => '2025-09-01',
]);
// Check if a specific link is active
$link->isActive(); // depends on current date
// Get only active links
$user->activeAddressLinks();
// Query scopes on AddressLink
AddressLink::active()->get();
AddressLink::expired()->get();
```
---
## Working with the pivot
When accessing addresses through the `addresses()` relationship, all pivot columns are available:
```php
foreach ($user->addresses as $address) {
$address->pivot->type; // "office"
$address->pivot->label; // "Main Office"
$address->pivot->is_primary; // true
$address->pivot->active_from; // "2025-01-01 00:00:00"
$address->pivot->active_until; // null
$address->pivot->meta; // "{...}"
}
```
---
## Full Example
```php
use App\Models\User;
use Blax\Addresses\Enums\AddressLinkType;
$user = User::create(['name' => 'Jane Doe', 'email' => 'jane@example.com']);
// Add a home address (primary)
$home = $user->addAddress([
'street' => 'Musterstraße 42',
'postal_code' => '1010',
'city' => 'Vienna',
'country_code' => 'AT',
'latitude' => 48.2082,
'longitude' => 16.3738,
], AddressLinkType::Home, [
'is_primary' => true,
]);
// Add an office address
$office = $user->addAddress([
'street' => 'Kärntner Straße 21',
'postal_code' => '1010',
'city' => 'Vienna',
'country_code' => 'AT',
], AddressLinkType::Office, [
'label' => 'Downtown Office',
]);
// Query
$user->hasAddresses(); // true
$user->hasAddressOfType(AddressLinkType::Shipping); // false
$user->primaryAddress(AddressLinkType::Home); // → Address { Musterstraße 42 }
$user->addressesOfType(AddressLinkType::Office); // → Collection with 1 Address
// Switch primary
$newHome = $user->addAddress(['city' => 'Graz'], AddressLinkType::Home);
$user->setPrimaryAddressLink($newHome->id);
$user->primaryAddress(AddressLinkType::Home); // → Address { city: Graz }
// Clean up
$user->removeAddressLink($office->id); // remove office link
$user->detachAllAddresses(); // remove everything
```

106
docs/installation.md Normal file
View File

@ -0,0 +1,106 @@
# Installation & Configuration
## Requirements
- PHP 8.1 or higher
- Laravel 9.x, 10.x, 11.x or 12.x
- `blax-software/laravel-workkit` (pulled in automatically as a dependency)
## Install via Composer
```bash
composer require blax-software/laravel-addresses
```
The service provider is registered automatically via Laravel's package auto-discovery.
## Publish Migrations
```bash
php artisan vendor:publish --tag="addresses-migrations"
php artisan migrate
```
This creates three tables:
| Table | Purpose |
|-----------------------|--------------------------------------------------------|
| `addresses` | Physical address records (street, city, coordinates …) |
| `address_links` | Polymorphic pivot connecting addresses to models |
| `address_assignments` | References an address link from another model context |
## Publish Configuration (optional)
```bash
php artisan vendor:publish --tag="addresses-config"
```
This copies `config/addresses.php` into your application's `config/` directory.
## Configuration Reference
```php
// config/addresses.php
return [
/*
|--------------------------------------------------------------------------
| Model Classes
|--------------------------------------------------------------------------
|
| Override with your own model classes if you need to extend the package
| models. Your custom models should extend the corresponding package model.
|
*/
'models' => [
'address' => \Blax\Addresses\Models\Address::class,
'address_link' => \Blax\Addresses\Models\AddressLink::class,
'address_assignment' => \Blax\Addresses\Models\AddressAssignment::class,
],
/*
|--------------------------------------------------------------------------
| Table Names
|--------------------------------------------------------------------------
|
| Change these if the default names collide with existing tables.
|
*/
'table_names' => [
'addresses' => 'addresses',
'address_links' => 'address_links',
'address_assignments' => 'address_assignments',
],
/*
|--------------------------------------------------------------------------
| Default Address Link Type
|--------------------------------------------------------------------------
|
| Applied when attaching an address without specifying a type.
|
*/
'default_link_type' => \Blax\Addresses\Enums\AddressLinkType::Other,
];
```
### Models
Each model key maps to a fully-qualified class name. To customise behaviour, create your own model that extends the package model and update the config:
```php
'models' => [
'address' => \App\Models\CustomAddress::class,
// ...
],
```
See [Customization](customization.md) for details.
### Table Names
All three table names are configurable. If you change them, make sure to update the published migration before running it.
### Default Link Type
When you call `$model->addAddress([...])` without a second argument, this type is used. Defaults to `AddressLinkType::Other`.

18
phpunit.xml Normal file
View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
</phpunit>

4
pint.json Normal file
View File

@ -0,0 +1,4 @@
{
"preset": "laravel",
"rules": {}
}

View File

@ -0,0 +1,111 @@
<?php
namespace Blax\Addresses;
use Blax\Addresses\Services\AddressService;
use Illuminate\Support\ServiceProvider;
class AddressesServiceProvider extends ServiceProvider
{
/**
* Register the service provider.
*
* Merges the package config so that it is available even when the
* consuming application has not published it.
*/
public function register(): void
{
$this->mergeConfigFrom(
__DIR__ . '/../config/addresses.php',
'addresses'
);
// Register AddressService as a singleton.
$this->app->singleton(AddressService::class);
}
/**
* Bootstrap the application events.
*
* Publishes config and migration stubs, and registers model bindings
* so that the container always resolves the (possibly overridden) model.
*/
public function boot(): void
{
$this->offerPublishing();
$this->registerModelBindings();
}
/*
|--------------------------------------------------------------------------
| Publishing
|--------------------------------------------------------------------------
*/
/**
* Set up publishing of config and migration files for `php artisan vendor:publish`.
*/
protected function offerPublishing(): void
{
if (! $this->app->runningInConsole()) {
return;
}
// Config
$this->publishes([
__DIR__ . '/../config/addresses.php' => $this->app->configPath('addresses.php'),
], 'addresses-config');
// Migrations
$this->publishes([
__DIR__ . '/../database/migrations/create_blax_address_tables.php.stub' => $this->getMigrationFileName('create_blax_address_tables.php'),
], 'addresses-migrations');
}
/**
* Returns an existing migration file if one is already published,
* otherwise generates a timestamped path.
*/
protected function getMigrationFileName(string $migrationFileName): string
{
$timestamp = date('Y_m_d_His');
$filesystem = $this->app->make(\Illuminate\Filesystem\Filesystem::class);
return \Illuminate\Support\Collection::make([
$this->app->databasePath() . DIRECTORY_SEPARATOR . 'migrations' . DIRECTORY_SEPARATOR,
])
->flatMap(fn($path) => $filesystem->glob($path . '*_' . $migrationFileName))
->push($this->app->databasePath() . "/migrations/{$timestamp}_{$migrationFileName}")
->first();
}
/*
|--------------------------------------------------------------------------
| Model Bindings
|--------------------------------------------------------------------------
*/
/**
* Bind the package model abstractions to the (potentially customised)
* concrete classes from config.
*/
protected function registerModelBindings(): void
{
$this->app->bind(
\Blax\Addresses\Models\Address::class,
fn($app) => $app->make($app->config['addresses.models.address'])
);
$this->app->bind(
\Blax\Addresses\Models\AddressLink::class,
fn($app) => $app->make($app->config['addresses.models.address_link'])
);
$this->app->bind(
\Blax\Addresses\Models\AddressAssignment::class,
fn($app) => $app->make($app->config['addresses.models.address_assignment'])
);
}
}

View File

@ -0,0 +1,120 @@
<?php
namespace Blax\Addresses\Enums;
/**
* Describes the purpose or role an address fulfils when linked to a model.
*
* This enum gives consuming applications a sensible pre-selection while still
* being flexible: the `Other` case combined with the `label` column on the
* pivot allows developers to store any custom designation.
*/
enum AddressLinkType: string
{
/*
|--------------------------------------------------------------------------
| Residential
|--------------------------------------------------------------------------
*/
/** Primary living / home address. */
case Home = 'home';
/** Secondary or holiday residence. */
case SecondaryResidence = 'secondary_residence';
/*
|--------------------------------------------------------------------------
| Business / Work
|--------------------------------------------------------------------------
*/
/** General office address. */
case Office = 'office';
/** Company headquarters. */
case Headquarters = 'headquarters';
/** Branch or satellite office. */
case Branch = 'branch';
/** Factory or production site. */
case Factory = 'factory';
/** Warehouse or storage facility. */
case Warehouse = 'warehouse';
/*
|--------------------------------------------------------------------------
| Logistics & Shipping
|--------------------------------------------------------------------------
*/
/** Address used for shipping / delivery. */
case Shipping = 'shipping';
/** Address used for billing / invoicing. */
case Billing = 'billing';
/** Return / reverse-logistics address. */
case Return = 'return';
/** Pick-up point (e.g. parcel locker, shop). */
case Pickup = 'pickup';
/*
|--------------------------------------------------------------------------
| Special Purpose
|--------------------------------------------------------------------------
*/
/** Point of interest or landmark (e.g. a rural stone, monument). */
case PointOfInterest = 'point_of_interest';
/** Construction or project site. */
case Site = 'site';
/** Temporary / event-based address. */
case Temporary = 'temporary';
/** Contact / correspondence address (may differ from legal). */
case Contact = 'contact';
/** Registered / legal address. */
case Legal = 'legal';
/*
|--------------------------------------------------------------------------
| Catch-All
|--------------------------------------------------------------------------
*/
/** Any purpose not covered above — use the `label` on the pivot for detail. */
case Other = 'other';
/**
* Human-readable label for display in UIs.
*/
public function label(): string
{
return match ($this) {
self::Home => 'Home',
self::SecondaryResidence => 'Secondary Residence',
self::Office => 'Office',
self::Headquarters => 'Headquarters',
self::Branch => 'Branch',
self::Factory => 'Factory',
self::Warehouse => 'Warehouse',
self::Shipping => 'Shipping',
self::Billing => 'Billing',
self::Return => 'Return',
self::Pickup => 'Pick-up',
self::PointOfInterest => 'Point of Interest',
self::Site => 'Site',
self::Temporary => 'Temporary',
self::Contact => 'Contact',
self::Legal => 'Legal',
self::Other => 'Other',
};
}
}

165
src/Models/Address.php Normal file
View File

@ -0,0 +1,165 @@
<?php
namespace Blax\Addresses\Models;
use Blax\Workkit\Traits\HasMeta;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* Represents a physical address from a rural GPS coordinate to a specific
* room inside a high-rise building.
*
* Coordinate system:
* latitude / longitude WGS-84 decimal degrees
* altitude metres Above Mean Sea Level (AMSL)
*
* @property int $id
* @property string|null $street Primary street line (name + number)
* @property string|null $street_extra Secondary line (c/o, suite, P.O. box )
* @property string|null $building Building / complex name
* @property string|null $floor Floor / level (string: "GF", "B2", "Mezzanine")
* @property string|null $room Room, suite or unit identifier
* @property string|null $postal_code Postal / ZIP code
* @property string|null $city City, town, village or locality
* @property string|null $state State, province, canton
* @property string|null $county County, district
* @property string|null $country_code ISO 3166-1 alpha-2 ("AT", "US", "JP")
* @property float|null $latitude Decimal degrees (90 +90)
* @property float|null $longitude Decimal degrees (180 +180)
* @property float|null $altitude Metres AMSL (positive = above, negative = below)
* @property string|null $notes Free-form notes / delivery instructions
* @property object|null $meta Arbitrary JSON data
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
* @property \Carbon\Carbon|null $deleted_at
*/
class Address extends Model
{
use HasMeta;
use SoftDeletes;
/**
* Mass-assignable attributes.
*
* Every column is intentionally nullable so that an address record can be
* as sparse as a single coordinate pair or as detailed as a full postal
* address with indoor precision.
*/
protected $fillable = [
'street',
'street_extra',
'building',
'floor',
'room',
'postal_code',
'city',
'state',
'county',
'country_code',
'latitude',
'longitude',
'altitude',
'notes',
'meta',
];
/**
* Attribute casting.
*/
protected $casts = [
'latitude' => 'float',
'longitude' => 'float',
'altitude' => 'float',
'meta' => 'object',
];
/*
|--------------------------------------------------------------------------
| Constructor configurable table name
|--------------------------------------------------------------------------
*/
public function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->table = config('addresses.table_names.addresses') ?: parent::getTable();
}
/*
|--------------------------------------------------------------------------
| Relationships
|--------------------------------------------------------------------------
*/
/**
* All links that reference this address (polymorphic pivot rows).
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function links()
{
return $this->hasMany(
config('addresses.models.address_link', AddressLink::class),
'address_id'
);
}
/*
|--------------------------------------------------------------------------
| Helpers
|--------------------------------------------------------------------------
*/
/**
* Whether the address has geographic coordinates set.
*/
public function hasCoordinates(): bool
{
return $this->latitude !== null && $this->longitude !== null;
}
/**
* Whether the address has altitude (AMSL) set.
*/
public function hasAltitude(): bool
{
return $this->altitude !== null;
}
/**
* Build a single-line formatted representation of the address.
*
* Useful for display purposes joins non-empty components with ", ".
*/
public function getFormattedAttribute(): string
{
return collect([
$this->street,
$this->street_extra,
$this->building ? "({$this->building})" : null,
$this->floor ? "Floor {$this->floor}" : null,
$this->room ? "Room {$this->room}" : null,
$this->postal_code,
$this->city,
$this->state,
$this->county,
$this->country_code,
])->filter()->implode(', ');
}
/**
* Return coordinates as an associative array.
*
* @return array{latitude: float|null, longitude: float|null, altitude: float|null}
*/
public function toCoordinates(): array
{
return [
'latitude' => $this->latitude,
'longitude' => $this->longitude,
'altitude' => $this->altitude,
];
}
}

View File

@ -0,0 +1,130 @@
<?php
namespace Blax\Addresses\Models;
use Blax\Workkit\Traits\HasMeta;
use Illuminate\Database\Eloquent\Model;
/**
* Assigns an existing AddressLink to another model / context.
*
* While an AddressLink connects an Address to its *owner* (e.g. a User's
* "Office" address), an AddressAssignment *references* that link from a
* completely different model (e.g. a Job, Order or Event).
*
* Example flow:
* Address ("350 Fifth Avenue, New York")
* └── AddressLink (User #7 — type: office)
* └── AddressAssignment (Job #42 — role: "pickup")
*
* This lets you say: "Job #42 picks up from User #7's office address."
*
* @property int $id
* @property int $address_link_id FK address_links
* @property string $assignable_type Morph type of the consuming model
* @property int $assignable_id Morph ID of the consuming model
* @property string|null $role Context-specific purpose ("pickup", "delivery", )
* @property string|null $label Free-text label
* @property object|null $meta Arbitrary JSON data
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
*/
class AddressAssignment extends Model
{
use HasMeta;
/**
* Mass-assignable attributes.
*/
protected $fillable = [
'address_link_id',
'assignable_type',
'assignable_id',
'role',
'label',
'meta',
];
/**
* Attribute casting.
*/
protected $casts = [
'meta' => 'object',
];
/*
|--------------------------------------------------------------------------
| Constructor configurable table name
|--------------------------------------------------------------------------
*/
public function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->table = config('addresses.table_names.address_assignments') ?: parent::getTable();
}
/*
|--------------------------------------------------------------------------
| Relationships
|--------------------------------------------------------------------------
*/
/**
* The address link this assignment references.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function addressLink()
{
return $this->belongsTo(
config('addresses.models.address_link', AddressLink::class),
'address_link_id'
);
}
/**
* The address (shortcut through the link).
*
* @return \Illuminate\Database\Eloquent\Relations\HasOneThrough
*/
public function address()
{
$addressModel = config('addresses.models.address', Address::class);
$linkModel = config('addresses.models.address_link', AddressLink::class);
return $this->hasOneThrough(
$addressModel,
$linkModel,
'id', // FK on address_links (intermediate)
'id', // FK on addresses (final)
'address_link_id', // local key on address_assignments
'address_id' // FK on address_links → addresses
);
}
/**
* The model this address link is assigned to (Job, Order, Event ).
*
* @return \Illuminate\Database\Eloquent\Relations\MorphTo
*/
public function assignable()
{
return $this->morphTo();
}
/*
|--------------------------------------------------------------------------
| Scopes
|--------------------------------------------------------------------------
*/
/**
* Only assignments with a specific role.
*/
public function scopeForRole($query, string $role)
{
return $query->where('role', $role);
}
}

181
src/Models/AddressLink.php Normal file
View File

@ -0,0 +1,181 @@
<?php
namespace Blax\Addresses\Models;
use Blax\Addresses\Enums\AddressLinkType;
use Blax\Workkit\Traits\HasMeta;
use Illuminate\Database\Eloquent\Model;
/**
* Polymorphic pivot that links an Address to any Eloquent model.
*
* A single address can serve multiple purposes for the same or different
* models (e.g. both "Office" and "Billing" for a company), each tracked
* as a separate AddressLink row.
*
* @property int $id
* @property int $address_id FK addresses
* @property string $addressable_type Morph type of the owning model
* @property int $addressable_id Morph ID of the owning model
* @property string $type AddressLinkType enum value
* @property string|null $label Free-text label (refines or overrides type)
* @property bool $is_primary Whether this is the primary link for its type
* @property \Carbon\Carbon|null $active_from When the link becomes effective
* @property \Carbon\Carbon|null $active_until When the link expires
* @property object|null $meta Arbitrary JSON data for consuming apps
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
*/
class AddressLink extends Model
{
use HasMeta;
/**
* Mass-assignable attributes.
*/
protected $fillable = [
'address_id',
'addressable_type',
'addressable_id',
'type',
'label',
'is_primary',
'active_from',
'active_until',
'meta',
];
/**
* Attribute casting.
*/
protected $casts = [
'type' => AddressLinkType::class,
'is_primary' => 'boolean',
'active_from' => 'datetime',
'active_until' => 'datetime',
'meta' => 'object',
];
/*
|--------------------------------------------------------------------------
| Constructor configurable table name
|--------------------------------------------------------------------------
*/
public function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->table = config('addresses.table_names.address_links') ?: parent::getTable();
}
/*
|--------------------------------------------------------------------------
| Relationships
|--------------------------------------------------------------------------
*/
/**
* The address record this link points to.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function address()
{
return $this->belongsTo(
config('addresses.models.address', Address::class),
'address_id'
);
}
/**
* The owning model (User, Company, Order ).
*
* @return \Illuminate\Database\Eloquent\Relations\MorphTo
*/
public function addressable()
{
return $this->morphTo();
}
/**
* All assignments that reference this link from other models.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function assignments()
{
return $this->hasMany(
config('addresses.models.address_assignment', AddressAssignment::class),
'address_link_id'
);
}
/*
|--------------------------------------------------------------------------
| Scopes
|--------------------------------------------------------------------------
*/
/**
* Only links that are currently active (respects active_from / active_until).
*
* A link is active when:
* - active_from is null OR in the past/present, AND
* - active_until is null OR in the future.
*/
public function scopeActive($query)
{
return $query->where(function ($q) {
$q->whereNull('active_from')
->orWhere('active_from', '<=', now());
})->where(function ($q) {
$q->whereNull('active_until')
->orWhere('active_until', '>', now());
});
}
/**
* Only expired links (active_until is in the past).
*/
public function scopeExpired($query)
{
return $query->whereNotNull('active_until')
->where('active_until', '<=', now());
}
/**
* Only links of a specific type.
*/
public function scopeOfType($query, AddressLinkType|string $type)
{
$value = $type instanceof AddressLinkType ? $type->value : $type;
return $query->where('type', $value);
}
/**
* Only primary links.
*/
public function scopePrimary($query)
{
return $query->where('is_primary', true);
}
/*
|--------------------------------------------------------------------------
| Helpers
|--------------------------------------------------------------------------
*/
/**
* Whether this link is currently active.
*/
public function isActive(): bool
{
$fromOk = $this->active_from === null || $this->active_from->isPast() || $this->active_from->isToday();
$untilOk = $this->active_until === null || $this->active_until->isFuture();
return $fromOk && $untilOk;
}
}

View File

@ -0,0 +1,553 @@
<?php
namespace Blax\Addresses\Services;
use Blax\Addresses\Enums\AddressLinkType;
use Blax\Addresses\Models\Address;
use Blax\Addresses\Models\AddressLink;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
/**
* Central service for address operations that go beyond simple CRUD.
*
* Provides distance calculations (Haversine), proximity queries, duplicate
* detection, geocoordinate helpers and bulk operations.
*
* Retrieve via DI or the `address()` helper:
*
* app(AddressService::class)->distanceBetween($a, $b);
* address()->nearby($lat, $lng, 5);
*/
class AddressService
{
/**
* Mean radius of the Earth in kilometres (WGS-84 volumetric mean).
*/
public const EARTH_RADIUS_KM = 6371.0;
/**
* Mean radius of the Earth in miles.
*/
public const EARTH_RADIUS_MI = 3958.8;
/*
|--------------------------------------------------------------------------
| Distance Calculation
|--------------------------------------------------------------------------
*/
/**
* Calculate the great-circle distance between two addresses using the
* Haversine formula.
*
* Both addresses must have latitude and longitude set; returns null if
* either is missing coordinates.
*
* @param Address $from
* @param Address $to
* @param string $unit 'km' (default) or 'mi'
* @return float|null Distance in the requested unit, or null if coordinates are missing
*/
public function distanceBetween(Address $from, Address $to, string $unit = 'km'): ?float
{
if (! $from->hasCoordinates() || ! $to->hasCoordinates()) {
return null;
}
return $this->haversine(
$from->latitude,
$from->longitude,
$to->latitude,
$to->longitude,
$unit
);
}
/**
* Calculate the great-circle distance between two coordinate pairs.
*
* @param float $lat1 Latitude of point A (decimal degrees)
* @param float $lng1 Longitude of point A (decimal degrees)
* @param float $lat2 Latitude of point B (decimal degrees)
* @param float $lng2 Longitude of point B (decimal degrees)
* @param string $unit 'km' (default) or 'mi'
* @return float Distance in the requested unit
*/
public function haversine(float $lat1, float $lng1, float $lat2, float $lng2, string $unit = 'km'): float
{
$radius = $unit === 'mi' ? self::EARTH_RADIUS_MI : self::EARTH_RADIUS_KM;
$dLat = deg2rad($lat2 - $lat1);
$dLng = deg2rad($lng2 - $lng1);
$a = sin($dLat / 2) ** 2
+ cos(deg2rad($lat1)) * cos(deg2rad($lat2))
* sin($dLng / 2) ** 2;
$c = 2 * atan2(sqrt($a), sqrt(1 - $a));
return $radius * $c;
}
/**
* Calculate the altitude difference between two addresses in metres.
*
* Returns null if either address is missing altitude data.
*
* @param Address $from
* @param Address $to
* @return float|null Signed difference (to from) in metres
*/
public function altitudeDifference(Address $from, Address $to): ?float
{
if (! $from->hasAltitude() || ! $to->hasAltitude()) {
return null;
}
return $to->altitude - $from->altitude;
}
/*
|--------------------------------------------------------------------------
| Proximity Queries
|--------------------------------------------------------------------------
*/
/**
* Find all addresses within a given radius of a coordinate point.
*
* Uses a bounding-box pre-filter for performance, then refines with
* Haversine. Results are ordered by distance (nearest first).
*
* @param float $latitude Centre latitude (decimal degrees)
* @param float $longitude Centre longitude (decimal degrees)
* @param float $radius Search radius
* @param string $unit 'km' (default) or 'mi'
* @return Collection<int, Address> Each model has a `->distance` attribute appended
*/
public function nearby(float $latitude, float $longitude, float $radius, string $unit = 'km'): Collection
{
$earthRadius = $unit === 'mi' ? self::EARTH_RADIUS_MI : self::EARTH_RADIUS_KM;
// Bounding box pre-filter (rough but fast — avoids full-table Haversine)
$boundingBox = $this->boundingBox($latitude, $longitude, $radius, $unit);
$addressModel = config('addresses.models.address', Address::class);
return $addressModel::query()
->whereNotNull('latitude')
->whereNotNull('longitude')
->whereBetween('latitude', [$boundingBox['minLat'], $boundingBox['maxLat']])
->whereBetween('longitude', [$boundingBox['minLng'], $boundingBox['maxLng']])
->get()
->map(function (Address $address) use ($latitude, $longitude, $unit) {
$address->distance = $this->haversine(
$latitude,
$longitude,
$address->latitude,
$address->longitude,
$unit
);
return $address;
})
->filter(fn(Address $address) => $address->distance <= $radius)
->sortBy('distance')
->values();
}
/**
* Find addresses near a given address (convenience wrapper around nearby()).
*
* @param Address $address The reference address (must have coordinates)
* @param float $radius Search radius
* @param string $unit 'km' or 'mi'
* @param bool $excludeSelf Whether to exclude the reference address from results
* @return Collection<int, Address>
*/
public function nearbyAddress(Address $address, float $radius, string $unit = 'km', bool $excludeSelf = true): Collection
{
if (! $address->hasCoordinates()) {
return collect();
}
$results = $this->nearby($address->latitude, $address->longitude, $radius, $unit);
if ($excludeSelf) {
$results = $results->reject(fn(Address $a) => $a->id === $address->id)->values();
}
return $results;
}
/**
* Get the closest address to a coordinate point.
*
* @param float $latitude
* @param float $longitude
* @return Address|null The nearest address, with `->distance` appended (km)
*/
public function closest(float $latitude, float $longitude): ?Address
{
$addressModel = config('addresses.models.address', Address::class);
$addresses = $addressModel::query()
->whereNotNull('latitude')
->whereNotNull('longitude')
->get();
if ($addresses->isEmpty()) {
return null;
}
return $addresses
->map(function (Address $address) use ($latitude, $longitude) {
$address->distance = $this->haversine(
$latitude,
$longitude,
$address->latitude,
$address->longitude,
);
return $address;
})
->sortBy('distance')
->first();
}
/*
|--------------------------------------------------------------------------
| Bounding Box
|--------------------------------------------------------------------------
*/
/**
* Calculate a latitude/longitude bounding box around a centre point.
*
* Useful as a fast pre-filter before applying the more expensive
* Haversine calculation.
*
* @param float $latitude
* @param float $longitude
* @param float $radius
* @param string $unit 'km' or 'mi'
* @return array{minLat: float, maxLat: float, minLng: float, maxLng: float}
*/
public function boundingBox(float $latitude, float $longitude, float $radius, string $unit = 'km'): array
{
$earthRadius = $unit === 'mi' ? self::EARTH_RADIUS_MI : self::EARTH_RADIUS_KM;
// Angular radius in degrees
$angularRadius = rad2deg($radius / $earthRadius);
// Latitude bounds are straightforward
$minLat = $latitude - $angularRadius;
$maxLat = $latitude + $angularRadius;
// Longitude bounds must account for meridian convergence
$lngDelta = rad2deg(asin(sin(deg2rad($angularRadius)) / cos(deg2rad($latitude))));
$minLng = $longitude - $lngDelta;
$maxLng = $longitude + $lngDelta;
return [
'minLat' => max($minLat, -90),
'maxLat' => min($maxLat, 90),
'minLng' => max($minLng, -180),
'maxLng' => min($maxLng, 180),
];
}
/*
|--------------------------------------------------------------------------
| Duplicate / Match Detection
|--------------------------------------------------------------------------
*/
/**
* Find addresses that look like potential duplicates of the given address.
*
* Matches on the combination of street, postal_code, city and country_code.
* Coordinates are NOT considered (two records for the same street may have
* slightly different GPS positions).
*
* @param Address $address
* @return Collection<int, Address> Potential duplicates (excluding the address itself)
*/
public function findDuplicates(Address $address): Collection
{
$addressModel = config('addresses.models.address', Address::class);
return $addressModel::query()
->where('id', '!=', $address->id)
->when($address->street, fn(Builder $q) => $q->where('street', $address->street))
->when($address->postal_code, fn(Builder $q) => $q->where('postal_code', $address->postal_code))
->when($address->city, fn(Builder $q) => $q->where('city', $address->city))
->when($address->country_code, fn(Builder $q) => $q->where('country_code', $address->country_code))
->get();
}
/**
* Merge a duplicate address into a target address.
*
* All address_links currently pointing to `$duplicate` are re-pointed to
* `$target`. The duplicate address is then soft-deleted.
*
* @param Address $target The address to keep
* @param Address $duplicate The address to merge away
* @return int Number of links reassigned
*/
public function merge(Address $target, Address $duplicate): int
{
$linkModel = config('addresses.models.address_link', AddressLink::class);
$affected = $linkModel::where('address_id', $duplicate->id)
->update(['address_id' => $target->id]);
$duplicate->delete();
return $affected;
}
/*
|--------------------------------------------------------------------------
| Query Scopes / Builders
|--------------------------------------------------------------------------
*/
/**
* Get a query builder for addresses filtered by country code.
*
* @param string $countryCode ISO 3166-1 alpha-2 code
* @return Builder
*/
public function inCountry(string $countryCode): Builder
{
$addressModel = config('addresses.models.address', Address::class);
return $addressModel::where('country_code', strtoupper($countryCode));
}
/**
* Get a query builder for addresses in a specific city.
*
* @param string $city
* @param string|null $countryCode Optional ISO alpha-2 filter
* @return Builder
*/
public function inCity(string $city, ?string $countryCode = null): Builder
{
$addressModel = config('addresses.models.address', Address::class);
$query = $addressModel::where('city', $city);
if ($countryCode) {
$query->where('country_code', strtoupper($countryCode));
}
return $query;
}
/**
* Get a query builder for addresses matching a postal code.
*
* @param string $postalCode
* @param string|null $countryCode Optional ISO alpha-2 filter
* @return Builder
*/
public function inPostalCode(string $postalCode, ?string $countryCode = null): Builder
{
$addressModel = config('addresses.models.address', Address::class);
$query = $addressModel::where('postal_code', $postalCode);
if ($countryCode) {
$query->where('country_code', strtoupper($countryCode));
}
return $query;
}
/**
* Get all addresses that have coordinates set.
*
* @return Builder
*/
public function withCoordinates(): Builder
{
$addressModel = config('addresses.models.address', Address::class);
return $addressModel::whereNotNull('latitude')
->whereNotNull('longitude');
}
/*
|--------------------------------------------------------------------------
| Formatting
|--------------------------------------------------------------------------
*/
/**
* Build a single-line formatted string from an address.
*
* Joins non-empty components with the given separator.
*
* @param Address $address
* @param string $separator Glue between parts (default: ", ")
* @return string
*/
public function format(Address $address, string $separator = ', '): string
{
return collect([
$address->street,
$address->street_extra,
$address->building ? "({$address->building})" : null,
$address->floor ? "Floor {$address->floor}" : null,
$address->room ? "Room {$address->room}" : null,
$address->postal_code,
$address->city,
$address->state,
$address->county,
$address->country_code,
])->filter()->implode($separator);
}
/**
* Build a multi-line formatted string from an address.
*
* Produces a postal-style block, e.g.:
*
* 350 Fifth Avenue, Suite 3200
* Empire State Building, Floor 32, Room 3201
* 10118 New York, NY
* US
*
* @param Address $address
* @return string
*/
public function formatMultiline(Address $address): string
{
$lines = [];
// Line 1: street
$street = collect([$address->street, $address->street_extra])->filter()->implode(', ');
if ($street) {
$lines[] = $street;
}
// Line 2: building / indoor
$indoor = collect([
$address->building,
$address->floor ? "Floor {$address->floor}" : null,
$address->room ? "Room {$address->room}" : null,
])->filter()->implode(', ');
if ($indoor) {
$lines[] = $indoor;
}
// Line 3: postal code + city + state
$cityLine = collect([
collect([$address->postal_code, $address->city])->filter()->implode(' '),
$address->state,
])->filter()->implode(', ');
if ($cityLine) {
$lines[] = $cityLine;
}
// Line 4: county (if present)
if ($address->county) {
$lines[] = $address->county;
}
// Line 5: country
if ($address->country_code) {
$lines[] = $address->country_code;
}
return implode("\n", $lines);
}
/**
* Format coordinates as a human-readable string.
*
* Example: "48.2082000°N, 16.3738000°E (alt: 171.00m AMSL)"
*
* @param Address $address
* @return string|null null if no coordinates are set
*/
public function formatCoordinates(Address $address): ?string
{
if (! $address->hasCoordinates()) {
return null;
}
$lat = number_format(abs($address->latitude), 7);
$latDir = $address->latitude >= 0 ? 'N' : 'S';
$lng = number_format(abs($address->longitude), 7);
$lngDir = $address->longitude >= 0 ? 'E' : 'W';
$result = "{$lat}°{$latDir}, {$lng}°{$lngDir}";
if ($address->hasAltitude()) {
$alt = number_format($address->altitude, 2);
$result .= " (alt: {$alt}m AMSL)";
}
return $result;
}
/*
|--------------------------------------------------------------------------
| Coordinate Conversion
|--------------------------------------------------------------------------
*/
/**
* Convert degrees, minutes, seconds (DMS) to decimal degrees.
*
* @param int $degrees
* @param int $minutes
* @param float $seconds
* @param string $direction 'N', 'S', 'E' or 'W'
* @return float Decimal degrees (negative for S/W)
*/
public function dmsToDecimal(int $degrees, int $minutes, float $seconds, string $direction): float
{
$decimal = $degrees + ($minutes / 60) + ($seconds / 3600);
if (in_array(strtoupper($direction), ['S', 'W'])) {
$decimal *= -1;
}
return $decimal;
}
/**
* Convert decimal degrees to degrees, minutes, seconds (DMS).
*
* @param float $decimal Decimal degrees
* @param string $axis 'lat' or 'lng' determines the direction letter
* @return array{degrees: int, minutes: int, seconds: float, direction: string}
*/
public function decimalToDms(float $decimal, string $axis = 'lat'): array
{
$direction = $axis === 'lat'
? ($decimal >= 0 ? 'N' : 'S')
: ($decimal >= 0 ? 'E' : 'W');
$decimal = abs($decimal);
$degrees = (int) floor($decimal);
$minutesDecimal = ($decimal - $degrees) * 60;
$minutes = (int) floor($minutesDecimal);
$seconds = round(($minutesDecimal - $minutes) * 60, 4);
return [
'degrees' => $degrees,
'minutes' => $minutes,
'seconds' => $seconds,
'direction' => $direction,
];
}
}

View File

@ -0,0 +1,193 @@
<?php
namespace Blax\Addresses\Traits;
use Blax\Addresses\Models\Address;
use Blax\Addresses\Models\AddressAssignment;
use Blax\Addresses\Models\AddressLink;
use Illuminate\Support\Collection;
/**
* Adds address-assignment capabilities to any Eloquent model.
*
* Use this trait on models that *consume* addresses owned by other models.
* For example a `Job` or `Order` that needs to reference a `User`'s office
* address without owning the address itself.
*
* Usage:
* class Job extends Model {
* use \Blax\Addresses\Traits\HasAddressAssignments;
* }
*
* // User owns the address link
* $link = $user->addAddress([], AddressLinkType::Office);
*
* // Job references it
* $job->assignAddressLink($link, 'pickup');
*/
trait HasAddressAssignments
{
/*
|--------------------------------------------------------------------------
| Relationships
|--------------------------------------------------------------------------
*/
/**
* All address assignments for this model.
*
* @return \Illuminate\Database\Eloquent\Relations\MorphMany
*/
public function addressAssignments()
{
return $this->morphMany(
config('addresses.models.address_assignment', AddressAssignment::class),
'assignable'
);
}
/*
|--------------------------------------------------------------------------
| Assigning / unassigning
|--------------------------------------------------------------------------
*/
/**
* Assign an existing address link to this model.
*
* @param AddressLink|int $addressLink AddressLink model or ID
* @param string|null $role Context-specific role ("pickup", "delivery", )
* @param array $extra Additional attributes (label, meta)
* @return AddressAssignment
*/
public function assignAddressLink(AddressLink|int $addressLink, ?string $role = null, array $extra = []): AddressAssignment
{
$linkId = $addressLink instanceof AddressLink ? $addressLink->id : $addressLink;
$assignmentModel = config('addresses.models.address_assignment', AddressAssignment::class);
/** @var AddressAssignment $assignment */
$assignment = $assignmentModel::create(array_merge([
'address_link_id' => $linkId,
'assignable_type' => $this->getMorphClass(),
'assignable_id' => $this->getKey(),
'role' => $role,
], $extra));
$assignment->load('addressLink.address');
return $assignment;
}
/**
* Remove a specific address assignment by its ID.
*
* @param int $assignmentId
* @return bool
*/
public function removeAddressAssignment(int $assignmentId): bool
{
return (bool) $this->addressAssignments()->where('id', $assignmentId)->delete();
}
/**
* Remove all assignments for a specific role.
*
* @param string $role
* @return int Number of assignments removed
*/
public function removeAssignmentsForRole(string $role): int
{
return $this->addressAssignments()->where('role', $role)->delete();
}
/**
* Remove all address assignments from this model.
*
* @return int Number of assignments removed
*/
public function removeAllAddressAssignments(): int
{
return $this->addressAssignments()->delete();
}
/*
|--------------------------------------------------------------------------
| Querying
|--------------------------------------------------------------------------
*/
/**
* Get the assignment for a specific role.
*
* @param string $role
* @return AddressAssignment|null
*/
public function addressAssignmentForRole(string $role): ?AddressAssignment
{
return $this->addressAssignments()
->where('role', $role)
->with('addressLink.address')
->first();
}
/**
* Get all assignments for a specific role.
*
* @param string $role
* @return Collection<AddressAssignment>
*/
public function addressAssignmentsForRole(string $role): Collection
{
return $this->addressAssignments()
->where('role', $role)
->with('addressLink.address')
->get();
}
/**
* Get the address for a specific assignment role (convenience shortcut).
*
* @param string $role
* @return Address|null
*/
public function assignedAddressForRole(string $role): ?Address
{
$assignment = $this->addressAssignmentForRole($role);
return $assignment?->addressLink?->address;
}
/**
* Get all addresses assigned to this model (through their links).
*
* @return Collection<Address>
*/
public function assignedAddresses(): Collection
{
return $this->addressAssignments()
->with('addressLink.address')
->get()
->map(fn(AddressAssignment $a) => $a->addressLink?->address)
->filter()
->values();
}
/**
* Check whether this model has any address assignments.
*/
public function hasAddressAssignments(): bool
{
return $this->addressAssignments()->exists();
}
/**
* Check whether this model has an assignment for the given role.
*
* @param string $role
*/
public function hasAssignmentForRole(string $role): bool
{
return $this->addressAssignments()->where('role', $role)->exists();
}
}

283
src/Traits/HasAddresses.php Normal file
View File

@ -0,0 +1,283 @@
<?php
namespace Blax\Addresses\Traits;
use Blax\Addresses\Enums\AddressLinkType;
use Blax\Addresses\Models\Address;
use Blax\Addresses\Models\AddressLink;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
/**
* Adds address management capabilities to any Eloquent model.
*
* Usage:
* class Customer extends Model {
* use \Blax\Addresses\Traits\HasAddresses;
* }
*
* $customer->addAddress([], AddressLinkType::Office);
* $customer->addresses; // all linked addresses
* $customer->addressesOfType('billing'); // filtered by type
*/
trait HasAddresses
{
/*
|--------------------------------------------------------------------------
| Relationships
|--------------------------------------------------------------------------
*/
/**
* All address links for this model (polymorphic one-to-many through pivot).
*
* Access the actual Address via `$link->address`.
*
* @return \Illuminate\Database\Eloquent\Relations\MorphMany
*/
public function addressLinks()
{
return $this->morphMany(
config('addresses.models.address_link', AddressLink::class),
'addressable'
);
}
/**
* All addresses attached to this model (many-to-many through address_links).
*
* Pivot columns (type, label, is_primary, active_from, active_until, meta)
* are included automatically.
*
* @return \Illuminate\Database\Eloquent\Relations\MorphToMany
*/
public function addresses()
{
$linkTable = config('addresses.table_names.address_links', 'address_links');
return $this->morphToMany(
config('addresses.models.address', Address::class),
'addressable',
$linkTable,
)->withPivot('id', 'type', 'label', 'is_primary', 'active_from', 'active_until', 'meta')
->withTimestamps();
}
/*
|--------------------------------------------------------------------------
| Adding / attaching
|--------------------------------------------------------------------------
*/
/**
* Create a new address and link it to this model in one step.
*
* @param array $attributes Address attributes (street, city, )
* @param AddressLinkType|string|null $type Purpose of the link (default from config)
* @param array $pivot Extra pivot data (label, is_primary, active_from, active_until, meta)
* @return AddressLink The created link (with address relation loaded)
*/
public function addAddress(array $attributes, AddressLinkType|string|null $type = null, array $pivot = []): AddressLink
{
$addressModel = config('addresses.models.address', Address::class);
/** @var Address $address */
$address = $addressModel::create($attributes);
return $this->linkAddress($address, $type, $pivot);
}
/**
* Link an existing address to this model.
*
* @param Address|int $address Address model or ID
* @param AddressLinkType|string|null $type Purpose of the link
* @param array $pivot Extra pivot data
* @return AddressLink The created link
*/
public function linkAddress(Address|int $address, AddressLinkType|string|null $type = null, array $pivot = []): AddressLink
{
$addressId = $address instanceof Address ? $address->id : $address;
$type = $type instanceof AddressLinkType
? $type->value
: ($type ?? config('addresses.default_link_type', AddressLinkType::Other)->value);
$linkModel = config('addresses.models.address_link', AddressLink::class);
/** @var AddressLink $link */
$link = $linkModel::create(array_merge([
'address_id' => $addressId,
'addressable_type' => $this->getMorphClass(),
'addressable_id' => $this->getKey(),
'type' => $type,
], $pivot));
$link->load('address');
return $link;
}
/*
|--------------------------------------------------------------------------
| Removing / detaching
|--------------------------------------------------------------------------
*/
/**
* Remove a specific address link by its pivot ID.
*
* This only removes the link the address itself is preserved.
*
* @param int $linkId The AddressLink ID
* @return bool
*/
public function removeAddressLink(int $linkId): bool
{
return (bool) $this->addressLinks()->where('id', $linkId)->delete();
}
/**
* Detach an address completely from this model (removes all links to it).
*
* The address record itself is preserved.
*
* @param Address|int $address Address model or ID
* @return int Number of links removed
*/
public function detachAddress(Address|int $address): int
{
$addressId = $address instanceof Address ? $address->id : $address;
return $this->addressLinks()->where('address_id', $addressId)->delete();
}
/**
* Remove all address links from this model.
*
* Address records remain untouched.
*
* @return int Number of links removed
*/
public function detachAllAddresses(): int
{
return $this->addressLinks()->delete();
}
/*
|--------------------------------------------------------------------------
| Querying
|--------------------------------------------------------------------------
*/
/**
* Get all addresses of a specific link type.
*
* @param AddressLinkType|string $type
* @return Collection<Address>
*/
public function addressesOfType(AddressLinkType|string $type): Collection
{
$value = $type instanceof AddressLinkType ? $type->value : $type;
return $this->addresses()
->wherePivot('type', $value)
->get();
}
/**
* Get all currently active address links.
*
* @return Collection<AddressLink>
*/
public function activeAddressLinks(): Collection
{
return $this->addressLinks()
->where(function ($q) {
$q->whereNull('active_from')
->orWhere('active_from', '<=', now());
})
->where(function ($q) {
$q->whereNull('active_until')
->orWhere('active_until', '>', now());
})
->with('address')
->get();
}
/**
* Get the primary address for a given type (or across all types if null).
*
* @param AddressLinkType|string|null $type
* @return Address|null
*/
public function primaryAddress(AddressLinkType|string|null $type = null): ?Address
{
$query = $this->addressLinks()
->where('is_primary', true);
if ($type !== null) {
$value = $type instanceof AddressLinkType ? $type->value : $type;
$query->where('type', $value);
}
/** @var AddressLink|null $link */
$link = $query->with('address')->first();
return $link?->address;
}
/**
* Check whether this model has any address linked.
*/
public function hasAddresses(): bool
{
return $this->addressLinks()->exists();
}
/**
* Check whether this model has an address of the given type.
*
* @param AddressLinkType|string $type
*/
public function hasAddressOfType(AddressLinkType|string $type): bool
{
$value = $type instanceof AddressLinkType ? $type->value : $type;
return $this->addressLinks()->where('type', $value)->exists();
}
/*
|--------------------------------------------------------------------------
| Updating
|--------------------------------------------------------------------------
*/
/**
* Set an address link as the primary for its type, unsetting any previous primary.
*
* @param int $linkId The AddressLink ID to promote
* @return bool
*/
public function setPrimaryAddressLink(int $linkId): bool
{
$linkModel = config('addresses.models.address_link', AddressLink::class);
/** @var AddressLink|null $link */
$link = $this->addressLinks()->where('id', $linkId)->first();
if (! $link) {
return false;
}
// Unset any existing primary for the same type on this model.
$this->addressLinks()
->where('type', $link->type instanceof AddressLinkType ? $link->type->value : $link->type)
->where('is_primary', true)
->update(['is_primary' => false]);
$link->update(['is_primary' => true]);
return true;
}
}

15
src/helpers.php Normal file
View File

@ -0,0 +1,15 @@
<?php
use Blax\Addresses\Services\AddressService;
if (! function_exists('address')) {
/**
* Get the AddressService singleton.
*
* @return AddressService
*/
function address(): AddressService
{
return app(AddressService::class);
}
}

File diff suppressed because it is too large Load Diff