DEV Community

Cover image for Laravel Eloquent Traits: Reusable Model Behavior (Guide)
Shakil Alam
Shakil Alam

Posted on Originally published at blog.shakiltech.com

Laravel Eloquent Traits: Reusable Model Behavior (Guide)

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

To use it:

class Category extends Model
{
    use Sluggable;

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

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) {
                    // Skip guards this app doesn't define: auth('missing') throws
                    if (config("auth.guards.{$guard}") !== null && auth($guard)->check()) {
                        $model->created_by = auth($guard)->id();
                        break;
                    }
                }
            }
        });
    }

    public function creator(): BelongsTo
    {
        // Correct for the 'web' guard only. If admins or merchandisers create
        // records too, also store the guard (created_by_type) and resolve per guard.
        return $this->belongsTo(User::class, 'created_by');
    }
}
Enter fullscreen mode Exit fullscreen mode

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]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Use in any model with an is_active column:

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

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

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;

        // MONTH() is MySQL/MariaDB. PostgreSQL: EXTRACT(MONTH FROM created_at);
        // SQLite: CAST(strftime('%m', created_at) AS INTEGER).

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

Usage in the DashboardController:

Order::lastDays(30)->count();
Order::previousDays(30)->count();
Order::countByMonth(2024);
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

FAQs

When should I extract a trait from an Eloquent model?

When two or more models share the same behavior (slugs, change logging, media). One model = keep it in the model.

How do I boot a trait in Laravel?

Define a static bootYourTraitName() method – Laravel calls it automatically when the model boots. Register model event hooks there.

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.
  • Sluggable, HasCreator, CanBeActivated, DateScopes, InteractsWithMedia 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 (creating, updating, deleting) 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 →

Top comments (0)