| name | eloquent-patterns |
| description | Eloquent ORM best practices: query builders, scopes, relations, N+1 prevention, batch operations, soft deletes, model events, raw queries when needed.
Apply when: writing or reviewing Eloquent queries and model interactions.
Activated automatically by laravel-plugin/stack.md as a convention skill for the development phase.
|
Eloquent Patterns
Patterns for working with Eloquent that catch the common pitfalls (N+1, mass assignment, race conditions on counts, raw SQL injection).
1. N+1 prevention
The single most common Eloquent performance bug.
Problem
$users = User::all();
foreach ($users as $user) {
echo $user->subscription->plan;
}
Solution: eager loading
$users = User::with('subscription')->get();
foreach ($users as $user) {
echo $user->subscription?->plan;
}
Nested eager loading
$users = User::with('subscription.invoices')->get();
Selective columns (further optimization)
$users = User::with(['subscription:id,user_id,plan,status'])->get();
Detection in code review
Look for any foreach or array_map over an Eloquent collection followed by ->relation access without with() upstream. That's N+1 90% of the time.
2. Scopes for reusable query logic
Encapsulate common query fragments as model scopes.
class Subscription extends Model
{
public function scopeActive(Builder $query): void
{
$query->where('status', 'active')
->where('ends_at', '>=', now());
}
public function scopeForUser(Builder $query, User $user): void
{
$query->where('user_id', $user->id);
}
}
$activeForUser = Subscription::active()->forUser($user)->get();
Benefits:
- DRY — definition lives once.
- Testable — scopes can be unit-tested.
- Self-documenting —
Subscription::active() reads better than where('status', 'active')->where(...).
3. Mass assignment safety
class Subscription extends Model
{
protected $fillable = [
'user_id',
'plan',
'status',
'starts_at',
];
}
Then:
Subscription::create($request->validated());
Never:
class Subscription extends Model
{
protected $guarded = [];
}
4. Relations: declare return types
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function invoices(): HasMany
{
return $this->hasMany(Invoice::class);
}
public function paymentMethod(): MorphOne
{
return $this->morphOne(PaymentMethod::class, 'payable');
}
Return types let the IDE / static analyzers understand the relation, and they catch errors at boot time, not runtime.
5. Counts and aggregates
Lazy count (incurs a query each time)
$count = $user->subscriptions()->count();
Eager count
$users = User::withCount('subscriptions')->get();
foreach ($users as $user) {
echo $user->subscriptions_count;
}
Eager sum / avg / max
$users = User::withSum('subscriptions', 'amount')
->withMax('invoices', 'created_at')
->get();
6. Batch operations
Inserting many rows
foreach ($rows as $row) {
Model::create($row);
}
Model::insert($rows);
Model::query()->chunkById(500, function ($chunk) {
});
If you need events (timestamps, observers), use Model::insert() only when you've consciously accepted that timestamps and events won't fire.
Updating many rows
Subscription::where('status', 'pending')
->where('created_at', '<', now()->subDays(7))
->update(['status' => 'expired']);
Subscription::where(...)->each(function ($s) {
$s->update(['status' => 'expired']);
});
7. Soft deletes
use Illuminate\Database\Eloquent\SoftDeletes;
class Subscription extends Model
{
use SoftDeletes;
}
Implications:
- Migration must have
$table->softDeletes();.
- Queries auto-exclude soft-deleted:
Subscription::all().
- To include them:
Subscription::withTrashed()->get().
- To get only soft-deleted:
Subscription::onlyTrashed()->get().
- To restore:
$subscription->restore().
- Force delete:
$subscription->forceDelete().
Watch out: relations don't auto-cascade soft deletes. Use observers or explicit logic.
8. Race conditions on counts/aggregates
Concurrent updates to a counter cause classic race bugs. Use atomic operations.
Wrong
$user = User::find(1);
$user->credits = $user->credits + 10;
$user->save();
Right (atomic via SQL)
User::where('id', 1)->increment('credits', 10);
Or for a balance debit with overflow protection:
$affected = User::where('id', 1)
->where('credits', '>=', 10)
->decrement('credits', 10);
if ($affected === 0) {
throw new InsufficientCreditsException();
}
9. Raw queries — when and how
Sometimes Eloquent isn't enough (CTEs, window functions, complex aggregations). Use raw queries with bindings.
Right
DB::select(
'SELECT * FROM subscriptions WHERE user_id = ? AND status = ?',
[$userId, 'active']
);
Wrong (SQL injection)
DB::select("SELECT * FROM subscriptions WHERE user_id = $userId");
DB::raw("user_id = $userId")
Right with whereRaw + bindings
Subscription::whereRaw('amount > ?', [100])->get();
Right with selectRaw for aggregates
$stats = Subscription::query()
->selectRaw('plan, COUNT(*) as count, AVG(amount) as avg_amount')
->groupBy('plan')
->get();
10. Model events and observers
For cross-cutting behaviors (audit logs, cache invalidation, notifications), use observers — not boot methods inline.
class SubscriptionObserver
{
public function created(Subscription $subscription): void
{
AuditLog::record('subscription.created', $subscription);
}
public function deleting(Subscription $subscription): void
{
}
}
Register in AppServiceProvider::boot():
Subscription::observe(SubscriptionObserver::class);
11. Checklist before merging Eloquent changes