laravel-enso/historytracker
HistoryTracker for Laravel Enso automatically records model changes, storing a versioned audit trail of created, updated, and deleted records with the user and timestamp behind each change, so you can review and roll back history from the Enso UI.
The package is archived, so first pin a version compatible with your Laravel release and read composer.json before installing:
composer require laravel-enso/historytracker
Then publish and run its config/migrations (tag names vary by version; check the service provider if these fail):
php artisan vendor:publish --provider="LaravelEnso\HistoryTracker\ServiceProvider"
php artisan migrate
Look first at:
src/Traits/HistoryTracker.php — confirms which Eloquent events are hooked.src/Models/History.php (or equivalent) — confirms table columns, casts, and relation names.historable_type / historable_id.Register a model in config:
// config/historytracker.php — confirm exact keys against your installed version
return [
'models' => [
Company::class => [
'label' => 'Company',
'events' => ['created', 'updated', 'deleted'],
],
],
];
Add the trait:
use LaravelEnso\HistoryTracker\Traits\HistoryTracker;
class Company extends Model
{
use HistoryTracker;
}
First useful case — audit edits to a business model:
$company->update(['name' => 'Acme International']);
$entry = $company->history()->latest()->first();
$entry->type; // event name, e.g. "updated"
$entry->data; // stored payload, usually the dirty/old attributes
$entry->created_by; // acting user id, if available
Treat history as a read model, not business logic. Keep writes going through normal Eloquent saves so events fire, and read from $model->history() anywhere you need an audit trail.
$entries = $company->history()
->with('createdBy:id,name')
->latest()
->paginate(25);
Normalize the stored payload once. The package stores event data as JSON; shape differs between created/updated/deleted. Centralize presentation in a resource or presenter:
final class HistoryEntryResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'event' => $this->type,
'changes' => $this->changes(),
'user' => $this->createdBy?->name,
'at' => $this->created_at->toIso8601String(),
];
}
private function changes(): array
{
$data = $this->data ?? [];
return collect($data['attributes'] ?? $data)
->map(fn ($value, $key) => [
'attribute' => $key,
'old' => data_get($data, "old.{$key}"),
'new' => $value,
])
->values()
->all();
}
}
Log domain events that are not Eloquent events. Approvals, exports, impersonated changes, and pivot changes need explicit entries:
$company->history()->create([
'type' => 'approved',
'data' => ['approved_by' => $user->id, 'reason' => $request->reason],
'created_by' => auth()->id(),
]);
Wrap writes and history-sensitive operations in transactions when the history record is part of the same logical unit. History inserts happen in the same connection and roll back with the parent write.
DB::transaction(function () use ($company, $payload) {
$company->update($payload);
// history row is written by the trait inside this transaction
});
Expose history through a policy-guarded endpoint. Don't rely on UI hiding alone:
public function history(Company $company)
{
$this->authorize('viewHistory', $company);
return HistoryEntryResource::collection(
$company->history()->with('createdBy')->latest()->paginate()
);
}
Scope for multi-tenancy. If the histories table is shared, either add a tenant column via a migration and a global scope, or always query through the parent model relation rather than the History model directly.
Plan retention early. History tables grow quickly. Schedule pruning or archival:
History::where('created_at', '<', now()->subMonths(18))->delete();
Override extension points in your app, not vendor code. Bind a custom History model/resource through the container if the package supports it, or extend the trait in an app-level trait so upgrades (or forks) remain possible.
In tests, assert rows directly. Because the trait writes side-effect rows, assert against the table rather than only the model:
$this->assertDatabaseHas('histories', [
'historable_type' => Company::class,
'historable_id' => $company->id,
'type' => 'updated',
]);
Model::where(...)->update(), insert(), upsert(), and raw SQL do not fire Eloquent events, so no history is recorded. Use ->each->update() or log manually.saveQuietly(), updateQuietly(), Model::withoutEvents(), and Event::fake() around writes suppress tracking.php artisan config:cache (or clear it). Otherwise new models silently produce no history.historable_type stores the model class unless you enforce a morph map. Renaming/moving a model orphans old records. Enforce Relation::enforceMorphMap() before adopting the package.created_by is usually auth()->id(), which is null in console commands, queues, and unauthenticated jobs. Make the column nullable and display “System” in the UI.deleted may be tracked, but forceDelete() fires a different event and may not be captured. Check the trait before relying on it for compliance.json behaves differently from SQLite in tests (sorting, casting, empty arrays vs objects). Keep payload assertions loose.(historable_type, historable_id, created_at), avoid tracking chatty models, and consider queued/async history if write volume is high.createdBy relation and any published config references resolve to your classes.withoutEvents/quiet call is in the path.History model binding, and the trait in app code over editing vendor/. A small app-level TracksHistory trait wrapping the package trait makes future replacement easier.How can I help you explore Laravel packages today?