Docs / Packages / Holocron

Holocron

Contents


Introduction

Holocron is a Laravel package for recording and querying historical events across your application. It combines audit-style field diffing with human-readable event logging, so you can track both low-level attribute changes and meaningful business events in a single, consistent history record.

Rather than choosing between a rigid audit log and a custom activity feed, Holocron gives you both in one entry — the what changed and the what happened side by side.

// Record a manual event with a human-readable message
Holocron::record('status_changed')
->on($order)
->by(auth()->user())
->message('Order marked as paid')
->withMeta(['from' => 'pending', 'to' => 'paid'])
->save();
// Automatically track field changes via a model trait
class Order extends Model
{
use HasHistory;
protected array $holocronTrack = ['status', 'total'];
}
// Query the timeline
$order->holocronTimeline();

When To Use This Package

Use Holocron when your application needs a persistent, queryable record of what happened, who caused it, and what changed.

Common use cases:

  • Order and payment lifecycle tracking
  • Content moderation and editorial history
  • Support ticket and case notes
  • User account activity logs
  • Audit trails for compliance or debugging

When not to use this package:

Holocron is not an error-tracking or performance monitoring tool. For exceptions and stack traces, a tool like Flare or Sentry is more appropriate.

If you only need lightweight model diffing without human-readable events or actor tracking, a simpler audit package may be sufficient. Holocron is designed for applications where both the audit trail and the activity timeline matter.


Installation

Install the package via Composer:

composer require egough/holocron

Publishing Assets

Publish the configuration file:

php artisan vendor:publish --tag=holocron-config

Publish the migrations:

php artisan vendor:publish --tag=holocron-migrations

Run your migrations:

1php artisan migrate

This creates the holocron_entries table (or whichever table name you configure) used to store all history records.


Configuration

After publishing, the configuration file will be available at config/holocron.php.

return [
'table_name' => 'holocron_entries',
'auto_record' => [
'created' => true,
'updated' => true,
'deleted' => true,
'restored' => true,
],
'exclude' => [
'created_at',
'updated_at',
],
'actor_resolver' => static fn () => auth()->user(),
];

Options

table_name

The database table used to store history entries. Defaults to holocron_entries. Change this before running migrations if you need a different table name.

auto_record

Controls which Eloquent model events are automatically recorded when using the HasHistory trait. Each event can be toggled independently. These defaults apply globally and can be overridden per model.

exclude

A list of attribute names that should be excluded from automatic field diffing. created_at and updated_at are excluded by default, as they change on almost every write and are rarely meaningful in a history record.

actor_resolver

A callable used to resolve the actor when one is not provided explicitly. Defaults to auth()->user(). You can replace this with any closure that returns a model or null — useful for applications using custom auth guards or tenant-scoped user resolution.


Manual Recording

Use the Holocron facade to record events anywhere in your application — controllers, jobs, listeners, services, or commands.

Recording an Event

use Egough\Holocron\Facades\Holocron;
Holocron::record('status_changed')
->on($order)
->by(auth()->user())
->message('Order marked as paid')
->save();

Each call to record() starts a fluent builder. The only required call is save() — all other methods are optional and additive.

Method Description
->on($model) The subject model this history entry belongs to (polymorphic).
->by($actor) The actor who caused the event. Falls back to actor_resolver if omitted.
->message(string) A human-readable description of what happened.
->category(string) An optional category for grouping and filtering entries.
->withMeta(array) Arbitrary key-value metadata stored as JSON.
->withChanges(array) Explicit attribute diffs in ['field' => ['old' => ..., 'new' => ...]] format.
->save() Persists the entry to the database.

Recording Field Changes

You can record explicit attribute diffs alongside or instead of a message. This is useful when you want to capture the before and after state of specific fields without relying on automatic model tracking.

Holocron::record('updated')
->on($post)
->by($user)
->withChanges([
'title' => [
'old' => 'Old Title',
'new' => 'New Title',
],
'status' => [
'old' => 'draft',
'new' => 'published',
],
])
->message('Post published with updated title')
->save();

Changes are stored as JSON and can be retrieved and rendered for an audit-style diff view.

Attaching Metadata

Metadata allows you to store any additional context that does not fit into the standard fields. It is stored as a JSON column and is freely structured.

Holocron::record('invoice_resent')
->on($invoice)
->by($user)
->withMeta([
'recipient' => 'billing@example.com',
'attempt' => 3,
'triggered_by' => 'admin_panel',
])
->save();

Metadata is separate from field changes. You may use both withMeta() and withChanges() on the same entry.

Categorising Events

Categories allow you to group related events for filtering and display purposes.

Holocron::record('note_added')
->on($ticket)
->by(auth()->user())
->category('communication')
->message('Support note added by agent')
->save();
Holocron::record('status_changed')
->on($ticket)
->by(auth()->user())
->category('lifecycle')
->message('Ticket escalated to tier 2')
->save();

Categories are stored as plain strings. There is no predefined list — you define the taxonomy that makes sense for your application.


Model Integration

The HasHistory trait can be added to any Eloquent model to enable both automatic event recording and convenient history querying directly from the model.

Preparing Your Model

use Egough\Holocron\Concerns\HasHistory;
use Illuminate\Database\Eloquent\Model;
class Order extends Model
{
use HasHistory;
}

You can add the trait to as many models as you need. Each model's history is stored polymorphically, so all entries live in the same table regardless of which model they belong to.

class Post extends Model
{
use HasHistory;
}
class Project extends Model
{
use HasHistory;
}

Automatic Model Events

Once the trait is applied, Holocron will automatically record entries for the following Eloquent events:

Event Recorded by default
created Yes
updated Yes
deleted Yes
restored Yes

For updated events, Holocron compares the model's dirty attributes against their original values and stores the diff. Only changed attributes are recorded.

{
"status": {
"old": "draft",
"new": "active"
},
"published_at": {
"old": null,
"new": "2025-06-01 09:00:00"
}
}

Attributes listed in config/holocron.php under exclude are ignored during diffing, as are attributes not present in the model's $holocronTrack list if one is defined.

Controlling Tracked Fields

By default, all changed attributes are diffed on update (minus the global exclusions). You can narrow this to a specific list of fields per model using the $holocronTrack property.

class Order extends Model
{
use HasHistory;
protected array $holocronTrack = ['status', 'total', 'notes'];
}

When $holocronTrack is set, only changes to those fields will be included in the diff. Changes to other attributes will be silently ignored.

Controlling Tracked Events

You can override which Eloquent events are recorded for a specific model using $holocronEvents. This takes precedence over the global auto_record configuration for that model.

class Order extends Model
{
use HasHistory;
protected array $holocronEvents = ['created', 'deleted'];
}

In this example, updated and restored events will not be automatically recorded for Order, even if they are enabled globally in the config.

Recording History from the Model

You can record history entries directly from a model instance, which is convenient when the subject is implicit:

$project->recordHistory(
event: 'archived',
message: 'Project archived by admin',
actor: auth()->user(),
);

You may also pass meta and changes as named arguments:

$project->recordHistory(
event: 'settings_updated',
message: 'Project visibility changed to private',
actor: $user,
meta: ['visibility' => 'private'],
);

Querying History from the Model

The HasHistory trait provides several ways to query a model's history:

// All history entries as a collection
$order->history;
// Query builder for full Eloquent control
$order->history()->latest('recorded_at')->get();
// Convenience scope returning the most recent entries first
$order->latestHistory()->get();
// Timeline-ready output
$order->holocronTimeline();

The holocronTimeline() method returns entries in a format suitable for rendering a human-readable activity feed, with messages and metadata pre-formatted for display.


Querying

The HolocronEntry model provides a set of query scopes for retrieving history records across your entire application, independent of a specific model instance.

Filtering by Subject

use Egough\Holocron\Models\HolocronEntry;
HolocronEntry::query()
->forSubject($order)
->latest('recorded_at')
->get();

forSubject() accepts any Eloquent model and scopes results to entries with a matching polymorphic relationship.

Filtering by Actor

HolocronEntry::query()
->causedBy($user)
->latest('recorded_at')
->get();

causedBy() accepts any Eloquent model and scopes results to entries where that model is the recorded actor.

Filtering by Event and Category

HolocronEntry::query()
->forSubject($order)
->event('status_changed')
->get();
HolocronEntry::query()
->causedBy($user)
->category('communication')
->get();

Scopes can be chained freely:

HolocronEntry::query()
->forSubject($ticket)
->causedBy($agent)
->category('communication')
->latest('recorded_at')
->get();

The HolocronEntry Model

Each history entry is stored as a HolocronEntry Eloquent model. The following attributes are available:

Attribute Type Description
id int Primary key.
subject_type string The class name of the subject model.
subject_id int The ID of the subject model.
actor_type string|null The class name of the actor model.
actor_id int|null The ID of the actor model.
event string The event name (e.g. status_changed, created).
message string|null A human-readable description of the event.
category string|null An optional category for grouping.
metadata array|null Arbitrary JSON metadata, cast to array.
changes array|null Field-level diffs, cast to array. Each entry follows ['old' => ..., 'new' => ...].
recorded_at Carbon The timestamp the event was recorded.

Testing

The package ships with a full test suite backed by an in-memory SQLite database, so no external database service is required.

Running the Tests

Install development dependencies:

1composer install

Run the test suite:

1composer test

Or run PHPUnit directly:

1vendor/bin/phpunit

Testing Your Own Application

When writing tests for code that records or queries history, you can interact with the Holocron facade or HolocronEntry model directly to assert state.

Asserting that an event was recorded:

use Egough\Holocron\Models\HolocronEntry;
it('records a status_changed event when an order is paid', function () {
$order = Order::factory()->create(['status' => 'pending']);
$order->markAsPaid();
expect(
HolocronEntry::query()
->forSubject($order)
->event('status_changed')
->exists()
)->toBeTrue();
});

Asserting the recorded actor:

it('records the correct actor when an admin marks an order as paid', function () {
$admin = User::factory()->admin()->create();
$order = Order::factory()->create();
$this->actingAs($admin);
$order->markAsPaid();
$entry = HolocronEntry::query()
->forSubject($order)
->event('status_changed')
->first();
expect($entry->actor_id)->toBe($admin->id);
expect($entry->message)->toBe('Order marked as paid');
});

Asserting field-level diffs:

it('records the old and new status when an order is updated', function () {
$order = Order::factory()->create(['status' => 'pending']);
$order->update(['status' => 'paid']);
$entry = $order->history()
->event('updated')
->latest('recorded_at')
->first();
expect($entry->changes['status']['old'])->toBe('pending');
expect($entry->changes['status']['new'])->toBe('paid');
});

Because entries are written to the database, they will be reset between tests as long as you use the RefreshDatabase trait in your test cases.


Requirements

Requirement Version
PHP 8.2+
Laravel 11, 12, or 13