Three Eloquent features that clean up your models
Published April 4, 2026 · by Majd Ghithan, written with the help of AI
The messiest Laravel model I ever wrote wasn't messy because of Eloquent. It was messy because I ignored the parts of Eloquent that exist specifically to keep it clean. The same where('status', 'active')->where('deleted_at', null) copy-pasted into eleven controllers. Raw json_decode($model->settings) everywhere. A getFullNameAttribute that I'd reinvented as a helper function three times because I forgot accessors existed.
Three features fix almost all of it: query scopes, attribute casts, and observers/accessors. None of them are advanced. All of them are underused.
1. Query scopes — name your where clauses
A scope gives a repeated query fragment a name and a home. Instead of every caller knowing what "active" means, the model knows, and callers just ask for it.
class Subscription extends Model
{
public function scopeActive(Builder $query): void
{
$query->where('status', 'active')
->where('ends_at', '>', now());
}
public function scopeForTeam(Builder $query, Team $team): void
{
$query->where('team_id', $team->id);
}
}
Now the definition of "active" lives once. The day the business decides active also means "not in grace period," you change one method, not eleven controllers.
Subscription::active()->forTeam($team)->get();
That reads like the sentence it represents. And the parameterized scope (forTeam) shows scopes aren't just constants — they take arguments, so you can build a whole readable query vocabulary specific to your domain.
2. Casts — stop hand-parsing columns
A column is a string in the database. Your code shouldn't care. Casts translate at the boundary so the rest of your app works with real types.
protected function casts(): array
{
return [
'settings' => 'array',
'is_trial' => 'boolean',
'trial_ends_at' => 'datetime',
'price' => MoneyCast::class,
];
}
$sub->settings['theme'] now just works — no json_decode, no ?? []. $sub->trial_ends_at->isPast() gives you a Carbon instance, not a string you have to parse at every call site.
The one worth learning is the custom cast, because it's how you get real value objects into your models. A price shouldn't be a bare integer of cents floating around your code — it should be a Money object that knows how to format and add itself.
class MoneyCast implements CastsAttributes
{
public function get($model, $key, $value, $attributes): Money
{
return new Money($value); // cents -> Money
}
public function set($model, $key, $value, $attributes): int
{
return $value instanceof Money ? $value->cents() : (int) $value;
}
}
Now $sub->price->formatted() works everywhere, and money arithmetic stops being scattered / 100 and * 100 conversions waiting to introduce a rounding bug.
3. Accessors and observers — derived data and lifecycle hooks
An accessor computes a value from columns without storing it, so you stop reinventing it as a helper.
protected function fullName(): Attribute
{
return Attribute::make(
get: fn () => trim("{$this->first_name} {$this->last_name}"),
);
}
$user->full_name — no helper, no Blade concatenation, one definition.
An observer moves lifecycle side effects out of controllers and into one place that fires no matter how the model was created — controller, command, seeder, or tinker.
class SubscriptionObserver
{
public function created(Subscription $sub): void
{
$sub->team->notify(new SubscriptionStarted($sub));
}
}
Register it and every Subscription::create(...) triggers the notification — you can never forget it in a new code path, because it isn't in the code paths at all. It's on the model's lifecycle.
Before and after
Here's the same read, once with everything inline and once with scopes and casts doing the work:
$subs = Subscription::query()
->where('team_id', $team->id)
->where('status', 'active')
->where('ends_at', '>', now())
->get();
foreach ($subs as $s) {
$settings = json_decode(
$s->settings, true
) ?? [];
$price = '$' . number_format(
$s->price / 100, 2
);
}
$subs = Subscription::active()
->forTeam($team)
->get();
foreach ($subs as $s) {
$settings = $s->settings;
$price = $s->price->formatted();
}
The right column isn't just shorter. Every business rule in it — what active means, how money formats, what shape settings has — lives in exactly one place. The left column has those rules smeared across every caller, each free to drift from the others.
Eloquent already has the tools to keep your models thin and your query language expressive. Most fat-model, copy-pasted-query problems aren't Eloquent's fault — they're what happens when you use it as a dumb data bag instead of the domain layer it's trying to be.
