Site icon IT & Life Hacks Blog|Ideas for learning and practicing

[Complete Practical Guide] Designing with Laravel Eloquent — Relationships, Scopes, N+1 Prevention, Aggregation, Separation from DTOs/Resources, and Maintainable Model Design

php elephant sticker

Photo by RealToughCandy.com on Pexels.com

[Complete Practical Guide] Designing with Laravel Eloquent — Relationships, Scopes, N+1 Prevention, Aggregation, Separation from DTOs/Resources, and Maintainable Model Design

What you will learn in this article (key points)

  • How to move from “using Eloquent because it is convenient” to “using it in a way that is resilient and well-designed”
  • The basics of relationship design (belongsTo / hasMany / belongsToMany) and naming
  • Practical patterns for avoiding the N+1 problem, and when to use with, withCount, and loadMissing
  • How to organize local scopes, accessors/mutators, casts, and value objects
  • How to avoid Fat Models while separating responsibilities into Service / Action / DTO / API Resource
  • How to keep aggregation, search conditions, pagination, and update logic readable
  • Eloquent design patterns that are easy to test, and how they connect to accessible list/detail screens

Who will benefit from this?

  • Laravel beginner to intermediate engineers: people who can already write Eloquent, but are struggling as models become too large
  • Tech leads: people who want to align responsibility boundaries between models, services, and API Resources across a team
  • QA / maintenance staff: people who want to reduce bugs caused by “not knowing where formatting is happening” in list screens and APIs
  • Designers / frontend developers: people who want a stable foundation that provides the data needed for screens in a predictable shape

Accessibility level: ★★★★☆

The main theme of this article is ORM design, but because the way lists, detail views, and aggregate results are presented directly affects UI clarity, this guide also keeps in mind heading structure, count display, non-color-only state expression, and data formatting that is easy to interpret with screen readers.


1. Introduction: Eloquent is convenient, but if you write only for convenience, complexity grows quickly

Laravel’s Eloquent is very easy to write with, so at first it is tempting to feel, “If I just put it in the model, it will work somehow.” In fact, for small screens or APIs, it often works immediately. But once the number of screens grows, conditions branch more, and aggregation, permissions, and API response shaping begin to mix together, problems like the following start to appear:

  • The model becomes huge, and you no longer know where anything is
  • The list view is fast, but the detail view is slow
  • API response formatting is mixed into the model
  • The same search conditions are copy-pasted across controllers
  • The N+1 problem keeps coming back
  • When you try to test, there is too much prerequisite data, and identifying the cause takes time

Eloquent is not the problem. On the contrary, it is powerful. What matters is deciding what to let Eloquent handle, and what not to let it handle. In this article, we will organize practical patterns for Eloquent design that remain maintainable over time.


2. Basic policy: decide first what goes into the model and what does not

If you decide your policy first, hesitation decreases.

Things that fit naturally in the model

  • Mapping to tables
  • Relationships
  • Scopes (reusable search conditions)
  • Casts
  • Small domain rules
  • State checks (is it published, has it expired, etc.)

Things that are better kept out of the model

  • Long aggregation logic
  • External API integrations
  • Sending emails
  • Response formatting that is specific to one screen
  • Large business processes spanning multiple models
  • Display logic used only for a particular screen or API

In other words, the model becomes more stable if it stays focused on “data and the small rules around it.” Heavy processes and presentation-specific formatting are easier to read if they are moved into Services / Actions / DTOs / Resources.


3. Relationship design: the clearer the names and directions, the easier maintenance becomes later

As an example, consider User, Post, Comment, and Tag in a blog system.

3.1 Basic relationships

// app/Models/Post.php
class Post extends Model
{
    public function user()
    {
        return $this->belongsTo(User::class);
    }

    public function comments()
    {
        return $this->hasMany(Comment::class);
    }

    public function tags()
    {
        return $this->belongsToMany(Tag::class);
    }
}
// app/Models/User.php
class User extends Model
{
    public function posts()
    {
        return $this->hasMany(Post::class);
    }
}

3.2 Naming basics

  • Singular: belongsTo, hasOne
  • Plural: hasMany, belongsToMany
  • Use names with clear meaning
    • user() is easy to understand
    • If owner() fits the context better, then owner() is also fine
  • If you deviate from table-name or foreign-key conventions, make it explicit
public function owner()
{
    return $this->belongsTo(User::class, 'owner_id');
}

Relationship names also affect the readability of screens and APIs. Prioritize names whose meaning is still clear when read later.


4. The N+1 problem: practical Eloquent skill you want to build early

The most common performance problem in Eloquent is N+1.

4.1 Bad example

$posts = Post::latest()->take(20)->get();

foreach ($posts as $post) {
    echo $post->user->name;
}

This code fetches the list of posts first, and then fetches user again for each post, so the number of SQL queries keeps increasing.

4.2 Good example

$posts = Post::with('user')->latest()->take(20)->get();

foreach ($posts as $post) {
    echo $post->user->name;
}

4.3 If you only want counts, use withCount

$posts = Post::with('user')
    ->withCount('comments')
    ->latest()
    ->paginate(20);

4.4 Narrow it down to only the columns needed for display

$posts = Post::query()
    ->select(['id', 'user_id', 'title', 'published_at'])
    ->with(['user:id,name'])
    ->withCount('comments')
    ->latest()
    ->paginate(20);

Key points

  • In list views, you almost never need every column
  • If related data is also narrowed to only what is needed, such as id,name, it becomes lighter
  • N+1 prevention should not be “something to optimize later,” but a habit you build when writing list queries

5. How to use with, load, and loadMissing

There are several similar methods, so it is helpful to organize how they differ.

with

Load relationships together in the initial query

Post::with('user')->get();

load

Load additional relationships for an already retrieved model

$post->load('comments');

loadMissing

Load only if it has not already been loaded

$post->loadMissing('user');

In practice:

  • Use with for the first query in list/detail views
  • Use load when you want to add relationships conditionally
  • Use loadMissing in shared or reusable code
    This tends to be the most manageable pattern.

6. Scopes: move search conditions out of controllers

If the same where conditions are scattered across controllers and APIs, it becomes easy to miss changes. This is where local scopes help.

6.1 Example: published posts

// app/Models/Post.php
public function scopePublished($query)
{
    return $query->whereNotNull('published_at')
        ->where('published_at', '<=', now());
}

Used like this:

$posts = Post::published()->latest()->paginate(20);

6.2 Example: keyword search

public function scopeKeyword($query, ?string $keyword)
{
    if (!$keyword) {
        return $query;
    }

    return $query->where(function ($q) use ($keyword) {
        $q->where('title', 'like', "%{$keyword}%")
          ->orWhere('body', 'like', "%{$keyword}%");
    });
}
$posts = Post::published()
    ->keyword($request->string('q')->toString())
    ->latest()
    ->paginate(20);

Key points

  • Keep scopes focused on conditions that are reused
  • If you put every screen-specific complex sort into scopes, they can become harder to read
  • It is best to extract only commonly used conditions into short, clear names

7. Accessors, mutators, and casts: absorb small formatting concerns in the model

7.1 Casts

protected function casts(): array
{
    return [
        'published_at' => 'datetime',
        'is_featured' => 'boolean',
        'meta' => 'array',
    ];
}

Using casts reduces the need for Carbon::parse() or json_decode() in controllers and views, which improves readability.

7.2 State-check methods

public function isPublished(): bool
{
    return !is_null($this->published_at) && $this->published_at->isPast();
}

Small state checks like this fit naturally in the model. It prevents rewriting the same condition in screens and APIs.

7.3 Do not overdo accessors

For example, returning a display label can be convenient.

protected function statusLabel(): Attribute
{
    return Attribute::make(
        get: fn () => $this->isPublished() ? 'Published' : 'Draft'
    );
}

However, once accessors start returning HTML or contain long wording logic, the model becomes too tied to presentation concerns. It is safer to move display-specific formatting into a ViewModel or Resource.


8. appends, hidden, fillable, guarded: quiet details, but important

8.1 fillable

protected $fillable = [
    'title',
    'body',
    'published_at',
];

Being explicit here helps protect against mass-assignment risks.

8.2 hidden

Hide values you do not want included in arrays or API responses.

protected $hidden = [
    'password',
    'remember_token',
];

8.3 appends

You can use this to automatically append accessor values to arrays, but if you add too many, it can become heavy. If each API needs a different shape, it is usually easier to manage through Resources.


9. API Resources and DTOs: separate screen/API formatting from the model

If you keep adding “fields only for this API” or “formatting for this screen” to the model, its responsibilities start to collapse. API Resources and DTOs help here.

9.1 API Resource example

// app/Http/Resources/PostResource.php
class PostResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'id' => (string) $this->id,
            'title' => $this->title,
            'author' => [
                'id' => (string) $this->user->id,
                'name' => $this->user->name,
            ],
            'comments_count' => $this->comments_count,
            'status' => $this->isPublished() ? 'published' : 'draft',
            'published_at' => optional($this->published_at)?->toIso8601String(),
        ];
    }
}

9.2 Controller side

public function index()
{
    $posts = Post::with('user')
        ->withCount('comments')
        ->published()
        ->latest()
        ->paginate(20);

    return PostResource::collection($posts);
}

With this structure, the model stays focused on data and small rules, while the way the API is presented is completed on the Resource side.


10. Service / Action: move processes spanning multiple models out of the model

If you start writing logic like “when saving a post, also update tags, send notifications, and write an audit log” directly into the model, it quickly becomes a Fat Model. Such processes are easier to understand when moved into a Service or Action.

10.1 Example: post creation Action

// app/Actions/CreatePostAction.php
class CreatePostAction
{
    public function execute(User $user, array $data): Post
    {
        return DB::transaction(function () use ($user, $data) {
            $post = $user->posts()->create([
                'title' => $data['title'],
                'body' => $data['body'],
                'published_at' => $data['published_at'] ?? null,
            ]);

            if (!empty($data['tag_ids'])) {
                $post->tags()->sync($data['tag_ids']);
            }

            return $post;
        });
    }
}

10.2 Controller

public function store(StorePostRequest $request, CreatePostAction $action)
{
    $post = $action->execute($request->user(), $request->validated());

    return redirect()->route('posts.show', $post)
        ->with('status', 'The post has been created.');
}

Instead of forcing everything into the model, cutting it out as “the business operation of saving” also makes it easier to test.


11. Aggregation and reporting: do not make the model carry too much SQL just for screens

Dashboards and list screens often need aggregates. If you start placing huge aggregation methods on the model, responsibilities become unclear.

11.1 For light aggregation, a query is enough

$total = Order::whereDate('created_at', today())->sum('total_amount');

11.2 If it becomes complex, separate it into a Query Service

// app/Services/DashboardStatsService.php
class DashboardStatsService
{
    public function todayStats(): array
    {
        return [
            'orders_count' => Order::whereDate('created_at', today())->count(),
            'sales_total' => Order::whereDate('created_at', today())->sum('total_amount'),
        ];
    }
}

Keep the model close to “the nature of a single record” and “reusable conditions,” and move aggregates into separate classes for better clarity.


12. How to write Eloquent for fast, understandable list screens

In list views, both readability and performance matter.

12.1 Example: admin post list

$posts = Post::query()
    ->select(['id', 'user_id', 'title', 'published_at', 'created_at'])
    ->with(['user:id,name'])
    ->withCount('comments')
    ->keyword($request->string('q')->toString())
    ->latest()
    ->paginate(20)
    ->withQueryString();

12.2 How this connects to the UI

  • Explicitly show counts as numbers
  • Show publish status with text, not color alone
  • Make sorting and filtering conditions visible on screen
  • Use withQueryString() to preserve conditions during pagination

If Eloquent prepares the minimum data needed for the list, then Blade or frontend conditions become simpler, and accessibility is easier to maintain.


13. Update processing: cases where update() alone is not enough

For simple updates, update() is enough.

$post->update($request->validated());

However, be careful in cases like these:

  • You need to leave an audit log before and after the update
  • Related tables also need updating
  • Notifications or events must fire conditionally
  • A transaction is needed to preserve consistency

In such cases, moving the logic into an Action is safer.


14. Testable Eloquent design: keep factories and prerequisites small

When the Eloquent design is good, tests also become easier to write.

14.1 Scope test example

public function test_published_scope_returns_only_published_posts()
{
    Post::factory()->create(['published_at' => now()->subDay()]);
    Post::factory()->create(['published_at' => null]);

    $posts = Post::published()->get();

    $this->assertCount(1, $posts);
}

14.2 Action test example

public function test_create_post_action_creates_post_and_syncs_tags()
{
    $user = User::factory()->create();
    $tags = Tag::factory()->count(2)->create();

    $post = app(CreatePostAction::class)->execute($user, [
        'title' => 'New Post',
        'body' => 'Body',
        'tag_ids' => $tags->pluck('id')->all(),
    ]);

    $this->assertDatabaseHas('posts', ['id' => $post->id, 'title' => 'New Post']);
    $this->assertCount(2, $post->tags);
}

When responsibilities are separated, it becomes much clearer what exactly should be tested.


15. Common pitfalls and how to avoid them

  • Writing external API calls or email sending directly in the model
    • Avoid by separating into Action / Service
  • Reintroducing N+1 in list views over and over
    • Avoid by making with a default habit in lists
  • Having so many scopes that no one can tell what they do
    • Avoid by extracting only short, reusable conditions
  • Returning HTML or long wording through accessors
    • Avoid by moving display formatting into Resource / ViewModel
  • Scattering API array formatting across models
    • Avoid by standardizing on API Resources
  • Leaving fillable vague and keeping mass-assignment risks around
    • Avoid by managing it explicitly

16. Checklist (for sharing)

Model

  • [ ] Relationship names match their meaning
  • [ ] fillable / hidden / casts are organized
  • [ ] State-check methods are small and easy to understand

Performance

  • [ ] List queries are written with with, withCount, and select in mind
  • [ ] Screens where N+1 is likely are identified
  • [ ] Pagination and withQueryString() are used

Responsibility separation

  • [ ] Reusable conditions are grouped into scopes
  • [ ] Large save operations are separated into Actions / Services
  • [ ] API formatting is handled by Resources
  • [ ] Aggregation is moved into Query Services or dedicated classes

Testing

  • [ ] There are unit tests for scopes
  • [ ] There are tests for Actions
  • [ ] Counts and structure in lists/APIs are protected by Feature tests

Accessibility

  • [ ] Counts and states in lists are understandable as text
  • [ ] State representation does not rely on color alone
  • [ ] The screen receives data shaped in a granularity that is easy to read

17. Summary

Eloquent is at the heart of Laravel’s productivity. That is exactly why, rather than writing everything into the model, it is easier to maintain code over time if you decide what belongs in the model and what does not. Relationships, scopes, casts, and state checks can stay in the model. Large save operations, aggregations, API formatting, and external integrations belong in Actions / Services / Resources. On top of that, simply making with and withCount a habit in list queries avoids N+1 from the start and significantly improves both performance and readability. Eloquent is strong not when used “thinly,” but when used with the right responsibilities. Even in today’s project, try first by reorganizing one list screen and one save operation.


Reference links

Exit mobile version