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- Foris_*andhas_*columnsdatetime- For timestamp columnsarray- For JSON columnsdecimal:2- For money/precise decimalsEnum::class- For enum columnsData::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,
);
}