Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 97 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ The Address model's **own** primary key is independent of this — it stays a
### Address type enum

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

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

Expand Down Expand Up @@ -144,8 +144,9 @@ Add the `HasAddresses` trait to any model that should own addresses:

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

class User extends Model
class User extends Model implements Addressable
{
use HasAddresses;
}
Expand All @@ -172,6 +173,74 @@ $user->latestAddress;
$address->addressable;
```

## Creating addresses

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

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

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

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

Both forms return the persisted `Address` instance.

### `AddressData` — the write DTO

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

The lookup and schema.org DTOs provide typed bridges:

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

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

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

### Swapping the creation implementation

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

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

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

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

## schema.org mapping

The columns map to [schema.org/PostalAddress](https://schema.org/PostalAddress):
Expand All @@ -189,6 +258,32 @@ The columns map to [schema.org/PostalAddress](https://schema.org/PostalAddress):
`latitude` / `longitude` are stored as `decimal(10,8)` / `decimal(11,8)` and cast
to `decimal:8`.

### Emitting a `PostalAddress`

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

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

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

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

## Form validation rules

Three Laravel validation rules ship for validating address input (e.g. in a
Expand Down
1 change: 1 addition & 0 deletions src/AddressableServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public function register(): void

$this->app->singleton(Contracts\AddressRules::class, Services\AddressRules::class);
$this->app->singleton(Contracts\Countries::class, Services\Countries::class);
$this->app->singleton(Contracts\CreatesAddresses::class, Services\AddressCreator::class);

$this->app->singleton(Services\AddressLookupManager::class);
$this->app->singleton(Services\AddressValidationManager::class);
Expand Down
25 changes: 25 additions & 0 deletions src/App/Concerns/HasAddresses.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,19 @@

namespace Byte5\Addressable\App\Concerns;

use Byte5\Addressable\App\Contracts\Addressable;
use Byte5\Addressable\App\Contracts\CreatesAddresses;
use Byte5\Addressable\App\Data\AddressData;
use Byte5\Addressable\App\Enums\AddressType;
use Byte5\Addressable\App\Models\Address;
use Byte5\Addressable\App\Support\Config;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Relations\MorphOne;

/**
* @phpstan-require-extends Model
* @phpstan-require-implements Addressable
*/
trait HasAddresses
{
Expand Down Expand Up @@ -37,4 +43,23 @@ public function latestAddress(): MorphOne
Config::morphKey(),
)->latestOfMany();
}

/**
* Create and persist a new address for this model.
*
* Accepts either an AddressData DTO or a loose attribute array. The optional
* $type parameter overrides whatever type is already set on the data.
*/
public function addAddress(
AddressData|array $data,
AddressType|string|null $type = null,
): Address {
$data = $data instanceof AddressData ? $data : AddressData::fromArray($data);

if ($type !== null) {
$data = $data->withType($type);
}

return app(CreatesAddresses::class)->create($this, $data);
}
}
21 changes: 21 additions & 0 deletions src/App/Contracts/Addressable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace Byte5\Addressable\App\Contracts;

use Byte5\Addressable\App\Models\Address;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphMany;

/**
* A model that owns addresses. Implemented by the HasAddresses trait; declare
* `implements Addressable` on any model that uses the trait.
*/
interface Addressable
{
/**
* All addresses attached to this model.
*
* @return MorphMany<Address, Model>
*/
public function addresses(): MorphMany;
}
15 changes: 15 additions & 0 deletions src/App/Contracts/CreatesAddresses.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

namespace Byte5\Addressable\App\Contracts;

use Byte5\Addressable\App\Data\AddressData;
use Byte5\Addressable\App\Models\Address;
use Illuminate\Database\Eloquent\Model;

interface CreatesAddresses
{
/**
* Persist a new address for the given owner model.
*/
public function create(Model&Addressable $owner, AddressData $data): Address;
}
87 changes: 87 additions & 0 deletions src/App/Data/AddressData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php

namespace Byte5\Addressable\App\Data;

use BackedEnum;
use Byte5\Addressable\App\Enums\AddressType;

readonly class AddressData
{
public function __construct(
public AddressType|string|null $type = null,
public ?string $street = null,
public ?string $extra = null,
public ?string $postal = null,
public ?string $city = null,
public ?string $region = null,
public ?float $latitude = null,
public ?float $longitude = null,
public ?string $country = null,
) {}

/**
* Build from a loose attribute array (unknown keys ignored).
*
* @param array<string, mixed> $attributes
*/
public static function fromArray(array $attributes): self
{
$type = $attributes['type'] ?? null;

return new self(
type: $type instanceof AddressType ? $type : (is_string($type) ? $type : null),
street: isset($attributes['street']) && is_string($attributes['street']) ? $attributes['street'] : null,
extra: isset($attributes['extra']) && is_string($attributes['extra']) ? $attributes['extra'] : null,
postal: isset($attributes['postal']) && is_string($attributes['postal']) ? $attributes['postal'] : null,
city: isset($attributes['city']) && is_string($attributes['city']) ? $attributes['city'] : null,
region: isset($attributes['region']) && is_string($attributes['region']) ? $attributes['region'] : null,
latitude: isset($attributes['latitude']) && is_numeric($attributes['latitude'])
? (float) $attributes['latitude']
: null,
longitude: isset($attributes['longitude']) && is_numeric($attributes['longitude'])
? (float) $attributes['longitude']
: null,
country: isset($attributes['country']) && is_string($attributes['country']) ? $attributes['country'] : null,
);
}

/**
* Immutable copy with the address type set.
*/
public function withType(AddressType|string $type): self
{
return new self(
type: $type,
street: $this->street,
extra: $this->extra,
postal: $this->postal,
city: $this->city,
region: $this->region,
latitude: $this->latitude,
longitude: $this->longitude,
country: $this->country,
);
}

/**
* Column-ready array for `addresses()->create(...)`. `type` is normalised to
* its scalar backing value so it works whether or not the model casts `type`
* to an enum.
*
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'type' => $this->type instanceof BackedEnum ? $this->type->value : $this->type,
'street' => $this->street,
'extra' => $this->extra,
'postal' => $this->postal,
'city' => $this->city,
'region' => $this->region,
'latitude' => $this->latitude,
'longitude' => $this->longitude,
'country' => $this->country,
];
}
}
17 changes: 17 additions & 0 deletions src/App/Data/PlaceDetails.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace Byte5\Addressable\App\Data;

use Byte5\Addressable\App\Enums\AddressType;

readonly class PlaceDetails
{
public function __construct(
Expand Down Expand Up @@ -33,4 +35,19 @@ public function toArray(): array
'longitude' => $this->longitude,
];
}

public function toAddressData(AddressType|string|null $type = null): AddressData
{
return new AddressData(
type: $type,
street: $this->street,
extra: $this->extra,
postal: $this->postal,
city: $this->city,
region: $this->region,
latitude: $this->latitude,
longitude: $this->longitude,
country: $this->country,
);
}
}
61 changes: 61 additions & 0 deletions src/App/Data/PostalAddress.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

namespace Byte5\Addressable\App\Data;

use Byte5\Addressable\App\Enums\AddressType;

readonly class PostalAddress
{
public function __construct(
public ?string $street = null,
public ?string $extra = null,
public ?string $postal = null,
public ?string $city = null,
public ?string $region = null,
public ?string $country = null,
) {}

/**
* schema.org PostalAddress as a nestable fragment (`@type`, no `@context`).
* Null fields are omitted.
*
* @return array<string, string>
*/
public function toArray(): array
{
return array_filter([
'@type' => 'PostalAddress',
'streetAddress' => $this->street,
'extendedAddress' => $this->extra,
'postalCode' => $this->postal,
'addressLocality' => $this->city,
'addressRegion' => $this->region,
'addressCountry' => $this->country,
], fn (?string $value): bool => $value !== null);
}

/**
* Standalone JSON-LD document (includes `@context`), ready for a
* `<script type="application/ld+json">` tag.
*/
public function toJsonLd(): string
{
return json_encode(
['@context' => 'https://schema.org'] + $this->toArray(),
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
);
}

public function toAddressData(AddressType|string|null $type = null): AddressData
{
return new AddressData(
type: $type,
street: $this->street,
extra: $this->extra,
postal: $this->postal,
city: $this->city,
region: $this->region,
country: $this->country,
);
}
}
Loading