Skip to content

Custom Models ​

This guide shows how to extend Laravel Nusa with custom models and integrate Indonesian administrative data into your application's domain models.

Extending Base Models ​

Custom Province Model ​

php
namespace App\Models;

use Creasi\Nusa\Models\Province as BaseProvince;

class Province extends BaseProvince
{
    // Add custom attributes
    protected $appends = ['region_name', 'is_java'];
    
    // Custom accessor
    public function getRegionNameAttribute(): string
    {
        $regions = [
            'Sumatra' => ['11', '12', '13', '14', '15', '16', '17', '18', '19', '21'],
            'Java' => ['31', '32', '33', '34', '35', '36'],
            'Kalimantan' => ['61', '62', '63', '64', '65'],
            'Sulawesi' => ['71', '72', '73', '74', '75', '76'],
            'Eastern Indonesia' => ['81', '82', '91', '94', '95', '96'],
        ];
        
        foreach ($regions as $region => $codes) {
            if (in_array($this->code, $codes)) {
                return $region;
            }
        }
        
        return 'Unknown';
    }
    
    // Custom accessor
    public function getIsJavaAttribute(): bool
    {
        return in_array($this->code, ['31', '32', '33', '34', '35', '36']);
    }
    
    // Custom scope
    public function scopeJava($query)
    {
        return $query->whereIn('code', ['31', '32', '33', '34', '35', '36']);
    }
    
    // Custom scope
    public function scopeOutsideJava($query)
    {
        return $query->whereNotIn('code', ['31', '32', '33', '34', '35', '36']);
    }
    
    // Custom method
    public function getPopulationDensityCategory(): string
    {
        // This would require additional population data
        if ($this->is_java) {
            return 'High';
        }
        
        return 'Medium'; // Simplified logic
    }
}

// Usage
$javaProvinces = Province::java()->get();
$outsideJava = Province::outsideJava()->get();

foreach ($javaProvinces as $province) {
    echo "{$province->name} is in {$province->region_name}";
}

Custom Address Model ​

php
namespace App\Models;

use Creasi\Nusa\Models\Address as BaseAddress;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Address extends BaseAddress
{
    protected $fillable = [
        'user_id',
        'name',
        'phone',
        'province_code',
        'regency_code',
        'district_code',
        'village_code',
        'address_line',
        'postal_code',
        'is_default',
        // Custom fields
        'label',           // 'Home', 'Office', 'Other'
        'notes',           // Additional notes
        'latitude',        // Custom coordinates
        'longitude',
        'is_verified',     // Address verification status
        'delivery_notes',  // Special delivery instructions
    ];
    
    protected $casts = [
        'is_default' => 'boolean',
        'is_verified' => 'boolean',
        'latitude' => 'decimal:8',
        'longitude' => 'decimal:8',
    ];
    
    protected $appends = ['formatted_label', 'distance_from_center'];
    
    // Custom accessor
    public function getFormattedLabelAttribute(): string
    {
        return $this->label ? ucfirst($this->label) : 'Address';
    }
    
    // Custom accessor with calculation
    public function getDistanceFromCenterAttribute(): ?float
    {
        if (!$this->latitude || !$this->longitude || !$this->village) {
            return null;
        }
        
        return $this->calculateDistance(
            $this->latitude,
            $this->longitude,
            $this->village->latitude,
            $this->village->longitude
        );
    }
    
    // Custom scopes
    public function scopeVerified($query)
    {
        return $query->where('is_verified', true);
    }
    
    public function scopeByLabel($query, $label)
    {
        return $query->where('label', $label);
    }
    
    public function scopeWithinRadius($query, $lat, $lon, $radiusKm)
    {
        return $query->whereNotNull('latitude')
            ->whereNotNull('longitude')
            ->whereRaw("
                (6371 * acos(
                    cos(radians(?)) * 
                    cos(radians(latitude)) * 
                    cos(radians(longitude) - radians(?)) + 
                    sin(radians(?)) * 
                    sin(radians(latitude))
                )) <= ?
            ", [$lat, $lon, $lat, $radiusKm]);
    }
    
    // Custom methods
    public function markAsVerified(): void
    {
        $this->update(['is_verified' => true]);
    }
    
    public function updateCoordinates(float $lat, float $lon): void
    {
        $this->update([
            'latitude' => $lat,
            'longitude' => $lon,
        ]);
    }
    
    private function calculateDistance($lat1, $lon1, $lat2, $lon2): float
    {
        $earthRadius = 6371; // km
        
        $dLat = deg2rad($lat2 - $lat1);
        $dLon = deg2rad($lon2 - $lon1);
        
        $a = sin($dLat / 2) * sin($dLat / 2) +
             cos(deg2rad($lat1)) * cos(deg2rad($lat2)) *
             sin($dLon / 2) * sin($dLon / 2);
             
        $c = 2 * atan2(sqrt($a), sqrt(1 - $a));
        
        return $earthRadius * $c;
    }
}

// Usage
$homeAddresses = Address::byLabel('home')->get();
$verifiedAddresses = Address::verified()->get();
$nearbyAddresses = Address::withinRadius(-6.200000, 106.816666, 10)->get();

Domain-Specific Models ​

Store Model with Location ​

php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Creasi\Nusa\Contracts\HasAddress;
use Creasi\Nusa\Models\Concerns\WithAddress;

class Store extends Model implements HasAddress
{
    use WithAddress;
    
    protected $fillable = [
        'name',
        'description',
        'category',
        'phone',
        'email',
        'website',
        'opening_hours',
        'province_code',
        'regency_code',
        'district_code',
        'village_code',
        'address_line',
        'postal_code',
        'latitude',
        'longitude',
        'is_active',
    ];
    
    protected $casts = [
        'opening_hours' => 'array',
        'is_active' => 'boolean',
        'latitude' => 'decimal:8',
        'longitude' => 'decimal:8',
    ];
    
    protected $appends = ['full_location', 'region'];
    
    // Custom accessor
    public function getFullLocationAttribute(): string
    {
        return $this->full_address;
    }
    
    public function getRegionAttribute(): string
    {
        if (!$this->province) {
            return 'Unknown';
        }
        
        $javaProvinces = ['31', '32', '33', '34', '35', '36'];
        return in_array($this->province_code, $javaProvinces) ? 'Java' : 'Outside Java';
    }
    
    // Scopes
    public function scopeActive($query)
    {
        return $query->where('is_active', true);
    }
    
    public function scopeInProvince($query, $provinceCode)
    {
        return $query->where('province_code', $provinceCode);
    }
    
    public function scopeInRegency($query, $regencyCode)
    {
        return $query->where('regency_code', $regencyCode);
    }
    
    public function scopeByCategory($query, $category)
    {
        return $query->where('category', $category);
    }
    
    public function scopeNearby($query, $lat, $lon, $radiusKm = 10)
    {
        return $query->whereNotNull('latitude')
            ->whereNotNull('longitude')
            ->whereRaw("
                (6371 * acos(
                    cos(radians(?)) * 
                    cos(radians(latitude)) * 
                    cos(radians(longitude) - radians(?)) + 
                    sin(radians(?)) * 
                    sin(radians(latitude))
                )) <= ?
            ", [$lat, $lon, $lat, $radiusKm]);
    }
    
    // Methods
    public function getDistanceFrom(float $lat, float $lon): ?float
    {
        if (!$this->latitude || !$this->longitude) {
            return null;
        }
        
        return $this->calculateDistance($lat, $lon, $this->latitude, $this->longitude);
    }
    
    private function calculateDistance($lat1, $lon1, $lat2, $lon2): float
    {
        $earthRadius = 6371;
        
        $dLat = deg2rad($lat2 - $lat1);
        $dLon = deg2rad($lon2 - $lon1);
        
        $a = sin($dLat / 2) * sin($dLat / 2) +
             cos(deg2rad($lat1)) * cos(deg2rad($lat2)) *
             sin($dLon / 2) * sin($dLon / 2);
             
        $c = 2 * atan2(sqrt($a), sqrt(1 - $a));
        
        return $earthRadius * $c;
    }
}

// Usage
$stores = Store::active()
    ->inProvince('33')
    ->byCategory('restaurant')
    ->with(['province', 'regency'])
    ->get();

$nearbyStores = Store::nearby(-6.200000, 106.816666, 5)
    ->active()
    ->get();

Delivery Zone Model ​

php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Creasi\Nusa\Models\{Province, Regency, District, Village};

class DeliveryZone extends Model
{
    protected $fillable = [
        'name',
        'type', // 'province', 'regency', 'district', 'village'
        'code', // Administrative code
        'delivery_fee',
        'estimated_days',
        'is_active',
        'notes',
    ];
    
    protected $casts = [
        'delivery_fee' => 'decimal:2',
        'estimated_days' => 'integer',
        'is_active' => 'boolean',
    ];
    
    // Dynamic relationships based on type
    public function administrativeRegion()
    {
        switch ($this->type) {
            case 'province':
                return $this->belongsTo(Province::class, 'code', 'code');
            case 'regency':
                return $this->belongsTo(Regency::class, 'code', 'code');
            case 'district':
                return $this->belongsTo(District::class, 'code', 'code');
            case 'village':
                return $this->belongsTo(Village::class, 'code', 'code');
            default:
                return null;
        }
    }
    
    // Scopes
    public function scopeActive($query)
    {
        return $query->where('is_active', true);
    }
    
    public function scopeByType($query, $type)
    {
        return $query->where('type', $type);
    }
    
    // Methods
    public function covers(string $villageCode): bool
    {
        $village = Village::find($villageCode);
        if (!$village) {
            return false;
        }
        
        switch ($this->type) {
            case 'province':
                return $village->province_code === $this->code;
            case 'regency':
                return $village->regency_code === $this->code;
            case 'district':
                return $village->district_code === $this->code;
            case 'village':
                return $village->code === $this->code;
            default:
                return false;
        }
    }
    
    public static function findForAddress(string $villageCode): ?self
    {
        $village = Village::find($villageCode);
        if (!$village) {
            return null;
        }
        
        // Check in order of specificity: village -> district -> regency -> province
        $zones = [
            ['type' => 'village', 'code' => $village->code],
            ['type' => 'district', 'code' => $village->district_code],
            ['type' => 'regency', 'code' => $village->regency_code],
            ['type' => 'province', 'code' => $village->province_code],
        ];
        
        foreach ($zones as $zone) {
            $deliveryZone = self::active()
                ->where('type', $zone['type'])
                ->where('code', $zone['code'])
                ->first();
                
            if ($deliveryZone) {
                return $deliveryZone;
            }
        }
        
        return null;
    }
}

// Usage
$deliveryZone = DeliveryZone::findForAddress('33.75.01.1002');
if ($deliveryZone) {
    echo "Delivery fee: Rp " . number_format($deliveryZone->delivery_fee);
    echo "Estimated delivery: {$deliveryZone->estimated_days} days";
}

Service Classes ​

Location Service ​

php
namespace App\Services;

use Creasi\Nusa\Models\{Province, Regency, District, Village};
use Illuminate\Support\Collection;

class LocationService
{
    public function getLocationHierarchy(string $villageCode): ?array
    {
        $village = Village::with(['district', 'regency', 'province'])
            ->find($villageCode);
            
        if (!$village) {
            return null;
        }
        
        return [
            'village' => [
                'code' => $village->code,
                'name' => $village->name,
                'postal_code' => $village->postal_code,
            ],
            'district' => [
                'code' => $village->district->code,
                'name' => $village->district->name,
            ],
            'regency' => [
                'code' => $village->regency->code,
                'name' => $village->regency->name,
            ],
            'province' => [
                'code' => $village->province->code,
                'name' => $village->province->name,
            ],
            'full_address' => $this->buildFullAddress($village),
        ];
    }
    
    public function findNearestRegency(float $lat, float $lon): ?Regency
    {
        $regencies = Regency::whereNotNull('latitude')
            ->whereNotNull('longitude')
            ->get();
            
        $nearest = null;
        $minDistance = PHP_FLOAT_MAX;
        
        foreach ($regencies as $regency) {
            $distance = $this->calculateDistance(
                $lat, $lon,
                $regency->latitude, $regency->longitude
            );
            
            if ($distance < $minDistance) {
                $minDistance = $distance;
                $nearest = $regency;
            }
        }
        
        return $nearest;
    }
    
    public function getRegionStatistics(): array
    {
        return [
            'provinces' => Province::count(),
            'regencies' => Regency::count(),
            'districts' => District::count(),
            'villages' => Village::count(),
            'java_provinces' => Province::whereIn('code', ['31', '32', '33', '34', '35', '36'])->count(),
            'cities' => Regency::where('name', 'like', '%Kota%')->count(),
            'regencies_proper' => Regency::where('name', 'like', '%Kabupaten%')->count(),
        ];
    }
    
    public function validateAddressHierarchy(array $addressData): bool
    {
        $village = Village::find($addressData['village_code']);
        
        if (!$village) {
            return false;
        }
        
        return $village->district_code === $addressData['district_code'] &&
               $village->regency_code === $addressData['regency_code'] &&
               $village->province_code === $addressData['province_code'];
    }
    
    private function buildFullAddress(Village $village): string
    {
        return implode(', ', array_filter([
            $village->name,
            $village->district->name,
            $village->regency->name,
            $village->province->name,
            $village->postal_code,
        ]));
    }
    
    private function calculateDistance(float $lat1, float $lon1, float $lat2, float $lon2): float
    {
        $earthRadius = 6371;
        
        $dLat = deg2rad($lat2 - $lat1);
        $dLon = deg2rad($lon2 - $lon1);
        
        $a = sin($dLat / 2) * sin($dLat / 2) +
             cos(deg2rad($lat1)) * cos(deg2rad($lat2)) *
             sin($dLon / 2) * sin($dLon / 2);
             
        $c = 2 * atan2(sqrt($a), sqrt(1 - $a));
        
        return $earthRadius * $c;
    }
}

// Usage
$locationService = new LocationService();

$hierarchy = $locationService->getLocationHierarchy('33.75.01.1002');
$nearestRegency = $locationService->findNearestRegency(-6.200000, 106.816666);
$stats = $locationService->getRegionStatistics();

Configuration ​

Custom Model Binding ​

php
// In your AppServiceProvider
use Creasi\Nusa\Contracts;
use App\Models\{Province, Address};

public function register()
{
    // Bind custom models
    $this->app->bind(Contracts\Province::class, Province::class);
    $this->app->bind(Contracts\Address::class, Address::class);
}

Custom Configuration ​

php
// config/creasi/nusa.php
return [
    'connection' => env('CREASI_NUSA_CONNECTION', 'nusa'),
    'addressable' => \App\Models\Address::class,
    'routes_enable' => env('CREASI_NUSA_ROUTES_ENABLE', true),
    'routes_prefix' => env('CREASI_NUSA_ROUTES_PREFIX', 'nusa'),
    
    // Custom settings
    'custom_models' => [
        'province' => \App\Models\Province::class,
        'store' => \App\Models\Store::class,
    ],
    
    'delivery' => [
        'default_fee' => 10000,
        'default_days' => 3,
    ],
];

These examples show how to extend Laravel Nusa with custom models that add business logic, additional attributes, and domain-specific functionality while maintaining the core administrative data relationships.

Released under the MIT License.