Aureus ERP Hosting

How every record in our ERP got a comment thread and an audit trail in one line

Updated 19 June 2026

image-31-1

Two questions get asked about every important record in an ERP, and they get asked constantly.

The first is “who changed this, and to what?” Someone swears the delivery date was the 14th. The system says the 20th.

You need the history with names and timestamps, not a shrug.

The second is “Can we talk about this one?” The sales order has a weird discount, and finance wants to flag it.

That conversation should live on the order, not in an email thread nobody on the next shift can find.

Most teams solve these two separately. We solved them together, with one polymorphic table and two traits you add to a model.

This is how the chatter system in our Laravel + Filament ERP actually works.

image-222
A purchase order with its chatter panel open, a system log entry (“Status: RFQ → PO”), a human comment, and a follower avatar, all in one timeline.

The two setups we’d outgrown

We’d built both of the usual answers before, and both wear thin.

The first is a dedicated audit_logs table you wire up by hand. Every model that matters gets its own observer, its own “what changed” logic, its own glue.

It works until you have thirty models, and now thirty places drift out of sync, and nobody adds logging to the new one because it’s a chore.

The second is keeping the conversation off the record entirely, email, chat, or a comment field someone bolted on.

The discussion exists, but it’s divorced from the thing it’s about. Six months later, the context is gone.

We wanted something blunter: any model, in any plugin, gets a full history and a discussion thread by adding a trait. No per-model wiring.

And the two should share one storage mechanism. A “system said X changed” entry and a “Priya said let’s hold this” comment are really the same shape as a message, attached to a record, by someone, at a time.

A message is a message, whatever it’s about

Everything lives in one table, chatter_messages, behind a single Message model.

Two polymorphic relationships do the heavy lifting:

Messageable is the record. The causer is who, and because it’s polymorphic too, the actor can be an internal user, an external partner, or nobody at all (a queued job, a webhook). A type column tells the three kinds of message apart:

The diffed changes for an audit entry get stored in a properties JSON column.

One table, three behaviours, and the queries stay trivial because type is just a where.

image-224
one chatter_messages table → messageable points at any model, causer points at any actor, type splits comment/notification/activity.

Step 1: Drop into the conversation

The HasChatter trait gives a model its thread. The relationships are just morphs filtered by type:

History is a second trait, HasLogActivity, and this is the part that replaced thirty hand-written observers.

It hooks the model lifecycle once:

Step 2: Turn on the audit trail

The history is a second trait, HasLogActivity, and this is the part that replaced thirty hand-written observers. It hooks the model lifecycle once:

Notice it’s soft-delete aware. If the model uses SoftDeletes, a delete is recorded as soft_deleted (recoverable) versus hard_deleted (gone), and a restored The event is logged when it comes back.

Two genuinely different facts, recorded as two different things.

When updated fires, we diff getDirty() against getOriginal() and write the result into that properties JSON:

If nothing meaningful changed, no entry is written, so saving a record without touching a watched field doesn’t litter the timeline.

Step 3: Log what matters, in human words

A raw diff is noise. total_amount: 100 -> 120 is fine; updated_at changing on every save is not, and status: 1 -> 2 is useless to a human.

So a model declares which attributes are worth logging, and the trait formats the values:

A boolean reads “Yes”/”No”. An enum-backed status logs “Confirmed”, not 2.

And the whitelist understands a dotted syntax category.name , so a change to category_id is logged as the category’s name changing, by following the relationship and reading the human-friendly attribute.

The audit trail ends up reading like a sentence, which is the only kind anyone actually reads.

image-223
The Activity Scheduler helps teams organize meetings, calls, and tasks while maintaining complete visibility into upcoming actions.

The subtle part is one thread across model subclasses

Here’s the detail that bit us, and the fix we’re proudest of.

In our plugin architecture, the same record is often represented by a base model and a plugin-specific subclass sitting on the same table.

Attach a comment to the subclass naively and messageable_type record the subclass name. Open the same record through the base model, and the thread looks empty. The morph type doesn’t match.

The conversation fragments by which class you happened to load.

So before attaching anything, the trait walks up the inheritance chain to the base Webkul model and anchors every message there:

Every morphMany In the trait routes through this. The result: no matter which subclass any plugin loads the record through, the comments, the followers, and the history are all in the same thread.


(If you’ve ever been bitten by Eloquent firing model events per concrete subclass, this is the same gotcha wearing a different hat, same table, many classes, and you have to deliberately collapse them back to one.) You have to deliberately collapse them back to one.)

What we’d warn you about

This isn’t free, and a few things will bite you if you’re not deliberate.

Log on purpose, not on autopilot. The whitelist exists for a reason; without it, you’ll either log nothing useful or log everything, including updated_at churn.

Decide what a human would care about and list exactly that.

The causer can be null. A queue worker or an inbound webhook has no logged-in user, so causer_id it is nullable, and your UI has to render “System” gracefully.

Don’t assume every entry has a face next to it.

It’s one growing table. Every comment and every diff across every model lands in chatter_messages.

It’s polymorphic and append-only, so index messageable_type/messageable_id, and have a retention story before it gets large, not after.

The payoff

The win is that “add history and discussion to this model” stopped being a project and became two use statements.

The new model in the new plugin gets an audit trail because adding it is now easier than skipping it, and the conversation about a record finally lives on the record, next to the history that explains it.

We didn’t build an audit system and a messaging system. We noticed they were the same table, a message, about a record, by someone, at a time, and let one polymorphic model carry both.

Everything after that was just deciding what’s worth writing down.

. . .

Leave a Comment

Your email address will not be published. Required fields are marked*


Be the first to comment.

Tell us about Your Company




    success-logo

    Message Sent!

    If you have more details or questions, you can reply to the received confirmation email.

    Back to Home