.cursor/skills/laravel-best-practices/rules/eloquent.md
Use hasMany, belongsTo, morphMany, etc. with proper return type hints.
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
Extract reusable query constraints into local scopes to avoid duplication.
Incorrect:
$active = User::where('verified', true)->whereNotNull('activated_at')->get();
$articles = Article::whereHas('user', function ($q) {
$q->where('verified', true)->whereNotNull('activated_at');
})->get();
Correct:
public function scopeActive(Builder $query): Builder
{
return $query->where('verified', true)->whereNotNull('activated_at');
}
// Usage
$active = User::active()->get();
$articles = Article::whereHas('user', fn ($q) => $q->active())->get();
Global scopes silently modify every query on the model, making debugging difficult. Prefer local scopes and reserve global scopes for truly universal constraints like soft deletes or multi-tenancy.
Incorrect (global scope for a conditional filter):
class PublishedScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->where('published', true);
}
}
// Now admin panels, reports, and background jobs all silently skip drafts
Correct (local scope you opt into):
public function scopePublished(Builder $query): Builder
{
return $query->where('published', true);
}
Post::published()->paginate(); // Explicit
Post::paginate(); // Admin sees all
Use the casts() method (or $casts property following project convention) for automatic type conversion.
protected function casts(): array
{
return [
'is_active' => 'boolean',
'metadata' => 'array',
'total' => 'decimal:2',
];
}
Always cast date columns. Use Carbon instances in templates instead of formatting strings manually.
Incorrect:
{{ Carbon::createFromFormat('Y-d-m H-i', $order->ordered_at)->toDateString() }}
Correct:
protected function casts(): array
{
return [
'ordered_at' => 'datetime',
];
}
{{ $order->ordered_at->toDateString() }}
{{ $order->ordered_at->format('m-d') }}
whereBelongsTo() for Relationship QueriesCleaner than manually specifying foreign keys.
Incorrect:
Post::where('user_id', $user->id)->get();
Correct:
Post::whereBelongsTo($user)->get();
Post::whereBelongsTo($user, 'author')->get();
Never use string literals for table names in raw queries, joins, or subqueries. Hardcoded table names make it impossible to find all places a model is used and break refactoring (e.g., renaming a table requires hunting through every raw string).
Incorrect:
DB::table('users')->where('active', true)->get();
$query->join('companies', 'companies.id', '=', 'users.company_id');
DB::select('SELECT * FROM orders WHERE status = ?', ['pending']);
Correct — reference the model's table:
DB::table((new User)->getTable())->where('active', true)->get();
// Even better — use Eloquent or the query builder instead of raw SQL
User::where('active', true)->get();
Order::where('status', 'pending')->get();
Prefer Eloquent queries and relationships over DB::table() whenever possible — they already reference the model's table. When DB::table() or raw joins are unavoidable, always use (new Model)->getTable() to keep the reference traceable.
Exception — migrations: In migrations, hardcoded table names via DB::table('settings') are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration.