Search by

hasyirin / laravel-address

hasyirin

Polymorphic address management for Laravel with hierarchical geographical data and built-in Malaysian dataset.

Package info

github.com/hasyirin/laravel-address

pkg:composer/hasyirin/laravel-address

Statistics

Installs: 1 126

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v5.3.1 2026-11-04 18:04 UTC

README

Latest Version on Packagist GitHub Tests Action Status GitHub Code Style Action Status Total Downloads

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:

  1. Recipient
  2. House, lot, or floor number, and building
  3. Number and street, or district
  4. Postcode and town, for example 40000 Shah Alam
  5. State, for mail from outside Malaysia
  6. 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:

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

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.