On this page
Quick start#
<?php
require 'vendor/autoload.php';
use RtlyKit\Validation\NationalCode;
var_dump(NationalCode::isValid('0499370899')); // bool(true)
var_dump(NationalCode::isValid('۰۴۹۹۳۷۰۸۹۹')); // bool(true), Persian digits
var_dump(NationalCode::isValid('049-937-0899')); // bool(true), hyphens removed
$result = NationalCode::validate('0499370899');
echo json_encode($result->details(), JSON_UNESCAPED_UNICODE);
// {"normalized":"0499370899","location":{"province":"تهران","city":"شهرری"}}
The class implements the shared Validator contract. validate() returns a Result, and nothing throws for bad input. The helpers are RtlyKit\is_national_code() and RtlyKit\validate_national_code():
use function RtlyKit\is_national_code;
use function RtlyKit\validate_national_code;
var_dump(is_national_code('۰۴۹۹۳۷۰۸۹۹')); // bool(true)
print_r(validate_national_code('0499370898')->errors()); // Array ( [0] => invalid_checksum )
What is checked#
The checks run in this order and stop at the first failure:
- Type and size. Strings,
intand wholefloatvalues are accepted. Anything else isinvalid_type. A string over 4096 bytes isinput_too_long. - Cleaning. Persian and Arabic digits become English. Spaces, hyphens and half-spaces (ZWNJ) are removed, and the ends are trimmed.
- Format. Exactly 10 digits, otherwise
invalid_format. - Repeated digits.
0000000000to9999999999pass the arithmetic by chance but are not real codes. They returnrepeated_digits. - Check digit (the mod-11 rule below), otherwise
invalid_checksum.
The check-digit algorithm#
Multiply each of the first nine digits by a weight from 10 down to 2. Add the products and take the remainder of the sum divided by 11. If the remainder is 0 or 1, the check digit equals the remainder. Otherwise it is 11 minus the remainder.
Take 0499370899. The products are 0, 36, 72, 63, 18, 35, 0, 24 and 18, and they add up to 266. The remainder of 266 divided by 11 is 2. So the check digit must be 11 − 2 = 9, and the last digit is 9.
print_r(NationalCode::validate('0499370898')->errors()); // [invalid_checksum]
print_r(NationalCode::validate('1111111111')->errors()); // [repeated_digits]
print_r(NationalCode::validate('12345')->errors()); // [invalid_format]
print_r(NationalCode::validate('abc')->errors()); // [invalid_format]
NationalCode::validate(499370899) receives the nine digits 499370899 and returns invalid_format. Do not cast the value to an integer anywhere between the input and the validator. That includes a database column or a JSON number.Accepted input#
| Input | Result |
|---|---|
'0499370899' | valid |
'۰۴۹۹۳۷۰۸۹۹' (Persian digits) | valid |
'٠٤٩٩٣٧٠٨٩٩' (Arabic-Indic digits) | valid |
'049-937-0899', ' 0499370899 ' | valid. Separators and spaces are removed |
null, [], 1.5 | invalid_type |
12.0 | read as '12', so invalid_format |
| 4097 characters | input_too_long |
Only spaces, hyphens and ZWNJ count as separators. Slashes, dots and letters are not removed and cause invalid_format.
To get the cleaned form without validating, call NationalCode::normalize():
echo NationalCode::normalize(' ۰۴۹-۹۳۷ ۰۸۹۹ '); // 0499370899
The Result details#
| Key | Type | Meaning |
|---|---|---|
normalized | string | The code after cleaning (empty for invalid_type and input_too_long) |
location | array or null | {province, city} for a valid code whose prefix is in the table, otherwise null |
Error keys: invalid_format, repeated_digits, invalid_checksum, invalid_type, input_too_long. Each one is explained in the error key table.
Place-of-issue hint#
The first three digits of a national code are a prefix set by the civil registry. NationalCode::getLocation() maps it to a province and a city. It returns null when the code is invalid or the prefix is not in the table:
print_r(NationalCode::getLocation('0499370899'));
// Array ( [province] => تهران [city] => شهرری )
var_dump(NationalCode::getLocation('0499370898')); // NULL (invalid code)
$a = NationalCode::validate('0012345679'); // prefix 001
echo json_encode($a->details()['location'], JSON_UNESCAPED_UNICODE);
// {"province":"تهران","city":"تهران مرکزی"}
$b = NationalCode::validate('9991234561'); // valid checksum, prefix 999 not in the table
var_dump($b->isValid(), $b->details()['location']); // bool(true) NULL
The civil registry publishes no machine-readable list, so the table was built from public data. It has 547 prefixes. A prefix is included only when three community datasets agree on both province and city and a fourth does not contradict it. These datasets share some history, so we do not count their agreement as fully independent. A missing prefix means unknown, not invalid. The sources are listed in Accuracy and data.
What validation does not tell you#
- A valid check digit means the number is well formed. It does not mean the code was issued or belongs to a given person.
- Legal entities use a different 11-digit identifier. This validator does not handle it and returns
invalid_format. - Identification numbers of foreign nationals (Amayesh and similar) follow a different scheme and are not covered.
Using it in a form#
Show the user a message chosen by the error key, and store the cleaned value:
$messages = [
'invalid_format' => 'The national code must be 10 digits.',
'repeated_digits' => 'This national code is not valid.',
'invalid_checksum' => 'This national code is not valid.',
];
$result = NationalCode::validate($_POST['national_code'] ?? null);
if (! $result->isValid()) {
$key = $result->errors()[0];
echo $messages[$key] ?? 'Invalid input.';
} else {
$code = $result->details()['normalized']; // save this value
}
In Laravel, use the ready-made rule and translations instead. See Laravel validation and cast.