https://github.com/bensondevs/indonesian-ktp
Indonesian NIK validation for PHP/Laravel using compiled wilayah data.
https://github.com/bensondevs/indonesian-ktp
dukcapil indonesian ktp laravel nik
Last synced: 26 days ago
JSON representation
Indonesian NIK validation for PHP/Laravel using compiled wilayah data.
- Host: GitHub
- URL: https://github.com/bensondevs/indonesian-ktp
- Owner: bensondevs
- Created: 2026-05-14T08:22:45.000Z (about 1 month ago)
- Default Branch: main
- Last Pushed: 2026-05-18T04:24:00.000Z (28 days ago)
- Last Synced: 2026-05-19T08:44:32.263Z (27 days ago)
- Topics: dukcapil, indonesian, ktp, laravel, nik
- Language: PHP
- Homepage:
- Size: 157 KB
- Stars: 28
- Watchers: 0
- Forks: 11
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Funding: .github/FUNDING.yml
- Notice: NOTICE
Awesome Lists containing this project
README
# 🇮🇩 Indonesian KTP (NIK) validation
Validate Indonesian NIK (*Nomor Induk Kependudukan*) with structural checks and bundled wilayah data (no MySQL, no Nusantara). Public API: [`KTP`](src/KTP.php).
## Table of contents
- [What gets validated](#what-gets-validated)
- [Requirements](#requirements)
- [Install](#install)
- [Quick start](#quick-start)
- [Usage](#usage)
- [Basic validation](#basic-validation)
- [Laravel Validator (rule object and ktp-nik)](#laravel-validator-rule-object-and-ktp-nik)
- [Quick checks](#quick-checks)
- [Expectations and aliases](#expectations-and-aliases)
- [validate() and ValidationResult](#validate-and-validationresult)
- [Parsed values](#parsed-values)
- [Region inputs](#region-inputs)
- [Two-digit birth years: ambiguity and asOf()](#two-digit-birth-years-ambiguity-and-asof)
- [Region hierarchy lookup](#region-hierarchy-lookup)
- [Eloquent HasIndonesianKtp](#eloquent-hasindonesianktp)
- [Explicit matchers](#explicit-matchers)
- [Trait methods](#trait-methods)
- [NIK column and accessors](#nik-column-and-accessors)
- [Develop and test](#develop-and-test)
- [Data source](#data-source)
- [Security](#security)
- [Versioning and support](#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`](data/wilayah.php) (province → regency → subdistrict).
Optional checks (birth, age, gender, wilayah names/codes): [Usage](#usage). Dataset: [Data source](#data-source).
Invalid length or unknown district:
```php
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 📦
```bash
composer require bensondevs/indonesian-ktp
```
## Quick start ✅
Minimal check (structure + wilayah hierarchy):
```php
use Bensondevs\IndonesianKtp\KTP;
KTP::nik('3315131501901235')->isValid();
```
Structure + region only until you add expectations (see [Usage](#usage)).
## Usage
- `KTP::nik($raw)` returns a fluent, **immutable** `Query`: each chained call is a new instance.
- `isValid()` → one `bool`. `validate()` → [`ValidationResult`](src/NIK/ValidationResult.php) with per-flag detail.
- Laravel’s [`Validator`](https://laravel.com/docs/validation) is supported via [`KtpNik`](src/Rules/KtpNik.php) and string rules registered in [`IndonesianKtpServiceProvider`](src/IndonesianKtpServiceProvider.php) — see [Laravel Validator (rule object and ktp-nik)](#laravel-validator-rule-object-and-ktp-nik).
```php
use Bensondevs\IndonesianKtp\Gender;
use Bensondevs\IndonesianKtp\KTP;
KTP::nik('3315131501901235')
->expectGender(Gender::Male)
->isValid();
```
### Basic validation
```php
use Bensondevs\IndonesianKtp\KTP;
KTP::nik('3315131501901235')->isValid();
```
### Laravel Validator (rule object and ktp-nik)
With [package discovery](https://laravel.com/docs/packages#package-discovery), [`IndonesianKtpServiceProvider`](src/IndonesianKtpServiceProvider.php) registers translation lines and validation extensions automatically. You can validate request input either with a [rule object](https://laravel.com/docs/validation#using-rule-objects) or with a string rule.
**Rule object vs string rule**
```php
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](#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](https://laravel.com/docs/localization).
**Custom wilayah data**
If you rebind [`RegionHierarchyLookup`](src/Regions/Lookup/RegionHierarchyLookup.php), `KTP::nik()` uses it when the container is available — so these validator rules pick up the same lookup. See [Region hierarchy lookup](#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**
```php
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](#two-digit-birth-years-ambiguity-and-asof).
### 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**
```php
use Bensondevs\IndonesianKtp\Gender;
use Bensondevs\IndonesianKtp\KTP;
KTP::nik('3315131501901235')
->expectBirthDate('1990-01-15')
->expectGender(Gender::Male)
->expectProvince('jawa tengah')
->isValid();
```
**Aliases**
```php
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](#two-digit-birth-years-ambiguity-and-asof).
### validate() and ValidationResult
[`ValidationResult`](src/NIK/ValidationResult.php) 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()` |
```php
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
```
```php
use Bensondevs\IndonesianKtp\KTP;
$validationResult = KTP::nik('3315131501901235')->validate();
$validationResult->isFullyValid(); // same as isValid() on the query
```
### Parsed values
`parsed()` returns a [`Parsed`](src/NIK/Parsed.php) 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](#two-digit-birth-years-ambiguity-and-asof)) |
| `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](#two-digit-birth-years-ambiguity-and-asof)). For real validation, prefer `validate()` / `isValid()`.
```php
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`](src/Regions/Matching/NikRegionMatcher.php): province, regency, and subdistrict each accept **codes** (int / string shapes) or **names**. More cases: [`tests/Feature/Ktp/KtpRegionInputsTest.php`](tests/Feature/Ktp/KtpRegionInputsTest.php).
```php
use Bensondevs\IndonesianKtp\KTP;
$sampleNik = '3315131501901235';
KTP::nik($sampleNik)->expectProvince(33)->isValid();
KTP::nik($sampleNik)->expectProvince('JAWA TENGAH')->isValid();
```
```php
use Bensondevs\IndonesianKtp\KTP;
KTP::nik('3315131501901235')
->expectRegency(15) // province taken from NIK (33…)
->isValid();
```
```php
use Bensondevs\IndonesianKtp\KTP;
KTP::nik('3315131501901235')
->expectRegency(15)
->expectSubdistrict(13)
->isValid();
```
Unknown district:
```php
use Bensondevs\IndonesianKtp\KTP;
KTP::nik('9999991501900001')->isValid(); // false
```
### Two-digit birth years: ambiguity and asOf()
```php
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):
```php
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/Feature/Ktp/KtpTwoDigitYearAndAsOfTest.php), [`tests/Unit/NIK/ParserTest.php`](tests/Unit/NIK/ParserTest.php).
```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](#reference-date-indonesianktpreferencedate) under [NIK column and accessors](#nik-column-and-accessors).
### Region hierarchy lookup
- **Auto-discovery:** [`IndonesianKtpServiceProvider`](src/IndonesianKtpServiceProvider.php) registers [`RegionHierarchyLookup`](src/Regions/Lookup/RegionHierarchyLookup.php) → bundled [`data/wilayah.php`](data/wilayah.php) via [`FileRegionHierarchyLookup`](src/Regions/Lookup/FileRegionHierarchyLookup.php).
- **[`KTP::nik()`](src/KTP.php)** 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:
```php
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`](src/Concerns/HasIndonesianKtp.php) 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.
```php
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';
// }
}
```
```php
// $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
```php
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`.
```php
// 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`:
```php
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.
```php
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 🧪
```bash
composer install && composer test
```
## Data source
Hierarchy file: [`data/wilayah.php`](data/wilayah.php) (from [cahyadsn/wilayah](https://github.com/cahyadsn/wilayah), MIT). Attribution: [`NOTICE`](NOTICE). Maintainers can compile from [upstream `db/wilayah.sql`](https://github.com/cahyadsn/wilayah) 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`](SECURITY.md).
## Versioning and support
[Semantic Versioning](https://semver.org/). Upgrades: [`CHANGELOG.md`](CHANGELOG.md).
- **Releases:** [Packagist — bensondevs/indonesian-ktp](https://packagist.org/packages/bensondevs/indonesian-ktp)
- **Changelog:** [`CHANGELOG.md`](CHANGELOG.md)
- **Contributing:** [`CONTRIBUTING.md`](CONTRIBUTING.md)
- **Security:** [`SECURITY.md`](SECURITY.md)
Match supported Laravel majors to [`composer.json`](composer.json) `illuminate/*` constraints when upgrading.