luketowers/wn-easyaudit-plugin
Winter CMS plugin that integrates EasyAudit to automatically record an audit trail of model changes, tracking who created, updated, or deleted records with detailed old and new value diffs for easy review.
Install it like any other Winter plugin (plugins are Composer packages installed from the project root, not per-plugin directories):
composer require luketowers/wn-easyaudit-plugin
php artisan winter:up
Pin at least ^4.3.1 if you intend to override what a model tracks at runtime rather than only in declared class properties — that behaviour was fixed in 4.3.1 (see below). If your project pins plugin versions or runs in a locked-down CI, install it exactly like the rest of your plugin set and let winter:up apply the migration that creates the audit table.
First three places to look after install:
Plugin.php — navigation registration, backend permissions, and any settings model.models/Audit.php — the audit record itself: event, auditable_type/auditable_id, old_values, new_values, user_id, url/ip_address/user_agent (depending on version), plus the casts that turn the diffs into arrays.controllers/Audits.php — the backend list used to filter and inspect entries (this is the actual day-to-day UI).The fastest first use case is to audit one model end-to-end. Add the auditing trait/behavior the plugin documents to a single model:
class Product extends Model
{
use \LukeTowers\EasyAudit\Traits\Auditable; // confirm exact class in the plugin README
}
Then edit a record in the backend, open the audit log list, filter by auditable_type, and confirm you see created / updated rows with old_values and new_values. Once that loop works, you know the wiring is right and can roll out to the rest of your models.
Opt models in deliberately, not globally. The plugin is most useful when you attach auditing to the handful of models where change history matters (pricing, permissions, user records, CMS content). Blanket-auditing every model produces a lot of rows with little signal.
Shape what gets logged at the model level. Typical setup is to declare which events matter and which attributes must never be stored:
class User extends Model
{
use \LukeTowers\EasyAudit\Traits\Auditable;
protected $auditEvents = ['created', 'updated', 'deleted', 'restored'];
protected $auditExclude = ['password', 'remember_token', 'api_token', 'last_seen_at'];
}
Keeping last_seen_at, counters, and any “touched” timestamps out of the event list is the difference between a readable log and a noisy one.
Override tracking dynamically when the decision is contextual. Declared properties are the default, but since v4.3.1 overrides registered as dynamic properties on the model are honoured too. That covers the cases where a single class needs different tracking depending on the flow — a privileged import path, a multi-tenant scope, or a behavior that adds fields to a model it doesn’t own:
// e.g. in a controller, command, or behavior's extend()/boot setup
$product->auditExclude = [...$product->auditExclude, 'internal_notes'];
$product->save();
Use this sparingly: a runtime override is invisible to anyone reading the model class, so the log’s contents stop being predictable from the source. Prefer declared properties for stable policy, dynamic overrides for genuinely per-request/per-tenant rules — and if you do use them, say why in a comment or in the behavior that registers them.
Surface history on the record’s own page. Rather than sending editors to a global log screen, expose the relation on the model and render it as a tab or a partial on the update form:
public $morphMany = [
'audits' => [\LukeTowers\EasyAudit\Models\Audit::class, 'name' => 'auditable'],
];
Then in the controller’s form config add a secondary tab or a relation widget pointing at audits. This is where reviewers actually use the feature — “who changed this product’s price last Tuesday” is answered on the product page, not in a separate list.
Use the Audit model for reporting. Because entries are ordinary Eloquent rows, custom reporting is straightforward:
Audit::where('auditable_type', Product::class)
->where('event', 'updated')
->where('created_at', '>=', now()->subDays(30))
->orderByDesc('created_at')
->get();
That same query is the basis for “recent changes” dashboard widgets, per-user activity feeds, and export jobs.
Cover the writes Eloquent doesn’t see. Bulk operations, query()->update(), raw SQL, and migrations bypass model events. For those paths, write the audit entry explicitly through the Audit model (or wrap the operation in a small service that does both) so the log stays complete for the changes that matter most.
Build retention in from day one. A scheduled command that prunes entries older than a retention window (90/180/365 days depending on your compliance posture) should ship with the initial rollout, not after the table gets large:
Audit::where('created_at', '<', now()->subDays(365))->delete();
Wire it into existing review workflows. The plugin’s value compounds when the log becomes the source of truth for approvals: a Slack/email notification on sensitive changes, a “this record changed since you last viewed it” indicator, or an admin dashboard summarizing the day’s changes. Hooking the plugin’s audit-recorded event (or an observer on the Audit model) is usually cleaner than polling.
new_values.auditable_type stores the model class by default. If you later enable Relation::enforceMorphMap() or rename/move a model, existing rows will no longer resolve and the UI will show blank or orphaned entries. Decide on a morph map early, or plan a backfill.old_values / new_values are not symmetric. On created, old_values is empty; on deleted, new_values is empty. Any custom renderer must handle null and [] without assuming both sides exist.auditExclude before you enable auditing on user-like models, and treat the first rollout as a security review step. If you use dynamic overrides to exclude a field, verify with a real save that the override was applied before the row was written — that’s exactly the class of bug 4.3.1 fixed.$hidden doesn’t help. Attributes hidden from JSON serialization are still audited unless explicitly excluded. Check what actually lands in the new_values payload with a real save.Model::where(...)->update(...) fires no events. If your admin panel uses mass actions, audit those through the action handler explicitly.user_id will be null for cron jobs and workers. The list UI should render that gracefully (“System”), and any per-user report needs to account for it.afterCommit. Audit rows written inside a rolled-back transaction disappear with it. Whether that’s correct depends on the use case — for security-relevant events you may want the log entry outside the transaction.(auditable_type, auditable_id), created_at, and user_id. The list screen filters by all three, and the table grows monotonically.old_values/new_values differs between MySQL and PostgreSQL. If you need that, prefer extracting the specific column into a dedicated field or querying in PHP after a narrowed fetch.$auditEvents, check for disableAuditing/withoutAuditing in the call path, confirm any dynamic override was set before the save (and on the version you actually deployed), verify the migration ran (php artisan winter:up), and tail storage/logs/laravel.log for constraint or JSON-cast errors on insert.Audit model, and keep the plugin’s controller as the read-only viewer. Forking a small UI plugin is the fast route to a maintenance burden at upgrade time.How can I help you explore Laravel packages today?