Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel South African Id Laravel Package

spoorsny/laravel-south-african-id

Validate, parse, and generate South African ID numbers in Laravel. This package checks the 13-digit ID format, verifies the Luhn checksum, extracts date of birth, gender, and citizenship, and offers clean validation rules and helper methods for your models and forms.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and confirm discovery

composer require spoorlsny/laravel-south-african-id

The package ships a Laravel service provider that is auto-discovered — there is nothing to register in config/app.php for a standard Laravel 9/10/11 app. If you are on a stripped-down or Lumen-style bootstrap, you will need to register the provider manually.

Where to look first

Read the package README before anything else, then follow the description of the South African ID number that the docs link to (this link was the sole change in v1.2.0). Understanding the format up front saves hours of confusion later, because almost every "gotcha" with this package is really a misunderstanding of the number itself:

  • 13 digits total: YYMMDD (date of birth) + SSSS (gender sequence) + C (citizenship) + A (usually 8) + Z (checksum).
  • Sequence 0000–4999 is female, 5000–9999 male.
  • Citizenship 0 = South African citizen, 1 = permanent resident.
  • The final digit is a Luhn check digit, not arbitrary.

First use case

The 80% case is validating an id_number field on a registration, KYC, or policy-application form. Wire it into your existing FormRequest rather than the controller:

public function rules(): array
{
    return [
        'id_number' => ['required', 'string', 'size:13', new SouthAfricanId],
    ];
}

That is the whole integration for most teams. Everything below is refinement.

Implementation Patterns

Normalise before you validate. Users paste ID numbers out of PDFs, spreadsheets, and WhatsApp messages with spaces, dashes, and non-breaking whitespace. Strip non-digits in prepareForValidation() so the rule sees a clean string, and store the normalised value:

protected function prepareForValidation(): void
{
    $this->merge([
        'id_number' => preg_replace('/\D/', '', (string) $this->id_number),
    ]);
}

Be deliberate about this: silently stripping characters is right for whitespace, but for a field this sensitive it is worth surfacing a clear "digits only" message instead of quietly repairing genuinely malformed input.

Validate, then derive. Once a number is well-formed, the same 13 digits carry usable data. A common pattern is to run the rule, then extract date of birth, gender, and citizenship to pre-fill a profile or to branch onboarding flow (citizen vs. permanent resident document requirements). Do the derivation after validation succeeds — never parse a number you have not validated.

Pair with database rules deliberately. Order matters in Laravel's validator, and unique on a users.id_number column is a natural companion. Combine them in the same rules array and use a named error bag so a duplicate reads differently from a checksum failure:

'id_number' => ['required', 'string', 'size:13', new SouthAfricanId, Rule::unique('users', 'id_number')],

Use it for age gates, not just format checks. Because the date of birth is embedded, this rule is the cheapest way to enforce an 18+ requirement on South African sign-ups without asking for a second field. Extract the DOB in a withValidator() after-callback so the age rule only fires once the ID is known-valid.

Testing. Do not generate ID numbers randomly in factories — random 13-digit strings will fail the Luhn check roughly 90% of the time and your suite will flake or, worse, pass for the wrong reason. Keep a small fixture set of known-valid numbers covering both genders, both citizenship values, a leap-day birth date, and one number with a deliberately wrong checksum. Drive them through a Pest/PHPUnit dataset so each edge case is named.

Front-end and component stacks. The rule is a plain ValidationRule object, so it drops unchanged into Livewire #[Validate] attributes, Filament form fields, and Laravel Folio/Inertia form requests. There is no JS counterpart — expect to do the check server-side only, and treat client-side maxlength="13" as UX polish, never as validation.

Gotchas and Tips

A passing rule is not an identity check. The Luhn checksum plus date sanity proves the string is well-formed, nothing more. Valid-looking numbers are trivially constructible, and the same number is reused across many real records in the wild (it is a common test value, and Home Affairs has reissued numbers). Never treat a pass as proof of identity, and never use it as an authentication factor. Layer real KYC/Home Affairs verification on top if the business requirement is identity assurance.

Store it as a string, always. Even though 13 digits fit in a BIGINT, leading zeros in the YYMMDD segment and future format changes make an integer column a mistake. Use string(13) (or char(13)) and never cast to int in PHP either — (int) '0001015009088' silently loses information.

Century inference is a policy decision, not a fact. YY is ambiguous between 1900s and 2000s. A naive "always 20xx" pivot makes anyone born before 2000 look like a toddler. Pick and document a cutoff (commonly: two-digit years greater than the current year's suffix belong to the previous century) and put it in one place, not scattered across controllers.

Leap days and impossible dates. 990230 is thirteen digits and can produce a plausible-looking Luhn sum while being a nonexistent date. Confirm the package rejects invalid calendar dates — do not assume the checksum covers it — and add a test for 29 February on a non-leap year.

Whitespace and invisible characters. Copy-paste from a Word document or a scanned PDF routinely introduces zero-width spaces and non-breaking spaces that trim() will not remove. preg_replace('/\D/', '', ...) handles this; a bare trim() does not. Symptom to recognise: a number the user swears is correct fails validation, but retyping it by hand works.

POPIA and logging. A South African ID number is personal information under POPIA. Laravel's default exception handler and query log will happily dump it into laravel.log, Telescope, and Sentry breadcrumbs. Mask it (e.g. substr($id, 0, 6) . '*******') before it reaches any log channel, exclude the column from $guarded-adjacent debug output, and consider encrypting the column at rest if you store it for more than the minimum retention period. Check your config/logging.php and APP_DEBUG posture before shipping.

Custom messages. The package's default messages are fine for developers but terse for end users. Add a messages() entry on the FormRequest explaining why a 13-digit South African ID is expected — this single message measurably reduces support tickets on onboarding forms. If the package publishes translations, publish them and override rather than wrapping the rule.

Extending rather than forking. If you need policy on top of format (age gate, citizenship gate, cross-field checks against a declared date of birth), compose a new rule that delegates to the package's rule first and only then applies your logic. This keeps the upstream package upgradable. Forking for a two-line policy difference is a maintenance trap.

Upgrading to v1.2.0 is safe. This release contains a single documentation change — a link to an explanation of the South African ID number — with no code, signature, or behaviour changes from v1.1.0. There is no migration, no config publication step, and no reason to hold the upgrade. The practical benefit is onboarding: point new team members at that link before they touch the validation layer, since most bugs in this area come from format misconceptions rather than from the rule itself. Pin with ^1.2 and keep an eye out for future releases that might change the rule's constructor or add config, which is where a real breaking change would land.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
stevenay/myanfont
gmostafa/php-graphql-client
xeviant/paystack
snowsoft/faker
meditationsteps/blogetc
animalyoung/laravel-influxdb
onix-solutions/sms-telera
lonely-lights/prosetta
oxygen/data
oxygen/core
jengo/base
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin
artisan-build/community-prompts
vleroy/smart
ayles-software/laravel-australian-public-holidays
cboxdk/laravel-id-spatie
cboxdk/laravel-id-billing
cboxdk/laravel-id-billing-bridge