Eloquent Model Traits — Extracting Reusable Behaviors

Eloquent Model Traits — Extracting Reusable Behaviors

Reading Time: 5 minutes

Series: Every Laravel Project Should Have These Building Blocks 
Part: 11 of 35 | Level: Intermediate | Prerequisites: Folder Structure


What You’ll Learn

  • How Eloquent model traits work and how to create them
  • How to use bootable traits for automatic behavior
  • The most valuable traits across these production projects
  • How to combine multiple traits without conflicts
  • Where traits live and how to organize them

The Problem: Fat Models

Without traits, models grow. First you add getFullNameAttribute(). Then some scopes. Then a helper method. Then lifecycle logic in boot(). Then before you know it, your User.php model is 600 lines of mixed concerns.

Traits extract cohesive behaviors into separate files. Each trait is responsible for one thing. The model becomes a list of capabilities:

class ServiceRequest extends Model
{
    use ModelChangeLogger;   // automatic audit trail
    use HasCreator;          // created_by relationship
    use Sluggable;           // auto-generated slug
    use InteractsWithMedia;  // file upload/retrieval
    use HasAddress;          // address fields + geocoding
    use DateScopes;          // lastDays(), thisMonth(), etc.

    // Now only model-specific logic here
    public function tasks(): HasMany
    {
        return $this->hasMany(Task::class);
    }
}

Each trait in a separate file. Single responsibility for each. Reusable across any model that needs it.


Bootable Traits

The most powerful type of trait is a bootable trait. Laravel automatically calls static::bootTraitName() for any trait used on a model. This lets your trait register lifecycle hooks (creating, updating, deleting) without the model doing anything:

trait Sluggable
{
    // Laravel calls this automatically when the trait is used
    public static function bootSluggable(): void
    {
        static::creating(static function (self $model): void {
            if (empty($model->{$model->getSlugColumn()})) {
                $model->{$model->getSlugColumn()} = $model->generateUniqueSlug();
            }
        });

        static::updating(static function (self $model): void {
            if ($model->isDirty($model->getSlugSource())) {
                $model->{$model->getSlugColumn()} = $model->generateUniqueSlug();
            }
        });
    }

    private function generateUniqueSlug(): string
    {
        $base = \Illuminate\Support\Str::slug($this->{$this->getSlugSource()});
        $slug = $base;
        $i    = 1;

        while (static::where($this->getSlugColumn(), $slug)
                      ->where($this->getKeyName(), '!=', $this->getKey() ?? 0)
                      ->exists()) {
            $slug = "{$base}-{$i}";
            $i++;
        }

        return $slug;
    }

    protected function getSlugColumn(): string
    {
        return 'slug';
    }

    protected function getSlugSource(): string
    {
        return 'name'; // override in model if different
    }
}

To use it:

class Category extends Model
{
    use Sluggable;

    // Override default source if needed
    protected function getSlugSource(): string
    {
        return 'title';
    }
}

That’s all. The trait handles everything automatically.


HasCreator — Track Who Created a Record

// app/Support/Models/Traits/HasCreator.php
trait HasCreator
{
    public static function bootHasCreator(): void
    {
        static::creating(static function (self $model): void {
            if (empty($model->created_by)) {
                // Try each guard in order
                foreach (['admin', 'merchandiser', 'web'] as $guard) {
                    if (auth($guard)->check()) {
                        $model->created_by = auth($guard)->id();
                        break;
                    }
                }
            }
        });
    }

    public function creator(): BelongsTo
    {
        return $this->belongsTo(User::class, 'created_by');
    }
}

Any model using HasCreator automatically records who created it without any controller code.


CanBeActivated — Active/Inactive Toggle

// app/Support/Traits/CanBeActivated.php
trait CanBeActivated
{
    public function scopeActive(Builder $query): Builder
    {
        return $query->where('is_active', true);
    }

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

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

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

    public function isActive(): bool
    {
        return (bool) $this->is_active;
    }

    public function toggle(): bool
    {
        return $this->update(['is_active' => ! $this->is_active]);
    }
}

Use in any model with an is_active column:

// In a controller
$vendor->deactivate();

// In a query
Vendor::active()->get();
Vendor::inactive()->paginate(20);

DateScopes — Common Date Filters

Instead of writing ->where('created_at', '>=', now()->subDays(30)) in every query, use a trait:

// app/Support/Traits/DateScopes.php
trait DateScopes
{
    public function scopeLastDays(Builder $query, int $days): Builder
    {
        return $query->where('created_at', '>=', now()->subDays($days));
    }

    public function scopePreviousDays(Builder $query, int $days): Builder
    {
        return $query->whereBetween('created_at', [
            now()->subDays($days * 2),
            now()->subDays($days),
        ]);
    }

    public function scopeThisMonth(Builder $query): Builder
    {
        return $query->whereMonth('created_at', now()->month)
                     ->whereYear('created_at', now()->year);
    }

    public function scopeThisYear(Builder $query): Builder
    {
        return $query->whereYear('created_at', now()->year);
    }

    public static function countByMonth(int $year = null): array
    {
        $year = $year ?? now()->year;

        return static::selectRaw('MONTH(created_at) as month, COUNT(*) as count')
            ->whereYear('created_at', $year)
            ->groupByRaw('MONTH(created_at)')
            ->pluck('count', 'month')
            ->toArray();
    }
}

Usage in the DashboardController:

Order::lastDays(30)->count();
Order::previousDays(30)->count();
Order::countByMonth(2024);

InteractsWithMedia — Consistent File Handling

// app/Support/Traits/InteractsWithMedia.php
trait InteractsWithMedia
{
    public function getMediaUrl(string $collection = 'default'): ?string
    {
        $media = $this->media()->where('collection', $collection)->latest()->first();
        return $media?->url;
    }

    public function attachMedia(UploadedFile $file, string $collection = 'default'): Media
    {
        $path = $file->store(
            sprintf('%s/%s/%s', $this->getTable(), $this->getKey(), $collection),
            'public'
        );

        return $this->media()->create([
            'path'       => $path,
            'url'        => Storage::url($path),
            'collection' => $collection,
            'mime_type'  => $file->getMimeType(),
            'size'       => $file->getSize(),
            'filename'   => File::uniqueName($file),
        ]);
    }

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

HasCachedAttributes — In-Memory Caching for Computed Values

When a model has computed attributes that are expensive to calculate (e.g., recursively walking a tree), cache them for the duration of the request:

// app/Support/Models/Traits/HasCachedAttributes.php
trait HasCachedAttributes
{
    protected array $cachedAttributes = [];

    public function getCachedAttribute(string $key, mixed $default = null): mixed
    {
        return $this->hasCachedAttribute($key) ? $this->cachedAttributes[$key] : $default;
    }

    public function setCachedAttribute(string $key, mixed $value): void
    {
        $this->cachedAttributes[$key] = $value;
    }

    public function rememberCachedAttribute(string $key, callable $callback): mixed
    {
        if (! $this->hasCachedAttribute($key)) {
            $this->cachedAttributes[$key] = $callback();
        }
        return $this->cachedAttributes[$key];
    }

    public function hasCachedAttribute(string $key): bool
    {
        return array_key_exists($key, $this->cachedAttributes);
    }
}

Usage:

class Partner extends Model
{
    use HasCachedAttributes;

    public function getFullHierarchy(): array
    {
        return $this->rememberCachedAttribute('full_hierarchy', function (): array {
            // expensive recursive query — runs only once per instance
            return $this->buildHierarchyTree();
        });
    }
}

LogsCommandMessages — Dual Output for Artisan Commands

This isn’t a model trait but a Console trait — it belongs in Support/Traits/Console/:

// app/Support/Traits/Console/LogsCommandMessages.php
trait LogsCommandMessages
{
    public function logInfo(string $message, array $context = []): void
    {
        $this->info($message);
        logger()->info($message, $context);
    }

    public function logError(string $message, array $context = []): void
    {
        $this->error($message);
        logger()->error($message, $context);
    }

    public function logWarning(string $message, array $context = []): void
    {
        $this->warn($message);
        logger()->warning($message, $context);
    }
}

Usage in any Artisan command:

class ProcessPaymentsCommand extends Command
{
    use LogsCommandMessages;

    public function handle(): int
    {
        $this->logInfo('Starting payment processing run...');

        $payments = Payment::pending()->get();

        foreach ($payments as $payment) {
            try {
                $this->processPayment($payment);
                $this->logInfo("Processed payment #{$payment->id}");
            } catch (Throwable $e) {
                $this->logError("Failed payment #{$payment->id}: {$e->getMessage()}");
            }
        }

        $this->logInfo('Done. Processed ' . $payments->count() . ' payments.');

        return self::SUCCESS;
    }
}

Every message goes to both the terminal (visible when running manually) and the log file (persisted for later review).


Organizing Traits

app/Support/
├── Traits/
│   ├── Console/
│   │   └── LogsCommandMessages.php   # For Artisan commands
│   ├── Models/                        # Alternatively: Support/Models/Traits/
│   │   ├── HasCreator.php
│   │   ├── HasCachedAttributes.php
│   │   └── HasNonDevelopmentAccess.php
│   ├── ModelChangeLogger.php          # Audit trail (complex, top-level)
│   ├── Sluggable.php
│   ├── CanBeActivated.php
│   ├── DateScopes.php
│   ├── HasAddress.php
│   ├── HasPermissions.php
│   ├── HasRoles.php
│   ├── InteractsWithMedia.php
│   └── BarcodeGenerator.php

Key Takeaways

  • Traits extract cohesive behaviors out of fat models. A model becomes a list of capabilities.
  • Bootable traits (bootTraitName()) register lifecycle hooks automatically when the trait is used.
  • SluggableHasCreatorCanBeActivatedDateScopesInteractsWithMedia are the most universally useful.
  • LogsCommandMessages (in Support/Traits/Console/) gives every Artisan command dual console+log output.
  • Keep traits in app/Support/Traits/ — they’re shared infrastructure, not domain code.
  • If a trait is specific to one model, it doesn’t need to be a trait. Traits are for reuse.

Tips and Gotchas

⚠️ Warning: Bootable trait methods must be named exactly boot{TraitName}() — Pascal case, prefixed with boot. A typo here silently fails: the method exists but Laravel never calls it. If your trait isn’t working, check the method name first.

💡 Tip: Put heavy scope logic in a dedicated Scopes/ class using Illuminate\Database\Eloquent\Scope and register it in the model’s booted() method. This keeps trait files short and makes scopes reusable across models.

🔥 Expert Note: Traits that interact with model events (creatingupdatingdeleting) should always account for the case where the model isn’t persisted yet. $model->exists returns false for unsaved models — guard against this in traits that read from the database.

Further Reading


← DTOs | Next: Eloquent Observers →

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

2 Comments