Search by

devletes / filament-lineage

devletes

Hierarchies as a canvas for Filament — org charts, category trees, anything with a parent. Node cards are an infolist, laid out and drawn natively in Livewire and Alpine.

Package info

github.com/devletes/filament-lineage

pkg:composer/devletes/filament-lineage

Statistics

Installs: 36

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v1.0.0 2026-10-26 18:47 UTC

This package is auto-updated.

Last update: 2026-10-26 18:48:46 UTC


README

Latest Version on Packagist Total Downloads License GitHub Stars

Hierarchies as a canvas for Filament — org charts, category trees, anything with a parent. Each card is an infolist schema, laid out and drawn natively in Livewire and Alpine.

  • 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)

Requirements

  • PHP ^8.2
  • Filament ^5.0 on Laravel 12 or 13

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)')

Navigating

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

Method Default What 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 key The 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 / 96 Card size, for the whole tree
nodeGap($horizontal, $vertical) 24 / 56 Space between cards on each screen axis
orientation($orientation) / horizontal() vertical Which way the tree grows
collapsible($condition) true Per-branch toggles and the expand / collapse all buttons
initialDepth($levels) null Levels open on first paint; null opens everything
minimap($condition, $width, $height) true, 160, 110 The overview in the corner
canvasHeight($height) 32rem Any CSS length

Every option accepts a closure.

Testing

composer install
composer test

The suite runs on Testbench with the real Filament stack, against PHP 8.2–8.4 on Laravel 12 and 13.

Need something custom?

We build production Filament panels and plugins for teams that want to ship fast without compromising on polish. If you need a custom feature, an extended variant of this package, or a fully bespoke component built for your stack, we can help.

Typical engagements: new Filament plugins, custom resources/widgets/actions, theme + UX work, integrations with your existing services, and one-off tailored forks of our open-source packages.

Have you seen our Orbit theme?

Orbit is our dark-first theme for Filament 5. Every panel surface is restyled — sidebar, topbar, forms, tables, modals, notifications.

Orbit theme, light mode Orbit theme, dark mode

Check it out here →

Credits

License

MIT. See LICENSE.md.