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);
}
}
}
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.