Laravel Core

Model Standards

Basic Structure

<?php

declare(strict_types=1);

namespace App\Models;

use App\Models\Traits\HasHashIds;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;

final class Ticket extends Model
{
    use HasFactory;
    use HasHashIds;
    use SoftDeletes;

    protected $guarded = ['id'];

    protected function casts(): array
    {
        return [
            'is_active' => 'boolean',
            'due_at' => 'datetime',
            'settings' => 'array',
        ];
    }

    // Relationships
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    public function items(): HasMany
    {
        return $this->hasMany(TicketItem::class)
            ->orderBy('position');
    }

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

Mass Assignment

Use $guarded Instead of $fillable

// Good - Guard only the ID
protected $guarded = ['id'];

// Bad - Listing all fillable fields
protected $fillable = [
    'name',
    'email',
    'body',
    // Easy to forget new fields
];

Type Casting

Use the casts() Method

// Good - Method syntax (Laravel 11+)
protected function casts(): array
{
    return [
        'is_active'       => 'boolean',
        'is_admin'        => 'boolean',
        'due_at'          => 'datetime',
        'published_at'    => 'datetime',
        'settings'        => 'array',
        'metadata'        => 'array',
        'amount'          => 'decimal:2',
        'status'          => Status::class,
        'frequency_data'  => RepeatingFrequencyData::class,
    ];
}

// Acceptable - Property syntax
protected $casts = [
    'is_active' => 'boolean',
];

Common Cast Types

  • boolean - For is_* and has_* columns
  • datetime - For timestamp columns
  • array - For JSON columns
  • decimal:2 - For money/precise decimals
  • Enum::class - For enum columns
  • Data::class - For Spatie LaravelData columns

Relationships

Always Type Return Values

// Good
public function user(): BelongsTo
{
    return $this->belongsTo(User::class);
}

public function category(): BelongsTo
{
    return $this->belongsTo(Category::class);
}

public function items(): HasMany
{
    return $this->hasMany(Item::class)
        ->orderBy('position')
        ->orderBy('created_at');
}

public function tags(): BelongsToMany
{
    return $this->belongsToMany(Tag::class);
}

public function equipment(): HasManyThrough
{
    return $this->hasManyThrough(
        Equipment::class,
        ExerciseEquipment::class,
        'exercise_id',
        'id',
        'id',
        'equipment_id'
    );
}

Naming Conventions

  • BelongsTo / HasOne → singular: user(), category()
  • HasMany / BelongsToMany → plural: items(), tags()

Query Scopes

Use 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 inactive(Builder $query): void
{
    $query->where('is_active', false);
}

#[Scope]
protected function dueThisWeek(Builder $query): void
{
    $query->whereBetween('due_at', [
        now()->startOfWeek(),
        now()->endOfWeek(),
    ]);
}

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

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

Traditional Scope Syntax

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

public function scopeSearch(Builder $query, string $term): Builder
{
    return $query->where('name', 'like', "%{$term}%");
}

Common Traits

HasHashIds

Generate URL-safe IDs:

trait HasHashIds
{
    protected static function boot(): void
    {
        parent::boot();

        static::created(function ($model): void {
            $reflect = new ReflectionClass(self::class);
            $connection = Str::lower($reflect->getShortName());

            if ($model->hash_id) {
                return;
            }

            $model->hash_id = Hashids::connection($connection)
                ->encode((string) $model->id);
            $model->save();
        });
    }
}

HasActiveInactive

Toggle active state:

trait HasActiveInactive
{
    public function activate(): void
    {
        $this->update(['is_active' => true]);
    }

    public function deactivate(): void
    {
        $this->update(['is_active' => false]);
    }

    public function toggleActive(): void
    {
        $this->update(['is_active' => !$this->is_active]);
    }
}

HasSlug

Auto-generate slugs:

trait HasSlug
{
    protected static function bootHasSlug(): void
    {
        static::creating(function ($model): void {
            $model->slug = $model->generateSlug();
        });
    }

    protected function generateSlug(): string
    {
        return Str::slug($this->name ?? $this->title);
    }
}

Observers

Register in Model Boot or Service Provider

// In AppServiceProvider
public function boot(): void
{
    User::observe(UserObserver::class);
}

// Or in model
protected static function boot(): void
{
    parent::boot();
    static::observe(UserObserver::class);
}

Observer Structure

<?php

declare(strict_types=1);

namespace App\Observers;

use App\Models\User;

final class UserSettingDefaultsObserver
{
    public function creating(User $user): void
    {
        $defaults = app(BuildUserSettingDefaults::class)->execute();
        $user->settings = $defaults;
    }

    public function created(User $user): void
    {
        // Post-creation logic
    }

    public function updating(User $user): void
    {
        // Pre-update logic
    }

    public function deleted(User $user): void
    {
        // Cleanup logic
    }
}

Model Safety (AppServiceProvider)

public function boot(): void
{
    // Prevent N+1 queries
    Model::preventLazyLoading();

    if ($this->app->isProduction()) {
        // Log instead of throwing in production
        Model::handleLazyLoadingViolationUsing(
            function ($model, $relation): void {
                info("Attempted to lazy load [{$relation}] on model [{$model}].");
            }
        );

        // Prevent destructive migrations
        DB::prohibitDestructiveCommands();
    } else {
        // Strict mode in development
        Model::preventAccessingMissingAttributes();
        Model::preventSilentlyDiscardingAttributes();
        Model::shouldBeStrict();
    }
}

Eager Loading

Always eager load relationships to prevent N+1:

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

// In controller/action
$tickets = Ticket::query()
    ->with(['user', 'category'])
    ->where('is_active', true)
    ->get();

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

Accessors and Mutators

Use the attribute syntax:

// Good - Laravel 9+ syntax
protected function fullName(): Attribute
{
    return Attribute::make(
        get: fn () => "{$this->first_name} {$this->last_name}",
    );
}

protected function email(): Attribute
{
    return Attribute::make(
        set: fn (string $value) => strtolower($value),
    );
}

// For computed attributes
protected function isOverdue(): Attribute
{
    return Attribute::make(
        get: fn () => $this->due_at?->isPast() ?? false,
    );
}