paulohps/laravel-timeline

A causer-agnostic activity timeline for Laravel: base event class, Livewire UI and fully customizable CSS.

Maintainers

Package info

github.com/paulohps/laravel-timeline

pkg:composer/paulohps/laravel-timeline

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-07-07 02:59 UTC

This package is auto-updated.

Last update: 2026-07-07 03:08:05 UTC


README

Tests Latest Version on Packagist

A causer-agnostic activity timeline for Laravel. It gives you:

  • A polymorphic causer instead of a hardcoded user_id, so any model — a User, a Team, an Order — can own a timeline.
  • A base TimelineEvent class: 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.