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.