← Domain-Driven Design in Laravel Chapter 04

Eloquent Without the Bloat

Test your knowledge

Take a quiz on this chapter to see how well you understood the concepts.

Take Quiz

Laravel's Eloquent models are incredibly powerful. They represent data in a data store, build queries, load and save data, have a built-in event system, support casting, and more.

That power is also their danger. Because models can do so much, developers tend to pile on even more functionality: progress tracking, certificate generation, enrollment management, complex filtering. Before long, your Course model is 800 lines and impossible to navigate.

The goal of this chapter is not to abandon Eloquent. It is to embrace the framework while preventing models from becoming dumping grounds for business logic.

Models are not business logic

The first pitfall is thinking of models as the home for business calculations. It sounds appealing to write $lesson->estimated_completion_time or $course->total_duration. And those properties should exist on the model. But they should not calculate anything.

Here is what not to do:

class Lesson extends Model
{
    public function getEstimatedCompletionTimeAttribute(): int
    {
        $calculator = app(DurationCalculator::class);
        $baseDuration = $this->video_count * $this->duration_minutes;

        if ($this->has_quiz) {
            $baseDuration = $calculator->withQuiz(
                $baseDuration,
                $this->quiz_duration
            );
        }

        return $baseDuration;
    }
}

And a course model that sums all lessons:

class Course extends Model
{
    public function getTotalDurationAttribute(): int
    {
        return $this->lessons
            ->reduce(
                fn (int $total, Lesson $lesson) =>
                    $total + $lesson->estimated_completion_time,
                0
            );
    }
}

The problems compound:

  • Performance: the calculation runs every time you access the property, not once
  • Not queryable: you cannot use WHERE total_duration > 120 in SQL
  • Side effects: service location (app()) inside accessors is unpredictable
  • Testing: you need a full model instance with relationships to test a calculation

The better approach: calculate durations in actions, store the results in the database, and let the model simply return the stored value. When you access $course->total_duration, it reads a column, not a complex calculation.

Scaling down models

If models should only provide data, where does everything else go? Into dedicated classes that Laravel already supports through built-in hooks.

Custom query builders

Instead of cluttering your model with scopes:

// Don't do this — the model keeps growing
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;

class Course extends Model
{
    #[Scope]
    protected function wherePublished(Builder $query): void
    {
        $query->whereState('status', PublishedCourseState::class);
    }

    #[Scope]
    protected function whereExpiring(Builder $query): void
    {
        $query->where('ends_at', '<', now()->addDays(30));
    }

    #[Scope]
    protected function forInstructor(Builder $query, Instructor $instructor): void
    {
        $query->where('instructor_id', $instructor->id);
    }
}

Move them to a dedicated query builder class:

namespace Domain\Courses\QueryBuilders;

use Domain\Courses\States\PublishedCourseState;
use Illuminate\Database\Eloquent\Builder;

class CourseQueryBuilder extends Builder
{
    public function wherePublished(): self
    {
        return $this->whereState('status', PublishedCourseState::class);
    }

    public function whereExpiring(): self
    {
        return $this->where('ends_at', '<', now()->addDays(30));
    }

    public function forInstructor(Instructor $instructor): self
    {
        return $this->where('instructor_id', $instructor->id);
    }
}

Then connect it to the model. Laravel 12 provides the #[UseEloquentBuilder] attribute — a clean, declarative way to bind a query builder to a model:

namespace Domain\Courses\Models;

use Domain\Courses\QueryBuilders\CourseQueryBuilder;
use Illuminate\Database\Eloquent\Attributes\UseEloquentBuilder;

#[UseEloquentBuilder(CourseQueryBuilder::class)]
class Course extends Model
{
    
}

On older Laravel versions, override newEloquentBuilder instead:

class Course extends Model
{
    public function newEloquentBuilder($query): CourseQueryBuilder
    {
        return new CourseQueryBuilder($query);
    }
}

Both approaches achieve the same result. The attribute is simply cleaner.

This is not fighting the framework. Query builder classes are actually the normal way of using Eloquent — scopes are syntactic sugar on top of them. Your model stays small, and the query builder is independently testable:

it('filters only published courses', function (): void {
    $publishedCourse = Course::factory()->published()->create();
    $draftCourse = Course::factory()->create();

    expect(Course::query()->wherePublished()->whereKey($publishedCourse->id)->count())
        ->toBe(1);

    expect(Course::query()->wherePublished()->whereKey($draftCourse->id)->count())
        ->toBe(0);
});

Custom collection classes

When you find yourself writing long chains of collection methods in your controllers or other places:

$course->lessons
    ->filter(fn (Lesson $lesson) => $lesson->has_quiz)
    ->map(fn (Lesson $lesson) => /* ... */)
    ->sortBy('title');

Bundle that logic into a dedicated collection class:

namespace Domain\Courses\Collections;

use Domain\Courses\Models\Lesson;
use Illuminate\Database\Eloquent\Collection;

class LessonCollection extends Collection
{
    public function withQuizzes(): self
    {
        return $this->filter(
            fn (Lesson $lesson) => $lesson->hasQuiz()
        );
    }

    public function totalDuration(): int
    {
        return $this->sum(
            fn (Lesson $lesson) => $lesson->duration_minutes
        );
    }
}

Link it to the model using the #[CollectedBy] attribute:

namespace Domain\Courses\Models;

use Domain\Courses\Collections\LessonCollection;
use Illuminate\Database\Eloquent\Attributes\CollectedBy;

#[CollectedBy(LessonCollection::class)]
class Lesson extends Model
{
    public function hasQuiz(): bool
    {
        return $this->has_quiz === true;
    }
}

On older Laravel versions, override newCollection instead:

class Lesson extends Model
{
    public function newCollection(array $models = []): LessonCollection
    {
        return new LessonCollection($models);
    }
}

Now every HasMany relation to Lesson automatically uses your custom collection:

$course->lessons->withQuizzes();
$course->lessons->totalDuration();

Clean, readable, testable.

Event-driven models

Laravel emits generic model events (saving, deleting, etc.) and expects you to use model observers or configure listeners on the model itself. A more flexible approach is to remap generic events to specific event classes:

class Course extends Model
{
    protected $dispatchesEvents = [
        'saving' => CourseSavingEvent::class,
        'deleting' => CourseDeletingEvent::class,
    ];
}

Each event is a simple class:

class CourseSavingEvent
{
    public function __construct(public Course $course) {}
}

And you handle them with dedicated subscribers:

use Illuminate\Events\Dispatcher;

class CourseSubscriber
{
    public function __construct(
        private CalculateTotalDurationAction $calculateTotalDurationAction,
    ) {}

    public function saving(CourseSavingEvent $event): void
    {
        $course = $event->course;
        $course->total_duration = $this->calculateTotalDurationAction
            ->execute($course);
    }

    public function subscribe(Dispatcher $dispatcher): void
    {
        $dispatcher->listen(
            CourseSavingEvent::class,
            self::class . '@saving'
        );
    }
}

Register the subscriber in the boot method of your AppServiceProvider:

use Illuminate\Support\Facades\Event;

public function boot(): void
{
    Event::subscribe(CourseSubscriber::class);
}

This approach gives you specific event classes you can type-hint against, proper dependency injection in subscribers (no service location), and it keeps model classes small. For a deeper look at using PHP 8 attributes with event subscribers, see Use PHP 8 Attributes in Event Subscribers.

Are we making "empty bags of nothingness"?

Robert C. Martin (Uncle Bob) championed the Single Responsibility Principle: a class should have only one reason to change. When your model handles data access, business calculations, event handling, and query filtering all at once, it has many reasons to change. By extracting each concern into its own class, we are not hollowing out the model — we are giving it a clear, single responsibility.

This is a fair concern. But Eloquent models in their trimmed-down state are not empty bags. They still provide:

  • Accessors and casts for rich data transformation
  • Relationships that define how data connects
  • Attribute handling that bridges the database and your code

They are not plain value objects. They are data-rich objects that delegate behavior to other classes.

Eric Evans drew a clear line between entities, value objects, and services in his original Domain-Driven Design work. Entities carry identity and data; services carry behavior. Our lean models follow this same separation: the model is the entity that owns identity and data, while actions, states, and subscribers are the services that operate on it.

Your model's responsibility is to represent and expose data from the database in a meaningful way. Let the surrounding classes — actions, states, subscribers — own the logic that computes, transforms, and enforces rules on that data.

Summary

  • Eloquent models are powerful, but that power invites developers to pile on business logic. Keep models lean by delegating behavior to dedicated classes.
  • Do not put business calculations in accessors. Calculate values in actions, store them in the database, and let the model return the stored value.
  • Custom query builders replace model scopes. Use the #[UseEloquentBuilder] attribute (or override newEloquentBuilder() on older versions) to bind a dedicated builder class — this is how Eloquent is designed to work. Scopes are syntactic sugar on top.
  • Custom collection classes encapsulate filtering and aggregation logic. Use the #[CollectedBy] attribute (or override newCollection() on older versions), and every HasMany relation automatically uses your custom collection.
  • Event-driven models remap generic Eloquent events (saving, deleting) to specific event classes via $dispatchesEvents. Handle them with subscribers that use proper dependency injection.
  • Lean models are not "empty bags of nothingness." They still own accessors, casts, and relationships. They are entities that carry identity and data, while actions, states, and subscribers carry behavior.

References

Test your knowledge

Take a quiz on this chapter to see how well you understood the concepts.

Take Quiz
Share