Skip to content
Tech

Eloquent Relationships and Query Scopes: Patterns for Cleaner Models

How to use relationships, local scopes and query builder macros to keep business logic out of controllers and in models where it belongs.

Eloquent Relationships and Query Scopes: Patterns for Cleaner Models
Eloquent Relationships and Query Scopes: Patterns for Cleaner Models
In this article

    It's easy to write Eloquent that technically works but slowly turns every controller into a pile of where() chains and manual joins. Relationships and scopes exist precisely to pull that logic somewhere reusable, named, and testable. The payoff shows up months later, when a filter that lived in one controller needs to apply in three more places.

    Relationships Beyond the Basics

    Most Laravel apps lean on hasMany and belongsTo, but hasManyThrough and polymorphic relationships solve real problems that would otherwise end up as raw joins. Reaching for the right relationship type up front saves you from retrofitting a cleaner query later, once the "temporary" raw join has spread across half a dozen controllers.

    It's worth thinking about relationships as part of your domain vocabulary, not just a way to avoid writing SQL. A relationship method name should read the way a domain expert would describe the connection — $organization->invoices says something an experienced teammate immediately understands, where a hand-rolled join buried in a controller says nothing until someone reads every line of it.

    class Organization extends Model
    {
        public function invoices(): HasManyThrough
        {
            return $this->hasManyThrough(Invoice::class, Project::class);
        }
    
        public function comments(): MorphMany
        {
            return $this->morphMany(Comment::class, 'commentable');
        }
    }

    The hasManyThrough relationship above lets you call $organization->invoices directly, even though invoices belong to projects, which belong to the organization. Without it, that's a manual join repeated everywhere invoices are listed by organization.

    Local Scopes for Reusable Filters

    A local scope is a method prefixed with scope that the query builder exposes as a fluent call. It's the cleanest way to name a filter instead of repeating the same where clause across the codebase.

    class Invoice extends Model
    {
        public function scopeOverdue(Builder $query): void
        {
            $query->whereNull('paid_at')
                ->where('due_at', '<', now());
        }
    
        public function scopeForOrganization(Builder $query, Organization $organization): void
        {
            $query->whereHas('project', fn ($q) => $q->where('organization_id', $organization->id));
        }
    }

    Both scopes compose naturally: Invoice::overdue()->forOrganization($org)->get() reads like a sentence and stays correct even if the underlying schema changes, because the logic lives in one place.

    A scope you can read in one line is a filter nobody will accidentally reimplement slightly wrong six months from now.

    Global Scopes, Used Sparingly

    Global scopes apply automatically to every query on a model, which is powerful and dangerous in equal measure. Multi-tenant soft-boundaries are a legitimate use case:

    class TenantScope implements Scope
    {
        public function apply(Builder $builder, Model $model): void
        {
            if ($tenantId = tenant()?->id) {
                $builder->where('tenant_id', $tenantId);
            }
        }
    }
    Tip: Global scopes make it easy to forget they exist. When a query returns fewer rows than expected, check for a global scope before suspecting the data itself — it's a common source of confusing bug reports.

    Attribute Accessors Instead of Repeated Logic

    Computed values that depend on relationship data belong on the model as an accessor, not scattered across Blade templates or API transformers.

    protected function isOverdue(): Attribute
    {
        return Attribute::make(
            get: fn () => $this->paid_at === null && $this->due_at->isPast(),
        );
    }

    Now $invoice->is_overdue works the same way whether it's rendered in Blade, serialized in an API resource, or checked inside a job — one definition, used everywhere.

    Watch for Scopes That Hide N+1 Queries

    A scope that calls whereHas is fine for filtering, but it's easy to assume a relationship is already loaded when it isn't. If a view later accesses $invoice->project->organization->name, that's a fresh query per row unless you eager load it explicitly:

    • Pair filtering scopes with a matching with() call when the caller will also need the related data.
    • Consider a dedicated scope like scopeWithBillingDetails() that bundles the eager loads a particular screen needs.
    • Keep scopes focused — a scope that filters and silently eager loads unrelated data surprises the next developer who reuses it.

    It helps to think of whereHas and eager loading as answering two different questions. whereHas narrows which rows come back at all; eager loading decides what data arrives alongside the rows you already selected. Mixing them up — using whereHas when you actually need the related records rendered, not just used as a filter — is a common source of an extra, redundant query right after the main one runs.

    Testing Scopes in Isolation

    Because a local scope is just a method on the model, it's straightforward to test without spinning up a controller or route. Create a handful of invoices with different due dates and payment states, call the scope directly, and assert on the resulting collection. This kind of test tends to survive refactors better than a feature test asserting on an HTTP response, because it only breaks when the actual filtering logic changes — not when an unrelated field gets added to the JSON response.

    Relationships and scopes are the difference between an Eloquent model that's a thin wrapper around a table and one that actually encodes how your domain works. The investment is small — a few named methods — and it pays off every time someone needs the same filter again, instead of writing a slightly different version of it in a new controller.

    Written by Raj

    Founder & Editor

    I write HorizonDesk: practical, no-hype articles on technology, money, business and the vehicles we drive. Everything here is something I’ve researched, tested or wanted explained clearly myself.