mcpField
L
laravel-models

laravel-models

github.com/leeoveryDevelopment4338 installsUpdated 31w ago
Repository

About laravel-models

laravel-models is a development skill documenting conventions for Laravel Eloquent models. It helps Laravel developers structure relationships, casts, query builders, and lifecycle hooks while keeping business logic in Actions.

Key features

  1. 01Custom query builder guidance
  2. 02Relationship and cast patterns
  3. 03Accessors and mutators
  4. 04Observer-based lifecycle hooks
  5. 05Route binding and soft-delete guidance

Use cases

  1. 01Setting conventions for new Eloquent models
  2. 02Modeling related records and typed attributes
  3. 03Keeping business logic out of models
  4. 04Configuring route binding and soft deletes

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/leeovery/claude-laravel --skill laravel-models

After installing, type/laravel-modelsin Claude Code to confirm it loaded.

Bundled filesreferences/HasUuid.phpreferences/query-builders.md

Details

From the project README

Laravel Models

Models represent database tables and domain entities.

Related guides:

  • Query Builders - Custom query builders (not scopes)
  • Actions - Actions contain business logic
  • DTOs - Casting model JSON columns to DTOs

Philosophy

Models should:

  • Use custom query builders (not local scopes) - see Query Builders
  • Define relationships
  • Define casts
  • Contain simple accessors/mutators
  • NOT contain business logic (that belongs in Actions)

Basic Model Structure

<?php

declare(strict_types=1);

namespace App\Models;

use App\Builders\OrderBuilder;
use App\Enums\OrderStatus;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Order extends Model
{
    use HasFactory;

    protected function casts(): array
    {
        return [
            'status' => OrderStatus::class,
            'total' => 'integer',
        ];
    }

    // Custom Query Builder
    public function newEloquentBuilder($query): OrderBuilder
    {
        return new OrderBuilder($query);
    }

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

    public function items(): HasMany
    {
        return $this->hasMany(OrderItem::class);
    }
}

Casts

Define casts for type safety:

protected function casts(): array
{
    return [
        'status' => OrderStatus::class,         // Enum
        'total' => 'integer',                   // Integer
        'is_paid' => 'boolean',                 // Boolean
        'metadata' => OrderMetadataData::class, // DTO
        'completed_at' => 'datetime',           // Carbon
        'tags' => 'array',                      // JSON array
    ];
}

Available casts:

  • 'integer', 'real', 'float', 'double'
  • 'string', 'boolean'
  • 'array', 'json', 'object', 'collection'
  • 'date', 'datetime', 'immutable_date', 'immutable_datetime'
  • 'timestamp'
  • 'encrypted', 'encrypted:array', 'encrypted:collection', 'encrypted:json', 'encrypted:object'
  • Custom cast classes
  • Enum classes
  • DTO classes

Relationships

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

public function customer(): BelongsTo
{
    return $this->belongsTo(Customer::class, 'customer_id', 'id');
}
HasMany
public function orders(): HasMany
{
    return $this->hasMany(Order::class);
}

public function items(): HasMany
{
    return $this->hasMany(OrderItem::class);
}
HasOne
public function profile(): HasOne
{
    return $this->hasOne(UserProfile::class);
}
BelongsToMany
public function roles(): BelongsToMany
{
    return $this->belongsToMany(Role::class)
        ->withTimestamps()
        ->withPivot('assigned_at');
}
HasManyThrough
public function deployments(): HasManyThrough
{
    return $this->hasManyThrough(Deployment::class, Environment::class);
}
MorphTo / MorphMany
// MorphTo
public function commentable(): MorphTo
{
    return $this->morphTo();
}

// MorphMany
public function comments(): MorphMany
{
    return $this->morphMany(Comment::class, 'commentable');
}

Accessors & Mutators

Accessors (Get)
use Illuminate\Database\Eloquent\Casts\Attribute;

protected function fullName(): Attribute
{
    return Attribute::make(
        get: fn () => "{$this->first_name} {$this->last_name}",
    );
}

// Usage
$user->full_name; // "John Doe"
Mutators (Set)
protected function password(): Attribute
{
    return Attribute::make(
        set: fn (string $value) => bcrypt($value),
    );
}

// Usage
$user->password = 'secret'; // Automatically hashed
Both Get and Set
protected function email(): Attribute
{
    return Attribute::make(
        get: fn (string $value) => strtolower($value),
        set: fn (string $value) => strtolower(trim($value)),
    );
}

Model Methods

Simple helper methods are acceptable:

class Order extends Model
{
    public function isPending(): bool
    {
        return $this->status === OrderStatus::Pending;
    }

    public function isCompleted(): bool
    {
        return $this->status === OrderStatus::Completed;
    }

    public function canBeCancelled(): bool
    {
        return $this->isPending() || $this->status === OrderStatus::Processing;
    }
}

But NOT business logic:

// ❌ Bad - business logic in model
class Order extends Model
{
    public function cancel(): void
    {
        DB::transaction(function () {
            $this->update(['status' => OrderStatus::Cancelled]);
            $this->refundPayment();
            $this->notifyCustomer();
        });
    }
}

// ✅ Good - business logic in action
class CancelOrderAction
{
    public function __invoke(Order $order): Order
    {
        return DB::transaction(function () use ($order) {
            $order->update(['status' => OrderStatus::Cancelled]);
            resolve(RefundPaymentAction::class)($order);
            resolve(NotifyCustomerAction::class)($order);
            return $order;
        });
    }
}

Model Observers

For model lifecycle hooks:

<?php

declare(strict_types=1);

namespace App\Observers;

use App\Models\Order;
use Illuminate\Support\Str;

class OrderObserver
{
    public function creating(Order $order): void
    {
        if (! $order->uuid) {
            $order->uuid = Str::uuid();
        }
    }

    public function created(Order $order): void
    {
        // Dispatch event, queue job, etc.
    }

    public function updating(Order $order): void
    {
        // Before update
    }

    public function updated(Order $order): void
    {
        // After update
    }

    public function deleted(Order $order): void
    {
        // After delete
    }
}

Register in AppServiceProvider:

use App\Models\Order;
use App\Observers\OrderObserver;

public function boot(): void
{
    Order::observe(OrderObserver::class);
}

Model Concerns (Traits)

Extract reusable behavior:

View full implementation →

Use in models:

class Order extends Model
{
    use HasUuid;
}

Route Model Binding

Implicit Binding
// Route
Route::get('/orders/{order}', [OrderController::class, 'show']);

// Controller - automatically receives Order model
public function show(Order $order) { }
Custom Key
Route::get('/orders/{order:uuid}', [OrderController::class, 'show']);
Custom Resolution
public function resolveRouteBinding($value, $field = null)
{
    return $this->where($field ?? 'id', $value)
        ->where('is_active', true)
        ->firstOrFail();
}

Mass Assignment Protection

All models should be unguarded by default.

AppServiceProvider Setup

In your AppServiceProvider::boot() method, call Model::unguard():

<?php

declare(strict_types=1);

namespace App\Providers;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Model::unguard();
    }
}
Model Configuration

Do NOT use $fillable or $guarded properties on your models:

// ✅ Good - no fillable/guarded
class Order extends Model
{
    protected function casts(): array
    {
        return [
            'status' => OrderStatus::class,
        ];
    }
}

// ❌ Bad - don't use fillable
class Order extends Model
{
    protected $fillable = ['name', 'email'];
}

// ❌ Bad - don't use guarded
class Order extends Model
{
    protected $guarded = [];
}
Why Unguard?
  • Simplicity: No need to maintain fillable/guarded arrays
  • Flexibility: All attributes can be mass-assigned
  • Trust: With proper validation in Form Requests and Actions, mass assignment protection is redundant
  • Cleaner Models: Less boilerplate code

Important: Always validate input in Form Requests before passing to Actions/Models.

Timestamps

// Disable timestamps
public $timestamps = false;

// Custom timestamp columns
const CREATED_AT = 'creation_date';
const UPDATED_AT = 'updated_date';

Soft Deletes

use Illuminate\Database\Eloquent\SoftDeletes;

class Order extends Model
{
    use SoftDeletes;
}

Usage:

$order->delete();      // Soft delete
$order->forceDelete(); // Permanent delete
$order->restore();     // Restore

Order::withTrashed()->find($id);
Order::onlyTrashed()->get();

Collections

Query results return Collections:

$orders = Order::all(); // Illuminate\Database\Eloquent\Collection

$orders->filter(fn($order) => $order->isPending());
$orders->map(fn($order) => $order->total);
$orders->sum('total');

Model Organization

app/Models/
├── Order.php
├── User.php
├── Concerns/
│   ├── HasUuid.php
│   ├── BelongsToTenant.php
│   └── Searchable.php
└── Contracts/
    └── Searchable.php

Testing Models

it('can mass assign attributes', function () {
    $order = Order::create([
        'user_id' => 1,
        'status' => 'pending',
        'total' => 1000,
        'notes' => 'Test order',
    ]);

    expect($order->user_id)->toBe(1)
        ->and($order->total)->toBe(1000);
});

it('casts status to enum', function () {
    $order = Order::factory()->create(['status' => 'pending']);

    expect($order->status)->toBeInstanceOf(OrderStatus::class);
});

it('has user relationship', function () {
    $order = Order::factory()->create();

    expect($order->user)->toBeInstanceOf(User::class);
});

Summary

Models should:

  • Be unguarded globally via Model::unguard() in AppServiceProvider
  • Define structure (casts, relationships)
  • Use custom query builders (not scopes)
  • Have simple helper methods
  • Use observers for lifecycle hooks

Models should NOT:

  • Use $fillable or $guarded properties
  • Contain business logic (use Actions)
  • Have complex methods (use Actions)
  • Use local scopes (use custom builders)

Comments

Markdown supported