iliaal / nameparser
Parse a full-name string into its parts. Casing- and credential-aware fork of theiconic/name-parser, tuned for professional/clinician names.
Requires
- php: ^8.3
- ext-mbstring: *
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.64
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-25 16:51:43 UTC
README
Parse a string containing a full name into its parts (salutation, first name, middle names, initials, last name with prefixes, suffix, nickname).
This is a fork of theiconic/name-parser (dormant since ~2020), built on the modernization in codebyzach/name-parser. It adds casing- and credential-aware parsing and a confidence/ambiguity signal, and targets PHP 8.3+.
Why this fork
The upstream parser keys every token through strtolower() before matching it
against its salutation/suffix dictionaries, so it cannot tell an all-caps
credential from a same-spelled name. Two failure modes follow, both common in
professional and clinician name lists:
- A trailing credential without a comma swallows the surname:
"Jane Doe DDS"parsed to last name "Dds" (the real surname lost). - A short credential token that is also a real name is mis-stripped: the Vietnamese surname "Do" and given name "Vi" were consumed as the credentials DO / VI.
This fork fixes both and adds an advisory confidence pass for the ambiguous cases.
What changed
- An ambiguous token (
Do,Vi,Ma, roman numerals, two-letter credentials) is a credential only when written ALL-CAPS (DO,VI). Title or lower case keeps it as a name part, and lowercasevirenders asVi. People write credentials in caps and names in title case, so the original casing carries the signal that lowercasing discarded. - A lone name-colliding token in a comma given-name segment stays a name unless its casing reads as a credential.
Confidence::assess()flags input where a token matches a credential but the casing is uninformative (uniform-case input, or a lowercase token), so you can route it to manual review.- The English dictionary includes DDS, DO, DVM, PsyD, LCSW, MSW, MBA, EMBA, Esq,
roman numerals VI to X,
Hon., and more, inherited from the CodeByZach fork. - Nursing and allied-health credentials (RN, NP, PharmD, APRN, PA-C, OTR/L, and 30+ more), mined by frequency from the NPI registry, no longer leak into the first name.
- An opening
(or quote with no matching close no longer swallows the surname ("John (Bob Smith"keepsSmith). - Uniform-uppercase input can't mark a token as initials, so a two-letter given
name stays a name (
"JO ANDERSON"keepsJo, notJ+ initialO). Mixed-case combined initials still split ("JM Walker"toJMWalker). - Everything after the first comma is the given-name segment, so a
comma-separated middle name is kept (
"Smith, John, Robert"keepsRobert) while trailing credentials are still stripped. - Surname particles of one or two letters keep their place in the surname
instead of becoming initials: Irish
"Éamon Ó Cuív"keepsÓ Cuív, and capitalised continental particles ("Jean DE Vries","Mary LE Blanc") no longer split into initialsD EandL E. IrishÓ,Ní,Nic,Uí,Ua, andMhicare in the default dictionary and render capitalised. Dame,Lady,Lord,Pastor,Professor,Reverend, andRt Honare in the default dictionary, so"Lord Ashcroft"reads as a title plus surname.Rt Honalso matches its abbreviated and article-led forms ("Rt. Hon. Boris Johnson","The Rt Hon Boris Johnson"). Several of these are also real surnames, so the confidence pass flags the bare two-token form.
Requirements
- PHP 8.3+ (tested through 8.5)
ext-mbstring
Installation
composer require iliaal/nameparser
Usage
use Iliaal\NameParser\Parser; $parser = new Parser(); $name = $parser->parse('Dr. Jane A. Doe DDS'); $name->getSalutation(); // "Dr." $name->getFirstname(); // "Jane" $name->getInitials(); // "A." $name->getLastname(); // "Doe" $name->getSuffix(); // "DDS" $name->getFullName(); // "Jane A. Doe"
Name also exposes getMiddlename(), getNickname(),
getLastnamePrefix(), getGivenName(), getAll(), toArray(),
getSalutations(), isJoint(), getPartner(), getConfidence(), and getSource(). getLastname(true) returns the surname
without any particle prefix; the default getLastname() already includes
prefixes.
Structured output
toArray() returns every part under a fixed key set, with an empty string for
any part that is absent. Unlike getAll(), which omits empty parts, it needs no
existence checks:
$parser->parse('Dr. Jane A. Doe DDS')->toArray(); // [ // 'salutation' => 'Dr.', 'firstname' => 'Jane', 'initials' => 'A.', // 'middlename' => '', 'lastname_prefix' => '', 'lastname' => 'Doe', // 'suffix' => 'DDS', 'nickname' => '', 'given_name' => 'Jane A.', // 'full_name' => 'Jane A. Doe', // ]
lastname already includes any particle prefix (de la Torre);
lastname_prefix is a convenience extract, not a component to prepend.
Joint names
An honorific can cover two people. The parser still returns one Name, and the
given and family name belong to the person actually named, so isJoint() tells
you when the row implies a second contact:
$name = $parser->parse('Mr. and Mrs. Brad Smith'); $name->isJoint(); // true $name->getSalutation(); // "Mr. and Mrs." $name->getSalutations(); // ['Mr.', 'Mrs.'] $name->getFirstname(); // "Brad" $name->getLastname(); // "Smith"
getSalutation() renders the honorific the input carried. getSalutations()
splits it one entry per person, for a contact record that holds a single prefix:
$salutations = $name->getSalutations(); $prefix = $salutations[0] ?? ''; // "Mr." $partnerTitle = $salutations[1] ?? ''; // "Mrs." $partner = $partnerTitle === '' ? '' : $partnerTitle . ' ' . $name->getLastname();
The partner shares the surname, not the given name. Stacked titles address one
person and stay in one entry (Rev. Dr John Doe gives ['Rev. Dr.']), and a
name with no honorific gives an empty list. Mr. & Mrs. normalizes to the same
value as Mr. and Mrs..
getPartner() returns that second person as a Name, so you can read the parts
you need instead of assembling them:
$partner = $name->getPartner(); // Name, or null when isJoint() is false $partner->getSalutation(); // "Mrs." $partner->getLastname(); // "Smith" $partner->getFirstname(); // "", Brad's given name is not hers (string) $partner; // "Mrs. Smith"
A particle surname crosses over whole (Mr. and Mrs. van der Berg gives a
partner with van der Berg), while the given name, initials and any credential
stay with the person actually named.
Only the title-anchored form is detected. A bare Brad and Jane Smith has no
honorific for the connector to attach to and reports isJoint() === false.
Two people each given a name is the other undetected form:
$name = $parser->parse('Mr. Andrew and Mrs Sally Smith'); $name->toArray(); // salutation "Mr.", firstname "Andrew", middlename "Sally", lastname "Smith"
The conjunction and the second title are kept out of every getter rather than
title-cased into a name, so getMiddlename() gives Sally and not
And Mrs Sally. The parser does not decide that Sally is a second person, so her
given name stays where it lands. Both tokens remain visible as Ignored parts in
getParts() if you want to recover the structure yourself:
use Iliaal\NameParser\Part\Ignored; $household = array_values(array_filter( $name->getParts(), static fn($part): bool => $part instanceof Ignored, ));
Confidence / ambiguity
For batch imports where a wrong split is a data-integrity problem, check whether the input was decidable from its casing, either as a standalone pre-check on a raw string or on the parsed result.
use Iliaal\NameParser\Confidence; // pre-check, before parsing (default English ambiguous-key set) $result = Confidence::assess('NGUYEN, VI'); // ['ambiguous' => true, 'notes' => ["'VI' could be a name or a credential; input casing is uniform"]] // or read it off the parse; uses the parser's tokens and dictionaries $result = $parser->parse('NGUYEN, VI')->getConfidence(); if ($result['ambiguous']) { // queue the row for manual review instead of trusting the parse }
getConfidence() is read-only and does not change what parse() returns. A
mixed-case input like "Nguyen, Vi" stays unflagged; the title-case Vi
resolves to the given name.
A suffix, nickname, or empty trailing comma does not settle a two-part
salutation collision: "Lord Ashcroft MD", "Lord Ashcroft (Bob)", and
"Lord Ashcroft," are still flagged. A structural comma with name-bearing
content on both sides, as in "Lord, Ashcroft", resolves it.
For a non-default language set, standalone Confidence::assess($string) still
uses the English salutation scope and the full ambiguous-suffix table.
Name::getConfidence() uses the parser's configured suffixes, salutations, and
token boundaries, including custom whitespace rules, so prefer it when you need
confidence for an actual parse. Standalone callers can scope the
dictionaries with Confidence::assess($string, $parser->getSuffixes(), $parser->getSalutations()); standalone tokenization still splits on whitespace
and commas.
Disambiguation keys off casing, so both all-caps legacy data and all-lowercase input are ambiguous to the confidence pass. The parser treats an ambiguous ALL-CAPS trailing token as a credential, but keeps the lowercase form as a name part and normalizes its casing:
- Under uniform uppercase, Confidence flags a token only when it is name-leaning
(
Do,Vi,Ma,Ba,Lac) or a Census surname collision (II,III,IV,MBA). Credentials that are not also names (RN,PT,OD, and other roman numerals such asVII) stay unflagged to keep review volume manageable on all-caps datasets. - All-lowercase input flags any credential collision. The parser keeps a lowercase token as a name part, so a real lowercase credential there would be a wrong split worth routing to review.
Languages
new Parser() uses the English dictionary. Passing languages replaces that
list entirely (salutations, suffixes, and surname particles), it does not merge
onto English. new Parser([new German()]) gives German honorifics and ordinals
only, not English professional credentials or English particles such as van.
Compose dictionaries when you need both:
use Iliaal\NameParser\Language\English; use Iliaal\NameParser\Language\German; $parser = new Parser([new English(), new German()]);
Dictionary keys merge in constructor order, and the first language wins on
collisions. With English first, Fr. resolves to Fr.. With German first, it
resolves to Frau.
A language can also contribute joint-honorific connectors by implementing
ConnectorsInterface: the bundled German adds und, so Herr und Frau Schmidt parses as a joint name with isJoint() true. The English and and
& connectors always stay available regardless of the language set.
Configuration
Fluent setters on Parser:
setSurnameFirst(true)reads comma-less space-separated names in CJK order (Mao Zedong→ lastMao). Opt-in; romanized order cannot be auto-detected.setNicknameDelimiters(['<<' => '>>'])replaces the default pairs (()[]{}<>and quotes). An empty array restores the defaults; it does not disable nicknames. At most 32 valid pairs are used; opener and closer strings longer than 64 bytes are ignored.setWhitespaceandsetMaxSalutationIndextune collapse and mapper gates.setMaxCombinedInitials($limit)accepts 0 through 64. Values outside that range throwInvalidArgumentException; combined-token expansion is also capped at 131,072 expanded initial parts per parse and throwsLengthExceptionabove that expansion ceiling.setMappers([...])replaces the single-segment (Western, no-comma) pipeline only. Comma forms andsetSurnameFirst(true)use dedicated sub-parsers that always build their own mapper lists from the language dictionaries. Pass[]to restore the default pipeline.
Parsing limits
parse() accepts at most 1,048,576 input bytes and 65,536 non-empty tokens.
It throws LengthException before comma segmentation or mapper allocation when
either limit is exceeded. These bounds keep malformed import rows from
exhausting a PHP worker while retaining batch-scale inputs. Consecutive empty
comma segments are coalesced during structural splitting, so a delimiter-only
row at the byte limit does not allocate one array entry per comma.
Standalone Confidence::assess($string) applies the same byte and token limits.
Name::getConfidence() reuses the tokens from the already validated parse
instead of tokenizing the source again. Explicitly supplied token arrays are
also limited to 65,536 entries and 1,048,576 aggregate token bytes; supplying
tokens does not bypass validation of the original string.
Ambiguous inputs
Some inputs have no structural signal. A comma followed only by credentials can
mean full name plus credentials (Jane Doe, MD) or surname plus credentials
(Hidalgo Castillo, MD). The parser keeps the left side in Western order in
that case. Use an explicit given-name segment, for example
Hidalgo Castillo, Maria, MD, or post-process feeds where the left side is a
surname-only field.
An anglicised Irish surname with the fada dropped is undecidable the same way.
Eamon O Cuiv and John F Kennedy have identical structure and casing, so a
bare O between spaces stays a middle initial. The fada form Ó resolves as a particle, and the joined
apostrophe form (O'Cuiv) is a single token that never needed one.
Two-token surnames without particles are also ambiguous in space-separated names.
Jennifer Chen Wu and Mary Jo Li share the same token structure, but one wants
Chen Wu as a surname while the other wants Jo as a middle name. The parser
applies a compound-surname heuristic for two-character terminal surnames.
Unknown trailing credentials follow the same casing rule as the ambiguous
tokens. When a known credential anchors the tail and the input is mixed-case, an
adjacent unknown all-caps token is kept as a credential too: John Smith MD FACS
and Smith, John, MD, FACS keep both in the suffix. A pure all-caps segment
with no prior dictionary anchor is kept as a name (Smith, JOHN, MD → first
John, suffix MD), because it is indistinguishable from an all-caps given
name. Prefer the known credential first when the unknown stands alone
(Smith, MD, FACS). Uniform all-caps rows cannot recover unknown credentials:
with no case signal, an unknown token could equally be a surname, so it stays in
the name.
getFullName() and toArray()['full_name'] are the given name plus surname only
(no salutation, nickname, or suffix). __toString() is the richer display line
from getAll(true) (salutation through suffix, nickname wrapped). Both drop
comma structure and may not re-parse to the same fields, so treat them as
display output only.
Performance
Reuse one Parser across a batch rather than constructing a new one per row.
The parser memoizes its merged dictionaries, mapper pipeline, and comma-segment
sub-parsers on first use, so a shared instance pays that setup cost once.
Development
composer install composer test # phpunit with a 256 MiB memory limit composer analyse # phpstan (level 9) composer lint # php-cs-fixer (dry run)
Credits
Original library by The Iconic. Modernization to PHP 8.3+ by Zachary Miller. Casing/credential parsing and confidence signal in this fork by Ilia Alshanetsky.
License
MIT. See LICENSE. Upstream copyright notices are retained.