bensondevs / indonesian-ktp
Validate Indonesian NIK (Nomor Induk Kependudukan) using compiled wilayah data (cahyadsn/wilayah, MIT).
Fund package maintenance!
Requires
- php: ^8.3
- illuminate/contracts: ^10.0|^11.0|^12.0|^13.0
- illuminate/database: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
- illuminate/validation: ^10.0|^11.0|^12.0|^13.0
- nesbot/carbon: ^2.67|^3.0
Requires (Dev)
- orchestra/testbench: ^9.0|^10.0|^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- phpunit/phpunit: ^12.0
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
- Requirements
- Install
- Quick start
- Usage
- Develop and test
- Support
- Data source
- Security
- Versioning and support
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
KTP::nik($raw)returns a fluent, immutableQuery: each chained call is a new instance.isValid()→ onebool.validate()→ValidationResultwith per-flag detail.- Laravel’s
Validatoris supported viaKtpNikand string rules registered inIndonesianKtpServiceProvider— see Laravel Validator (rule object and ktp-nik).
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
- Auto-discovery:
IndonesianKtpServiceProviderregistersRegionHierarchyLookup→ bundleddata/wilayah.phpviaFileRegionHierarchyLookup. KTP::nik()uses the container when the contract is bound; otherwise the bundled path (e.g. some unit tests).
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.
- Releases: Packagist — bensondevs/indonesian-ktp
- Changelog:
CHANGELOG.md - Contributing:
CONTRIBUTING.md - Security:
SECURITY.md
Match supported Laravel majors to composer.json illuminate/* constraints when upgrading.