Open source · MIT

Lineage

An infolist entry that draws any parent-child hierarchy as a pannable, zoomable canvas. Cards are a shared Filament schema, laid out by a contour-packed tidy-tree pass over fixed-size nodes, with collapsible branches, an initial depth, a minimap, vertical or horizontal orientation, and broken data (self-references, missing parents, cycles) repaired rather than dropped. Vanilla JavaScript on Alpine, themed off Filament's palette in light and dark.

PHP ^8.2 Filament ^5.0
$ composer require devletes/filament-lineage
GitHub
  • Any record with a parent. Point it at manager_id or parent_id, or resolve the parent yourself — models or plain arrays
  • Cards are infolists. One nodeSchema() of ordinary Filament entries, shared by every node
  • Pan, zoom and collapse with a minimap, expand and collapse all, and fit-to-view
  • A layout that stays compact. Contour-packed Reingold–Tilford, so a deep branch tucks in beside a wide one instead of pushing it away
  • Broken data still draws. Self-references, missing parents and cycles are demoted to roots, never dropped
  • Vertical or horizontal, opening every level or just the first few
  • No dependencies — vanilla JavaScript on the Alpine you already have, themed off Filament’s palette in light and dark
An org chart on a Filament page (light)An org chart on a Filament page (dark)

Installation

composer require devletes/filament-lineage

Then publish Filament’s assets so the stylesheet and script are served on your panels:

php artisan filament:assets

The package registers its own stylesheet and script with Filament’s asset manager — there is nothing to add to your custom Tailwind theme.

Quick start

TreeEntry is an infolist entry, so it goes anywhere a schema does — a page, a resource’s view page, a modal:

use Devletes\Lineage\Infolists\Components\TreeEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Schema;
use Filament\Support\Enums\FontWeight;

public function content(Schema $schema): Schema
{
    return $schema->components([
        TreeEntry::make('org')
            ->records(fn () => Employee::query()->get())
            ->parentKey('manager_id')
            ->nodeUrl(fn (Employee $record) => EmployeeResource::getUrl('view', ['record' => $record]))
            ->nodeSchema([
                TextEntry::make('name')->hiddenLabel()->weight(FontWeight::SemiBold),
                TextEntry::make('title')->hiddenLabel()->color('gray'),
            ]),
    ]);
}
An org chart with the first two levels open (light)An org chart with the first two levels open (dark)

Records and parents

Hand over a flat set of records and say how each one points at its parent. A flat set, rather than a children relationship, is what keeps it to one query — eager-load what the cards need and pass the lot:

TreeEntry::make('org')
    ->records(fn () => Employee::query()->with('department')->get())
    ->parentKey('manager_id')

records() takes a collection, an array or a closure returning either. Records can be models or plain arrays; a model’s card is bound to the record, an array’s to its values, so TextEntry::make('name') reads the same either way.

To work the parent out yourself, use parentUsing() and return a key, a model, or null for a root:

TreeEntry::make('org')
    ->records(fn () => Employee::query()->with('lineManager')->get())
    ->parentUsing(fn (Employee $record) => $record->lineManager)

A tree can mix kinds of record — departments with the people in them, say. Their primary keys will collide, so give each node a namespaced key with recordKey():

TreeEntry::make('structure')
    ->records(fn () => $departments->concat($people))
    ->recordKey(fn ($record) => class_basename($record).':'.$record->getKey())
    ->parentUsing(fn ($record) => $record instanceof Employee
        ? 'Department:'.$record->department_id
        : null)

Broken hierarchies

Real reporting data arrives broken, and none of it should take a page down. Three cases are repaired before anything is drawn, each demoted to a root so the record still appears:

  • a node pointing at itself
  • a node pointing at an id that is not in the set — filtered out, soft-deleted, outside the tenant
  • a genuine cycle, A reports to B reports to A

Cards

nodeSchema() is one schema shared by every card. nodeUrl() makes the whole card a link:

TreeEntry::make('org')
    ->nodeSchema([
        TextEntry::make('name')->hiddenLabel()->weight(FontWeight::SemiBold)->lineClamp(1),
        TextEntry::make('title')->hiddenLabel()->color('gray')->lineClamp(1),
    ])
    ->nodeUrl(fn (Employee $record) => EmployeeResource::getUrl('view', ['record' => $record]))

Node size is fixed, deliberately

Every card in a tree is the same size, declared up front:

->nodeWidth(240)->nodeHeight(96)

That single constraint is what keeps the package small. Layout becomes arithmetic over known constants — no render → measure → reposition cycle, no layout shift on first paint, and collapsing a branch is a pure recompute with no round trip.

The trade is that content has to fit its box, so keep node schemas short and lean on lineClamp(). Node schemas render with their gap collapsed for the same reason; a schema’s default spacing would overflow a fixed-height card on its own.

Avatars

An avatar straddling the card’s top edge is the classic org chart look, and the edge from the parent ends neatly behind it. A ViewEntry renders its partial with no wrapper around it, so nothing clips the overhang:

ViewEntry::make('avatar')->view('org-chart.avatar'),
{{-- resources/views/org-chart/avatar.blade.php --}}
<div class="fi-lineage-avatar">
    @if ($getRecord()->photo_url)
        <x-filament::avatar :src="$getRecord()->photo_url" size="lg" />
    @else
        <div class="fi-lineage-avatar-fallback">{{ $getRecord()->initials }}</div>
    @endif
</div>

fi-lineage-avatar rises by its overhang plus the card’s padding and rings whatever it wraps in the card’s own colour. fi-lineage-avatar-fallback is a matching disc for initials, coloured from the panel’s primary. Give the row enough vertical nodeGap() to hold the overhang.

If the overhanging content has to be a TextEntry instead, add the fi-lineage-bleed class. Filament’s text entry clips its overflow for lineClamp(), and the class frees it for that one entry:

TextEntry::make('badge')->extraAttributes(['class' => 'fi-lineage-bleed'])

Layout

Opening only the first few levels

A whole organisation drawn at once fits to a few percent and reads as dots. initialDepth() opens that many levels and leaves the rest a click away — 2 shows the roots and their direct children:

TreeEntry::make('org')->initialDepth(2)

Each parent carries a toggle, and the cards inside a collapsed branch are never mounted, so a large tree stays fast to pan. The markup still ships with the page, so opening a branch needs no round trip.

One branch opened under the CTO (light)One branch opened under the CTO (dark)

collapsible(false) drops the toggles and the expand / collapse all buttons, and draws every level.

Horizontal trees

horizontal() grows the tree rightward from its roots, which suits deep, narrow hierarchies like a product catalogue:

TreeEntry::make('categories')
    ->records(fn () => Category::query()->get())
    ->parentKey('parent_id')
    ->horizontal()
    ->nodeWidth(200)
    ->nodeHeight(64)
    ->nodeGap(horizontal: 64, vertical: 16)
A product category tree drawn horizontally (light)A product category tree drawn horizontally (dark)

Spacing

nodeGap() sets the space between cards along each screen axis — horizontal left to right, vertical top to bottom — whichever way the tree grows. Either argument can be left out to keep its default:

->nodeGap(vertical: 96)

On a vertical tree, a generous vertical gap is what lets the drop from parent to child carry the relationship; on a horizontal tree, the horizontal gap does that job.

Canvas height

The canvas is 32rem tall unless told otherwise. Any CSS length works:

->canvasHeight('calc(100vh - 20rem)')

Drag the canvas to pan, and scroll or pinch to zoom around the cursor. The toolbar expands and collapses every branch, zooms in and out, and fits the whole tree back into view. The canvas frames itself on load and whenever it is resized, until the reader pans or zooms — after that the view is theirs.

The minimap in the corner shows where the visible area sits in the whole tree:

->minimap(width: 200, height: 140)
->minimap(false)
The whole organisation fitted to the canvas (light)The whole organisation fitted to the canvas (dark)

Configuration

config/lineage.php sets the defaults every tree starts from:

php artisan vendor:publish --tag=lineage-config
return [
    'height' => '32rem',     // default canvas height
    'zoom' => [
        'min' => 0.25,       // how far wheel and button zoom can go out
        'max' => 2.0,        // and in; fit-to-view may go below min for a large tree
    ],
];

Theming

Every colour resolves through Filament’s palette variables, so the canvas follows your panel’s primary colour and its light and dark themes without being told. To change it further, override the --fi-lineage-* variables in your theme stylesheet:

html .fi-lineage {
    --fi-lineage-accent: var(--success-600);
    --fi-lineage-edge: var(--gray-300);
}

html .fi-lineage-avatar {
    --fi-lineage-avatar-overhang: 1rem;
}

The canvas variables are surface, border, grid, node-bg, text, edge, muted, accent and scrim (the wash over the minimap). Avatars read avatar-overhang, avatar-bg and avatar-color.

Or publish the views to change the markup:

php artisan vendor:publish --tag=lineage-views

Translations

The toolbar’s labels go through lineage::lineage.*. Publish the language files to reword them or add a locale:

php artisan vendor:publish --tag=lineage-translations

Then edit or add files under lang/vendor/lineage/.

API reference

MethodDefaultWhat it does
records($records)—A collection, array or closure of the records to draw
parentKey($attribute)—The attribute holding each record’s parent key
parentUsing($callback)—Resolve the parent yourself: return a key, a model, or null for a root
recordKey($attribute)primary keyThe attribute, or a closure, giving each record’s key
nodeSchema($components)—One schema, shared by every card
nodeUrl($callback)—Makes the whole card a link
nodeWidth($px) / nodeHeight($px)240 / 96Card size, for the whole tree
nodeGap($horizontal, $vertical)24 / 56Space between cards on each screen axis
orientation($orientation) / horizontal()verticalWhich way the tree grows
collapsible($condition)truePer-branch toggles and the expand / collapse all buttons
initialDepth($levels)nullLevels open on first paint; null opens everything
minimap($condition, $width, $height)true, 160, 110The overview in the corner
canvasHeight($height)32remAny CSS length

Every option accepts a closure.