Addresses
Addresses look easy until you try to own them. Line order differs by country, the postal code moves, a construction site has GPS coordinates and no street, and the only reliable way to know an address is real is to ask someone who sells that answer. So the SDK owns the part that is genuinely its own — the shape — and declares seams for the two parts that are not: validating an address, and rendering it.

An address belongs to a partner
There is no sdk_addresses table and no shared address entity. An address is stored as an attribute of exactly one partner in sdk_partner_addresses, and it is never shared across partners.
That is a decision, not an oversight. A shared address invites the question "who else uses this row?" before every edit, and the answer is always the same in practice: nobody should. Two partners at the same street address hold two rows, and each can correct its own without touching the other.
Deleting a partner cascades its addresses away with it.
The shape
NormalizedAddress is the lingua franca of the whole domain — the one type all three layers speak:
final readonly class NormalizedAddress
{
public function __construct(
public ?string $addressLine1,
public ?string $addressLine2,
public ?string $city,
public ?string $stateProvinceRegion,
public ?string $postalCode,
public string $countryCode, // ISO 3166-1 alpha-2 — always known
public bool $hasNoPostalAddress = false,
public bool $isCommercial = false,
public ?string $formattedAddress = null, // the provider's own canonical string
public ?int $providedBy = null, // sdk_address_providers.code (null = typed by hand)
public ?string $providerId = null, // opaque provider place id
public ?float $latitude = null,
public ?float $longitude = null,
) {}
public function hasStructuredAddress(): bool; // line 1 + city present
}A vendor adapter maps into it. A stored row produces one via ->toNormalizedAddress(). A formatter consumes one. So a fresh suggestion from a lookup and a row saved three years ago render through the identical path.
"Normalized" is the shape, not the provenance — an address someone typed by hand is carried in exactly these fields, with providedBy left null.
Either structured, or explicitly not
One invariant runs through the whole domain: a row is valid when it has the structured triple (address_line_1, city, country_code), or when it says has_no_postal_address and carries a formatted_address plus both coordinates. Nothing else. The second branch is for real things — a construction site, a plot that has no street number yet, a delivery point you can only describe.
The coordinates are not decoration on that branch, they are what makes it usable. A row with no postal identity and no point on the map is not a location, it is a note: nothing can route to it, nothing can map it, nothing can plan against it. So the two travel together — both or neither, a single coordinate is refused.
country_code is the one field that is always known. Coordinates stay optional on the structured branch, where the address itself is the identity — but the pairing holds there too: a real address may carry the point its driver needs, and half a point is refused wherever it appears.
Two seams
Validation — you bring the vendor
interface AddressProviderInterface
{
public function code(): int; // catalog code
public function lookup(string $query, ?string $countryCode = null): array; // type-ahead
public function fetch(string $providerId): NormalizedAddress; // resolve a pick
}The SDK ships no adapter. Google, Radar, Mapbox, or a national register like Statistik Austria — all of them are licensed, and bought, never owned means none of them enters the SDK's composer.json. A national register is simply a provider whose "API" is a local database query.
Until you bind one, resolving the port throws NoAddressProviderBoundException with a message that says so. Manual capture works without a provider — the exception fires only on the lookup path.
A provider also wants a catalog row so a stored address can say where it came from. That row is declared in code as a Customizing entry on the module that binds the adapter:
#[AddressProvider(code: 1, name: 'Statistik Austria')]
final class AcmeModule extends AbstractUi5Module { /* … */ }ui5:sync lands it in sdk_address_providers. The table is empty on a fresh install, because the SDK ships no adapter to catalog. Note that sdk_partner_addresses.provided_by is a neutral reference, not an enforced foreign key: a captured address survives its provider being synced away.
Rendering — a default you can beat
interface AddressFormatterInterface
{
public function supports(string $countryCode): bool;
public function format(NormalizedAddress $address): AddressString;
}The formatter is chosen from the address's country_code, never picked in the UI. The AddressFormatterRegistry walks the registered formatters in registration order and returns the first that supports() the code, falling back to DefaultAddressFormatter.
That default is honest about itself: a sane Western/ISO line order, good enough for anything without a dedicated formatter, and it renders the country as its ISO code rather than pretending to own a name table. Worldwide postal correctness is exactly the burden the seam exists to avoid.
// in your service provider's boot()
$this->app->make(AddressFormatterRegistry::class)->register(new AustrianAddressFormatter());The output is an AddressString — a Stringable value object carrying already-broken lines, so a card, an envelope-window layout and a one-line label all take correct line breaking from the same source (lines(), oneLine(', '), or cast to string for the newline form).
Writing an address
One path writes sdk_partner_addresses, wherever the write comes from:
$this->saveAddress->save($partnerId, $normalized, AddressType::Primary, $existingId);SaveAddress does three things that are easy to get wrong separately:
- Derives the validation status from the provenance.
providedByset →Validated; null →Unverified. Status is a consequence of how the row was captured, never something a caller asserts. - Renders the formatted string once and caches it — the provider's own canonical string if it carried one, else the country-derived formatter's output at save time. An improved formatter later never retro-edits a printed-envelope snapshot.
- Fires the model's invariant guards — either/or capture, the coordinate pairing (both columns or neither, on either branch; present is what the free-text branch additionally requires), and at most one
Primaryper partner.
All three are write-path guards on the Eloquent model, because none is expressible in the shared MySQL schema — there is no partial unique index for "one Primary per partner", and no conditional NOT NULL for "coordinates when there is no postal address". They throw IncompleteAddressException, MissingCoordinatesException and DuplicatePrimaryAddressException; the shipped action turns all three into a clean 422. A raw DB::table() insert bypasses them — the same acknowledged residue as elsewhere in the Partners domain.
Kind, not purpose
AddressType says what kind of postal point a row is — and nothing about what it is used for:
Primary | The partner's principal / identity address. At most one. |
PoBox | A post-office box — mail only, no physical presence. |
CareOf | A c/o correspondence address, delivered via a third party. |
Mailing | An alternative postal address that is neither of those. |
Deliberately not in this enum: Billing and ShipTo. Those are a purpose — a functional role on a party, which can change per order and per contract — and mixing a purpose axis into a kind enum is the modelling mistake this list exists to avoid. The legal seat is not here either: it is the registered_office scalar on sdk_partners.
What ships, and what does not
The Partners app captures addresses on a partner's detail page and lists them with their type and validation status. It is a real, working master-data surface — and it exercises a deliberate subset of the domain. Know where the edge is:
- The form captures both branches, and no provider. A radio group above the fields writes
has_no_postal_address: one branch takes line 1/2, city, region and postal code, the other takes the free-text description. Kind, country, coordinates and the commercial flag stand above the switch, so a real address can carry its point too. The provider fields are the part with no shipped UI —provided_byandprovider_idare reachable from your own code throughSaveAddress, not from the console, which is why a row the console writes isUnverifiedby construction. Editing a provider-backed row there demotes it toUnverifiedand drops its provider id: a human has retyped what the provider said, and the row should no longer claim otherwise. - No map picker. Coordinates are two number fields. A pin on a map means a map vendor, and the SDK binds none — that is your adapter's half of the seam, not ours.
- Every address captured through the shipped app is
Unverified, because no provider adapter ships to make it anything else.Validatedappears as soon as you bind one;Suspectis reserved for a provider that flags an address as questionable, and nothing in the SDK produces it today.
The storage, the invariants, the formatter chain and the write path are exercised end to end by the test suite. The vendor half is waiting for your vendor.