cgdprojects/php-gedcom
A PHP library for parsing, creating, and writing GEDCOM genealogy files. It offers an object-oriented model for individuals, families, events, and sources, making it easy to import or export family-tree data in PHP applications.
Install and locate the moving parts
composer require cgdprojects/php-gedcom
The package is small; you can read the whole src/ tree in an afternoon. Read in this order:
Parser.php — entry point, the only class most apps touch on import.Writer.php — the serializer; confirm line endings and charset behaviour before you trust it.Gedcom.php — the root container; getIndi(), getFam(), getSour(), getHead() are your accessors.Record/Indi.php, Record/Fam.php — the two records that carry 95% of genealogy data.Record/Indi/* (or Record/Indi/Event*) — birth/death/marriage sub-records and their Date/Plac children.First use case: inventory a file
Parse a .ged, iterate individuals, print identifiers and names. This proves your autoload path, the parser's tolerance of real-world files, and the object shape in one shot.
require __DIR__ . '/vendor/autoload.php';
use PhpGedcom\Parser;
$gedcom = (new Parser())->parse(__DIR__ . '/family.ged');
printf("INDI: %d FAM: %d\n", count($gedcom->getIndi()), count($gedcom->getFam()));
foreach ($gedcom->getIndi() as $indi) {
$names = $indi->getName(); // array of Name objects
$name = $names[0] ?? null;
echo $indi->getId() . "\t" . ($name?->getName() ?? '(unnamed)') . PHP_EOL;
}
Two things to do before writing real code:
var_dump($indi) a single record. Accessor names in this family of libraries are inconsistent (getName() returning an array vs. a single object is a common surprise), and guessing costs more time than inspecting.$head = $gedcom->getHead();
echo $head->getChar()->getChar(); // UTF-8, ANSEL, ASCII, UNICODE
echo $head->getGedc()->getVers(); // 5.5.1, 7.0, ...
Smoke-test the round trip immediately. Parse a file and write it back out, then diff against the original. Do this on day one, before you build features on top. It tells you instantly whether unknown tags survive, whether CONC/CONT are re-emitted, and whether line endings change.
Never let GEDCOM objects leak into your domain layer. Parse, map to your own entities, persist. Keep the raw file on disk so you can re-import after a parser bug or a mapping fix.
foreach ($gedcom->getIndi() as $indi) {
Person::updateOrCreate(
['xref' => $indi->getId()],
[
'given' => $indi->getName()[0]?->getGivn(),
'surname'=> $indi->getName()[0]?->getSurn(),
'sex' => $indi->getSex()?->getSex(),
]
);
}
Do the same for Fam, then resolve relationships in a second pass (see the XREF map below).
GEDCOM links records by pointer string, not by object reference. FAMC/FAMS/HUSB/WIFE/CHIL all come back as @I123@-style strings. Build the map once:
$indiByXref = [];
foreach ($gedcom->getIndi() as $i) {
$indiByXref[$i->getId()] = $i;
}
foreach ($gedcom->getFam() as $fam) {
$husband = $indiByXref[$fam->getHusb()] ?? null; // may be null: orphan pointer
// ...
}
Always null-check. Real files contain dangling pointers, @VOID@, and references to records that were stripped by the exporting tool.
Resist the urge to sprintf GEDCOM lines. Use the object model, then serialize — it handles CONC folding and level numbering for you.
use PhpGedcom\Gedcom;
use PhpGedcom\Record\Indi;
$out = new Gedcom();
// Seed HEAD from a known-good template file; hand-building HEAD is tedious.
$out->setHead($templateGedcom->getHead());
$indi = new Indi();
$indi->setId('@I1@');
// ... populate NAME, SEX, BIRT ...
$out->addIndi($indi);
(new \PhpGedcom\Writer())->write($out, 'export.ged');
Copying the HEAD from a template you have validated against Gramps or Ancestry saves hours of GEDC/SOUR/SUBM fiddling.
Anonymization, place-name normalization, and source-citation backfills all follow the same shape: parse → mutate in memory → write. Wrap it in a console command with --dry-run that emits a diff summary rather than a file.
foreach ($gedcom->getIndi() as $indi) {
if ($this->isLiving($indi)) {
$indi->getEvent() // strip RESI, OCCU, NOTE, EMAIL, etc.
}
}
Because the library holds everything in memory, chunk by file, not by record, for anything over ~50 MB.
Cheapest high-value test you can write for this package: a tests/fixtures/ directory of real GEDCOMs (Gramps export, Ancestry export, MyHeritage export, one with a non-ASCII charset, one with heavy CONT notes), each asserting parse → write → re-parse produces an equivalent object graph. This catches writer regressions and tag-loss the moment you upgrade.
Charset is the number-one source of corrupted imports. GEDCOM 5.5.1 permits ANSEL, and plenty of old European files use it. PHP has no ANSEL codec. Check HEAD.CHAR and, if it isn't UTF-8 or ASCII, transcode with iconv (//TRANSLIT off) or a dedicated mapping table before parsing. Never assume UTF-8. Also watch for a UTF-8 BOM at byte 0 breaking the first 0 HEAD line.
CONC vs CONT semantics. CONC concatenates without a separator (used to fold long lines); CONT inserts a newline. If note text comes back with missing spaces or run-together words, the writer is folding at the wrong place — GEDCOM caps lines at 255 characters and the split must land on a byte boundary, ideally at whitespace. Verify against a note containing multi-byte UTF-8, which is where naive strlen/substr folding corrupts characters.
Unknown tags are the round-trip killer. Ancestry injects _APID, FamilySearch uses _UID, MyHeritage uses _MTTAG. If the parser silently drops unrecognized tags, a parse→write cycle will strip provenance metadata from the user's file. Test this explicitly on day one; if tags are lost, either patch the record classes to retain raw lines or keep the original file as the canonical artifact and never write back over it.
Dates are not strtotime-able. ABT 1850, BEF 12 MAR 1900, BET 1880 AND 1890, FROM 1900 TO 1910, @#DJULIAN@ 1732, INT 1 JAN 2000 (guess). All are valid. Store the raw string verbatim alongside any parsed approximation, and never let a bad parse overwrite the original. A DateParser that returns null for unrecognized input is far safer than one that guesses.
Line endings and the final TRLR. The spec mandates CRLF. Many tools tolerate LF; some don't. After writing, assert the file ends with 0 TRLR and that your line endings match what your target consumer expects. A missing trailer produces a truncated-import error in Gramps with no useful message.
PHP 8.2+ dynamic properties. Last release was November 2021, so expect Creation of dynamic property ... is deprecated notices on PHP 8.2 and above if any record classes assign undeclared properties. You cannot fix this without forking. Pin the version in composer.json, add #[AllowDynamicProperties] via a subclass if needed, or suppress deprecations in your import job's error handler — but do not let it hide real errors elsewhere.
Treat the package as unmaintained. 24 stars and a three-year-old release means no security response and no PHP-version guarantee. Mitigate by isolating it behind your own GedcomImporter/GedcomExporter service classes so swapping in a maintained fork is a one-file change. Budget for vendoring a fork if you hit a parser bug.
Memory ceiling on large trees. A 100k-individual file builds a large object graph. If you ingest user uploads, enforce an upload size limit, run imports in a queued job with an increased memory_limit, and measure peak usage on your largest fixture early rather than discovering it in production.
Extension points are limited but sufficient. There is no plugin system. Your options are (a) subclass record classes to add accessors, (b) post-process the object graph between parse and write, or (c) fork. Option (b) covers nearly everything you'll want — computed full names, place canonicalization, living-person detection.
Debugging tip: when output looks wrong, dump the object graph to a normalized array (json_encode with a recursive mapper) and diff that between input and output, not the GEDCOM text. Text diffs are drowned in record-ordering noise that is semantically irrelevant.
How can I help you explore Laravel packages today?