Initial release
This commit is contained in:
commit
615da8c4c1
|
|
@ -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
|
||||||
|
|
@ -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/
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
[](https://github.com/blax-software)
|
||||||
|
|
||||||
|
# Laravel Addresses
|
||||||
|
|
||||||
|
[](https://php.net)
|
||||||
|
[](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
|
||||||
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
```
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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();
|
||||||
|
```
|
||||||
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
@ -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`.
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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'])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
Loading…
Reference in New Issue