Advanced Eloquent - Scopes, Accessors, Observers

Master query scopes, attribute accessors/mutators, and model observers for cleaner code

Advanced Eloquent features help you write DRY (Don't Repeat Yourself) code by encapsulating common queries and business logic directly in your models. These features make your code more maintainable and expressive.

Query Scopes:
Reusable query constraints that you can chain like any Eloquent method. Instead of repeating ->where('published', true) everywhere, create a scope: ->published()

Accessors & Mutators:
Automatically transform attributes when getting or setting them. Perfect for formatting names, encrypting data, or combining fields.

Observers:
Listen to model events (creating, created, updating, updated, deleting, deleted) without cluttering your controllers. Ideal for sending emails, clearing caches, or logging changes.

Real-World Impact:
Instead of scattered business logic in controllers, you centralize it in models. This makes testing easier and ensures consistency across your application.

Code Examples

Query Scopes - Reusable Filters
💡 Solution php
<?php
class Post extends Model
{
    // Local scope: prefix with scope
    public function scopePublished($query)
    {
        return $query->where('published', true);
    }
    
    public function scopeFeatured($query)
    {
        return $query->where('is_featured', true);
    }
    
    public function scopeRecent($query)
    {
        return $query->orderBy('created_at', 'desc');
    }
    
    // Scope with parameters
    public function scopeByAuthor($query, $authorId)
    {
        return $query->where('user_id', $authorId);
    }
    
    public function scopePublishedInYear($query, $year)
    {
        return $query->whereYear('published_at', $year);
    }
}

// Usage: Chain scopes like any Eloquent method
$posts = Post::published()->featured()->recent()->get();

// Without scopes (repetitive):
$posts = Post::where('published', true)
    ->where('is_featured', true)
    ->orderBy('created_at', 'desc')
    ->get();

// With parameters:
$posts = Post::byAuthor(5)->published()->get();
$posts = Post::publishedInYear(2024)->recent()->take(10)->get();

// Real-world example: E-commerce
class Product extends Model
{
    public function scopeInStock($query)
    {
        return $query->where('stock', '>', 0);
    }
    
    public function scopeOnSale($query)
    {
        return $query->whereNotNull('sale_price');
    }
    
    public function scopePriceBetween($query, $min, $max)
    {
        return $query->whereBetween('price', [$min, $max]);
    }
    
    public function scopeByCategory($query, $categoryId)
    {
        return $query->where('category_id', $categoryId);
    }
}

// Clean, expressive queries:
$products = Product::inStock()
    ->onSale()
    ->priceBetween(10, 100)
    ->byCategory(5)
    ->get();

💡 Query scopes encapsulate common queries into reusable, chainable methods. They make your code more readable and DRY.

Accessors & Mutators - Attribute Transformation
💡 Solution php
<?php
class User extends Model
{
    // MODERN WAY (Laravel 9+): Using Attribute class
    use Illuminate\Database\Eloquent\Casts\Attribute;
    
    // Accessor: Transform when retrieving
    protected function fullName(): Attribute
    {
        return Attribute::make(
            get: fn () => "{$this->first_name} {$this->last_name}"
        );
    }
    
    // Mutator: Transform when setting
    protected function password(): Attribute
    {
        return Attribute::make(
            get: fn ($value) => $value,  // Return as-is
            set: fn ($value) => bcrypt($value)  // Hash when setting
        );
    }
    
    // Both accessor and mutator
    protected function email(): Attribute
    {
        return Attribute::make(
            get: fn ($value) => strtolower($value),
            set: fn ($value) => strtolower(trim($value))
        );
    }
}

// Usage:
$user = User::find(1);
echo $user->full_name;  // "John Doe" (accessor)

$user->password = 'secret123';  // Automatically hashed (mutator)
$user->save();

$user->email = '  TEST@EXAMPLE.COM  ';  // Stored as "test@example.com"

// OLD WAY (still works):
class User extends Model
{
    public function getFullNameAttribute()
    {
        return "{$this->first_name} {$this->last_name}";
    }
    
    public function setPasswordAttribute($value)
    {
        $this->attributes['password'] = bcrypt($value);
    }
}

// Real-world examples:
class Product extends Model
{
    // Format price with currency
    protected function formattedPrice(): Attribute
    {
        return Attribute::make(
            get: fn () => '$' . number_format($this->price, 2)
        );
    }
    
    // Calculate discount percentage
    protected function discountPercent(): Attribute
    {
        return Attribute::make(
            get: fn () => $this->sale_price 
                ? round((($this->price - $this->sale_price) / $this->price) * 100)
                : 0
        );
    }
    
    // Always uppercase SKU
    protected function sku(): Attribute
    {
        return Attribute::make(
            get: fn ($value) => strtoupper($value),
            set: fn ($value) => strtoupper($value)
        );
    }
}

$product = Product::find(1);
echo $product->formatted_price;  // "$99.99"
echo $product->discount_percent;  // 25

💡 Accessors transform attributes when reading, mutators transform when writing. Perfect for formatting, calculations, or data sanitization.

Model Observers - Centralized Event Handling
💡 Solution php
<?php
// Create an observer: php artisan make:observer UserObserver --model=User

namespace App\Observers;

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

class UserObserver
{
    // Called before creating
    public function creating(User $user): void
    {
        // Generate UUID if not set
        if (empty($user->uuid)) {
            $user->uuid = Str::uuid();
        }
    }
    
    // Called after created
    public function created(User $user): void
    {
        // Send welcome email
        Mail::to($user->email)->send(new WelcomeEmail($user));
        
        // Create default settings
        $user->settings()->create([
            'theme' => 'light',
            'notifications_enabled' => true,
        ]);
        
        // Log the event
        Log::info("New user registered: {$user->email}");
    }
    
    // Called before updating
    public function updating(User $user): void
    {
        // If email changed, mark as unverified
        if ($user->isDirty('email')) {
            $user->email_verified_at = null;
        }
    }
    
    // Called after updated
    public function updated(User $user): void
    {
        // Clear cache
        Cache::forget("user.{$user->id}");
        
        // If role changed, update permissions cache
        if ($user->wasChanged('role_id')) {
            Cache::forget("user.{$user->id}.permissions");
        }
    }
    
    // Called before deleting
    public function deleting(User $user): void
    {
        // Delete related data
        $user->posts()->delete();
        $user->comments()->delete();
    }
    
    // Called after deleted
    public function deleted(User $user): void
    {
        Log::info("User deleted: {$user->email}");
    }
}

// Register observer in AppServiceProvider:
use App\Models\User;
use App\Observers\UserObserver;

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

// Now all these events fire automatically:
$user = User::create([...]);  // Fires: creating → created
$user->update([...]);          // Fires: updating → updated
$user->delete();               // Fires: deleting → deleted

// Real-world example: Product observer
class ProductObserver
{
    public function creating(Product $product): void
    {
        // Auto-generate slug from title
        $product->slug = Str::slug($product->title);
    }
    
    public function updated(Product $product): void
    {
        // If price changed, notify subscribers
        if ($product->wasChanged('price')) {
            event(new ProductPriceChanged($product));
        }
        
        // Clear product cache
        Cache::tags(['products'])->flush();
    }
    
    public function deleted(Product $product): void
    {
        // Delete product images from storage
        Storage::delete($product->images->pluck('path')->toArray());
    }
}

💡 Observers listen to model events and perform actions automatically. They keep your controllers clean and ensure consistent behavior.