sylius/addressing-bundle
Sylius AddressingBundle provides sensible, fully customizable models and forms for managing addresses, countries, provinces, and flexible zones that group geographical areas for tax and shipping rules in ecommerce applications.
Read this first: you cannot composer require this into a Laravel app. It is a Symfony2 bundle — Bundle class, DI service definitions, Doctrine ORM mappings, Symfony Form types, Twig views. There is no service provider, no Eloquent, no config publishing, and the repo is explicitly [READ-ONLY] (the code was folded into Sylius core). Treat it as a reference implementation you port by hand, not a dependency. The only things you consume are the domain model and the zone-matching semantics.
Where to look in the source, in order:
Models/ — Address, AddressInterface, Country, Province, Zone, ZoneMember. These define the entity boundaries and the fields you will recreate as migrations.Resources/config/doctrine/*.orm.xml — the actual column types, nullability, and foreign keys. This is your schema spec.Form/Type/ — the bundled address/country/province forms; useful as a baseline for checkout field requirements and as a checklist of validation rules (postcode, province requirement, etc.).Zone matching code — small, relational, and the genuinely valuable part.First concrete use case (a day of work): stand up three migrations — countries, provinces, zones + zone_members — with Eloquent models mirroring the Doctrine relationships. Seed ISO 3166 data from a maintained source (not the bundle's fixtures, which are stale). Then write a ~40-line ZoneMatcher service that takes an address and returns the zones it belongs to by country and/or province. That service is the whole payoff: it decouples "where is this address" from "what tax/shipping rule applies."
Port the schema, not the ORM. Translate the Doctrine mappings into Laravel migrations directly:
countries: id, code (ISO alpha-2), name, timestamps. Keep the code as the stable business key.provinces: id, country_id (FK), code, name. Nullable in spirit — many countries have no subdivisions, so don't force a province on every address.zones: id, name, type/scope if you need to distinguish shipping vs. tax zones.zone_members: id, zone_id, nullable country_id, nullable province_id. A membership row targets either a country or a province — model this as two nullable FKs with a check that exactly one is set, rather than a polymorphic morph, so joins stay simple and indexed.addresses: id, country_id, province_id (nullable), first_name, last_name, street, city, postcode, phone, plus a type discriminator if you follow the bundle's "split into multiple address models" pattern.Zone matching as a domain service, not model scope creep. Implement it as a dedicated class (App\Services\ZoneMatcher) with one method: given an Address, return a collection of matching Zone IDs. Query zone_members where country_id = address.country_id OR province_id = address.province_id, distinct the zone IDs, done. Keeping this out of the model means you can cache it (Cache::remember keyed on country/province pair) and swap the implementation later without touching call sites.
Extend the address model with columns, not subclassing. The bundle lets you "add custom fields to your address entity or split it into several models for different address types." In Laravel, prefer the pragmatic version: real columns for anything you filter/sort/report on (company, tax_id, delivery_notes), and a JSON meta column only for genuinely freeform, never-queried attributes. Splitting billing and shipping into separate tables is almost always a mistake — add an addresses.type enum or a billing_address_id/shipping_address_id pair on the owning order/customer instead.
Billing vs. shipping. The bundle's guidance ("split into several models to handle different address types") maps cleanly to: one addresses table, two foreign keys on the order (billing_address_id, shipping_address_id). This keeps de-duplication trivial and avoids parallel validation rules for two nearly identical entities.
Reuse the form logic as validation rules. Translate the Symfony Form constraints into Laravel FormRequest rules — the bundle's forms encode real-world quirks (postcode formats, required-vs-optional province per country) that are easy to get wrong. Mirror those into a StoreAddressRequest and, importantly, make province/state required conditional on the selected country.
Reference data as a seeded table, versioned by migration. Import ISO 3166 countries and subdivisions from a maintained dataset into a seeder or CSV-backed migration. Keep it separate from addresses so you can refresh it without touching user data.
composer.json constraints on symfony/framework-bundle, doctrine/orm, and php will collide with a modern Laravel install (Symfony 6/7). Don't spike it expecting it to work — verify constraints once, then move on.countries and reference by ID from addresses. Comparing on country name strings is a classic source of mismatches ("USA" vs "United States" vs "US").province_id will force garbage data.zone_members on both country_id and province_id (plus zone_id). Zone matching runs on every cart/checkout price and shipping calculation — an unindexed scan there is a latency bug waiting to surface under load.address.country and address.province are two lazy relations rendered on every checkout summary — eager-load both (with(['country', 'province'])) on the order/address query.ZoneMatcher interface (one method, match(Address): Collection) so a future rules engine — tax-by-region, shipping-by-band — can be layered on without rewriting call sites. That interface is the portable idea; the bundle's DI config is not.(country_id, province_id) pair from the address first — the bug is almost always at the address→geography mapping, not in the zone membership query itself.How can I help you explore Laravel packages today?