paulohps / laravel-timeline
A causer-agnostic activity timeline for Laravel: base event class, Livewire UI and fully customizable CSS.
Requires
- php: ^8.3
- illuminate/contracts: ^12.0|^13.0
- illuminate/database: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- livewire/livewire: ^3.6|^4.0
Requires (Dev)
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
README
A causer-agnostic activity timeline for Laravel. It gives you:
- A polymorphic
causerinstead of a hardcodeduser_id, so any model — aUser, aTeam, anOrder— can own a timeline. - A base
TimelineEventclass: your app defines the concrete events (what they're called, how they look, what they link to); the package handles storage, context capture and rendering. - A Livewire component (
<livewire:timeline />) with per-type filter chips, day grouping and pagination. - Framework-agnostic styling: every element carries a stable
lt-*CSS class. Use the shipped stylesheet (all CSS custom properties), override its variables, or style everything from scratch — no Tailwind/Bootstrap required.
Requires PHP 8.3+, Laravel 12+/13+ and Livewire 3.6+/4.
Installation
composer require paulohps/laravel-timeline
Publish and run the migration:
php artisan vendor:publish --tag=timeline-migrations php artisan migrate
This creates the timeline_entries table:
| Column | Purpose |
|---|---|
causer_type / causer_id |
The model the timeline belongs to (morph). |
type |
The event key (e.g. login, order_shipped). |
subject_type / subject_id |
Optional morph to the model the event is about. |
metadata |
JSON: request context + anything you pass when recording. |
To rename the table, publish the config first and change timeline.table before migrating.
Defining events
The package ships only the abstract base class — your application defines the actual events. Generate one:
php artisan make:timeline-event OrderShipped
This creates app/Timeline/OrderShipped.php and reminds you to register it. The generated stub is minimal because the single required method is label():
namespace App\Timeline; use Paulohps\Timeline\Events\TimelineEvent; class Login extends TimelineEvent { public function label(): string { return __('Logged in'); } }
Everything else is an optional override:
namespace App\Timeline; use Paulohps\Timeline\Events\TimelineEvent; use Paulohps\Timeline\Models\TimelineEntry; class OrderShipped extends TimelineEvent { // Stable storage/URL key. Defaults to the snake-cased class // basename ('order_shipped'), so this override is optional. public static function key(): string { return 'order_shipped'; } public function label(): string { return __('Order shipped'); } // Shown next to on/off switches on a settings page, if you build one. public function description(): string { return __('When an order leaves the warehouse.'); } // Inline HTML for the marker: an SVG, an emoji, an icon-font tag. // Return null (the default) for a plain dot. public function icon(): ?string { return '<svg viewBox="0 0 24 24">…</svg>'; } // Marker color token → styled by .lt-item__marker--{token}. // Shipped tokens: default, primary, success, info, warning, danger. public function color(): string { return 'success'; } // Secondary line under the title. Defaults to the request context // captured at record time; subjectTitle() prefers the metadata // 'title' snapshot and falls back to $entry->subject->title. public function detail(TimelineEntry $entry): ?string { return $this->subjectTitle($entry); } // Dropdown links rendered on the entry (plain <details>, no JS). public function navigation(TimelineEntry $entry): array { return [ ['label' => __('View order'), 'href' => route('orders.show', $entry->subject_id)], ]; } }
Registering events
Stored entries hold the event key, so the package needs a key → class map. Register your events in config/timeline.php:
'events' => [ App\Timeline\Login::class, App\Timeline\OrderShipped::class, ],
…or at runtime (e.g. in a service provider), with the facade:
use Paulohps\Timeline\Facades\Timeline; Timeline::register([ App\Timeline\Login::class, App\Timeline\OrderShipped::class, ]);
Entries whose type is no longer registered don't break the page: they render through a fallback that humanizes the raw key (legacy_thing → "Legacy Thing").
Recording entries
Add the HasTimeline trait to any model that should own a timeline:
use Paulohps\Timeline\Concerns\HasTimeline; class User extends Authenticatable { use HasTimeline; }
Then record from wherever fits your app (listeners, jobs, controllers):
// Via the trait — accepts an instance, class name or registered key: $user->recordTimelineEvent(new Login); $user->recordTimelineEvent(OrderShipped::class, $order, ['title' => $order->number]); $user->recordTimelineEvent('order_shipped', $order); // Via the event itself: (new OrderShipped)->record($user, $order); // Via the facade: Timeline::record($user, 'login');
The trait also gives you the relation, newest first:
$user->timelineEntries; // MorphMany of TimelineEntry
Because causer is a morph, the same works on any model — $team->recordTimelineEvent(...), $order->recordTimelineEvent(...) — with no schema changes.
Metadata & request context
Every entry stores a metadata JSON column. By default the current request's ip, user_agent and url are captured automatically; anything you pass when recording is merged on top:
$user->recordTimelineEvent('order_shipped', $order, [ 'title' => $order->number, // snapshot used by subjectTitle() 'carrier' => 'DHL', ]);
Storing a title snapshot keeps entries meaningful even if the subject is later renamed or deleted.
To capture richer context (e.g. parsed browser/OS via a package like matomo/device-detector), replace the resolver:
use Illuminate\Http\Request; use Paulohps\Timeline\Facades\Timeline; Timeline::resolveContextUsing(function (?Request $request) { // $request is null outside HTTP (queued jobs, artisan commands). return [ 'ip' => $request?->ip(), 'browser' => ..., 'os' => ..., ]; });
The default detail() line renders browser · os · ip from whatever of those keys exist, so a resolver that adds browser/os upgrades the display automatically.
Enabling/disabling events
Every event has an on/off switch, read from config on each write — wire it to your own settings UI if you need runtime control:
// config/timeline.php 'enabled' => [ 'login' => false, // stop recording logins; everything else stays on ],
Disabled events make record() return null instead of an entry. For different logic (per-tenant flags, feature flags), override enabled() on the event class.
Displaying the timeline
Drop the Livewire component anywhere, passing the model whose timeline should show:
<livewire:timeline :causer="$user" />
Props:
| Prop | Default | Purpose |
|---|---|---|
causer |
— (required) | The model whose entries are listed. |
per-page |
config('timeline.per_page') (25) |
Page size. |
filterable |
true |
Show the per-type filter chips. |
only |
[] |
Restrict to a subset of event keys (also narrows the chips). |
<livewire:timeline :causer="$team" :per-page="10" :filterable="false" /> <livewire:timeline :causer="$user" :only="['login', 'order_shipped']" />
The active filters sync to the URL (?event[]=login), so filtered views are shareable. Invalid keys in the URL are ignored.
Rendering entries yourself
The Livewire component is optional. Each entry renders through its event handler, so you can build your own listing:
@foreach($user->timelineEntries()->with('subject')->paginate(25) as $entry) {!! $entry->event()->render($entry) !!} @endforeach
Or skip the shipped item view entirely and use the handler's data:
@foreach($entries as $entry) @php($event = $entry->event()) <li> {{ $event->label() }} — {{ $event->detail($entry) }} <time>{{ $entry->created_at->diffForHumans() }}</time> </li> @endforeach
Querying
use Paulohps\Timeline\Models\TimelineEntry; TimelineEntry::forCauser($user)->ofType('login')->count(); TimelineEntry::forCauser($team)->ofType(['login', 'logout'])->latest()->get(); $user->timelineEntries()->ofType('login')->first(); // last login
Visual customization
The markup is intentionally plain HTML with stable, prefixed CSS classes. Three levers, from lightest to heaviest:
1. Use the shipped stylesheet and override variables
php artisan vendor:publish --tag=timeline-assets
<link rel="stylesheet" href="{{ asset('vendor/timeline/timeline.css') }}">
Everything funnels through CSS custom properties on .lt-timeline, so most branding is a few variable overrides in your own CSS:
.lt-timeline { --lt-accent: #7c3aed; /* active filter chip */ --lt-accent-contrast: #ffffff; --lt-marker-success-bg: #ede9fe; /* per-color marker tokens */ --lt-marker-success-fg: #7c3aed; --lt-marker-size: 2rem; }
Available variables: --lt-text, --lt-text-muted, --lt-surface, --lt-surface-hover, --lt-line, --lt-accent, --lt-accent-contrast, --lt-marker-{default|primary|success|info|warning|danger}-{bg|fg}, --lt-marker-size, --lt-font-size, --lt-font-size-sm.
2. Style the classes yourself
Skip the stylesheet and target the classes directly (they're part of the package's public API):
| Class | Element |
|---|---|
lt-timeline |
Component root. |
lt-filters / lt-filter / lt-filter--active |
Filter chip row / chip / selected chip. |
lt-day / lt-day__label / lt-day__line / lt-day__count |
Day separator: row, label ("Today"), rule, entry count. |
lt-entries / lt-entry / lt-entry__connector |
Entry list / one entry wrapper / vertical line between markers. |
lt-item |
One rendered row (also when you render entries manually). |
lt-item__marker / lt-item__marker--{color} |
Round marker / its color variant. |
lt-item__body / lt-item__header / lt-item__title / lt-item__meta |
Content column and title row. |
lt-item__time / lt-item__detail |
Timestamp / secondary line. |
lt-item__nav / lt-item__nav-toggle / lt-item__nav-menu / lt-item__nav-link |
Per-entry <details> dropdown. |
lt-empty |
Empty state. |
lt-footer / lt-footer__info |
Footer row / "Showing x–y of z". |
lt-pagination / lt-pagination__button |
Pager / prev-next buttons. |
Custom color() tokens work the same way: return 'purple' from an event and style .lt-item__marker--purple.
3. Publish the views
For structural changes (different markup, Tailwind classes, your design system's components):
php artisan vendor:publish --tag=timeline-views
resources/views/vendor/timeline/livewire/timeline.blade.php— the component: chips, day groups, pagination.resources/views/vendor/timeline/item.blade.php— a single row; receives$entry,$icon,$color,$title,$detail,$time,$navigation.
A single event can also bypass the shared item view by overriding render():
public function render(TimelineEntry $entry): View { return view('timeline.order-shipped', ['entry' => $entry]); }
Translations & formats
php artisan vendor:publish --tag=timeline-translations
Ships en and pt_BR strings ("Today", "No activity yet", …). Date/time display comes from config:
'date_format' => 'F j', // day-group labels (Today/Yesterday take precedence) 'time_format' => 'H:i', // per-entry timestamp
Artisan commands
make:timeline-event
php artisan make:timeline-event OrderShipped # app/Timeline/OrderShipped.php php artisan make:timeline-event Billing/InvoicePaid # app/Timeline/Billing/InvoicePaid.php php artisan make:timeline-event OrderShipped --force # overwrite an existing class
To change the generated code, publish the stub — a stubs/timeline-event.stub in your app root takes precedence:
php artisan vendor:publish --tag=timeline-stubs
timeline:prune
Timelines grow forever by default. Prune old entries ad hoc or on a schedule:
php artisan timeline:prune --days=365
php artisan timeline:prune --days=90 --type=login --type=logout # only these keys
// routes/console.php — with config('timeline.prune_days') set: Schedule::command('timeline:prune')->daily();
Without --days, the cutoff comes from config('timeline.prune_days'); if both are missing the command fails instead of guessing.
Configuration reference
php artisan vendor:publish --tag=timeline-config
| Key | Default | Purpose |
|---|---|---|
table |
timeline_entries |
Storage table (set before migrating). |
model |
TimelineEntry::class |
Swap in your own entry model subclass. |
events |
[] |
Event classes registered on boot. |
enabled |
[] |
Per-key kill switches ('login' => false). |
per_page |
25 |
Livewire component page size. |
prune_days |
null |
Default cutoff for timeline:prune (null = require --days). |
date_format / time_format |
F j / H:i |
Display formats. |
Extending the entry model
class ActivityEntry extends \Paulohps\Timeline\Models\TimelineEntry { // scopes, accessors, pruning, whatever you need }
// config/timeline.php 'model' => App\Models\ActivityEntry::class,
The trait relation, the recorder and the Livewire component all resolve the model from config.
Testing your integration
A model factory ships with the package:
use Paulohps\Timeline\Models\TimelineEntry; TimelineEntry::factory() ->type(OrderShipped::class) // or ->type('order_shipped') ->causedBy($user) ->about($order) ->withMetadata(['carrier' => 'DHL']) ->create();
Package development
composer test # run the suite composer test:coverage # enforce 100% coverage (needs PCOV or Xdebug)
License
MIT.