A GEDCOM 5.5.1 and 7.0 file parser for PHP. It reads
a GEDCOM stream (or a GEDZIP .gdz archive) line by line and exposes the records
(individuals, families, sources, notes, …) as a fully typed object model, detecting the
document version from its own header.
- PHP 8.3 or newer (verified on 8.3, 8.4 and 8.5)
- The
ext-mbstring,ext-intlandext-iconvextensions (used to transcode the source encoding to UTF-8) - The
ext-zipextension (used to read GEDZIP.gdzarchives)
Install with Composer:
composer require magicsunday/gedcom-parserTo remove the parser again:
composer remove magicsunday/gedcom-parserCreate a stream for your GEDCOM file, parse it, and traverse the resulting model:
<?php
use MagicSunday\Gedcom\Parser;
use MagicSunday\Gedcom\StreamFactory;
require 'vendor/autoload.php';
$stream = (new StreamFactory())->createStreamFromFile('/path/to/your/tree.ged');
$document = (new Parser($stream))->parse();
foreach ($document->individuals as $individual) {
$name = $individual->name[0] ?? null;
echo $individual->xref, ': ', $name ? $name->getDisplayName() : '(unknown)', "\n";
}A GEDCOM 7.0 dataset may also be packaged as a GEDZIP (.gdz) archive — a ZIP container whose
mandated gedcom.ged entry carries the dataset alongside any embedded media files. Read one with
GedcomZipReader, which extracts that entry and returns the same typed GedcomDocument:
use MagicSunday\Gedcom\GedcomZipReader;
$document = GedcomZipReader::readFile('/path/to/your/tree.gdz');GedcomZipReader::read() accepts a PSR-7 stream instead of a path (it is spooled to a temporary
file, since the ZIP facility needs a seekable source). Reading GEDZIP requires the ext-zip
extension.
To also read the archive's embedded media, open it with GedcomZipReader::openArchive(), which
returns a GedcomArchive handle exposing the parsed document and resolving an OBJE.FILE reference
to a stream over its embedded entry. Close the handle when done — the media streams read lazily from
the archive:
$archive = GedcomZipReader::openArchive('/path/to/your/tree.gdz');
try {
foreach ($archive->getDocument()->multimedia as $object) {
foreach ($object->file as $file) {
$media = $archive->openFile($file->value); // null for a web URL or an unresolved reference
}
}
} finally {
$archive->close();
}openFile() returns null when the reference is a web/file: URL, an absolute or traversing path,
or names an entry the archive does not contain.
Parser::parse() returns the typed GedcomDocument aggregate — it detects the GEDCOM version
from the header and maps the standard records (INDI, FAM, SOUR, NOTE / the GEDCOM 7.0 shared-note
SNOTE, REPO, OBJE, SUBM) onto their typed records grouped by type ($document->individuals,
->families, ->notes, …). The record dispatch is version-aware: a 5.5.1 document resolves its
NOTE record and a 7.0 document its SNOTE, both into ->notes, while a cross-version tag is
tolerated and skipped rather than aborting the parse. A 7.0 shared note additionally carries its
text's language and media type (NoteRecord::$lang / $mime) and any translations of the text
($tran, a list of typed NoteTranslations), all left empty for a 5.5.1 note. A GEDCOM 7.0
document's header extension-tag
schema (HEAD.SCHMA.TAG) is exposed on $document->extensionTags as a map of each extension tag to
its declared URIs (a list, since 7.0 allows a tag to be documented more than once; empty for a
5.5.1 document). You can also parse an in-memory GEDCOM string with StreamFactory::createStream().
A substructure the typed model does not consume is not dropped: it is preserved verbatim on the
carrying object's $unknown list as a MagicSunday\Gedcom\ValueObject\RawSubstructure (->tag,
->value, ->xref, ->children), at every object-bearing level of the typed record model. This
covers an extension (_-prefixed vendor tag such as _WT_USER) or out-of-place tag, a tag
the schema does permit but that the carrying object does not yet model as a typed field, and a tag
that is modelled but could not be attributed (see the malformed-input cases below) — whether
at the record level (such as OCCU or RESI on an individual, landing on the record's
$unknown) or nested under a modelled substructure (such as an unmodelled tag under a birth
event, landing on that event's own $unknown). So an unrecognised or not-yet-modelled tag remains
reachable and walkable rather than being silently lost:
foreach ($document->individuals[0]->unknown as $raw) {
echo $raw->tag, ' = ', $raw->value ?? '', "\n"; // e.g. "_CUSTOM = …" or "OCCU = Baker"
}The parsed value-object leaves (DATE/PLAC/AGE) also carry an $unknown list, so an
out-of-schema tag directly beneath one is preserved on that value object too (e.g.
$individual->birt[0]->date->unknown). A value-object leaf that carries a child is shaped as a
structured object for this purpose even when it declares no substructures of its own (a GEDCOM 5.5.1
DATE/AGE), so nothing beneath it is lost.
A scalar field (such as SEX, modelled as a bare ?string) has no object of its own to carry an
$unknown, so a tag nested beneath one is preserved on the carrying object instead, under an
entry repeating that scalar's own line. Every object-bearing position — a record, a nested typed
substructure, and a value-object leaf — preserves what it does not consume.
Malformed input is preserved on the same terms rather than dropped, in two further cases. A recognised substructure the schema permits only once but the file gives more than once keeps its first occurrence typed and every later one verbatim. And a substructure whose level skips one (more than one below its parent) is not attributed at all — the level number is the only thing stating where a line belongs, so attributing it would invent a nesting the file never expressed — but it is preserved with its whole subtree on the container it sat in.
An $unknown entry may therefore carry a tag that the model does have a field for, which means
three different things depending on how it got there:
// 1 SEX M / 2 _SRC x → the entry's own value IS consumed (it is $sex); its children are not.
// 1 SEX M / 1 SEX F → the entry's value is NOT consumed; $sex holds the first occurrence.
// 0 @I1@ INDI / 2 SEX M → the entry's value is NOT consumed; $sex is null, the level skipped one.So a consumer walking $unknown should not assume an entry whose tag matches a typed field is
redundant with it — nor that the field is populated at all. Where a duplicate repeats the same value
the first two are genuinely indistinguishable, which is a known limit of the shape rather than
something the model resolves.
Parsing accumulates the dataset in memory, so an oversized or hostile input is a denial-of-service
surface — most sharply for GEDZIP, where a tiny archive's gedcom.ged entry can inflate to a huge
dataset (a decompression bomb). The reader therefore caps the total number of bytes it reads from a
source and aborts past the cap with a MagicSunday\Gedcom\Exception\InputTooLargeException. The cap
is enforced at the single byte-reading choke point, so it bounds every read path uniformly — the
plain .ged Parser, the version-fixed TypedGedcomParser, and the GEDZIP reader, where it counts
the decompressed entry bytes and so bounds the parse independently of the compressed transport
size.
The cap defaults to Reader::DEFAULT_MAX_BYTES (512 MiB) — above the range a legitimate large tree
occupies, so it does not reject real data out of the box while still turning an otherwise unbounded
parse into a bounded one. It bounds the bytes read, not the in-memory object graph the parse builds
(a multiple of the input size), so a service parsing untrusted input should size the cap against its
PHP memory_limit (roughly memory_limit divided by the input-to-heap expansion factor) rather than
rely on the default ceiling. It is caller-configurable through every entry point; lower it when
parsing untrusted input, or raise it (up to PHP_INT_MAX for effectively unbounded) for a genuinely
huge trusted tree:
use MagicSunday\Gedcom\GedcomZipReader;
use MagicSunday\Gedcom\Parser;
$document = (new Parser($stream, 32 * 1024 * 1024))->parse(); // plain .ged, 32 MiB cap
$document = GedcomZipReader::readFile('/path/to/tree.gdz', 32 * 1024 * 1024); // decompressed capGenealogically structured values are exposed as typed, final readonly value objects (in
MagicSunday\Gedcom\ValueObject) alongside their raw strings, so you can sort, compare and
render them without re-parsing:
EventDetail::$date(?DateValue) — theDATE_VALUEgrammar (qualifiersABT/CAL/EST, rangesBEF/AFT/BET … AND …, periodsFROM/TO, interpreted and phrase dates) around one or two calendar-awareCalendarDates (every GEDCOM calendar, plusB.C.and dual1699/00years).CalendarDate::toJulianDay()gives a calendar-independent Julian Day Number for sorting and comparison (Gregorian and Julian; the other calendars follow).EventDetail::$plac(?PlaceValue) — the comma-separated jurisdiction hierarchy, with amapped()view onto the placeFORMlabels (the place's ownFORM, or theHEAD.PLAC.FORMdefault the header declares once for every place that carries none) and theMAPgeographic coordinates (?MapCoordinates, signed decimal degrees) when present.EventDetail::$age(?AgeValue) — theAGE_AT_EVENTgrammar (< 8y,72y 3m 2d, the GEDCOM 7.0 weeks unit8w,CHILD/INFANT/STILLBORN).
Each property is null when its tag is absent or empty; the parsed object keeps the original
raw text.
The parser reads any readable stream — including non-seekable ones such as a pipe
(cat tree.ged | your-app) or a network response body — and accepts all four GEDCOM 5.5.1
line terminators (CR, LF, CRLF and LFCR), so classic-Mac (CR-only) files parse correctly.
Blank and whitespace-only lines — which some exporters append, most commonly after the
trailer — are skipped rather than mis-parsed, while line numbers in error messages stay
aligned with the physical file.
Reading a stream is bounded, so hostile or broken input cannot exhaust memory or hang the
reader: a single terminator-less line beyond Reader::MAX_LINE_LENGTH raises a
LineTooLongException, the total byte count is capped as described in
Bounding the parse above, and a stream that never
signals end of stream is treated as ended after a bounded number of empty reads rather than
spun forever. This matters most for the untrusted-network-stream case noted above.
The source encoding is detected from the byte-order mark or the HEAD.CHAR declaration and
transcoded to UTF-8: ANSEL (the 5.5.1 default, decoded via the bundled Z39.47 table),
UTF-8, UNICODE (UTF-16, little- or big-endian) and ASCII. As a real-world convenience
beyond the 5.5.1 charset set, a Windows export is honoured too: a bare ANSI / WINDOWS decodes
as Windows-1252, and an explicit codepage (WINDOWS-1250, CP1257, …) decodes with that exact
codepage when the platform's iconv carries it, falling back to Windows-1252 otherwise. Reading
requires the ext-mbstring, ext-intl and ext-iconv extensions.
Every exception the library throws implements
MagicSunday\Gedcom\Exception\ExceptionInterface, so all parser and stream failures can
be caught as a single group.
Contributor and AI-assistant guidelines — coding standards, the buildbox-based tooling,
the review workflow and the GEDCOM conformance rules — are documented in
AGENTS.md. The authoritative GEDCOM specifications (5.5.1 and 7.0,
including the machine-readable 7.0 registry) are vendored under
docs/spec/ as the normative reference for conformance work.
The parser is built on a schema-driven, fully typed model. A generic tree
reader (MagicSunday\Gedcom\Parse) turns the flat reader lines into an immutable node
tree; a declarative schema (MagicSunday\Gedcom\Schema) is compiled from the vendored
registry for either GEDCOM version; and a mapping layer (MagicSunday\Gedcom\Mapping)
shapes a node subtree through that schema and hydrates immutable final readonly records
(MagicSunday\Gedcom\Model) via magicsunday/jsonmapper,
with the value-object leaves (dates, places, ages) parsed by their own grammar. Each leaf is
resolved regardless of the GEDCOM version — a bare payload string in 5.5.1, or the shaped node
a 7.0 substructure-bearing leaf (a DATE with PHRASE/TIME, a PLAC with FORM/MAP)
produces. A GEDCOM 7.0 DATE/AGE carried only by its PHRASE substructure is threaded onto
the value object as a phrase rather than dropped, a 7.0 DATE/SDATE exposes its wall-clock TIME
verbatim as DateValue::$time — the same form ExactDate keeps a record timestamp's time in — and a
PLAC's MAP coordinates are exposed as signed decimal degrees. The analysis runs clean at PHPStan level: max with no baseline — enforcing
architecture boundaries via phpat — and jscpd finds no duplication, so both are hard CI gates.
TypedGedcomParser ties the pipeline together: give it the GEDCOM version and a map of
record tag to your typed record class, and it streams the level-0 records and maps each
recognised one (unmapped records such as HEAD/TRLR are skipped):
use MagicSunday\Gedcom\Mapping\TypedGedcomParser;
use MagicSunday\Gedcom\Schema\GedcomVersion;
use MagicSunday\Gedcom\Model\FamilyRecord;
use MagicSunday\Gedcom\Model\IndividualRecord;
use MagicSunday\Gedcom\Model\MultimediaRecord;
use MagicSunday\Gedcom\Model\NoteRecord;
use MagicSunday\Gedcom\Model\RepositoryRecord;
use MagicSunday\Gedcom\Model\SourceRecord;
$parser = TypedGedcomParser::create(GedcomVersion::V551, [
'INDI' => IndividualRecord::class,
'FAM' => FamilyRecord::class,
'SOUR' => SourceRecord::class,
'NOTE' => NoteRecord::class,
'REPO' => RepositoryRecord::class,
'OBJE' => MultimediaRecord::class,
]);
foreach ($parser->parse($stream) as $record) {
// one typed IndividualRecord or FamilyRecord at a time, in document order (parse() yields, so
// a large file is never held in memory); wrap in iterator_to_array() if you need the full list
}When you need random access rather than a single streaming pass, parseDocument() drains the
same pipeline eagerly into a typed GedcomDocument aggregate that groups the records by their
modelled type ($document->individuals, ->families, ->sources, ->notes, ->repositories,
->multimedia, ->submitters); a record whose type is not modelled is kept in ->others rather
than dropped:
$document = $parser->parseDocument($stream);
foreach ($document->individuals as $individual) {
echo $individual->xref, "\n";
}TypedGedcomParser needs the version stated up front. When you would rather let the file say which
version it is, GedcomDocumentReader detects it from the header's GEDC.VERS line (defaulting to
5.5.1 for a version-less or unrecognised header) and reads the whole stream into the same typed
GedcomDocument — so you only supply the record-tag map:
use MagicSunday\Gedcom\Mapping\GedcomDocumentReader;
$document = GedcomDocumentReader::create([
'INDI' => IndividualRecord::class,
'FAM' => FamilyRecord::class,
])->read($stream);The typed record set is still growing; only the modelled records are mapped today. Currently an
IndividualRecord exposes its names — each a typed PersonalName that derives the given name,
surname and suffix from the John /Doe/ slash convention (an explicit GIVN/SURN/NPFX/SPFX/
NSFX/NICK piece always winning) and offers a slash-free getDisplayName() — its sex, its
life events (birth, death, burial, baptism, christening, cremation, confirmation, emigration,
immigration, naturalization, ordination, graduation, retirement, probate, will and the like, each a
typed EventDetail list) and its attributes (occupation, residence, education, religion,
nationality, title, description and the like, each a typed AttributeDetail list carrying the
attribute's value and TYPE alongside the same event detail), and its child- and
spouse-to-family links (FAMC/FAMS), a FamilyRecord exposes its partner and child
cross-references and its marriage events — each event a typed EventDetail (date, place, age) —
a SourceRecord exposes its descriptive fields (title, author, publication, abbreviation,
text), a NoteRecord exposes its shared-note text, a RepositoryRecord exposes its name and contact
details, and a MultimediaRecord exposes its file references (each with a typed format and
title). A file's format classifies what it depicts version-specifically: GEDCOM 5.5.1's free-text
MediaFormat::$type (TYPE) or GEDCOM 7.0's enumerated MediaFormat::$medi (MEDI, a typed
Medium carrying the enumerated value plus an optional PHRASE for the OTHER medium); each stays
null in the other version. Enumerated GEDCOM 7.0 values (a medium, an individual's sex, a
a name type) stay plain strings so an extension or unlisted value is
preserved, and the known standard values of each set are available as typed constants under
MagicSunday\Gedcom\Enumeration (MediumType, Sex, Pedigree, NameType — e.g.
MediumType::PHOTO, Sex::FEMALE) for discoverable comparison. Every record additionally exposes its GEDCOM 7.0 record-level external
identifiers — any number of UID values (a list of raw strings) and any number of EXID
identifiers (each a typed ExternalIdentifier carrying the identifier plus an optional TYPE
authority URI); both stay empty for a 5.5.1 record, which cannot carry them. A record's GEDCOM 7.0
creation timestamp (CREA) is exposed as a typed CreationDate nesting an ExactDate — the raw
exact date and optional time strings, kept unparsed because the timestamp uses the restricted
exact-date grammar rather than the genealogical date grammar; it is null for a 5.5.1 record. A
record's change timestamp (CHAN) is exposed as a typed ChangeDate — the same ExactDate plus any
inline notes (Note, with their 7.0 language, media type and translations) and 7.0 shared-note
references documenting the change; unlike the creation timestamp it exists in both GEDCOM versions, so
it is populated for a 5.5.1 record too.
Beyond these, the records expose the cross-cutting substructures the rollout has typed: record-level
notes (NOTE) and source citations (SOUR), submitter and research-interest pointers
(SUBM/ANCI/DESI), associations to other individuals (ASSO), asserted non-occurrences (NO),
LDS ordinances (BAPL/CONL/ENDL/INIL/SLGC/SLGS), aliases (ALIA), the version-aware child
count (FAM.NCHI), user reference numbers (REFN), multimedia links (OBJE, pointer form),
shared-note pointers (SNOTE), the restriction notice (RESN), the generic events and facts
(EVEN/FACT), the source record's data block (DATA, with the events it records), the structured
postal address (ADDR, with its city, state, postal code and country) and, on a birth, christening
or adoption event, the family the child belongs to (FAMC, with the adopting parent) — each a typed
value object or list. A family's events and attributes additionally expose the two partners' ages at
them (HUSB.AGE and WIFE.AGE). With these, every substructure the schema permits on an event or
attribute detail is typed, in both GEDCOM versions.
A note written as a pointer to a shared note names it in Note::$xref; its $value carries the
pointer target as well, so a consumer that renders the value sees the identifier rather than prose.
Substructures not yet modelled are preserved verbatim on the carrying object's $unknown list rather
than dropped, so a later release can type them without losing anything today. Where a tag the model
types as a plain value nonetheless carries substructures of its own, those descendants appear on
$unknown beneath a carrier repeating that tag's own line, so a qualifier stays attributable to the
entry it qualifies; only a carrier's children are unconsumed. This holds for a value object's own
substructures too: a GEDCOM 7.0 place keeps the language, translations, notes and identifiers its
grammar has no use for on the place's own $unknown.
All PHP tooling runs through the build container. Run the full check with
composer ci:test, or invoke the individual steps:
composer update
# everything at once
composer ci:test
# …or step by step
composer ci:test:php:lint # phplint
composer ci:test:php:unit # PHPUnit
composer ci:test:php:phpstan # PHPStan (static analysis)
composer ci:test:php:rector # Rector (dry-run)
composer ci:test:php:cgl # php-cs-fixer (dry-run)
composer ci:test:php:cpd # jscpd (copy/paste detection)The same steps run on PHP 8.3, 8.4 and 8.5 in GitHub Actions
(.github/workflows/ci.yml). The phpstan and
cpd steps are currently non-blocking while the typed-model refactor is in
progress.