Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

History Tracker Laravel Package

laravel-enso/history-tracker

Laravel Enso History Tracker records changes to Eloquent models, giving Laravel applications an audit trail of created, updated, and deleted records. It integrates with Enso to make model history easy to review in the admin interface.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Install (with an eye on the archived status). The package is archived, so treat it as a vendored dependency rather than a maintained one:

composer require laravel-enso/history-tracker

Before anything else, open vendor/laravel-enso/history-tracker/composer.json and confirm the laravel/framework and php constraints match your app. If they don't, pin the newest tag that does, or fork the repo into your own VCS and add it as a repositories entry.

2. Publish and migrate. Enso packages publish by tag; check the service provider for the exact tag:

php artisan vendor:publish --provider="LaravelEnso\HistoryTracker\HistoryTrackerServiceProvider"
php artisan migrate

This gives you a histories migration and, usually, a config/history-tracker.php. Inspect both — the migration tells you the real schema (model_type, model_id, type, data, created_by, timestamps), which is what you'll query against day to day.

3. Read the trait first. The whole package is essentially one trait plus one model. Open:

  • src/Traits/History.php (or HistoryTracker.php — the name differs slightly across versions)
  • src/Models/History.php
  • config/history-tracker.php

That's a 15-minute read and answers 90% of the questions you'll have later.

4. First use case — track a Post. Add the trait to a model:

use LaravelEnso\HistoryTracker\Traits\History;

class Post extends Model
{
    use History;

    // optionally narrow what gets recorded
    protected $historyExcept = ['updated_at', 'created_at'];
}

Then exercise it:

$post = Post::create(['title' => 'v1']);
$post->update(['title' => 'v2']);
$post->delete();

$post->history; // newest first or oldest, depending on the relation

If rows appear with type values like created / updated / deleted and a JSON data payload, you're wired up. If not, you're almost certainly hitting an event-bypass case (see Gotchas).

Implementation Patterns

Reading history in the UI. The common day-to-day shape is a timeline component fed by the morph relation:

$entries = $post->history()
    ->with('createdBy')          // if you have the author relation
    ->latest()
    ->paginate(20);

Map type to a label and render data as key/value diff rows. Because data is JSON of changed attributes only, the update entries are already diff-shaped; you don't need to compute old-vs-new yourself for the fields that were touched.

Manual / domain-level entries. The automatic events cover CRUD, but the interesting history is often semantic ("status changed to published by X"). Use the model to append a record directly rather than fighting the trait:

History::create([
    'model_type' => $post->getMorphClass(),
    'model_id'   => $post->getKey(),
    'type'       => 'published',
    'data'       => ['at' => now()->toIso8601String()],
    'created_by' => auth()->id(),
]);

Wrap this in a small service (PostHistory::record($post, 'published', $payload)) so the rest of the app never touches the table directly. When the package eventually needs replacing, you swap one class.

Scoping to a model or type. For admin dashboards, query the History model directly with morph-aware scopes:

History::where('model_type', (new Post)->getMorphClass())
    ->where('model_id', $post->id)
    ->whereIn('type', ['created', 'updated'])
    ->get();

Prefer getMorphClass() over Post::class everywhere — it stays correct if you adopt a morph map.

Queues, jobs, and "who did it". If your writes happen in jobs, auth()->id() will be null in the worker. Pass the actor explicitly: either re-authenticate with Auth::loginUsingId($userId) before the write, or set a created_by attribute yourself via a manual History::create and bypass the trait for those paths. Don't assume created_by is always populated.

Soft deletes and restores. If the model uses SoftDeletes, the trait's deleted/restored hooks only fire on real delete()/restore() calls. forceDelete() typically leaves history orphaned (which is usually what you want — an audit trail that survives the row). Decide this deliberately and document it.

Bulk operations. Anything that bypasses model events — Post::where(...)->update([...]), insert(), raw SQL, saveQuietly(), Model::withoutEvents() — records nothing. For batches, loop with ->each(fn ($m) => $m->update(...)) when history matters, or accept the gap and log a single summary record.

Testing. Assert against the table, not the relation, so you also verify the morph columns:

$this->assertDatabaseHas('histories', [
    'model_type' => 'posts',
    'model_id'   => $post->id,
    'type'       => 'updated',
]);

If history noise slows your suite, silence it globally in TestCase::setUp() with Model::withoutEvents() for tests that don't care, and use withoutEvents-free tests only for the few history-specific cases.

Gotchas and Tips

  • Archived means no fixes. Two practical consequences: pin an exact version ("laravel-enso/history-tracker": "x.y.z"), and keep a fork ready. If you hit a Laravel upgrade break, you're patching the trait yourself. Budget for a migration to spatie/laravel-activitylog or owen-it/laravel-auditing if this becomes load-bearing; both are maintained and cover the same ground with more configuration.

  • Morph map traps. model_type stores the FQCN by default. If you later introduce Relation::morphMap([...]) or rename/move a model class, old history rows stop resolving. Register the morph map before you start tracking, or write a data migration to rewrite model_type.

  • Trait boot collisions. The trait registers model event listeners in a boot<Name>() method. If the model already overrides boot(), or another package (audit log, search indexer, cache invalidator) hooks the same events, ordering can matter. If history rows are missing only on some models, this is the first thing to check.

  • Event bypass is the #1 source of "why is history empty". update(), insert(), upsert(), mass assignment on the query builder, increment(), and saveQuietly() all skip it. Grep your codebase for ->update( and ::where( before blaming the package.

  • Noisy diffs. updated_at and touch-only updates generate near-empty history rows. Exclude timestamp columns via the config/$historyExcept, and be aware that $model->touch() will still create entries in most versions. Same for password/remember-token columns — exclude secrets explicitly.

  • Cast and JSON columns. What gets recorded is the cast value (arrays, Carbon instances, enums depending on version), then serialized. Large JSON/text columns can bloat histories fast. Consider excluding big columns and logging a hash or a summary instead; a single rich-text field edited daily will dwarf every other table.

  • No auth context in CLI/queues. created_by is nullable and will be null. Make the column nullable in the migration and handle nulls in the UI ("System"). Never assume the relation exists.

  • Volume and indexing. Every tracked write is an insert. On a busy table this grows linearly. Add composite indexes on (model_type, model_id, created_at) and (created_by) if you filter by author, and plan a retention policy — a scheduled job that deletes rows older than N months is usually enough.

  • Deleted models return null relations. Loading $historyEntry->model() for a deleted record yields null. Render from the snapshot in data, not from the live model, in any audit view.

  • Extension points. The two that actually matter: swap the History model (via config, if supported, or by overriding the trait's relation) to add your own columns/relations, and override the recording method in your own trait that uses the package trait, rather than editing vendor code. Keep both in app/Support/History/ so a future package swap is a small diff.

  • Debugging checklist when a write produces no row: (1) trait is actually on the model, (2) the model's boot doesn't shadow the trait's, (3) the operation isn't event-bypassing, (4) the config isn't excluding the model or all attributes, (5) the migration ran and the connection matches (multi-DB apps often write to the wrong connection).

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle