liberu-genealogy/php-gedcom
A PHP library for reading, writing, and manipulating GEDCOM genealogy files. It offers an object-oriented API for parsing individuals, families, events, and records, simplifying import and export of family-tree data in Laravel or any PHP application.
Install the package and pin the minor version while the GEDCOM-X work is still marked WIP:
composer require liberu-genealogy/php-gedcom:^4.2
First places to look:
src/Gedcom/Parser.php — the entry point; parse() accepts a file path or raw string and returns a Gedcom object.src/Gedcom/Record/ — the typed record classes (Indi, Fam, Sour, Repo, Note, Obje, etc.). Each record exposes getXref(), getUid(), and typed accessors for its sub-tags.tests/ — the fixtures are the fastest way to see how real-world files (with CONC/CONT continuation, _UID, and vendor-specific tags) are represented in memory.Minimal first use case — a queued import job for an uploaded .ged file:
$parser = new \Gedcom\Parser();
$gedcom = $parser->parse(Storage::path($upload->path));
foreach ($gedcom->getIndi() as $indi) {
// $indi->getXref() e.g. "I0042"
// $indi->getName() array of \Gedcom\Record\Indi\Name
// $indi->getUid() array of UID/_UID values (v4.2.0+)
}
Don't render from the parse in the request cycle — dispatch a job and persist to your own Eloquent models so you can query, diff, and re-import later.
Import pipeline. Validate the upload (extension + a quick 0 HEAD sniff on the first line), move it to a private disk, record the original filename and checksum, then dispatch a job. Keep the raw file around: it is your audit trail and your only reliable way to reproduce a bad import.
class ImportGedcom implements ShouldQueue
{
public function handle(GedcomImporter $importer): void
{
$importer->import($this->upload, $this->tree);
}
}
Map to Eloquent with a stable key. The single most important decision is what you key on. GEDCOM xref (@I0042@) is file-local and changes on every re-export from desktop software, so never use it as a primary key. Use UID/_UID as the durable external identity, with the xref stored as a secondary gedcom_xref column scoped to the import. v4.2.0 adding UID support to all record types means you can now apply the same upsert strategy to sources, repositories, notes, and media — not just individuals and families.
Individual::upsert(
$rows,
uniqueBy: ['tree_id', 'uid'],
update: ['gedcom_xref', 'surname', 'given_names', 'updated_at']
);
Prefer upsert over updateOrCreate in the loop; a 50k-individual file will otherwise spend most of its time on round trips.
Dedupe on re-import. With UIDs available everywhere, a second import of the same file should be idempotent. Compute a checksum of the file first and short-circuit if unchanged; otherwise upsert on (tree_id, uid) and soft-delete records present in the DB but absent from the file (guarding behind a confirmation for the first import of a new file).
GEDCOM-X export. The new mapping layer converts GEDCOM records into GEDCOM-X (JSON) for FamilySearch-style consumers. Treat it as an adapter boundary, not a core model:
$gedcomX = (new \Gedcom\GedcomX\Mapper())->map($gedcom);
return response()->json($gedcomX->toArray());
Because this is explicitly WIP, wrap it in your own GedcomXExporter class with a feature flag and a contract test against a checked-in expected JSON. When the shape shifts in a later release, you fix one class instead of every call site.
UIDs propagate to export. If you build GEDCOM-X by hand rather than via the mapper, remember v4.2.0 now surfaces UID/_UID on export — preserving them is what lets a downstream consumer round-trip the record back to you without creating a duplicate person.
Multi-tree, multi-tenant. One trees row per uploaded file lineage; every individual/family/source row carries tree_id. This makes "delete this import" a single scoped query and prevents cross-family merges.
Testing. Store small (5–20 line) GEDCOM fixtures under tests/Fixtures/gedcom/, one per quirk you support: CONC/CONT splitting, ANSEL-encoded names, _UID without UID, missing xrefs. Assert on your Eloquent state, not on the package's internal objects, so package upgrades don't churn your test suite.
UID vs _UID. Both appear in the wild. UID is the spec tag; _UID is a common vendor extension (Frequently from older FamilySearch/PAF exports). Your resolver should try UID, fall back to _UID, and only then fall back to a hash of xref + name. Log when you take the fallback — it usually means the file came from software that will not round-trip cleanly.^4.2 in composer.json, not *). Expect the JSON shape — not necessarily the method signatures — to move.CONC (concatenation, no whitespace) and CONT (newline). If you ever touch raw text before parsing, you will silently corrupt long note fields. Do all text manipulation on the parsed objects.HEAD/CHAR declares UTF-8, ANSEL, or ASCII. ANSEL is not a PHP-supported encoding; if you accept files from older desktop software you need a transliteration step or you'll get mojibake in names — which then breaks UID-based matching if you ever key on name.ABT 1850, BET 1840 AND 1845, (1850). Store the raw string alongside your best-effort parsed date_from/date_to range. Casting to a single Carbon column loses information permanently and cannot be recovered without re-parsing.memory_limit in the job, set $timeout generously, and consider chunking by top-level record if you hit limits. ShouldQueue without a timeout override will kill long imports at 60s._APID, _MILT, _WEBTAG) are preserved on the record. Before discarding them, consider whether a client will expect them back on export — round-trip fidelity is often the actual requirement.php artisan tinker with dd($record->toArray()). Most "broken" imports are a single malformed CONT block or an unterminated NOTE.Parser accepts a custom record-class map; use it if you need typed access to a proprietary tag rather than reaching into the generic sub-record bag every time.How can I help you explore Laravel packages today?