bensondevs/indonesian-ktp

Validate Indonesian NIK (Nomor Induk Kependudukan) using compiled wilayah data (cahyadsn/wilayah, MIT).

Maintainers

Package info

github.com/bensondevs/indonesian-ktp

pkg:composer/bensondevs/indonesian-ktp

Fund package maintenance!

bensondevs

Statistics

Installs: 10

Dependents: 0

Suggesters: 0

Stars: 23

Open Issues: 0

v0.1.2 2026-05-16 03:04 UTC

This package is auto-updated.

Last update: 2026-05-18 04:32:35 UTC


README

Validate Indonesian NIK (Nomor Induk Kependudukan) with structural checks and bundled wilayah data (no MySQL, no Nusantara). Public API: KTP.

Table of contents

What gets validated

  • Structure — length, digits, birth date / gender encoding.
  • Region hierarchy — district code in the NIK must exist in data/wilayah.php (province → regency → subdistrict).

Optional checks (birth, age, gender, wilayah names/codes): Usage. Dataset: Data source.

Invalid length or unknown district:

use Bensondevs\IndonesianKtp\KTP;

KTP::nik('123')->isValid(); // false — wrong length
KTP::nik('9999991501900001')->isValid(); // false — unknown district (no expectations)

Requirements

Requirement Notes
PHP 8.3+
Laravel 10–13; illuminate/contracts, illuminate/database, illuminate/support, illuminate/validation match your app
Carbon nesbot/carbon ^2.67 or ^3.0

Install 📦

composer require bensondevs/indonesian-ktp

Quick start ✅

Minimal check (structure + wilayah hierarchy):

use Bensondevs\IndonesianKtp\KTP;

KTP::nik('3315131501901235')->isValid();

Structure + region only until you add expectations (see Usage).

Usage

use Bensondevs\IndonesianKtp\Gender;
use Bensondevs\IndonesianKtp\KTP;

KTP::nik('3315131501901235')
    ->expectGender(Gender::Male)
    ->isValid();

Basic validation

use Bensondevs\IndonesianKtp\KTP;

KTP::nik('3315131501901235')->isValid();

Laravel Validator (rule object and ktp-nik)

With package discovery, IndonesianKtpServiceProvider registers translation lines and validation extensions automatically. You can validate request input either with a rule object or with a string rule.

Rule object vs string rule

use Bensondevs\IndonesianKtp\Rules\KtpNik;

$request->validate([
    'nik' => ['required', 'string', new KtpNik],
]);

// Equivalent string rule (underscore alias: ktp_nik)
$request->validate([
    'nik' => ['required', 'string', 'ktp-nik'],
]);

What this checks

Same as Plain KTP::nik($value)->isValid()structure (length, digits, birth/gender segment rules) plus complete wilayah hierarchy for the district code.
Does not include Chained expectations such as expectBirthDate, expectGender, expectProvince, age rules, etc. For those, use the fluent Query API — Expectations and aliases.

Composing with required / nullable

Use Laravel’s built-in rules for presence: required|string|… when the field must be present, or nullable|string|… when it is optional. The KtpNik rule does not fail on null or '', so optional fields stay easy to express without fighting the custom rule.

Input types

Integer or numeric string values are cast to string before validation. Arrays, objects, and booleans fail. In practice, pair the rule with Laravel’s string rule as in the examples above.

Messages and localization

The default English message lives under the indonesian-ktp namespace (validation.ktp_nik). Override or translate it like any vendor lang line (for example files under lang/vendor/indonesian-ktp). See Laravel localization.

Custom wilayah data

If you rebind RegionHierarchyLookup, KTP::nik() uses it when the container is available — so these validator rules pick up the same lookup. See Region hierarchy lookup.

Quick checks

match* helpers compare the NIK to a value; they do not add expect* rules to the query.

Gender and birth date

use Bensondevs\IndonesianKtp\Gender;
use Bensondevs\IndonesianKtp\KTP;

$query = KTP::nik('3315131501901235');

$query->matchBirthDate('1990-01-15');
$query->matchGender(Gender::Male);
$query->matchGender('male');

Age / minimum age: matchAge() / matchAtLeastYears() need a resolved birth year — use asOf() as in Two-digit birth years.

Expectations and aliases

Chain then call isValid() or validate(). Each chained call returns a new Query.

Area expect* Alias
Birth date expectBirthDate birthDate
Integer age expectAge age
Minimum age expectAtLeastYears, expectSeventeenOrOlder, expectTwentyOneOrOlder
Gender expectGender gender
Province expectProvince province
Regency expectRegency regency
Subdistrict expectSubdistrict subdistrict

Full names

use Bensondevs\IndonesianKtp\Gender;
use Bensondevs\IndonesianKtp\KTP;

KTP::nik('3315131501901235')
    ->expectBirthDate('1990-01-15')
    ->expectGender(Gender::Male)
    ->expectProvince('jawa tengah')
    ->isValid();

Aliases

use Bensondevs\IndonesianKtp\Gender;
use Bensondevs\IndonesianKtp\KTP;

KTP::nik('3315131501901235')
    ->birthDate('1990-01-15')
    ->gender(Gender::Male)
    ->province('jawa tengah')
    ->isValid();

isValid() is the same as validate()->isFullyValid() (structure + hierarchy + every set expectation). Chaining expectAge / age needs asOf() — see Two-digit birth years.

validate() and ValidationResult

ValidationResult exposes methods (not public properties).

Method Return Notes
hasValidStructure() bool
hasValidRegionHierarchy() bool
hasValidBirthDate() bool or null null = expectation not set
hasValidGender() bool or null
hasValidProvince() bool or null
hasValidRegency() bool or null
hasValidSubdistrict() bool or null
hasValidAge() bool or null
hasValidMinimumAge() bool or null
isFullyValid() bool Same as isValid() on the Query
Alias Equivalent
hasValidKabupaten(), hasValidCity() hasValidRegency()
hasValidKecamatan() hasValidSubdistrict()
use Bensondevs\IndonesianKtp\KTP;

$validationResult = KTP::nik('3315131501901235')->validate();

$validationResult->hasValidStructure();
$validationResult->hasValidRegionHierarchy();
$validationResult->hasValidGender(); // null — no expectation

$validationResult = KTP::nik('3315131501901235')
    ->birthDate('1990-01-01')
    ->validate();

$validationResult->hasValidBirthDate(); // false — mismatch
use Bensondevs\IndonesianKtp\KTP;

$validationResult = KTP::nik('3315131501901235')->validate();

$validationResult->isFullyValid(); // same as isValid() on the query

Parsed values

parsed() returns a Parsed snapshot (read-only fields from the NIK). KTP::nik(...)->parsed() attaches wilayah names from the app’s region lookup when the district code is known; use provinceCode() / regencyCode() / districtCode() for keys from the NIK alone.

Method Role
raw() Normalized 16-digit string
structureValid() Structural segment checks
provinceCode(), regencyCode(), districtCode() Wilayah codes from the NIK (e.g. 33, 33.15, 33.15.13)
province(), provinsi() Province display name when the bound lookup resolves the district (e.g. Jawa Tengah); null if unknown or parser-only Parsed without withRegionHierarchy()
regency(), kabupaten(), kota(), city() Regency / city display name when resolved (same value for all four; NIK does not distinguish kabupaten vs kota); null otherwise
district(), kecamatan() Kecamatan display name when resolved; null otherwise
birthDate() Single date, or null if two-digit year is ambiguous (Two-digit birth years)
possibleBirthDates() All plausible dates when ambiguous
gender(), serial() Parsed gender / serial
age($asOf?), isAtLeastYears($min, $asOf), isSeventeenOrOlder($asOf), isTwentyOneOrOlder($asOf) Age helpers (conservative when ambiguous). age() with no argument uses the current instant (Carbon::now()).

On the Query, resolvedAge() uses the pivot instant when you chained asOf() (Two-digit birth years). For real validation, prefer validate() / isValid().

use Bensondevs\IndonesianKtp\KTP;
use Carbon\Carbon;

$parsed = KTP::nik('3315131501901235')->parsed();

$parsed->raw();
$parsed->structureValid();
$parsed->districtCode();              // NIK wilayah key, e.g. "33.15.13"
$parsed->provinceCode();              // "33"
$parsed->regencyCode();               // "33.15"
$parsed->province();                 // e.g. "Jawa Tengah" — null if lookup has no row
$parsed->regency();                  // e.g. "Kabupaten Grobogan"; alias: city(), kabupaten(), kota()
$parsed->district();                 // e.g. "Purwodadi"; alias: kecamatan()
$parsed->birthDate();                // null if ambiguous (no asOf on query)
$parsed->possibleBirthDates();
$parsed->gender();
$parsed->serial();
$parsed->age();                      // optional asOf; defaults to now()
$parsed->age(Carbon::parse('2026-01-01'));
$parsed->isSeventeenOrOlder(Carbon::parse('2026-01-01'));

Region inputs

NikRegionMatcher: province, regency, and subdistrict each accept codes (int / string shapes) or names. More cases: tests/Feature/Ktp/KtpRegionInputsTest.php.

use Bensondevs\IndonesianKtp\KTP;

$sampleNik = '3315131501901235';

KTP::nik($sampleNik)->expectProvince(33)->isValid();
KTP::nik($sampleNik)->expectProvince('JAWA TENGAH')->isValid();
use Bensondevs\IndonesianKtp\KTP;

KTP::nik('3315131501901235')
    ->expectRegency(15) // province taken from NIK (33…)
    ->isValid();
use Bensondevs\IndonesianKtp\KTP;

KTP::nik('3315131501901235')
    ->expectRegency(15)
    ->expectSubdistrict(13)
    ->isValid();

Unknown district:

use Bensondevs\IndonesianKtp\KTP;

KTP::nik('9999991501900001')->isValid(); // false

Two-digit birth years: ambiguity and asOf()

use Bensondevs\IndonesianKtp\KTP;
use Carbon\Carbon;

// No asOf: every plausible century for YY that fits a 17–120 age window (evaluated at query build time)
KTP::nik('3315131501901235');

// With asOf: single resolved birth year for that pivot
KTP::nik('3315131501901235')->asOf(Carbon::parse('2026-01-01'));

matchAge / minimum age (needs the same pivot):

use Bensondevs\IndonesianKtp\KTP;
use Carbon\Carbon;

$query = KTP::nik('3315131501901235')->asOf(Carbon::parse('2026-01-01'));

$query->matchAge(35);
$query->matchAtLeastYears(21);
Topic Behaviour
Birth / expectBirthDate Ambiguous: any matching candidate wins. Pivot: one resolved year.
parsed()->birthDate() null if multiple candidates; use possibleBirthDates(). Pivot: always set when structure is valid.
age / resolvedAge() Can stay null until year is unique; use asOf() or derive from possibleBirthDates().
Minimum-age helpers Conservative: every plausible birth candidate must pass.

Edge cases: 17–120 uses calendar boundaries; odd ages or midnight tests may need your own asOf(). Examples: tests/Feature/Ktp/KtpTwoDigitYearAndAsOfTest.php, tests/Unit/NIK/ParserTest.php.

use Bensondevs\IndonesianKtp\KTP;
use Carbon\Carbon;

Carbon::setTestNow('2026-09-01');

$ambiguousNik = '3315130109090002';

KTP::nik($ambiguousNik)->parsed()->birthDate();              // null
count(KTP::nik($ambiguousNik)->parsed()->possibleBirthDates()); // 2

KTP::nik($ambiguousNik)->asOf(Carbon::parse('2026-09-01'))->parsed()->birthDate(); // single date

Carbon::setTestNow();

Eloquent: same behaviour via indonesianKtpReferenceDate()Reference date under NIK column and accessors.

Region hierarchy lookup

Custom compiled file (same PHP array format), rebind after the package provider:

use Bensondevs\IndonesianKtp\Regions\Lookup\FileRegionHierarchyLookup;
use Bensondevs\IndonesianKtp\Regions\Lookup\RegionHierarchyLookup;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(RegionHierarchyLookup::class, function (): FileRegionHierarchyLookup {
            $path = storage_path('app/wilayah.php'); // your compiled file

            return new FileRegionHierarchyLookup($path);
        });
    }
}

Eloquent HasIndonesianKtp

HasIndonesianKtp reads the NIK column via getAttribute() (casts and accessors apply). Comparisons against birth date, age, gender, and wilayah fields are explicit: you pass the value you want checked (for example from another column, a relation, or request input). The trait does not scan nik_* or fallback attribute names for you.

use Bensondevs\IndonesianKtp\Concerns\HasIndonesianKtp;
use Illuminate\Foundation\Auth\User as Authenticatable;

class User extends Authenticatable
{
    use HasIndonesianKtp;

    // Optional: if your NIK column is not named `nik`
    // protected function getIndonesianKtpNikColumn(): string
    // {
    //     return 'id_number';
    // }
}
// $model is an Eloquent model using HasIndonesianKtp

$model->hasValidNik();
$model->nikGenderIs($model->gender);
$model->nikProvinceIs($model->province);
$model->nikAgeIs((int) $model->age);

Default NIK attribute name: nik (override getIndonesianKtpNikColumn()). Non-digits are stripped after read.

Explicit matchers

Each nik*Is($value) method compares the argument to what is encoded or implied by the NIK. Extra attributes on the model are ignored unless you pass them in. Each short name has a long alias (indonesianIdNumber*Is) for consistency with the rest of the package.

Trait methods

Method Purpose
hasValidNik() Structure + district hierarchy; same as hasValidIndonesianIdNumber()
nikBirthdateIs(mixed $birth) NIK birth segment matches $birth; alias indonesianIdNumberBirthdateIs()
nikGenderIs() NIK gender matches the argument (Gender or string); alias indonesianIdNumberGenderIs()
nikProvinceIs(mixed $expected) Wilayah province expectation; alias indonesianIdNumberProvinceIs()
nikRegencyIs(mixed $expected) Regency expectation; aliases indonesianIdNumberRegencyIs(), nikKabupatenIs(), nikCityIs(), nikDistrictIs() and matching indonesianIdNumber* forms
nikSubdistrictIs(mixed $expected) Subdistrict expectation; aliases indonesianIdNumberSubdistrictIs(), nikKecamatanIs(), indonesianIdNumberKecamatanIs()
nikAgeIs(int $age) Completed age from the NIK (per reference date rules) equals $age when unambiguous; alias indonesianIdNumberAgeIs()
ageFromNik() Completed full years from the NIK at the trait’s reference instant; null when ambiguous; alias ageFromIndonesianIdNumber()
isSeventeenOrOlderFromNik() Conservative 17+ check over all birth candidates; alias isSeventeenOrOlderFromIndonesianIdNumber()
isTwentyOneOrOlderFromNik() Conservative 21+ check; alias isTwentyOneOrOlderFromIndonesianIdNumber()
isAtLeastYearsFromNik(int $years) Conservative minimum-age check; alias isAtLeastYearsFromIndonesianIdNumber()

NIK column and accessors

Override getIndonesianKtpNikColumn() and/or use an accessor on that column. The trait does not expose raw NIK normalization beyond stripping non-digits after getAttribute.

NIK column name

protected function getIndonesianKtpNikColumn(): string
{
    return 'national_id';
}

NIK value (accessor on the configured column)

Formatted storage → normalize in an accessor; the trait still strips non-digits after getAttribute.

// With default getIndonesianKtpNikColumn() => 'nik'
protected function getNikAttribute(?string $value): ?string
{
    return $value !== null ? preg_replace('/\D/', '', $value) : null;
}

Applicant-style: custom column names, explicit matchers

When birth date, gender, or wilayah live on other attributes or relations, read them yourself and pass them into nik*Is:

use Bensondevs\IndonesianKtp\Concerns\HasIndonesianKtp;
use Illuminate\Database\Eloquent\Model;

class Applicant extends Model
{
    use HasIndonesianKtp;

    protected function getIndonesianKtpNikColumn(): string
    {
        return 'identity_number';
    }
}

// Validation-style usage (attributes / casts apply on reads):
$applicant->hasValidNik()
    && $applicant->nikBirthdateIs($applicant->profile?->dob ?? $applicant->legacy_birthdate)
    && $applicant->nikGenderIs($applicant->sex_code)
    && $applicant->nikProvinceIs($applicant->prov_name)
    && $applicant->nikRegencyIs($applicant->kab_name)
    && $applicant->nikSubdistrictIs($applicant->kec_name);

Reference date (indonesianKtpReferenceDate)

Default null → ambiguous two-digit years (like KTP::nik($nik) without asOf()). Return Carbon::now() (or any pivot) so every internal trait query uses asOf() for YY resolution.

use Carbon\Carbon;
use Carbon\CarbonInterface;

protected function indonesianKtpReferenceDate(): ?CarbonInterface
{
    return null; // ambiguous
}

protected function indonesianKtpReferenceDate(): ?CarbonInterface
{
    return Carbon::now(); // pivot on “now”
}

Develop and test 🧪

composer install && composer test

Support

If this package saves you time, you can support ongoing maintenance on GitHub Sponsors or Trakteer.

Data source

Hierarchy file: data/wilayah.php (from cahyadsn/wilayah, MIT). Attribution: NOTICE. Maintainers can compile from upstream db/wilayah.sql and ship their own wilayah.php; this repo has no compile script.

Security 🔒

Validation does not send NIKs off-device. Treat NIKs as sensitive in logs and traces. Disclosure: SECURITY.md.

Versioning and support

Semantic Versioning. Upgrades: CHANGELOG.md.

Match supported Laravel majors to composer.json illuminate/* constraints when upgrading.