Skip to content
A Bekkour
Writing

Polymorphic models in Laravel: one table, two different records (Eloquent morphs)

5 min read

A CRM I worked on tracked two very different kinds of record. One was a complex claim with vehicles, people, and third parties attached; the other was a simple product sale with a payment plan. On paper they share almost nothing — different fields, different sub-entities, different everything. But to the business they were the same object: both were “claims” that an agent owned, that moved through the same pipeline of statuses, that showed up in the same dashboards, that needed the same audit trail. Two things that are structurally unrelated but behaviourally identical is exactly the situation polymorphism is for.

One shared root, a polymorphic child

The move was to make everything the two records have in common — ownership, status, team, timestamps, audit — live on one claims table, and let the parts that differ hang off a polymorphic association:

// migration
$table->string('reference')->unique();
$table->morphs('claimable');   // claimable_type + claimable_id
$table->timestamps();

morphs('claimable') gives you two columns — a type and an id — so a single claims row can point at either kind of detail record. The claims table is the single source of truth for the cross-cutting stuff; the type-specific tables carry only their own weird fields. A dashboard that counts claims per day, a permission check on “your team’s claims,” an audit log of status changes — all of them operate on the one shared table and never need to know which flavour of record sits underneath.

The thing I’d underline for anyone reaching for this: polymorphism earns its keep when the shared behaviour is the point and the differing shape is incidental. If those two record types had needed different status flows, different ownership rules, different dashboards, this would’ve been the wrong call — two tables, no shared root. It was right here precisely because the business treated them identically, and only the data collection differed.

The shared root as the framework’s junction box

Because everything cross-cutting lived on Claim, that one model became where the whole Laravel ecosystem plugged in:

class Claim extends Model implements Auditable
{
    use ClaimTrait, SoftDeletes, Filterable, Searchable, PresentableTrait, OwenItAuditable;

    protected $presenter = ClaimPresenter::class;
}

Read the use line as a list of concerns, each a package doing one job: soft deletes, query filtering, full-text search, view presentation, and an audit trail — all attached once, to the shared root, and therefore working uniformly across both record types for free. That’s the real payoff of the single-table design: you wire audit/search/filtering to one model and both kinds of record inherit it. Add a third record type later and it gets all of that the moment its detail table exists.

Permissions rode the same rail. A reporting scope forced an agent to see only their own records right in the query, so authorization was a where on the shared table rather than a thing every caller had to remember:

if ($user->hasRole('Agent')) {
    $query->where('user_id', $user->id);
}

The seams, because there are always seams

I like this design, and I’d build it again — which is exactly why it’s worth pointing at the places it frayed, because they’re the honest cost.

The first is a real bug I only caught rereading my own code. A scope that was supposed to filter to accepted records owned by the current user:

public function scopeAccepted($query)
{
    return $query->whereHas('status', function ($q) {
        return $q->where('name', 'Accepted');
        $q->where('user_id', '=', auth()->user()->id);   // dead code — never runs
    });
}

The return on the first line means the second where is unreachable. So the “own records only” restriction that the code clearly intended silently never applied — the scope returned every accepted record regardless of owner. It’s a two-line function and I still got it wrong, which is a decent argument for two things: scopes deserve tests as much as anything else, and an early return inside a closure is a sharp edge worth being paranoid about.

The second is the tax you pay for the polymorphic reporting. Summing revenue across records meant reaching through claimable → plan → price, and doing that across a set of records without care is a straight N+1 — the shared root is clean, but the moment a report crosses back into the type-specific side, you’re one lazy relation away from a query per row. The design didn’t cause that; it just didn’t stop me from writing it. Polymorphism gives you a clean top; the joins back down to the specifics are where you still have to think.

The takeaway I’d actually give a teammate

Polymorphism isn’t about the two tables being similar — it’s about them being treated the same. When the status flow, the ownership, the audit, and the dashboards are shared and only the data shape differs, one root table plus a morphs child lets you wire all the cross-cutting machinery once and get it everywhere. When any of that shared behaviour would actually need to diverge, don’t — you’ll spend the rest of the project bolting on if type == .... And whichever you pick: test your scopes, and watch the joins back down into the polymorphic side, because that’s where a clean model quietly turns into a slow one.


I’ve spent years modeling domains where the data is messier than the diagrams — deciding what’s shared, what’s specific, and where the seams should go. If you’re staring at a schema decision like this, I’m happy to think it through with you. Related: the dashboards this same CRM ran on.

Al Mokhtar Bekkour

Senior Rails & Go engineer in Quebec. I build multi-tenant SaaS and financial integrations, and ship my own products — Constat, Railyard, and Recall. Open to work.

← All writing