7 Laravel Eloquent Patterns That Simplified My Codebase
These aren't the patterns you find in the docs. They're the ones I reached for after years of untangling spaghetti Eloquent queries in large production apps.
Eloquent is deceptively simple to start with and surprisingly deep once you need to do real work. After maintaining large Laravel codebases for several years, I keep reaching for the same patterns when complexity creeps in. These seven are the ones that paid off most consistently.
1. Query Scopes as a Domain Language
Local scopes let you define reusable query constraints directly on the model, turning cryptic where clauses into readable business logic. A scope like ->active()->forRegion($region)->withinBudget($max) reads like a requirements document. Define them on the model and your controllers stay thin.
public function scopeActive(Builder $query): void
{
$query->where('status', 'active')->where('expires_at', '>', now());
}
// Usage
$vendors = Vendor::active()->forRegion('jakarta')->get();2. Custom Collection Classes
Every Eloquent model can specify a custom Collection class. When you find yourself chaining the same collection operations in multiple places, move them into the collection class as named methods. This is especially useful for financial calculations, status aggregations, and anything that requires multiple passes over a result set.
3. Value Objects with Casts
Eloquent's custom casts let you deserialize database columns into rich value objects automatically. I use this for money columns (cast to a Money object with currency), address columns (cast to an Address record), and permission bitmasks. Your model properties become typed, validated objects instead of raw primitives.
4. withCount + withSum for Dashboard Queries
$clients = Client::query()
->withCount('projects')
->withSum('invoices', 'amount')
->withMax('invoices', 'due_date')
->get();
// Access as: $client->projects_count, $client->invoices_sum_amount5. Preventing N+1 with Strict Mode
In your AppServiceProvider, call Model::preventLazyLoading() in non-production environments. This throws an exception the instant you trigger a lazy-loaded relationship, forcing you to eager-load proactively. I've caught dozens of latent N+1 problems this way during local development before they ever reached production.
6. Subquery Selects for Virtual Columns
Correlated subqueries let you add computed columns to query results without loading relationships. A subquery that fetches the latest order date for each customer runs as a single SQL query. This pattern replaced a lot of in-PHP computation that was previously forcing eager loads of entire relationship sets.
7. Model Observers for Cross-Cutting Concerns
Audit logging, cache invalidation, and notification triggers don't belong in your controllers or even your service classes. Model observers give you a clean, discoverable place to attach these behaviors to model lifecycle events. One observer class, registered in the service provider, handles the concern globally.