fisharebest/webtrees
webtrees is an open-source, collaborative web genealogy application built with PHP. It lets families publish and manage GEDCOM-based family trees online, with multi-user editing, privacy controls, charts, reports, and media support.
First, reset your mental model: fisharebest/webtrees is not a Composer library you require into an existing Laravel app. It is a complete, standalone PHP application that vendors Laravel components (Eloquent, Blade, the container, the router). "Leveraging the package" day-to-day means deploying the app and extending it through its module system — you write code against webtrees' domain classes (Individual, Family, Tree, Fact, Media), not against a documented public API contract.
git clone https://github.com/fisharebest/webtrees.git
cd webtrees
composer install --no-dev --optimize-autoloader
Then:
/ — the install wizard writes data/config.ini.php with DB credentials and the data path.data/config.ini.php is chmod 0644-ish, owned by PHP, and gitignored (it holds credentials).| Path | What's there |
|---|---|
app/ |
Fisharebest\Webtrees\ — domain models (Individual, Family, Tree, Fact, Date, Name), services, HTTP layer |
modules_v4/ |
Where your code goes — one directory per module |
modules_v2/, modules_v3/ |
Legacy layout; useful as worked examples but do not target the old API |
resources/views/ |
Blade templates — the canonical examples of how core renders records |
resources/lang/ |
100+ translation catalogues (.po/.mo) |
themes/ |
Theme modules, if you need branding |
tests/ |
PHPUnit suite plus a WebtreesTestCase base — copy this setup for your own module tests |
Do not start with a chart. Start with a ModuleTab that adds a sidebar/research tab to an individual page. It is the smallest end-to-end slice: module registration → route → Blade view → domain model access → privacy check. Once that renders, every other module type (report, block, menu, chart, sidebar, widget) is the same skeleton with a different interface.
namespace MyVendor\ResearchTab;
use Fisharebest\Webtrees\Individual;
use Fisharebest\Webtrees\I18N;
use Fisharebest\Webtrees\Module\AbstractModule;
use Fisharebest\Webtrees\Module\ModuleTabInterface;
use Psr\Http\Message\ResponseInterface;
class ResearchTab extends AbstractModule implements ModuleTabInterface
{
public function title(): string
{
return I18N::translate('Research notes');
}
public function getTabContent(Individual $individual): string
{
if (!$individual->canShow()) {
return '';
}
return view($this->name() . '::tab', [
'individual' => $individual,
'sources' => $individual->facts(['SOUR']),
]);
}
public function isGrayedOut(Individual $individual): bool
{
return !$individual->canShow();
}
}
Drop it in modules_v4/research-tab/, give it a module.php metadata file, then enable it per-tree in Control Panel → Modules. If it does not appear in the list, your class is not autoloadable or the metadata name mismatches the directory.
Everything in webtrees is a module implementing one or more interfaces. Pick by where in the UI your output belongs:
ModuleTabInterface — extra tab on person/family pages (lowest risk, highest value).ModuleReportInterface — printable reports; usually wraps a Blade view over a FactQuery.ModuleChartInterface — interactive/rendered charts; heaviest to get right.ModuleBlockInterface / ModuleSidebarInterface — dashboard and sidebar widgets.ModuleMenuInterface, ModuleFooterInterface, ModuleAnalyticsInterface — global chrome.ModuleThemeInterface — CSS/branding.ModuleConfigInterface — settings screens, so admins can tune your module without code edits.A module can implement several; a "research helper" often wants ModuleTabInterface + ModuleConfigInterface (to store which fact tags to surface).
The whole point of the Eloquent layer is that you rarely touch raw GEDCOM text.
$individual->primaryName()->fullName(); // "Name" object, not a string
$individual->sex(); // 'M' | 'F' | 'U'
$individual->birth()->date(); // Date object (partial dates supported)
$individual->facts(['BIRT', 'DEAT']); // filter by tag — cheap
$individual->spouses(); // collection of Individual
$individual->children(); // from FAMC/FAMS links
$individual->familiesAsSpouse(); // Family models
For bulk work over a big tree, use the query services, not iteration over collections — loading every individual in a 200k-person tree will exhaust memory.
use Fisharebest\Webtrees\Services\IndividualService; // inject via constructor
// Prefer tag-filtered queries + pagination over ->all()
foreach ($tree->individuals() as $i) { /* avoid on large trees */ }
This is the single most common way to ship a security bug in webtrees extensions. Core templates gate everything through the record and fact objects:
if ($individual->canShow()) { ... }
if ($individual->canShowName()) { ... } // name only, no details
foreach ($individual->facts() as $fact) {
if ($fact->canShow()) { ... }
}
Never query the database directly to render a person's data — you will bypass the per-tree, per-person, per-user privacy rules and leak living individuals. If you must join across tables, resolve to models and filter with canShow() before output.
For role checks alongside privacy:
use Fisharebest\Webtrees\Auth;
Auth::user(); // current user or null
Auth::isMember($tree); // has any access to this tree
Auth::isEditor($tree); // can edit
Auth::isManager($tree); // can administer the tree
Modules are enabled per tree, and preferences are per tree. Anything you store as configuration should be keyed to the tree, not global:
$tree->getPreference('MY_MODULE_OPTION', 'default');
$tree->setPreference('MY_MODULE_OPTION', $value);
If you find yourself reaching for a global setting, ask whether a family with two trees would want different behaviour — the answer is usually yes.
Collaborative genealogy lives on the change log. Use the model's fact mutation methods so edits land in history and stay GEDCOM-valid:
// update an existing fact rather than string-splicing GEDCOM
$individual->updateFact($fact); // triggers change recording
Wrap multi-fact edits in a transaction (DB::transaction(...)) so a half-applied merge doesn't corrupt the tree. Never write GEDCOM files directly while the app is running — the DB is the source of truth between imports/exports; export regenerates the file.
Views are plain Blade: escape with {{ }}, build forms with CSRF tokens, and reference assets from your module directory.
<form method="post" action="{{ route(MyVendor\ResearchTab::class . ':save') }}">
@csrf
<input name="note" value="{{ $note }}">
<button>{{ I18N::translate('Save') }}</button>
</form>
Route handlers are methods on the module class; the router resolves them by ModuleClass:method. Return $this->viewResponse('module::view', $data) for HTML so you inherit the app's layout and theme, and only fall back to raw Response for non-HTML endpoints (CSV export, JSON for a chart).
I18N::translate('Research notes');
I18N::translateContext('tab heading', 'Notes');
I18N::plural('%s source', '%s sources', $count, $count);
Use %s placeholders — never string concatenation — because word order changes across the 100+ supported languages. Add a .po file for your module and the translation tooling picks it up; hardcoded English is a one-way door.
use Fisharebest\Webtrees\Date;
$date = $individual->birth()->date();
$date->minimumDate(); // Date — for sorting
$date->display(); // localised string
$date->isOK(); // parse succeeded?
$date->qual1; // ABT / BEF / AFT / EST
Partial and qualified dates are normal in genealogy (ABT 1850, BEF 12 MAR 1900). Never assume a full Y-m-d. Calendars matter too — Julian, Hebrew, and French Republican dates appear in real data.
use Fisharebest\Webtrees\Registry;
use Fisharebest\Webtrees\Cache;
// app-wide key/value store
Registry::cache()->remember('my-module:'.$tree->id(), 3600, fn () => $expensive);
Surname indexes, descendant counts, and report aggregations are the usual suspects. Cache key must include tree id and, if privacy-filtered, the user id — otherwise you serve one user's filtered result to another.
Reuse tests/ conventions: a base test case boots the app container. Seed a tiny GEDCOM fixture with a living person, a deceased person, and a private tree, then assert your module renders nothing for the living person to an anonymous visitor. That one test catches most extension privacy regressions.
It is an application, not a stable API. app/ internals change between minor releases; there is no semver contract on Fisharebest\Webtrees\* classes. Pin to a specific release tag in your deployment, read the upgrade notes before bumping, and keep every customisation inside modules_v4/. Editing core is a guaranteed upgrade-time rebase.
Version drift is the biggest trap. The package metadata claims a 2016 last release — that reflects the 1.x era, before the Laravel-component rewrite. The modern Eloquent/Blade architecture lives in the 2.x line. Verify which major version your target deployment runs before writing a single line; module directory names (modules_v2/v3/v4) and interfaces differ. If you inherit old tutorials, assume they're for the wrong generation.
The data directory is sacred. GEDCOM files, uploaded media, and logs live outside the webroot. Back it up together with the database — restoring only the DB leaves you with records pointing at missing media. Never place data/ under the document root "temporarily."
Privacy leaks are silent. There is no exception when you forget canShow() — you just render a living person's details to anonymous visitors. Treat every new template as a privacy review: record gate, name gate, per-fact gate. Also remember canShowName() is a distinct, weaker permission used for things like relationship paths.
GEDCOM 5.5.1 has sharp edges. Cross-references look like @I123@/@F45@ pointers; custom tags start with an underscore (_UID, _MILT) and are legal but non-standard; long values are split across 2 CONT lines and must be unescaped before display. Use the provided Gedcom helpers rather than manual explode("\n", $raw).
Date parsing is not strtotime. Date handles ABT, CAL, BEF/AFT, date ranges, and alternate calendars. Calling strtotime() on a webtrees date string will silently produce wrong sort orders and wrong "age at death" calculations.
Large trees change your algorithm. A 100k+ individual tree is normal for this user base. $tree->individuals() materialises everything; use tag-filtered queries, pagination, and Registry::cache(). If a page takes >2s, you almost certainly looped over a whole tree in a view.
Per-tree module enablement confuses first-timers. You can enable a module at the site level and see nothing, because it's disabled for that particular tree. Check both the Control Panel module list and the tree's own module settings before debugging code.
Forms need CSRF and the router needs the class name. Missing @csrf produces a bare 419/403 with no useful message. Route references must use the fully-qualified module class plus :method; a typo gives a 404 that looks like a routing bug but is usually a namespace mismatch.
Cache clears are mandatory after module changes. The module list, translation catalogues, and view compilation cache all persist. When a new tab "doesn't show up," clear the cache before rewriting the module.
Debugging: enable PHP error logging and inspect the logs in the data directory; set display_errors off in production because stray output breaks Blade rendering and GEDCOM downloads. dd()/dump() from Laravel work but will corrupt non-HTML responses — use them only on HTML routes.
Extension points worth knowing beyond tabs: ModuleConfigInterface (admin settings UI — ship this instead of asking users to edit files), the media/Media model for attaching thumbnails to records, and the theme module for CSS overrides. Those three cover most "just one small customisation" requests without forking core.
Licence hygiene. GPL-3.0 means derived modules are generally GPL-compatible too. If you're building for a client, confirm the distribution model before shipping closed-source modules.
How can I help you explore Laravel packages today?