PHPackages                             paulohps/laravel-timeline - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Utility &amp; Helpers](/categories/utility)
4. /
5. paulohps/laravel-timeline

ActiveLibrary[Utility &amp; Helpers](/categories/utility)

paulohps/laravel-timeline
=========================

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

v1.0.0(4d ago)00MITPHP ^8.3

Since Jul 7Compare

[ Source](https://github.com/paulohps/laravel-timeline)[ Packagist](https://packagist.org/packages/paulohps/laravel-timeline)[ RSS](/packages/paulohps-laravel-timeline/feed)WikiDiscussions Synced today

READMEChangelogDependencies (7)Versions (2)Used By (0)

Laravel Timeline
================

[](#laravel-timeline)

[![Tests](https://github.com/paulohps/laravel-timeline/actions/workflows/tests.yml/badge.svg)](https://github.com/paulohps/laravel-timeline/actions/workflows/tests.yml)[![Latest Version on Packagist](https://camo.githubusercontent.com/9b6f5da502d069337e598d84aba8ce5a700021d2fb910e336c696939ec9fbfec/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f7061756c6f6870732f6c61726176656c2d74696d656c696e652e737667)](https://packagist.org/packages/paulohps/laravel-timeline)

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** (``) 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
------------

[](#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:

ColumnPurpose`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
---------------

[](#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 '…';
    }

    // 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 , no JS).
    public function navigation(TimelineEntry $entry): array
    {
        return [
            ['label' => __('View order'), 'href' => route('orders.show', $entry->subject_id)],
        ];
    }
}
```

### Registering events

[](#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
-----------------

[](#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 &amp; request context

[](#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

[](#enablingdisabling-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
-----------------------

[](#displaying-the-timeline)

Drop the Livewire component anywhere, passing the model whose timeline should show:

```

```

Props:

PropDefaultPurpose`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).```

```

The active filters sync to the URL (`?event[]=login`), so filtered views are shareable. Invalid keys in the URL are ignored.

### Rendering entries yourself

[](#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())

        {{ $event->label() }} — {{ $event->detail($entry) }}
        {{ $entry->created_at->diffForHumans() }}

@endforeach
```

### Querying

[](#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
--------------------

[](#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

[](#1-use-the-shipped-stylesheet-and-override-variables)

```
php artisan vendor:publish --tag=timeline-assets
```

```

```

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

[](#2-style-the-classes-yourself)

Skip the stylesheet and target the classes directly (they're part of the package's public API):

ClassElement`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 `` 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

[](#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 &amp; formats

[](#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
----------------

[](#artisan-commands)

### `make:timeline-event`

[](#maketimeline-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`

[](#timelineprune)

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

[](#configuration-reference)

```
php artisan vendor:publish --tag=timeline-config
```

KeyDefaultPurpose`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

[](#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
------------------------

[](#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
-------------------

[](#package-development)

```
composer test           # run the suite
composer test:coverage  # enforce 100% coverage (needs PCOV or Xdebug)
```

License
-------

[](#license)

MIT.

###  Health Score

39

—

LowBetter than 84% of packages

Maintenance99

Actively maintained with recent releases

Popularity0

Limited adoption so far

Community2

Small or concentrated contributor base

Maturity48

Maturing project, gaining track record

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Unknown

Total

1

Last Release

4d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/36200923?v=4)[Paulo Henrique Paetzold Scheuermann](/maintainers/paulohps)[@paulohps](https://github.com/paulohps)

---

Tags

laravelactivityAuditlivewiretimeline

###  Code Quality

TestsPest

### Embed Badge

![Health badge](/badges/paulohps-laravel-timeline/health.svg)

```
[![Health](https://phpackages.com/badges/paulohps-laravel-timeline/health.svg)](https://phpackages.com/packages/paulohps-laravel-timeline)
```

###  Alternatives

[psalm/plugin-laravel

Psalm plugin for Laravel

3345.3M347](/packages/psalm-plugin-laravel)[laravel/pulse

Laravel Pulse is a real-time application performance monitoring tool and dashboard for your Laravel application.

1.7k15.1M134](/packages/laravel-pulse)[laravel/ai

The official AI SDK for Laravel.

1.0k3.2M216](/packages/laravel-ai)[tallstackui/tallstackui

TallStackUI is a powerful suite of Blade components that elevate your workflow of Livewire applications.

726176.2k14](/packages/tallstackui-tallstackui)[api-platform/laravel

API Platform support for Laravel

58174.6k17](/packages/api-platform-laravel)[tomshaw/electricgrid

A feature-rich Livewire package designed for projects that require dynamic, interactive data tables.

119.4k](/packages/tomshaw-electricgrid)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
