From laravel-plugin
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.
How this skill is triggered — by the user, by Claude, or both
Slash command
/laravel-plugin:eloquent-patternsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Patterns for working with Eloquent that catch the common pitfalls (N+1, mass assignment, race conditions on counts, raw SQL injection).
Patterns for working with Eloquent that catch the common pitfalls (N+1, mass assignment, race conditions on counts, raw SQL injection).
The single most common Eloquent performance bug.
$users = User::all();
foreach ($users as $user) {
echo $user->subscription->plan; // 1 query per user → N+1
}
$users = User::with('subscription')->get();
foreach ($users as $user) {
echo $user->subscription?->plan; // No extra queries
}
$users = User::with('subscription.invoices')->get();
$users = User::with(['subscription:id,user_id,plan,status'])->get();
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.
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);
}
}
// Usage:
$activeForUser = Subscription::active()->forUser($user)->get();
Benefits:
Subscription::active() reads better than where('status', 'active')->where(...).class Subscription extends Model
{
protected $fillable = [
'user_id',
'plan',
'status',
'starts_at',
];
}
Then:
Subscription::create($request->validated()); // ✅ safe
Never:
class Subscription extends Model
{
protected $guarded = []; // ❌ — opens door to mass-assigning is_admin, etc.
}
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.
$count = $user->subscriptions()->count(); // 1 query
$users = User::withCount('subscriptions')->get();
foreach ($users as $user) {
echo $user->subscriptions_count; // 0 extra queries
}
$users = User::withSum('subscriptions', 'amount')
->withMax('invoices', 'created_at')
->get();
// Slow: triggers events per row
foreach ($rows as $row) {
Model::create($row);
}
// Fast: single query, NO events fired
Model::insert($rows);
// Compromise: chunked + events
Model::query()->chunkById(500, function ($chunk) {
// process
});
If you need events (timestamps, observers), use Model::insert() only when you've consciously accepted that timestamps and events won't fire.
// One query, no events
Subscription::where('status', 'pending')
->where('created_at', '<', now()->subDays(7))
->update(['status' => 'expired']);
// Per-row with events (slower)
Subscription::where(...)->each(function ($s) {
$s->update(['status' => 'expired']);
});
use Illuminate\Database\Eloquent\SoftDeletes;
class Subscription extends Model
{
use SoftDeletes;
}
Implications:
$table->softDeletes();.Subscription::all().Subscription::withTrashed()->get().Subscription::onlyTrashed()->get().$subscription->restore().$subscription->forceDelete().Watch out: relations don't auto-cascade soft deletes. Use observers or explicit logic.
Concurrent updates to a counter cause classic race bugs. Use atomic operations.
$user = User::find(1);
$user->credits = $user->credits + 10; // race
$user->save();
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();
}
Sometimes Eloquent isn't enough (CTEs, window functions, complex aggregations). Use raw queries with bindings.
DB::select(
'SELECT * FROM subscriptions WHERE user_id = ? AND status = ?',
[$userId, 'active']
);
DB::select("SELECT * FROM subscriptions WHERE user_id = $userId"); // ❌
DB::raw("user_id = $userId") // ❌
whereRaw + bindingsSubscription::whereRaw('amount > ?', [100])->get();
selectRaw for aggregates$stats = Subscription::query()
->selectRaw('plan, COUNT(*) as count, AVG(amount) as avg_amount')
->groupBy('plan')
->get();
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
{
// Cleanup before deletion
}
}
Register in AppServiceProvider::boot():
Subscription::observe(SubscriptionObserver::class);
foreach over an Eloquent collection has with() upstream$fillable set on every new/modified model$castsDB::raw() with string concatenationincrement, decrement) for counters under concurrencywith() of the controller/actionnpx claudepluginhub aratkruglik/claude-sdlc --plugin laravel-pluginApplies Laravel Eloquent best practices for query optimization, eager loading to avoid N+1, relationships, mass assignment protection, casts, and chunking large datasets.
Defines Eloquent relationships and optimizes data loading: prevents N+1 queries, applies constrained eager loading, uses aggregates (counts/sums), and handles pivot syncing safely.
Provides complete Eloquent ORM guidance for Laravel 13 with PHP 8.3 Attributes (#[Table], #[Fillable], #[Casts]) for models, relationships, queries, observers, and factories. Activates when working with database models.