Laravel Core

Eloquent Standards

Querying

Basic Queries

// Get all records
$users = User::all();

// Get with conditions
$users = User::where('is_active', true)->get();

// Get single record
$user = User::find($id);
$user = User::findOrFail($id);
$user = User::where('email', $email)->first();
$user = User::where('email', $email)->firstOrFail();

// Get specific columns
$users = User::select(['id', 'name', 'email'])->get();

// Count
$count = User::where('is_active', true)->count();

// Check existence
if (User::where('email', $email)->exists()) {
    // ...
}

Use Query Builder Method

Always start queries with query() for clarity and IDE support:

// Good
$tickets = Ticket::query()
    ->with(['user', 'category'])
    ->where('status', Status::Open)
    ->latest()
    ->paginate(15);

// Acceptable for simple queries
$ticket = Ticket::find($id);
$tickets = Ticket::all();

Avoid Raw Queries

// Good
User::where('votes', '>', 100)->get();

// Bad - Raw query when not needed
User::whereRaw('votes > 100')->get();

// Acceptable - Complex queries that require raw
User::whereRaw('LOWER(email) = ?', [strtolower($email)])->first();

Query Scopes

Using the Scope Attribute (Laravel 11+)

use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;

#[Scope]
protected function active(Builder $query): void
{
    $query->where('is_active', true);
}

#[Scope]
protected function forUser(Builder $query, User $user): void
{
    $query->where('user_id', $user->id);
}

// Usage
Ticket::active()->forUser($user)->get();

Traditional Scope Syntax

public function scopeActive(Builder $query): Builder
{
    return $query->where('is_active', true);
}

public function scopeStatus(Builder $query, Status $status): Builder
{
    return $query->where('status', $status);
}

When to Use Scopes

  • Reusable query conditions
  • Complex multi-condition queries
  • Queries used across multiple locations
// Good - Reusable scope
Ticket::active()->recent()->get();

// Bad - Duplicating conditions
Ticket::where('is_active', true)->where('created_at', '>', now()->subWeek())->get();
// ... same conditions elsewhere in codebase

Custom Query Builders

For models with 3+ scopes, use a custom Eloquent Builder:

<?php

declare(strict_types=1);

namespace App\Builders;

use App\Models\User;
use Illuminate\Database\Eloquent\Builder;

/**
 * @extends Builder<User>
 */
final class UserBuilder extends Builder
{
    public function active(): self
    {
        return $this->where('is_active', true);
    }

    public function verified(): self
    {
        return $this->whereNotNull('email_verified_at');
    }

    public function withRole(string $role): self
    {
        return $this->where('role', $role);
    }

    public function createdAfter(Carbon $date): self
    {
        return $this->where('created_at', '>=', $date);
    }

    public function search(string $term): self
    {
        return $this->where(function (Builder $query) use ($term): void {
            $query->where('name', 'like', "%{$term}%")
                ->orWhere('email', 'like', "%{$term}%");
        });
    }
}
// In Model
use App\Builders\UserBuilder;

final class User extends Authenticatable
{
    /**
     * @param \Illuminate\Database\Query\Builder $query
     */
    public function newEloquentBuilder($query): UserBuilder
    {
        return new UserBuilder($query);
    }
}

// Usage
$users = User::query()
    ->active()
    ->verified()
    ->withRole('admin')
    ->search($term)
    ->paginate(15);

Reusable Scope Traits

For scopes shared across multiple models:

<?php

declare(strict_types=1);

namespace App\Models\Traits;

use Illuminate\Database\Eloquent\Builder;

trait HasActiveScope
{
    public function scopeActive(Builder $query): Builder
    {
        return $query->where('is_active', true);
    }

    public function scopeInactive(Builder $query): Builder
    {
        return $query->where('is_active', false);
    }
}
<?php

declare(strict_types=1);

namespace App\Models\Traits;

use Illuminate\Database\Eloquent\Builder;

trait HasDateScopes
{
    public function scopeCreatedBetween(Builder $query, Carbon $start, Carbon $end): Builder
    {
        return $query->whereBetween('created_at', [$start, $end]);
    }

    public function scopeCreatedToday(Builder $query): Builder
    {
        return $query->whereDate('created_at', today());
    }

    public function scopeRecent(Builder $query, int $days = 7): Builder
    {
        return $query->where('created_at', '>=', now()->subDays($days));
    }
}

Eager Loading

Prevent N+1 Queries

// Bad - N+1 problem
$tickets = Ticket::all();
foreach ($tickets as $ticket) {
    echo $ticket->user->name;  // Query for each ticket
}

// Good - Eager load
$tickets = Ticket::with('user')->get();
foreach ($tickets as $ticket) {
    echo $ticket->user->name;  // No additional queries
}

Multiple Relationships

$tickets = Ticket::with(['user', 'category', 'comments'])->get();

Nested Relationships

$tickets = Ticket::with(['user.profile', 'comments.author'])->get();

Constrained Eager Loading

$tickets = Ticket::with([
    'comments' => fn ($query) => $query->latest()->limit(5),
    'user:id,name,email',
])->get();

Lazy Eager Loading

$tickets = Ticket::all();

if ($needComments) {
    $tickets->load('comments');
}

Prevent Lazy Loading

Enable in AppServiceProvider to catch N+1 issues:

public function boot(): void
{
    Model::preventLazyLoading(!$this->app->isProduction());
}

Relationships

Query Through Relationships

// Get related records
$comments = $ticket->comments()->latest()->get();

// Query existence
$ticketsWithComments = Ticket::has('comments')->get();
$ticketsWithComments = Ticket::has('comments', '>=', 5)->get();

// Query with conditions
$tickets = Ticket::whereHas('comments', function ($query): void {
    $query->where('is_approved', true);
})->get();

// Count relationships
$tickets = Ticket::withCount('comments')->get();
// Access via $ticket->comments_count

// Sum relationship column
$users = User::withSum('orders', 'total')->get();
// Access via $user->orders_sum_total

Saving Relationships

// Create through relationship
$ticket->comments()->create([
    'body' => 'Comment text',
    'user_id' => auth()->id(),
]);

// Associate belongs to
$comment->ticket()->associate($ticket);
$comment->save();

// Sync many-to-many
$ticket->tags()->sync([1, 2, 3]);
$ticket->tags()->syncWithoutDetaching([4, 5]);

// Attach/detach
$ticket->tags()->attach($tagId);
$ticket->tags()->detach($tagId);

Collections

Use Collection Methods

// Filter
$activeUsers = $users->filter(fn (User $user) => $user->is_active);

// Map
$names = $users->map(fn (User $user) => $user->name);

// Pluck
$emails = $users->pluck('email');
$emailsByName = $users->pluck('email', 'name');

// Group
$byRole = $users->groupBy('role');

// First matching
$admin = $users->firstWhere('role', 'admin');

// Check contents
if ($users->contains('email', 'admin@example.com')) {
    // ...
}

// Aggregate
$total = $orders->sum('amount');
$average = $orders->avg('amount');

Avoid Multiple Queries

// Bad - Multiple queries
$activeCount = User::where('is_active', true)->count();
$inactiveCount = User::where('is_active', false)->count();

// Good - Single query, process in PHP
$users = User::all();
$activeCount = $users->where('is_active', true)->count();
$inactiveCount = $users->where('is_active', false)->count();

// Or use groupBy
$counts = User::query()
    ->selectRaw('is_active, COUNT(*) as count')
    ->groupBy('is_active')
    ->pluck('count', 'is_active');

Large Datasets

Chunking

// Process in chunks to avoid memory issues
User::chunk(100, function ($users): void {
    foreach ($users as $user) {
        $this->processUser($user);
    }
});

// Chunk by ID (safer for updates)
User::chunkById(100, function ($users): void {
    foreach ($users as $user) {
        $user->update(['processed' => true]);
    }
});

Lazy Collections

// Memory efficient iteration
User::lazy()->each(function (User $user): void {
    $this->processUser($user);
});

// With eager loading
User::with('profile')->lazy()->each(function (User $user): void {
    // ...
});

Cursor

// Most memory efficient for read-only
foreach (User::cursor() as $user) {
    $this->processUser($user);
}

Transactions

use Illuminate\Support\Facades\DB;

// Simple transaction
DB::transaction(function (): void {
    $order = Order::create([...]);
    $order->items()->createMany([...]);
    $order->user->decrement('balance', $order->total);
});

// With return value
$order = DB::transaction(function () use ($data): Order {
    $order = Order::create($data);
    $order->items()->createMany($data['items']);

    return $order;
});

// Manual transaction control
DB::beginTransaction();

try {
    // ... operations
    DB::commit();
} catch (Exception $e) {
    DB::rollBack();
    throw $e;
}

Mass Operations

Efficient Updates

// Good - Single query
Ticket::where('status', Status::Pending)
    ->where('created_at', '<', now()->subDays(30))
    ->update(['status' => Status::Expired]);

// Bad - Multiple queries
$tickets = Ticket::where('status', Status::Pending)->get();
foreach ($tickets as $ticket) {
    $ticket->update(['status' => Status::Expired]);
}

Efficient Inserts

// Good - Batch insert
User::insert([
    ['name' => 'John', 'email' => 'john@example.com'],
    ['name' => 'Jane', 'email' => 'jane@example.com'],
]);

// With timestamps
User::query()->insert(
    collect($users)->map(fn ($user) => [
        ...$user,
        'created_at' => now(),
        'updated_at' => now(),
    ])->toArray()
);

// Upsert
User::upsert(
    [
        ['email' => 'john@example.com', 'name' => 'John Updated'],
        ['email' => 'jane@example.com', 'name' => 'Jane Updated'],
    ],
    uniqueBy: ['email'],
    update: ['name']
);

Factories

Basic Structure

<?php

declare(strict_types=1);

namespace Database\Factories;

use App\Models\Ticket;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends Factory<Ticket>
 */
final class TicketFactory extends Factory
{
    protected $model = Ticket::class;

    public function definition(): array
    {
        return [
            'title' => fake()->sentence(),
            'body' => fake()->paragraphs(3, true),
            'status' => Status::Open,
            'priority' => fake()->randomElement(['low', 'medium', 'high']),
            'user_id' => User::factory(),
        ];
    }
}

States

public function closed(): static
{
    return $this->state(fn (array $attributes) => [
        'status' => Status::Closed,
        'closed_at' => now(),
    ]);
}

public function highPriority(): static
{
    return $this->state(fn (array $attributes) => [
        'priority' => 'high',
    ]);
}

public function forUser(User $user): static
{
    return $this->state(fn (array $attributes) => [
        'user_id' => $user->id,
    ]);
}

// Usage
Ticket::factory()->closed()->create();
Ticket::factory()->highPriority()->forUser($user)->create();

Relationships in Factories

// Create with relationship
$ticket = Ticket::factory()
    ->has(Comment::factory()->count(3))
    ->create();

// Create for existing model
$ticket = Ticket::factory()
    ->for($user)
    ->create();

// Many-to-many
$ticket = Ticket::factory()
    ->hasAttached(Tag::factory()->count(2))
    ->create();

// With pivot data
$ticket = Ticket::factory()
    ->hasAttached(
        User::factory()->count(2),
        ['role' => 'collaborator']
    )
    ->create();

Sequences

$users = User::factory()
    ->count(10)
    ->sequence(
        ['role' => 'admin'],
        ['role' => 'editor'],
        ['role' => 'viewer'],
    )
    ->create();

Soft Deletes

Querying

// Exclude soft deleted (default)
$tickets = Ticket::all();

// Include soft deleted
$tickets = Ticket::withTrashed()->get();

// Only soft deleted
$tickets = Ticket::onlyTrashed()->get();

// Check if soft deleted
if ($ticket->trashed()) {
    // ...
}

Operations

// Soft delete
$ticket->delete();

// Restore
$ticket->restore();

// Force delete (permanent)
$ticket->forceDelete();

Best Practices

Use Explicit Column Selection

// Good - Select only needed columns
$users = User::select(['id', 'name', 'email'])->get();

// Bad - Select all columns when not needed
$users = User::all();
$names = $users->pluck('name');

Avoid Queries in Loops

// Bad
foreach ($userIds as $id) {
    $user = User::find($id);
    $this->process($user);
}

// Good
$users = User::whereIn('id', $userIds)->get();
foreach ($users as $user) {
    $this->process($user);
}

Use Database Constraints

// Good - Let database handle uniqueness
try {
    User::create(['email' => $email]);
} catch (UniqueConstraintViolationException $e) {
    // Handle duplicate
}

// Bad - Race condition possible
if (!User::where('email', $email)->exists()) {
    User::create(['email' => $email]);
}