Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Filament Timeline View Laravel Package

devletes/filament-timeline-view

Render Filament Tables as chronological timelines with asTimeline() or asDoubleSidedTimeline() plus a TimelineEntry column, giving date-grouped cards, avatars, timestamps, kebab actions, and load-more.

View on GitHub
Deep Wiki
Context7

Getting Started

Install the package and publish its assets — that is the whole setup:

composer require devletes/filament-timeline-view
php artisan filament:assets

There is no plugin registration step. The service provider auto-loads, registers two macros on Filament\Tables\Table (asTimeline() and asDoubleSidedTimeline()) and registers the CSS through Filament's asset system. Confirm it worked by checking that the macros resolve on any table you already have — if asTimeline() throws an undefined-method error, the provider never booted (stale bootstrap/cache or a composer dump-autoload you skipped).

Requirements worth checking before you commit: PHP ^8.2 and filament/tables ^5.0. Note the second one — this is a Filament v5 package, and it depends only on filament/tables, not filament/filament. That is good news for embedding in a non-panel context, but it also means you cannot backport it to a v3/v4 app without a fork.

Where to look first: the README section "The TimelineEntry column" and the TimelineEntry class itself. Those two files define 90% of what you will touch day to day; everything else is stock Filament table API that passes straight through.

The canonical first use case — converting an existing resource list page into a feed — is a two-line diff. Take a ListRecords page, replace ->columns([...]) with a single TimelineEntry, group by your timestamp, and append the macro:

use Devletes\FilamentTimelineView\Tables\Columns\TimelineEntry;
use Filament\Tables\Grouping\Group;

public function table(Table $table): Table
{
    return $table
        ->defaultSort('published_at', 'desc')
        ->columns([
            TimelineEntry::make()
                ->title('title')
                ->content('excerpt')
                ->image('hero_url')
                ->author('author.name', 'author.avatar_url')
                ->time('published_at'),
        ])
        ->defaultGroup(
            Group::make('published_at')->date()
                ->orderQueryUsing(fn ($query) => $query->orderByDesc('published_at'))
        )
        ->recordActions([/* stock Filament actions */])
        ->paginated([10])
        ->asTimeline();
}

That is the moment the package earns its keep: no new view files, no new Livewire component, no reimplementing pagination or filters. You keep your existing query, your existing recordActions(), your existing authorization, and you get date-grouped cards with a Load-more button.

Implementation Patterns

Treat the timeline as a presentation layer over an existing query. The strongest adoption pattern is to leave the data layer alone and shape it via ->query(). Build the feed query in a service or query scope, eager-load relations, then hand it to the table:

->query(fn () => app(PulseFeed::class)->forUser(Filament::auth()->user())->with(['author']))
->defaultSort('published_at', 'desc')

This keeps the timeline reusable across a resource page, a dashboard widget and a test, and it makes the N+1 problem a query-layer concern rather than a markup concern.

Dashboard widgets are the highest-leverage integration. TimelineEntry is a Tables\Columns\Layout\Component, so any TableWidget can render a timeline. A "Company Pulse" widget on a panel dashboard is a few lines and needs no page of its own:

class CompanyPulseWidget extends TableWidget
{
    protected int|string|array $columnSpan = 'full';

    public function table(Table $table): Table
    {
        return $table
            ->heading('Company Pulse')
            ->query(fn () => app(PulseFeed::class)->forUser(Filament::auth()->user())->with('author'))
            ->columns([
                TimelineEntry::make()
                    ->title('title')
                    ->content('excerpt')
                    ->author('author.name', 'author.avatar_url')
                    ->time('published_at'),
            ])
            ->defaultGroup(Group::make('published_at')->date())
            ->recordActions([
                Action::make('view')
                    ->icon('heroicon-m-eye')
                    ->url(fn ($record) => PulsePostResource::getUrl('view', ['record' => $record])),
            ])
            ->paginated([5])
            ->asTimeline();
    }
}

Mount it from a custom dashboard via Filament\Schemas\Components\Livewire::make(CompanyPulseWidget::class). Because the widget owns its own table, you can run a resource-page timeline and a widget timeline off the same query with different page sizes.

Always use closure setters for anything derived. Every TimelineEntry setter accepts a string path or a closure receiving the record. Reach for the closure whenever the field is not a literal column:

TimelineEntry::make()
    ->title('title')
    ->content(fn ($record) => str($record->html_body)->stripTags()->limit(180))
    ->image(fn ($record) => $record->cover?->getUrl('thumb'))
    ->author('author.name', fn ($record) => $record->author?->getFilamentAvatarUrl())
    ->time(fn ($record) => $record->published_at)

time() is special: a closure returning a Carbon is formatted with translatedFormat($format), while a returned string is used verbatim. Use the string form when your API already returns a display-ready time, and the Carbon form when you need locale-aware formatting.

Wire up the chrome once and forget it. Filtering, searching and pagination are stock Filament; the package just re-skins the toolbar. Search in particular should be declared on the table, not the column:

->searchable(['title', 'body'])
->filters([
    SelectFilter::make('category')->multiple()->options([...]),
], FiltersLayout::BeforeContent)
->paginated([10])

FiltersLayout is fully supported — funnel dropdown by default, or BeforeContent / modal / sidebar as the second argument. Active filter indicators render as removable badges above the first date group without any extra work.

Use the double-sided variant for narrative content, the single variant for activity. asDoubleSidedTimeline() reads as a changelog, roadmap or history; asTimeline() reads as an inbox or audit trail. Double-sided alternates per day, and the first card of each date always lands on the left, so a day with one entry stays visually consistent instead of floating. Below 768px it collapses to a single column, so you do not need to write a mobile breakpoint.

Enable day collapse when the feed gets long, not by default. Collapsible groups are opt-in via ->collapsible() on the Group. Collapsing is Alpine-only — no Livewire round trip — and collapsed groups show a translated "N posts hidden" pill:

->defaultGroup(Group::make('published_at')->date()->collapsible())

This is the right lever for a widget showing 30+ items per day; leave it off for sparse feeds where the chevron is noise.

Escape hatch: drop TimelineEntry for Stack/Split. If the card needs a structure the setters cannot express (badges, progress bars, multi-column metadata), build a normal column-layout component. The package still supplies the dot, caret, card chrome and actions kebab around it:

->columns([
    Stack::make([
        Split::make([
            ImageColumn::make('cover_url')->circular()->size(60)->grow(false),
            Stack::make([
                TextColumn::make('title')->weight('bold')->size('lg'),
                TextColumn::make('summary')->color('gray'),
            ]),
        ]),
    ]),
])

Know when a "View" link appears twice. ->recordUrl() / ->recordAction() render a native eye-icon link in the card's top-right, separate from the kebab. The package suppresses it when one of your actions already goes to the same place — same action name for recordAction(), same URL for recordUrl(). On a resource list page Filament derives both from the table's view/edit action, so a ViewAction in recordActions() renders once, in the kebab. If you want the top-right link visible, either omit the ViewAction from the kebab group or point recordUrl() somewhere the group does not cover.

Gotchas and Tips

Date groups sort ascending by default — this is the single most common bug. Filament's date grouping orders ascending, which puts Today at the bottom of the timeline. Fix it on the Group, not on ->defaultSort():

Group::make('published_at')->date()->orderQueryUsing(fn ($query) => $query->orderByDesc('published_at'))

defaultSort() alone will not reorder the group blocks; the Group owns the group-level ordering.

Every column you pass to ->columns() is rendered inside the card. This is the trap documented in the search section — adding a TextColumn::make('body')->searchable() purely to enable search makes that text appear as stray content under the entry. Declare searchable fields on the table instead: ->searchable(['title', 'body']). If you genuinely need a hidden helper column, move it out of the card layout rather than trusting ->hidden().

TimelineEntry is a layout column, not a data column. Do not interleave it with TextColumns and expect a grid; the card renders whatever layout components you give it, so mixing plain columns produces unpredictable stacking. One TimelineEntry (or one Stack) per timeline, and put everything else in the closure setters.

Watch the N+1s on author() and image(). Both resolve per record via data_get() or a closure. An author avatar that lazily loads a relation across 25 cards is 25 queries. Eager-load in ->query() — ->with(['author', 'cover']) — and use the closure form for media URLs so you control which conversion is requested. Loading a full-size hero on a 10-per-page feed is an easy performance regression.

Publish assets as part of deploy. php artisan filament:assets is not a one-off local step — run it in your deployment pipeline alongside filament:optimize, or the card CSS will 404 in production while working perfectly on your machine.

Pin the Filament major. The constraint is filament/tables ^5.0. When you upgrade Filament, the timeline's reliance on internal table grouping and layout-component APIs is the thing most likely to break. Smoke-test one timeline page after every Filament minor upgrade — date headers, kebab menu, Load-more.

Keep pagination small. The Load-more button is driven by ->paginated([...]), so ->paginated([5]) on a widget and ->paginated([10]) on a full page are sensible defaults. Large page sizes on a double-sided layout look sparse because alternating columns halve the visual density per row.

Grouping is the whole point — hide the selector. Pass the Group straight to ->defaultGroup(...) rather than defining ->groups([...]). Defining a groups() array adds the group-selector dropdown to the toolbar, which lets users break the timeline's core layout assumption. Only add it if you actually want users switching between date and category grouping.

Group must be a date for date headers. The Today / weekday + M j header resolution only fires for Group::make(...)->date(). A non-date group falls back to a generic header, so your headings will look wrong rather than erroring — a silent-ish failure worth recognising quickly.

Debugging checklist when a card renders blank: log the resolved value from data_get($record, $path) for each setter, confirm the group column is present in the query's select (aggregate or raw selects frequently drop it), and check that paginated() is set — an unpaginated timeline renders everything and skips the Load-more affordance, which can look like the button is broken.

Maturity caveat. This is a 23-star package with no dependents. That does not disqualify it — it is MIT and the surface area is small — but isolate it: wrap the timeline configuration in a small trait or base class so a fork or replacement is a one-file change, and avoid depending on its internals (ftv-card class names, blade structure) from your own CSS beyond light theming. Your real extension points are the stock Filament ones: Group, FiltersLayout, recordActions(), and custom layout components — lean on those rather than on the package's own rendering.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
gmostafa/php-graphql-client
xeviant/paystack
snowsoft/faker
meditationsteps/blogetc
animalyoung/laravel-influxdb
onix-solutions/sms-telera
lonely-lights/prosetta
oxygen/data
oxygen/core
jengo/base
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin
artisan-build/community-prompts
vleroy/smart
ayles-software/laravel-australian-public-holidays
cboxdk/laravel-id-spatie
cboxdk/laravel-id-billing
cboxdk/laravel-id-billing-bridge
simple-as-fuck/php-performance-log