WordPress plugins, Laravel form requests, and shared-hosting intake scripts still validate South African ID numbers in PHP. This function uses checkdate() for the YYMMDD segment and the same Luhn positions CheckID uses. It is a format check only — not proof that Home Affairs issued the number.
8001015009087 is a fictional, structurally valid demonstration number used throughout CheckID docs. Do not treat tutorial IDs as real people.
The algorithm, in the order CheckID applies it
A South African ID number is a 13-digit code defined in regulation 3 of the Identification Regulations, 1998. CheckID validates that structure in memory. The snippets on these pages follow the same order as validateIDNumber in production: date, gender sequence, citizenship, then Luhn. Digit 12 (the population-register index, usually 8) is not used as a validity gate.
| Positions | Segment | What to check |
|---|---|---|
| 1–6 | YYMMDD | Must be a real calendar date after century inference |
| 7–10 | SSSS | 0000–4999 female, 5000–9999 male (use the first of these four digits) |
| 11 | C | 0 SA citizen, 1 non-SA citizen, 2 refugee — reject anything else |
| 12 | A | Population-register index (usually 8). Not a fail/pass rule here |
| 13 | Z | Luhn check digit over the first 12 digits |
Date of birth and century inference
Digits 1–6 are YYMMDD, not a four-digit year. CheckID infers the century from today's two-digit year: if YY is less than or equal to the current year's last two digits, the year is 20YY; otherwise it is 19YY. In 2026 that means 26 becomes 2026 and 27 becomes 1927. Impossible dates such as 31 February are rejected before a Date object is trusted. Encoded dates in the future (age below zero) are also rejected.
That two-digit year rule is a real limitation: a person born in 1926 and a child born in 2026 share the same YY. The snippets document the same rule CheckID uses rather than inventing a third century.
Citizenship digit
Position 11 must be 0 (SA citizen), 1 (non-SA citizen / permanent resident encoding), or 2 (refugee). Any other digit fails validation even if the date and checksum look plausible.
Luhn checksum
Using 0-based indexes, double the digits at positions 1, 3, 5, 7, 9 and 11 (the 2nd, 4th, … 12th digits). If doubling produces two digits, add those digits together. Sum the undoubled odd-position digits (indexes 0, 2, 4, 6, 8, 10). The check digit is (10 − (total mod 10)) mod 10 and must equal digit 13. A full worked example for 8001015009087 is in the SA ID checksum guide. Changing only the last digit to produce 8001015009086 fails Luhn while the birth-date segment still reads as 1980-01-01.
Edge cases the snippets must catch
- Length and characters: after stripping spaces and hyphens, the value must be exactly 13 digits. Letters, punctuation, and scientific-notation damage from spreadsheets all fail. CheckID's hosted API expects a 13-digit path segment — do not send spaces in the URL.
- Impossible dates: month 13, day 32, 31 April, and 29 February in a non-leap year (for example YY
99→ 1999). Leap-year 29 February in 2000 is valid. - Citizenship: digits other than 0, 1, or 2.
- Luhn failures: transposed digits and a mistyped final digit, which are the usual keyboard errors.
- Whitespace and formatting: the local snippets strip spaces and hyphens as a courtesy. Production CheckID still requires 13 digits after your own normalisation.
For the digit map in more detail, use the South African ID number structure guide or the interactive explainer.
Working PHP source
Pair the snippet with the synthetic fixtures on the SA ID validator and test cases page if you are writing unit tests. Copy the function below. It strips spaces and hyphens, then applies CheckID's date, citizenship, and Luhn rules. Keep it in your repo only if you are prepared to maintain century inference and leap-year behaviour yourself.
php
<?php
/**
* Structural South African ID validation (PHP 8+).
* Example ID 8001015009087 is fictional and labelled for demonstration only.
* Do not log or persist ID numbers (error_log, APM, or analytics).
*/
function validate_south_african_id(string $input, ?DateTimeImmutable $now = null): array
{
$now = $now ?? new DateTimeImmutable('today');
$id = preg_replace('/[\s-]/', '', $input) ?? '';
if (!preg_match('/^\d{13}$/', $id)) {
return ['isValid' => false, 'error' => 'Enter exactly 13 digits.'];
}
$yy = (int) substr($id, 0, 2);
$month = (int) substr($id, 2, 2);
$day = (int) substr($id, 4, 2);
$currentYearShort = (int) $now->format('y');
$year = ($yy <= $currentYearShort ? 2000 : 1900) + $yy;
if (!checkdate($month, $day, $year)) {
return ['isValid' => false, 'error' => 'YYMMDD is not a real calendar date.'];
}
$dob = DateTimeImmutable::createFromFormat('Y-m-d', sprintf('%04d-%02d-%02d', $year, $month, $day));
$age = (int) $dob->diff($now)->y;
if ($dob > $now) {
return ['isValid' => false, 'error' => 'Encoded date of birth is in the future.'];
}
$gender = ((int) $id[6]) <= 4 ? 'Female' : 'Male';
$citizenshipLabels = [0 => 'SA Citizen', 1 => 'Non-SA Citizen', 2 => 'Refugee'];
$citizenship = $citizenshipLabels[(int) $id[10]] ?? null;
if ($citizenship === null) {
return ['isValid' => false, 'error' => 'Citizenship digit must be 0, 1, or 2.'];
}
$digits = array_map('intval', str_split($id));
$evenSum = 0;
foreach ([1, 3, 5, 7, 9, 11] as $i) {
$evenSum += array_sum(str_split((string) ($digits[$i] * 2)));
}
$oddSum = 0;
foreach ([0, 2, 4, 6, 8, 10] as $i) {
$oddSum += $digits[$i];
}
$checkDigit = (10 - (($evenSum + $oddSum) % 10)) % 10;
if ($digits[12] !== $checkDigit) {
return ['isValid' => false, 'error' => 'Luhn checksum failed.'];
}
return [
'isValid' => true,
'id' => $id,
'dateOfBirth' => $dob->format('Y-m-d'),
'age' => $age,
'gender' => $gender,
'citizenship' => $citizenship,
'error' => null,
];
}
// Demonstration only — fictional structurally valid ID
// print_r(validate_south_african_id('8001015009087'));PHP-specific notes
DateTimeImmutable
The snippet uses DateTimeImmutable so callers cannot mutate the clock by accident. Pass a frozen $now in tests rather than depending on the server timezone for age assertions.
checkdate() leap years
PHP's checkdate() already rejects 31 February and 29 February in common years, which matches CheckID's calendar rules. Keep that helper rather than rolling a second day-count table.
error_log and APM
Do not write the ID to error_log, New Relic, or Sentry breadcrumbs. Strip it from exception messages. CheckID's API is designed so the vendor never retains the number; your PHP app must do the same.
Use CheckID's API instead of owning the snippet
A local function is enough for learning and for small internal tools. It becomes a liability when leap-year bugs, century collisions, or a copied blog snippet drift from the rules your product claims to use. CheckID exposes the same structural validation as a maintained REST endpoint: GET https://api.checkid.co.za/api/v1/validate/{idNumber} with Authorization: Bearer <token>. The JSON fields (isValid, dob, age, gender, citizenship) are documented in the developer guide — this page does not invent a second contract.
API Starter is R99/month for 1,000 single production calls. Professional is R299/month for bulk and team workflows, subject to fair usage. A free development test key is available with an account. Processing is in memory over TLS; CheckID does not store the ID number or the decoded result.
php · CheckID API
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.checkid.co.za/api/v1/validate/" . $idNumber,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . $apiKey],
]);
$response = curl_exec($curl);
curl_close($curl);
$result = json_decode($response, true);
// $result['isValid'], $result['dob'], $result['age'], $result['gender'], $result['citizenship']Walk through authentication and bulk requests in the 5-minute API tutorial, then compare quotas on pricing. Privacy-minded teams should also read POPIA and ID validation.
Other languages in this series
The algorithm above is shared. Each language page keeps its own title, runtime notes, and copy-paste source:
- Validate a South African ID number in JavaScript (Browser and Node.js)
- Validate a South African ID number in C# (.NET 6+)
- Validate a South African ID number in Python (Python 3.10+)