lavendla / personal-identity-number
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
Requires
- php: >=8.4
Requires (Dev)
- laravel/pint: ^1.30
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^11.0
- rector/rector: ^2.6
- squizlabs/php_codesniffer: ^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
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, A–F 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:
- Shape — ten digits, optional
-separator - Serial at least
0001 - The century resolves via CPR's published table
- 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, andparseForPerson()will happily accept a sole proprietorship's number — correctly, because it is a person's number. - If you are matching companies,
allowOrganizationNumber: falsewill 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,
allowUnknownBirthNumber — SchemeNotAllowed 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,VandUfor 1900s births andB–Ffor 2000s births, so that from 2023 a Finnish code is no longer unique without its seventh character.canonical()therefore keeps it, andFormat::DisplayandFormat::Shortare 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 DanishDDMMYY-SSSSform, and since Denmark has no checksum, most such codes are valid Danish numbers too — sodetect()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::Shortnever elides a century and never throwsReferenceDateRequired.
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()returningtruemeans 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 — seerecognizedCountry.- 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
nullrather than assuming a successful parse provides one. Format::Maskedstill 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 starting99, 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 whatvalidates()means everywhere else. Reservnummer resolve toParseFailure::NotAnIdentityNumber, indistinguishable from malformed input — the same treatment as Great Britain.