National code

Validate Iranian national codes (کد ملی) with the mod-11 check digit, accept Persian and Arabic digits, and read the place-of-issue hint safely.

On this page
  1. Quick start
  2. What is checked
  3. The check-digit algorithm
  4. Accepted input
  5. The Result details
  6. Place-of-issue hint
  7. What validation does not tell you
  8. Using it in a form

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:

  1. Type and size. Strings, int and whole float values are accepted. Anything else is invalid_type. A string over 4096 bytes is input_too_long.
  2. Cleaning. Persian and Arabic digits become English. Spaces, hyphens and half-spaces (ZWNJ) are removed, and the ends are trimmed.
  3. Format. Exactly 10 digits, otherwise invalid_format.
  4. Repeated digits. 0000000000 to 9999999999 pass the arithmetic by chance but are not real codes. They return repeated_digits.
  5. 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]
Good to know. A national code can start with zero, so keep it a string. 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#

InputResult
'0499370899'valid
'۰۴۹۹۳۷۰۸۹۹' (Persian digits)valid
'٠٤٩٩٣٧٠٨٩٩' (Arabic-Indic digits)valid
'049-937-0899', ' 0499370899 'valid. Separators and spaces are removed
null, [], 1.5invalid_type
12.0read as '12', so invalid_format
4097 charactersinput_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#

KeyTypeMeaning
normalizedstringThe code after cleaning (empty for invalid_type and input_too_long)
locationarray 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
Good to know. The prefix shows where the birth certificate or card was issued. It does not show where the person was born or lives. Many people hold codes issued elsewhere, and province borders have changed since (Karaj, for example, appears under Tehran). Use the location as a hint and not to confirm identity, origin or residence.

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.