Search by

lavendla / personal-identity-number

MartinCamen

Validation, formatting and classification of personal identity numbers for Sweden and Denmark

Package info

github.com/lavendla/personal-identity-number-php

pkg:composer/lavendla/personal-identity-number

Statistics

Installs: 595

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.2.0 2026-08-31 07:25 UTC

This package is auto-updated.

Last update: 2026-08-31 07:25:24 UTC


README

Parsing, formatting and classification of personal identity numbers, starting with Sweden. The package validates shape, checksum and calendar plausibility; it never encrypts, hashes or stores the values it is given, and it never reads the system clock.

This is the PHP half of a paired PHP/TypeScript implementation. Both packages are generated from and tested against the same corpus, so the same input resolves identically in both languages — see @lavendla/personal-identity-number for the equivalent TypeScript API, mirrored example for example. The source repository is private; this package is published from it.

Status

Not yet published to Packagist. The Skatteverket citation this previously waited on has landed — the published test datasets are committed and provenance:check enforces their use mechanically in the private source repository. What remains is the release pipeline itself: Packagist reads composer.json from a repository root, and this package lives in a subdirectory of that private repository, so each release is assembled into this public repository as a snapshot commit rather than published from the source tree directly. The install instructions below describe how this package will be required once it is released.

What's proven where

Claim Proven where
This package resolves the corpus as recorded This repository's CI
This package's output matches the agreed snapshot This repository's CI
The two runtimes agree with each other The private source repository
No unexplained real-looking number is committed anywhere The private source repository

The third row can't be checked here: comparing the two runtimes needs both of them plus tooling that stays in the private source repository, so this package instead diffs its own output against the committed snapshot (spec/golden.json) that both runtimes already agreed on there.

composer require lavendla/personal-identity-number

Requires PHP 8.4+.

Licensed MIT. The test corpus under spec/fixtures/ is Skatteverket's and MedCom's published data and carries its own terms — see ATTRIBUTION.md.

Quick start

use DateTimeImmutable;
use Lavendla\PersonalIdentityNumber\Enums\Country;
use Lavendla\PersonalIdentityNumber\Enums\Format;
use Lavendla\PersonalIdentityNumber\ParseOptions;
use Lavendla\PersonalIdentityNumber\PersonalIdentityNumber;

$referenceDate = new DateTimeImmutable('2026-08-16');

$number = PersonalIdentityNumber::parse(
    '19031204-9802',
    Country::Sweden,
    new ParseOptions($referenceDate),
);

$number->canonical();              // '190312049802'
$number->birthDate()->format('Y-m-d'); // '1903-12-04'
$number->gender();                 // Gender::Female
$number->isPerson();                // true
$number->format(Format::Display);   // '19031204-9802'
$number->format(Format::Short);     // '031204+9802' — this bearer is over 100 at the reference date
$number->format(Format::Masked);    // '19031204****'

The three resolution entry points

parse(), tryParse() and explain() all run the same resolution; they differ only in how they report failure.

// Throws Lavendla\PersonalIdentityNumber\Exceptions\ParseException on failure,
// carrying a typed Enums\ParseFailure reason via getFailure().
$number = PersonalIdentityNumber::parse('19031204-9802', Country::Sweden, new ParseOptions($referenceDate));

// Returns null on failure, discarding the reason. For guard clauses that
// don't need to know why.
$numberOrNull = PersonalIdentityNumber::tryParse('not a number', Country::Sweden, new ParseOptions($referenceDate));
// null

// Never throws. Returns a ParseOutcome — the diagnostic and migration entry
// point, and the one every other entry point above is built from.
$outcome = PersonalIdentityNumber::explain('19031204-9802', Country::Sweden, new ParseOptions($referenceDate));
$outcome->succeeded(); // true
$outcome->number();    // the PersonalIdentityNumber above
$outcome->failure();   // null

Facades over explain()

// A bare boolean, for guard clauses and form validation.
PersonalIdentityNumber::validates('19031204-9802', Country::Sweden, new ParseOptions($referenceDate)); // true

// For search paths, where the country is what you're trying to find out —
// never for writes. Returns every interpretation, in no priority order, and
// never picks a winner.
$candidates = PersonalIdentityNumber::detect('19031204-9802', new ParseOptions($referenceDate));
// one candidate: twelve digits cannot be a Danish CPR number

$ambiguous = PersonalIdentityNumber::detect('2601012384', new ParseOptions($referenceDate));
// two candidates: valid as Swedish (born 2026-01-01) and as Danish (born
// 1901-01-26). See the Denmark section.

// Asserts the result is a person — throws if it resolves to an organization
// number instead.
$person = PersonalIdentityNumber::parseForPerson('19031204-9802', Country::Sweden, $referenceDate);

// Asserts the result is an organization number — throws if the value resolves
// to a personal or coordination number instead.
$organization = PersonalIdentityNumber::parseForOrganization('202100-5448', Country::Sweden, $referenceDate);
$organization->canonical();            // '162021005448' — the 16 prefix is part of the canonical form
$organization->format(Format::Display); // '202100-5448' — the form Sweden actually writes
$organization->birthDate();            // null
$organization->gender();               // null
$organization->isPerson();             // false

Country is the issuing registry, not residence

Country always identifies the national registry that issued the number — never where the person lives or where an order was placed. A Norwegian resident in Sweden has a Swedish address and a Norwegian identity number. Pass residence instead of issuing country and you get wrong data silently: this is exactly how 261 rows in a production system came to be stored as Swedish when they are not.

All four countries have parsing logic, and that makes the responsibility sharper rather than softer: Sweden and Denmark collide, and Denmark has no checksum to catch a Swedish number handed to it. Denmark and Finland collide too — a Finnish code whose intermediate character is - and whose control character is a digit is character-for-character a Danish CPR number. Passing the wrong country will usually succeed and give you a plausible, wrong birth date. See the Denmark section below. Supplying the correct issuing country is entirely the caller's responsibility.

The closest thing this package has to a wrong-country alarm is recognizedCountry: hand a Finnish code to Sweden and the answer names Finland rather than shrugging. It is advisory and it is deliberately quiet — it names a country only when that country's own scheme would genuinely accept the value, never on a shape match alone.

referenceDate is required, and this package never calls the system clock

ParseOptions::$referenceDate has no default, and there is no now() anywhere in this package. Century inference for a ten-digit input depends on when you ask — the same two digits resolve to a different century depending on the date supplied — so a package that read the clock would parse the same input differently in different years. If that resolved value is ever used as a lookup key, the same person stops matching themselves the day the century guess flips. Requiring the caller to supply the date, every time, is what keeps parsing deterministic for the object's entire lifetime.

If your input is already century-complete (twelve digits) and you have no meaningful reference date, say so explicitly rather than fabricating one:

use Lavendla\PersonalIdentityNumber\Enums\ParseFailure;

$outcome = PersonalIdentityNumber::explain(
    '190312049802',
    Country::Sweden,
    ParseOptions::forCenturyCompleteInput(),
);
$outcome->succeeded(); // true — the century was already in the input

// But a ten-digit input under the same options fails loudly instead of guessing:
$tenDigitOutcome = PersonalIdentityNumber::explain(
    '031204-9802',
    Country::Sweden,
    ParseOptions::forCenturyCompleteInput(),
);
$tenDigitOutcome->failure(); // ParseFailure::CenturyRequired

Do not wrap ParseOptions::forCenturyCompleteInput() or the constructor in a helper that defaults $referenceDate to new DateTimeImmutable('now'). That reintroduces the exact drift this requirement exists to prevent.

ParseOptions

final readonly class ParseOptions
{
    public function __construct(
        public ?DateTimeImmutable $referenceDate,
        public bool $allowCoordinationNumber = true,
        public bool $allowOrganizationNumber = true,
        public bool $allowUnknownBirthNumber = true,
    ) {}

    public static function forCenturyCompleteInput(
        bool $allowCoordinationNumber = true,
        bool $allowOrganizationNumber = true,
        bool $allowUnknownBirthNumber = true,
    ): self;
}

All boolean flags default to true: the defaults are permissive and the caller narrows.

Flag Narrows out
allowCoordinationNumber Coordination numbers (day + 60). Set false to reject them with ParseFailure::SchemeNotAllowed on a path that only expects ordinary personal numbers.
allowOrganizationNumber Organization numbers. Passed by parseForPerson() internally. Set false to reject them with ParseFailure::SchemeNotAllowed — but read the sole-proprietorship section first, because it does not do what a reader often expects.
allowUnknownBirthNumber The 0000 unknown-birth-number convention. Set false to reject it with ParseFailure::SchemeNotAllowed on a path that requires a fully identified person.
$options = new ParseOptions($referenceDate, allowCoordinationNumber: false);
PersonalIdentityNumber::explain('140168+2396', Country::Sweden, $options)->failure();
// ParseFailure::SchemeNotAllowed

The value object

$number->scheme(): Scheme
$number->country(): Country
$number->canonical(): string
$number->format(Format $format, ?DateTimeImmutable $referenceDate = null): string
$number->birthDate(): ?DateTimeImmutable
$number->gender(): ?Gender
$number->ageOn(DateTimeImmutable $referenceDate): ?int
$number->isPerson(): bool
$number->equals(PersonalIdentityNumber $other): bool

Both nullable accessors are genuinely nullable, and for three different reasons. Nothing about a successful parse guarantees a birth date or a gender exists, and the three cases are worth knowing separately because a consumer usually cares about one of them and not the others:

Input birthDate() / ageOn() gender() Why
An organization number null null It has no bearer at all
A coordination number with month 00 or day 60 null the gender The date is partly unknown; the birth number is intact
A 0000 birth number the date null The date is fully present; the field carrying gender is the one declared unknown

The rule underneath all three, which is worth preferring over memorising the table: each accessor returns null exactly when the digits it reads carry no information. Reporting a gender for 0000 would be fabrication — the parity digit is inside the field being declared unknown — and reporting null for its birth date would be the mirror error, discarding a date the number states plainly.

A partial birth date does not make the identity partial. canonical() is complete, so matching, indexing and equals() are entirely unaffected, and succeeded() keeps one honest meaning: this is a valid identity number. There is deliberately no hasCompleteBirthDate(), no second success tier and no caveat flag — ask the specific question you care about instead.

ageOn() performs all date arithmetic in UTC and requires an explicit reference date; like everything else in this package, it never consults the system clock.

equals() compares country and canonical form.

format()'s optional reference date

$number->format(Format $format, ?DateTimeImmutable $referenceDate = null): string

The second parameter is consulted only by Format::Short, the one temporally-dependent rendering. It defaults to the reference date the object was parsed with, so the ordinary call passes nothing:

$number->format(Format::Short); // uses the reference date supplied at parse time

It exists for objects built via ParseOptions::forCenturyCompleteInput(), which carry no reference date at all — the call site can supply one just to render Short, without the object having pretended to need one at parse time. If neither the object nor the call site has one, rendering throws ParseFailure::ReferenceDateRequired rather than guessing:

$noDateObject = PersonalIdentityNumber::parse(
    '190312049802',
    Country::Sweden,
    ParseOptions::forCenturyCompleteInput(),
);

$noDateObject->format(Format::Display); // fine — no reference date involved
$noDateObject->format(Format::Short);   // throws ParseException(ParseFailure::ReferenceDateRequired)

There is deliberately no fallback separator. Defaulting the +/- to - when the reference date is unknown would be quietly catastrophic: Format::Short is the exact inverse of the century-resolution logic used at parse time, so rendering 19031204-9802 (over 100 years old) as 031204-9802 and re-parsing that against a recent date reads it back as 2003-12-04 — a hundred-year error, silently, in exactly the over-100 population this data most often describes. A loud failure is the correct trade.

Format

Member SE 19031204-9802 (reference date 2026-08-16) SE 190101-2391 (same date) SE organization 202100-5448 DK 251248-9996
Canonical 190312049802 201901012391 162021005448 2512489996
Display 19031204-9802 20190101-2391 202100-5448 251248-9996
Short 031204+9802+ because this bearer is over 100 at the reference date 190101-2391- because this bearer is not 202100-5448 — identical to Display, and needs no reference date 251248-9996 — identical to Display
Masked 19031204**** 20190101**** 16202100**** 251248****

An organization number's Display drops the 16. canonical() keeps it and Display does not, which is deliberate rather than an inconsistency: 202100-5448 is the form Sweden actually writes, prints and invoices with, and 16202100-5448 is a form nothing publishes. Nothing is lost — the canonical form is what applications index on, and it still carries the prefix. Masked keeps it too, so a caller can use the masking helper without branching on scheme.

Organization Short needs no reference date. There is no bearer, so there is no age, so there is nothing for the +/- to report. The same is true of Danish numbers for a different reason, below.

A partial birth date changes how Short picks its separator. With month 00 or day 60 there is no exact age, so the +/- is derived from the birth year against the reference year instead. This is not cosmetic: the + is the only thing carrying the century when a short form is read back, so 191500722390 renders 150072+2390 and re-parses to 1915. Rendering - there would re-parse as 2015.

Danish Short is Danish Display. A ten-digit CPR number carries no century, so there is nothing for Short to elide. It follows that Format::Short never raises ParseFailure::ReferenceDateRequired for a Danish number — the Swedish path's requirement does not apply, and a Danish number parsed with ParseOptions::forCenturyCompleteInput() renders every format without a reference date.

Swedish Display is deliberately YYYYMMDD-NNNN, not the shorter YYMMDD-NNNN seen in some Swedish UIs. Callers wanting the short convention ask for Short.

Masked replaces the final four characters with *. It still exposes the full date of birth. It is a display convenience for support contexts, not a redaction primitive — if true redaction is ever required it will arrive as a new Format member, since changing this one would be a golden-snapshot diff and therefore a spec major version bump.

Scheme

Member Country Status in this release
SePersonalNumber Sweden Implemented
SeCoordinationNumber Sweden Implemented, including partial birth dates — month 00 and day 60, both drawn from Skatteverket's published coordination-number datasets
SeOrganizationNumber Sweden Implemented. Validates the third-digit rule as well as Luhn, canonicalises with the 16 prefix, and yields neither a birth date nor a gender
DkCprNumber Denmark Implemented. Read the Denmark section below before relying on validates() for it
NoNationalIdentityNumber Norway Implemented — Skatteetaten's fødselsnummer. No publicly citable ordinary test number exists, so every safely constructible value for this scheme carries Skatteetaten/Digdir's synthetic +80 month convention; see allowSyntheticNumbers
NoDNumber Norway Implemented — Skatteetaten's D-number, issued to people not resident in Norway. Same layout as NoNationalIdentityNumber plus a +40 day offset and its own century table
FiPersonalIdentityCode Finland Implemented — DVV's personal identity code. Eleven characters, and the seventh is the century: + for the 1800s, - or Y/X/W/V/U for the 1900s, AF for the 2000s, per decree 690/2022. The marker stays in the canonical form, because a Finnish code is not unique without it

Every country the Country enum has now parses. There is no recognize-only country left, which means ParseFailure::CountryNotSupported is currently unreachable — see the ParseFailure table.

ParseFailure

Carried on ParseException and exposed via getFailure(). The exception message is the failure's reason code alone (e.g. 'checksum-mismatch') — never the raw input, masked or otherwise.

Member Meaning
NotAnIdentityNumber No scheme matched and nothing was recognized
UnsupportedScheme No country actually consulted accepted the value, but a country not consulted does — genuinely, by a real parse. ParseOutcome::recognizedCountry() always names that country here: this failure is reported precisely because one was found — see recognizedCountry below for when the hint rides along with a different, more specific failure instead
CountryNotSupported The country you asked for has no scheme. Currently unreachable: every Country member has a scheme, so nothing produces this today. It remains in the enum because the next country added will have a Country member before it has a scheme, and removing a public enum case is a breaking change either way
ChecksumMismatch Shape and date are valid, the check digit is not
ImpossibleDate The encoded date does not exist
ImplausibleBirthDate The date exists, but the resolved birth year is before the scheme's floor of 1800. Deliberately distinct from ImpossibleDate — "that date does not exist" and "that date exists but nobody alive was born then" are different facts, and a consumer bucketing contaminated data needs to tell them apart
FutureBirthDate The resolved birth date is after the reference date
InvalidCharacter Input contains a character not in the allow table
SchemeNotAllowed A scheme matched but was disabled by options, or by a preset such as parseForPerson(). Never carries recognizedCountry — see recognizedCountry: the asked country's own scheme would have parsed the value with different options, so the country was never in question
CenturyRequired Options carried no reference date, but the input has no century of its own. Never carries recognizedCountry, for the same reason as SchemeNotAllowed above
ReferenceDateRequired Format::Short (or ageOn()) was asked of an object with no reference date on it and none supplied at the call site. Also on the never-carries-recognizedCountry list, though unreachable from explain()/parse() today — it is thrown only by formatting, never by a scheme

Canonical forms

The canonical form is the value applications should index and match on.

Scheme Canonical shape Example
SE personal number 12 characters, CCYYMMDDNNNC 190312049802
SE coordination number 12 characters, day retains its +60 offset 198701612384
SE organization number 12 characters, 16 prefix 162021005448
DK CPR number 10 characters, DDMMYYNNNN 2512489996

canonical() is append-only in spirit: if its output ever changes for any input, previously matched records unmatch. Any such change is a spec major version bump with a documented migration path, never a patch.

Denmark: validates() means less here

Danish numbers have no checksum. This is the single most important thing to know before treating a Danish result the way you would treat a Swedish one.

Modulus-11 was abandoned by CPR on 1 October 2007, because modulus-11-compliant serials ran out for certain birth-year cohorts. CPR states plainly that numbers issued without it are fully valid — "Personnumre uden modulus 11 er fuldt ud gyldige personnumre" — so enforcing modulus-11 would reject real people. It is therefore not a Danish validity rule in either direction.

What Danish validation actually checks is the whole of it:

  1. Shape — ten digits, optional - separator
  2. Serial at least 0001
  3. The century resolves via CPR's published table
  4. The resulting calendar date is real, and not after the reference date

The consequence follows directly, and callers should be told rather than left to infer it:

Almost no single-digit typo in a Danish serial is caught. Most produce a different, equally valid number.

Nothing in the list above is a checksum, so nothing exists to detect a typo as a typo. The checks catch a mistyped serial only incidentally, in two narrow cases: a serial that becomes 0000, and a change to the leading digit that moves the number into a century where the date does not exist — 29 February is the usual way — or has not happened yet.

Every other single-digit change is accepted. Changing a non-leading digit keeps the same century and yields another valid number, differing only in gender if it was the last digit. Changing the leading digit usually just relocates the birth date by a century. Sweden's Luhn check rejects roughly 90% of single-digit errors; Denmark rejects a handful by luck.

The practical consequence: never guess the country

A Swedish number misread as Danish will usually pass Danish validation, for exactly the same reason. That is why parse() requires a Country and why there is no convenience method that tries one country and falls back to the other.

The collision is not theoretical. 2601012384 — the ten-digit short form of a published Skatteverket test number — is a Swedish personal number born 2026-01-01 and a Danish CPR number born 1901-01-26. Both readings are completely valid; they are 125 years apart, and nothing in the value distinguishes them.

Where the country genuinely is the unknown, use detect(). It returns every interpretation and never picks a winner:

$candidates = PersonalIdentityNumber::detect('2601012384', $options);
// two candidates: one SE, one DK

ParseOutcome::number() returns null when more than one scheme accepted the input, so a caller cannot silently commit a person to the wrong country by reading a single result.

Swedish sole proprietorships: isPerson() is load-bearing

This is the most misunderstood behaviour in the package, and it is not a defect.

A Swedish sole proprietorship (enskild näringsidkare) has no separate organization number. Its organization number is its owner's personal number — identical digits. SCB says so directly, in the entry describing the PeOrgNr variable of Variabelbeskrivning för Företagsregistret:

För fysiska personer, enskilda näringsidkare, motsvaras PeOrgNr av personnumret, dvs det inleds med 19 eller 20.

For natural persons, sole traders, PeOrgNr corresponds to the personal identity number, i.e. it begins with 19 or 20.

So such a value resolves as SePersonalNumber with isPerson() returning true, and that is the correct answer. The distinction between a sole trader and a private individual is contextual, not structural: nothing in the digits carries it, and no ParseOptions setting recovers it.

Two consequences follow, and they point in opposite directions:

  • If you are matching people, treat isPerson() as load-bearing. A successful parse does not imply a private individual, and parseForPerson() will happily accept a sole proprietorship's number — correctly, because it is a person's number.
  • If you are matching companies, allowOrganizationNumber: false will not keep sole proprietorships out, because they never took the organization-number path in the first place.

Note where the safety of the organization-number carve-out comes from and why it stops here. 4 § lagen (1974:174) fixes an organization number's third digit at 2 or above expressly to prevent confusion with personal numbers, and a personnummer's third digit is the first digit of its month, so it is always 0 or 1. That rule is what makes company numbers structurally distinguishable — and it is silent on exactly this case, because a sole proprietorship has no assigned organization number for the rule to apply to.

Partial identity: 0000 and partial coordination dates

Three shapes declare part of themselves unknown, and all three parse:

// A 0000 birth number — the stillborn and unknown-person convention.
$unknownBearer = PersonalIdentityNumber::parse('20240115-0000', Country::Sweden, $options);
$unknownBearer->birthDate()->format('Y-m-d'); // '2024-01-15' — fully present
$unknownBearer->ageOn($referenceDate);        // 2
$unknownBearer->gender();                     // null

// A coordination number with an unknown birth month.
$unknownMonth = PersonalIdentityNumber::parse('191500722390', Country::Sweden, $options);
$unknownMonth->canonical();  // '191500722390' — complete, so matching is unaffected
$unknownMonth->birthDate();  // null
$unknownMonth->gender();     // Gender::Male — the birth number is intact

Coordination numbers encode an unknown month as 00 and an unknown day as 00 plus the usual +60 offset, so the digits read 60. Both are Skatteverket's own convention, and they are not rare in historical data: of the 1,498 coordination numbers in Skatteverket's published 1914–2019 dataset, 130 (8.7%) have an unknown month and 41 (2.7%) an unknown day. That population is exactly who coordination numbers are issued to — people without Swedish residency, whose birth documentation is often incomplete.

allowUnknownBirthNumber: false rejects the 0000 case with ParseFailure::SchemeNotAllowed. There is no equivalent flag for partial dates: they are ordinary coordination numbers, and allowCoordinationNumber: false already covers the path that does not want them.

Month 00 is accepted only on a coordination number. No published personnummer uses it — zero rows across all four of Skatteverket's personal-number datasets — so accepting it on an ordinary personal number would mean admitting a shape the registry does not issue.

The 1800 birth-year floor

A resolved birth year before 1800 fails with ParseFailure::ImplausibleBirthDate, separately from ImpossibleDate:

PersonalIdentityNumber::explain('001203049802', Country::Sweden, $options)->failure();
// ParseFailure::ImplausibleBirthDate — structurally valid, Luhn-valid, and a birth in the year 12

Why 1800 rather than something tighter: Sweden's personnummer system began in 1947 and assigned numbers to people then alive, so the earliest year a genuine Swedish number can encode is around the 1840s. The floor leaves deliberate margin because estate and probate work meets records of people who died decades ago — a 1960 death record for an 1865 birth carries a perfectly valid number. A tighter floor would start rejecting real historical records, and that failure would read as a package defect rather than a data-quality signal.

The check runs after the date is known to exist, never before: a date that never occurred is not made plausible by being recent, and reporting the floor for 30 February would be actively misleading.

recognizedCountry: a hint, never a guarantee

Once every scheme actually consulted has refused a value, ParseOutcome::recognizedCountry() reports whether some other country would have a better answer — the country asked (or, with no country named, every country asked) is always excluded from its own hint, so "Sweden refused it and it looks Swedish" is never reported. It rides along with most failures that were actually returned, not only UnsupportedScheme: a Swedish ask that fails with ChecksumMismatch on a value Denmark's own scheme would accept still carries the hint.

$outcome = PersonalIdentityNumber::explain('0104909989', Country::Sweden, $options);
$outcome->failure();           // ParseFailure::ChecksumMismatch
$outcome->recognizedCountry(); // Country::Denmark

What "recognized" means differs by tier, and that difference is the whole point. For a country that has a scheme — Sweden, Denmark, Norway and Finland, which is all of them today — this runs that country's own scheme, with the caller-narrowing allow* flags relaxed (allowCoordinationNumber, allowOrganizationNumber, allowUnknownBirthNumberSchemeNotAllowed is suppressed outright below regardless), but the caller's own referenceDate and allowSyntheticNumbers passed through unchanged: those two define what the caller counts as an acceptable value in the first place rather than narrow the scope of the request, so relaxing either would answer a different question than the one actually asked. A synthetic Norwegian value therefore produces a Norwegian hint only if the caller opted into allowSyntheticNumbers themselves — on default options it is refused here exactly as the caller's own request would refuse it. It reports the country only if it genuinely accepts the value. Denmark above is a real hint: the same digits actually parse as a valid Danish CPR number once Denmark is the country asked. Every country is now asked for real. The second tier — a shape match against digit counts and separator positions, for a country with no scheme to consult — still exists in the code but has no country left to serve, and will not until a new country is added.

Why two tiers rather than one shape test for every country: a shape test alone is far too permissive to serve as a hint at all. Sweden's shape matches essentially any six-to-twelve-digit numeric string — measured, 100% of ten-digit strings — where the real scheme accepting one requires a correct check digit (10%) and a real calendar date (0.4%). Reporting a country whose own scheme would reject the value outright is worse than reporting nothing: advisory means less certain than a parse, not unconstrained by one. The practical consequence: a Finnish-shaped code whose control character is wrong gets no hint at all, and neither does an eleven-digit string that fails Norway's modulus-11.

Three exceptions: CenturyRequired, SchemeNotAllowed and ReferenceDateRequired never carry the hint. All three are statements about the request, not the value — the asked country's own scheme would have parsed the number given different options (a reference date, an allow* flag), so "you may have named the wrong country" would not be true. A Swedish coordination number missing its reference date fails CenturyRequired; it is still Swedish, just incompletely asked for, and recognizedCountry() is null even when the value is shape-valid Danish too.

Treat it as advisory even though it is a real parse. A country's own scheme accepting a value under permissive options is not proof a bearer exists — only that the registry would not reject it outright the way the asked country did. It is a hint for a human deciding which country to ask next, not a fact to store, match records on, or branch application logic on as though it came from a parse.

Finland

$number = PersonalIdentityNumber::parse('010280-952L', Country::Finland, $options);
$number->canonical();                  // '010280-952L'
$number->birthDate()->format('Y-m-d'); // '1980-02-01'
$number->gender();                     // Gender::Female
$number->format(Format::Display);      // '010280-952L'

Five things to know, and the first two are the ones that catch people out.

  • The intermediate character is part of the identity, not punctuation. Decree 690/2022 added Y, X, W, V and U for 1900s births and BF for 2000s births, so that from 2023 a Finnish code is no longer unique without its seventh character. canonical() therefore keeps it, and Format::Display and Format::Short are the canonical form unchanged — there is no separator to insert, because the marker already occupies the position one would go. Stripping it to "normalise" a Finnish code merges two people.
  • Denmark and Finland collide on the same eleven characters. A Finnish code whose marker is - and whose control character is a digit is exactly the Danish DDMMYY-SSSS form, and since Denmark has no checksum, most such codes are valid Danish numbers too — so detect() can return two candidates for one input, with different birth dates. Measured across every written form of the published corpus, 11,281 of 33,807 forms match the Finnish shape. Name the country you mean.
  • The control character is a modulus-31 lookup and it is enforced. A wrong one gives ParseFailure::ChecksumMismatch. The nine digits either side of the marker are checksummed as a single number and the marker itself is skipped, so the same nine digits carry the same control character under every marker.
  • The individual number is not range-checked. DVV states that "in practice, all individual numbers issued are between 002 and 899", which is an observation about issuance rather than a stated validity rule — and this package enforces only stated rules, exactly as it does not enforce modulus-11 on a Danish number. A code with an individual number of 900–999 parses.
  • The century is stated, never inferred, so no Finnish accessor moves with the reference date except the future-birth-date check. There is no age-dependent output on a Finnish code at all: Format::Short never elides a century and never throws ReferenceDateRequired.

Finnish codes must be uppercase. Twenty-one of Finland's thirty-one control characters are letters, and the allow table carries them in uppercase only; a lowercase Finnish code resolves to ParseFailure::InvalidCharacter. Case folding is deliberately absent because PHP's mb_strtoupper and JavaScript's toUpperCase do not agree for every input, and the two runtimes disagreeing is worse than this limitation.

What this package does not do

It never encrypts, hashes, or stores the values it is given. It parses, validates, formats and classifies a value passed to it and hands back a plain-text canonical form; where and how that value is persisted is entirely the caller's concern. It also never touches the system clock — every temporal decision is driven by a reference date the caller supplies.

Sensitive-value handling within the package itself: parameters carrying raw input are marked #[\SensitiveParameter], __debugInfo() returns the masked form, and there is deliberately no __toString() — rendering is always a deliberate format() call.

Limitations

Read this before relying on this package beyond what is listed above:

  • Danish validation has no checksum. Covered in full in the Denmark section above, and repeated here because it is the limitation most likely to be missed: validates() returning true means materially less for a Danish number than a Swedish one.
  • isPerson() does not separate a sole proprietorship from a private individual, because nothing in the digits does. See the sole-proprietorship section — this is the limitation most often mistaken for a bug.
  • Denmark and Finland collide, and validates() cannot warn you. Most Finnish codes with a - marker and a digit control character are also valid Danish CPR numbers, resolving to a different birth date. See the Finland section above.
  • A lowercase Finnish code is InvalidCharacter. The allow table carries Finland's letters in uppercase only and there is no case folding.
  • recognizedCountry() is quieter than its name suggests. It names a country only where that country's own scheme genuinely accepts the value, so a Finnish-shaped code with a wrong control character, or an eleven-digit string failing Norway's modulus-11, gets no hint at all — see recognizedCountry.
  • A partial birth date yields no birth date and no age, while remaining a fully valid identity number with a complete canonical form. If your consumer requires a birth date, check for null rather than assuming a successful parse provides one.
  • Format::Masked still exposes the full date of birth. It is a display convenience, not a redaction primitive.
  • Swedish interim numbers (reservnummer) are out of scope, entirely — not a Scheme, not recognize-only. They are not issued by Skatteverket but assigned locally by each healthcare region, with no national format, no check digit, and no guarantee of national uniqueness — Region Stockholm's variant alone (twelve characters starting 99, encoding the issue year rather than birth year) admits on the order of 10⁸ arbitrary strings with essentially no filter, against the roughly 90% rejection rate Sweden's Luhn check gives real personal numbers. A scheme that cannot discriminate does not extend coverage; it dilutes what validates() means everywhere else. Reservnummer resolve to ParseFailure::NotAnIdentityNumber, indistinguishable from malformed input — the same treatment as Great Britain.