hasyirin / laravel-address
Polymorphic address management for Laravel with hierarchical geographical data and built-in Malaysian dataset.
Requires
- php: ^8.4
- illuminate/contracts: ^13.35
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.14
- laravel/pint: ^1.32
- nunomaduro/collision: ^8.9.5
- orchestra/testbench: ^11.3
- pestphp/pest: ^5.3.5
- pestphp/pest-plugin-arch: ^5.0
- pestphp/pest-plugin-laravel: ^5.0.1
- phpstan/extension-installer: ^1.4.3
- phpstan/phpstan-deprecation-rules: ^2.0.5
- phpstan/phpstan-phpunit: ^2.1.1
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A Laravel package for managing addresses with polymorphic relationships and hierarchical geographical data (countries, states, districts, subdistricts, post offices, and postcodes).
Comes with built-in geographical data for Malaysia and a seeder command to populate reference tables.
Requirements
- PHP 8.4+
- Laravel 13.35+
Installation
Install the package via Composer:
composer require hasyirin/laravel-address
Publish and run the migrations:
php artisan vendor:publish --tag="address-migrations"
php artisan migrate
Optionally publish the config file:
php artisan vendor:publish --tag="address-config"
Seeding Geographical Data
Seed countries, states, districts, subdistricts, post offices, and postcodes:
php artisan address:seed
The command runs in one transaction, so a failure writes nothing. It compares each level with the database and writes only new or changed rows, so you can run it again to apply data updates, and a re-seed of unchanged data writes nothing. It follows the files down from countries.json and reads each level only for the parents listed in the level above.
Rows match on code. Post offices match by name within a state. Matching is exact on PostgreSQL and SQLite, and ignores case on MySQL and MariaDB.
Soft-deleted rows are updated and stay deleted. The unique index also covers soft-deleted rows, so an app restores a trashed post office instead of creating a new one with the same name. The command never deletes rows, so a row that leaves the data stays in the database.
Model events and observers do not run during the seed. The command clears the local() results of its own process when it finishes.
The bundled subdistrict data is partial: it covers Negeri Sembilan and the Kemaman district of Terengganu. The post office and postcode data comes from AsyrafHussin/malaysia-postcodes. data/NOTICE.md lists the data sources and their licenses.
To use your own data, publish the bundled files and edit them:
php artisan vendor:publish --tag="address-data"
The command reads from database/data/address when that directory contains countries.json. Otherwise, it uses the package's bundled data.
Usage
Preparing Your Model
Add the InteractsWithAddresses trait to any model that should have addresses:
use Hasyirin\Address\Concerns\InteractsWithAddresses; use Hasyirin\Address\Contracts\Addressable; class User extends Model implements Addressable { use InteractsWithAddresses; }
Creating Addresses
address() takes the address type, primary by default, as a string or an enum. It sets type for you when you call create(), make(), save(), or forceCreate() on it:
$user->address()->create([ 'line_1' => '123 Main Street', 'line_2' => 'Suite 4B', 'line_3' => 'Taman Example', 'postcode' => '50000', 'country_id' => $country->id, 'state_id' => $state->id, 'post_office_id' => $postOffice->id, 'latitude' => 3.1390, 'longitude' => 101.6869, 'properties' => ['notes' => 'Front gate access'], ]); $user->address('billing')->create([ 'line_1' => '456 Side Street', 'postcode' => '50000', ]);
Using the Factory
The Address model includes a factory for testing:
use Hasyirin\Address\Models\Address; Address::factory() ->for($user, 'addressable') ->create([ 'type' => 'primary', 'line_1' => 'No. 1, Jalan Test', 'postcode' => '40000', ]);
Retrieving Addresses
// Primary address (returns a default empty instance if none exists) $user->address; // All addresses $user->addresses; // Address by type $user->address('billing')->first();
Querying by Type
The ofType scope accepts a string, array, or enum:
use Hasyirin\Address\Models\Address; Address::ofType('shipping')->get(); Address::ofType(['billing', 'shipping'])->get(); Address::ofType(AddressType::Billing)->get();
Formatting
// The non-empty parts of the address as a list $address->lines(); // ['123 Main Street', 'Suite 4B', 'Taman Example', '50000 Kuala Lumpur', 'Selangor', 'Malaysia'] // The same parts as one string, comma-separated by default $address->formatted(); // "123 Main Street, Suite 4B, Taman Example, 50000 Kuala Lumpur, Selangor, Malaysia" // Another separator $address->formatted("\n"); // Exclude state or country $address->lines(state: false); $address->formatted(country: false); // Uppercase $address->formatted(capitalize: true);
Both return plain text. To show an address on several lines in Blade, loop over lines(), so that Blade escapes each value:
@foreach ($address->lines() as $line) <p>{{ $line }}</p> @endforeach
Eager load postOffice, state, and country before you format many addresses.
When an address is saved, each line is squished and loses its trailing commas, and postcode is squished and uppercased. A line or postcode that ends up blank is stored as null. Normalize request input the same way before validation, for example in prepareForValidation().
Copying an Address
Create an unsaved copy of an address (without the polymorphic relationship, type, or deleted_at):
$copy = $address->copy(); $copy->type = 'billing'; $copy->addressable()->associate($anotherModel); $copy->save();
Locality
Designate a "local" country, state, and district by setting their codes in config/address.php:
'locality' => [ 'country' => 'MYS', 'state' => '14', 'district' => '02', ],
Resolve them anywhere via the static helper — it enforces the parent chain, so a state only counts as local if its country also matches the configured code:
Country::local(); // ?Country State::local(); // ?State — matches state code AND parent country District::local(); // ?District — matches district code AND parent state AND parent country
If any level is null in the config, the corresponding local() returns null.
Compose with the local() query scope:
District::query()->local()->where(...)->get();
local() keeps its result for the current request or queue job, and Laravel discards it after each one. The result is kept per configured codes, connection, and database, so a config change or a tenant switch resolves the row again. When the code of a level or of a parent level is null, local() returns null without a query. A configured code with no row is never kept, so it runs one query per call.
Any Eloquent save, delete, increment(), or decrement() of a Country, State, or District clears the kept results, and so does a rollback of a transaction that saved one. address:seed clears them too. Long-running processes, such as commands and daemons, keep a result until they save one of these models or call clearLocalCache().
Writes that skip model events, such as query builder updates, upsert(), or saveQuietly(), do not clear the kept results. If the same request reads local() after such a write, clear them first:
Country::clearLocalCache();
Postcodes
Each PostOffice has many Postcode rows. One postcode can belong to more than one post office, because a postcode can cover more than one town.
Find the post office and state of a postcode:
use Hasyirin\Address\Models\Postcode; Postcode::query()->where('code', '50000')->with('postOffice.state.country')->get();
Validate that a postcode belongs to a post office:
use Illuminate\Validation\Rule; Rule::exists(config('address.models.postcode'), 'code') ->where('post_office_id', $postOfficeId) ->withoutTrashed();
This rule checks the postcode only. A postcode stays active when its post office is soft-deleted, so validate post_office_id with its own rule, for example Rule::exists(config('address.models.post-office'), 'id')->withoutTrashed().
Postcode::code is squished and uppercased when it is set. latitude and longitude are empty until your app fills them. Geocoding an address stays your app's job; store the result in latitude and longitude on the Address.
The unique index on post_office_id and code also covers soft-deleted rows. To bring back a trashed postcode, restore it instead of creating it again:
Postcode::withTrashed()->where('post_office_id', $postOfficeId)->where('code', '50000')->restore();
Malaysian Address Format
A Malaysian address has these lines, from top to bottom:
- Recipient
- House, lot, or floor number, and building
- Number and street, or district
- Postcode and town, for example
40000 Shah Alam - State, for mail from outside Malaysia
- Country, for mail from outside Malaysia
The postcode has 5 digits and comes before the town. The first 2 digits give the delivery area, and the last 3 give the delivery office.
The UPU standard calls the line after the postcode "town", with the national names "City" and "Bandar". Its value is the name of the post office that handles the mail, not the municipal name. For example, Simpang Pulai addresses use 31300 Kampung Kepayang, not Ipoh. That is why the package calls the model PostOffice. In a form, label the field "City" or "Bandar".
A post office near a state border can serve an area in another state. That area then uses the postcode of that post office.
Sources:
- Malaysia postcode and address format, UPU, supplied by Pos Malaysia, February 2010
- S42 standardized address format for Malaysia, UPU, approved 2012
- Addresses in Malaysia and Postal codes in Malaysia, Wikipedia
- Postcode dataset, MCMC, on data.gov.my
data/NOTICE.md lists where the bundled data comes from.
Configuration
// config/address.php return [ // Codes used to resolve `Country::local()`, `State::local()`, `District::local()` 'locality' => [ 'country' => null, // e.g. 'MYS' 'state' => null, // e.g. '14' 'district' => null, // e.g. '02' ], // Override model classes 'models' => [ 'address' => \Hasyirin\Address\Models\Address::class, 'country' => \Hasyirin\Address\Models\Country::class, 'state' => \Hasyirin\Address\Models\State::class, 'district' => \Hasyirin\Address\Models\District::class, 'subdistrict' => \Hasyirin\Address\Models\Subdistrict::class, 'post-office' => \Hasyirin\Address\Models\PostOffice::class, 'postcode' => \Hasyirin\Address\Models\Postcode::class, ], // Override table names 'tables' => [ 'addresses' => 'addresses', 'countries' => 'countries', 'states' => 'states', 'districts' => 'districts', 'subdistricts' => 'subdistricts', 'post_offices' => 'post_offices', 'postcodes' => 'postcodes', ], ];
Data Structure
The package uses a hierarchical location model:
Country
└── State
├── District
│ └── Subdistrict
└── Post Office
└── Postcode
Each Address optionally belongs to a Country, State, District, Subdistrict, and PostOffice, and is polymorphically attached to any model via the addressable morph relationship. These relations include soft-deleted places, so a retired post office still shows on old addresses. whereHas() and has() on these relations include them too. To leave them out, add withoutTrashed():
Address::whereHas('postOffice', fn ($query) => $query->withoutTrashed())->get();
Models
| Model | Key Fields |
|---|---|
Country |
code (ISO 3166-1 alpha-3), alpha_2, name |
State |
code, name, belongs to Country |
District |
code, name, belongs to State |
Subdistrict |
code, name, belongs to District |
PostOffice |
name, belongs to State, has many Postcode |
Postcode |
code, latitude, longitude, belongs to PostOffice |
Address |
type, line_1-line_3, postcode, latitude, longitude, properties (JSON), belongs to Country, State, District, Subdistrict, PostOffice |
All models use soft deletes.
Extending Models
To use your own model classes, extend the package models and update the config:
use Hasyirin\Address\Models\Address as BaseAddress; class Address extends BaseAddress { // your customizations }
// config/address.php 'models' => [ 'address' => \App\Models\Address::class, // ... ],
Testing
composer test
Changelog
Please see CHANGELOG for more information on what has changed recently.
Security Vulnerabilities
Please review our security policy on how to report security vulnerabilities.
Credits
- Hasyirin Fakhriy
- Asyraf Hussin, for the Malaysian postcode data
- Malaysian Communications and Multimedia Commission, for the postcodes from MCMC, under CC BY 4.0
- Luke Duncalfe, for the country data
- Muhammad Syafiq Zainuddin, for the district and subdistrict data
- All Contributors
License
The MIT License (MIT). Please see License File for more information.
The bundled data keeps the licenses of its sources. In particular, data/countries.json is under CC BY-SA 4.0. See data/NOTICE.md.