PHPackages                             aaronfrancis/eventable - 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. [Database &amp; ORM](/categories/database)
4. /
5. aaronfrancis/eventable

ActiveLibrary[Database &amp; ORM](/categories/database)

aaronfrancis/eventable
======================

A Laravel package for tracking events on Eloquent models using polymorphic relationships

v0.2.1(2mo ago)40MITPHPPHP ^8.2CI passing

Since Dec 28Pushed 2mo agoCompare

[ Source](https://github.com/aarondfrancis/eventable)[ Packagist](https://packagist.org/packages/aaronfrancis/eventable)[ RSS](/packages/aaronfrancis-eventable/feed)WikiDiscussions main Synced 1mo ago

READMEChangelogDependencies (14)Versions (5)Used By (0)

Eventable
=========

[](#eventable)

[![Latest Version on Packagist](https://camo.githubusercontent.com/eeca1db56ab4c6f6fff01cd35590343b41a3a908d19a25789a5be3f12f047423/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f6161726f6e6672616e6369732f6576656e7461626c652e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/aaronfrancis/eventable)[![Tests](https://github.com/aarondfrancis/eventable/actions/workflows/tests.yaml/badge.svg)](https://github.com/aarondfrancis/eventable/actions/workflows/tests.yaml)[![Total Downloads](https://camo.githubusercontent.com/bb2318db09f1fec5748492b95841cc0da2fa28316f6c81f72beaffda27164453/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f6161726f6e6672616e6369732f6576656e7461626c652e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/aaronfrancis/eventable)[![PHP Version](https://camo.githubusercontent.com/1748c8a8f998d673410f6739a5a7ae4ae47267bd81dbe79779ea6213dbacba2d/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f7068702d762f6161726f6e6672616e6369732f6576656e7461626c652e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/aaronfrancis/eventable)[![License](https://camo.githubusercontent.com/b0e9d9316311f7d4833e75b016876b4c2f8021021d37493c4c437cdd53de52e9/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f6c2f6161726f6e6672616e6369732f6576656e7461626c652e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/aaronfrancis/eventable)

A Laravel package for tracking events on Eloquent models with polymorphic relationships, backed enums, and query helpers for both individual event records and parent models.

Eventable stores both a registered enum alias in `type_class` and the enum backing value in `type`. That keeps overlapping enum values safe across multiple event enums and lets you rename enum classes without breaking historical data.

Highlights:

- Works with int-backed and string-backed enums
- Stores array payloads and exact scalar JSON values
- Lets you query models by event history
- Supports pruning by age, count, or both
- Tested in CI on SQLite, MySQL 8, PostgreSQL 17, and PostgreSQL 18

```
// Add the trait to your User model
class User extends Model
{
    use HasEvents;
}

// Record events
$user->addEvent(UserEvent::LoggedIn);
$user->addEvent(UserEvent::SubscriptionStarted, ['plan' => 'pro']);

// Query by events
User::whereEventHasntHappened(UserEvent::EmailVerified)->get(); // Unverified users
User::whereLatestEventIs(UserEvent::Churned)->get();            // Churned users
User::whereEventHasHappenedAtLeast(UserEvent::Purchase, 5)->get(); // VIP customers
```

Installation
------------

[](#installation)

```
composer require aaronfrancis/eventable
```

Publish the config and migration:

```
php artisan vendor:publish --tag=eventable-config
php artisan vendor:publish --tag=eventable-migrations
php artisan migrate
```

Quick Start
-----------

[](#quick-start)

### 1. Add the trait to your models

[](#1-add-the-trait-to-your-models)

```
use AaronFrancis\Eventable\Concerns\HasEvents;

class User extends Model
{
    use HasEvents;
}
```

### 2. Create a backed enum for your event types

[](#2-create-a-backed-enum-for-your-event-types)

```
enum UserEvent: int
{
    case LoggedIn = 1;
    case EmailVerified = 2;
    case SubscriptionStarted = 3;
    case Churned = 4;
    case Purchase = 5;
    case PageViewed = 6;
    case PasswordResetRequested = 7;
}
```

The published migration uses a `string` column for `type`, so int-backed and string-backed enums both work out of the box. If you customize the migration to use an integer column, string-backed enums will no longer fit.

### 3. Register your enum in `config/eventable.php`

[](#3-register-your-enum-in-configeventablephp)

```
'event_types' => [
    'user' => App\Enums\UserEvent::class,
],
```

This registration is required. It enables:

- Multiple enums without value collisions, such as `UserEvent::Created = 1` and `OrderEvent::Created = 1`
- Alias-aware queries when you pass a `BackedEnum` to `ofType()`
- Refactoring enum class names without breaking stored records

### 4. Review morph key and morph map setup

[](#4-review-morph-key-and-morph-map-setup)

The published migration uses `morphs('eventable')`, so it follows Laravel's default morph key type. If your app uses UUIDs or ULIDs for polymorphic keys, call `Schema::morphUsingUuids()` or `Schema::morphUsingUlids()` before running the migration.

Since Eventable uses polymorphic relationships, it is also a good idea to use Laravel's [enforced morph map](https://laravel.com/docs/eloquent-relationships#custom-polymorphic-types) for your own models:

```
// In AppServiceProvider::boot()
use Illuminate\Database\Eloquent\Relations\Relation;

Relation::enforceMorphMap([
    'user' => \App\Models\User::class,
    'order' => \App\Models\Order::class,
]);
```

Eventable separately registers its own Event model in Laravel's morph map when `eventable.register_morph_map` is enabled.

Recording Events
----------------

[](#recording-events)

```
$user->addEvent(UserEvent::LoggedIn);

$user->addEvent(UserEvent::Purchase, [
    'order_id' => 123,
    'total' => 99.99,
]);

$user->addEvent(UserEvent::PasswordResetRequested, false);
```

The second argument can be an array or any JSON-serializable scalar value. Exact scalar matching works for values like `false`, `0`, `'0'`, and `''`.

Helper Methods
--------------

[](#helper-methods)

```
// Check if an event exists
$user->hasEvent(UserEvent::EmailVerified); // true or false

// Get the most recent event
$user->latestEvent(); // or filter by type
$user->latestEvent(UserEvent::Purchase);

// Get the first event
$user->firstEvent(UserEvent::LoggedIn);

// Count events
$user->eventCount(); // all events
$user->eventCount(UserEvent::LoggedIn); // by type
```

`latestEvent()` and `whereLatestEventIs()` use the same definition of "latest": newest `created_at`, with `id` as the tie-breaker. Those latest-event queries also resolve through your configured Event model, so custom global scopes stay in effect.

Querying a Model's Events
-------------------------

[](#querying-a-models-events)

```
// Get all events for a model
$user->events;

// Filter by the enum alias and backing value
$user->events()->ofType(UserEvent::LoggedIn)->get();

// Filter by raw backing values when you also provide the enum alias
$user->events()->ofTypeClass('user')->ofType([1, 5])->get();

// Filter by data
$user->events()->whereData(['order_id' => 123])->get();
$user->events()->whereData(false)->get();

// Time-based queries
$user->events()->happenedAfter(now()->subDays(7))->get();
$user->events()->happenedBefore(now()->subMonth())->get();
$user->events()->happenedInTheLast(7, Unit::Day)->get();
$user->events()->happenedInTheLast(3, Unit::Hour)->get();
$user->events()->happenedToday()->get();
$user->events()->happenedThisWeek()->get();
$user->events()->happenedThisMonth()->get();

// With explicit timezone (defaults to app timezone)
$user->events()->happenedToday('America/Chicago')->get();
```

Raw values only filter the `type` column. If multiple enums can share the same backing values, pair raw values with `ofTypeClass()` or use an enum case directly.

Querying Models by Event Criteria
---------------------------------

[](#querying-models-by-event-criteria)

```
// Users who made a purchase over $100 in the last 7 days
User::whereHas('events', function ($query) {
    $query->ofType(UserEvent::Purchase)
        ->where('data->total', '>', 100)
        ->happenedAfter(now()->subDays(7));
})->get();

// Users who logged in today
User::whereHas('events', function ($query) {
    $query->ofType(UserEvent::LoggedIn)->happenedToday();
})->get();

// Users who viewed a specific page
User::whereHas('events', function ($query) {
    $query->ofType(UserEvent::PageViewed)
        ->whereData(['page' => '/pricing']);
})->get();
```

Querying Models by Events
-------------------------

[](#querying-models-by-events)

```
// Find users who have logged in
User::whereEventHasHappened(UserEvent::LoggedIn)->get();

// Find users who haven't verified their email
User::whereEventHasntHappened(UserEvent::EmailVerified)->get();

// With specific data
User::whereEventHasHappened(UserEvent::Purchase, ['total' => 99.99])->get();

// With advanced Laravel query constraints on the matching events
User::whereEventHasHappened(UserEvent::Purchase, function ($events) {
    $events->where('data->total', '>', 99);
})->get();

// Count-based queries
User::whereEventHasHappenedTimes(UserEvent::LoggedIn, 3)->get(); // exactly 3 times
User::whereEventHasHappenedAtLeast(UserEvent::Purchase, 5)->get(); // at least 5 times

// Find by latest event
User::whereLatestEventIs(UserEvent::SubscriptionStarted)->get();
```

Pruning Old Events
------------------

[](#pruning-old-events)

Implement `PruneableEvent` on your registered enums to configure retention policies:

```
use AaronFrancis\Eventable\Contracts\PruneableEvent;
use AaronFrancis\Eventable\Prune;
use AaronFrancis\Eventable\PruneConfig;

enum UserEvent: int implements PruneableEvent
{
    case LoggedIn = 1;
    case EmailVerified = 2;
    // ... other cases ...
    case PageViewed = 6;

    public function prune(): PruneConfig|Prune|null
    {
        return match ($this) {
            // Keep only the last 5 login events per user
            self::LoggedIn => Prune::keep(5),

            // Delete page views older than 30 days
            self::PageViewed => Prune::before(now()->subDays(30)),

            default => null, // Don't prune
        };
    }
}
```

If you prefer, you can still return `new PruneConfig(...)` directly.

Run the prune command:

```
# Preview what will be deleted
php artisan eventable:prune --dry-run

# Actually prune
php artisan eventable:prune
```

Schedule it in your `routes/console.php` or kernel:

```
Schedule::command('eventable:prune')->daily();
```

`PruneConfig` must define at least one retention rule: `before`, `keep`, or both. `Prune` is a fluent builder for producing that config. When pruning by `keep`, Eventable keeps the newest rows by `created_at desc, id desc`. If `varyOnData` is enabled, rows are partitioned by model and canonicalized JSON payload before the keep limit is applied, so equivalent JSON objects are grouped together across supported drivers.

Custom Event Models
-------------------

[](#custom-event-models)

You can extend the default Event model:

```
