Updated 19 June 2026

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.

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.
Everything lives in one table, chatter_messages, behind a single Message model.
Two polymorphic relationships do the heavy lifting:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
class Message extends Model { protected $casts = ['properties' => 'array']; // What is this message attached to? A sales order, a product, an invoice… public function messageable(): MorphTo { return $this->morphTo(); } // Who caused it? A User, a Partner, or the system. public function causer(): MorphTo { return $this->morphTo(); } } |
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.

The HasChatter trait gives a model its thread. The relationships are just morphs filtered by type:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
public function messages(): MorphMany { return $this->resolveChatterMessageOwner() ->morphMany(Message::class, 'messageable') ->whereNot('type', 'activity') ->orderBy('created_at', 'desc'); } public function addMessage(array $data): Message { $user = Filament::auth()->user() ?? Auth::user(); $message = new Message; $message->fill(array_merge([ 'causer_type' => $user?->getMorphClass(), 'causer_id' => $user?->id, 'company_id' => $user->defaultCompany?->id ?? null, ], $data)); $this->messages()->save($message); return $message; } |
History is a second trait, HasLogActivity, and this is the part that replaced thirty hand-written observers.
It hooks the model lifecycle once:
The history is a second trait, HasLogActivity, and this is the part that replaced thirty hand-written observers. It hooks the model lifecycle once:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
public static function bootHasLogActivity() { static::created(fn (Model $m) => $m->logModelActivity('created')); static::updated(fn (Model $m) => ! $m->wasRecentlyCreated ? $m->logModelActivity('updated') : null); if (method_exists(static::class, 'bootSoftDeletes')) { static::deleted(fn (Model $m) => $m->trashed() ? $m->logModelActivity('soft_deleted') : $m->logModelActivity('hard_deleted')); static::restored(fn (Model $m) => $m->logModelActivity('restored')); } else { static::deleting(fn (Model $m) => $m->logModelActivity('deleted')); } } |
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:
|
1 2 3 4 5 |
$changes[$title] = [ 'type' => is_null($oldValue) ? 'added' : 'modified', 'old_value' => $oldValue, 'new_value' => $newValue, ]; |
If nothing meaningful changed, no entry is written, so saving a record without touching a watched field doesn’t litter the timeline.
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:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
protected function formatAttributeValue(string $key, $value): mixed { if (is_bool($value)) { return $value ? 'Yes' : 'No'; } // Enums log their label, not their backing value if ($value !== null && isset($this->casts[$key])) { $castType = $this->casts[$key]; if (is_subclass_of($castType, BackedEnum::class)) { $enum = $value instanceof BackedEnum ? $value : $castType::from($value); return method_exists($enum, 'getLabel') ? $enum->getLabel() : $enum->value; } } return $value; } |
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.

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:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
public function chatterMessageOwner(): Model { $class = get_class($this); $parentWebkulClass = null; while (($parent = get_parent_class($class)) !== false) { if (str_starts_with($parent, 'Webkul\\')) { $parentWebkulClass = $parent; $class = $parent; continue; } break; } if ($parentWebkulClass && $parentWebkulClass !== get_class($this)) { $parentInstance = (new $parentWebkulClass)->newQuery()->find($this->getKey()); return $parentInstance ?? $this; } return $this; } |
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.)
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 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.
Tell us about Your Company

If you have more details or questions, you can reply to the received confirmation email.
Back to Home
Be the first to comment.