← All posts

Eloquent: Abstracting Query Logic vs. Just Using It

Eloquent: Abstracting Query Logic vs. Just Using It

The Problem with Over-Abstraction

I've seen it countless times: a team starts a new Laravel project, and before the first model is even defined, someone suggests building a full repository pattern on top of Eloquent. The thinking usually goes something like, "What if we switch databases?" or "We need to decouple our business logic from the ORM." For 99% of Laravel applications, this is a waste of time and introduces unnecessary indirection. Eloquent is your ORM. It's not just a database abstraction; it's a core part of the Laravel ecosystem, designed to be used directly.

When you wrap every Eloquent call in a separate interface and concrete repository class, you lose all the benefits of Eloquent's expressiveness. You end up with methods like UserRepository::findById($id) that internally just call User::find($id). You've added boilerplate, made the code harder to navigate, and gained almost nothing. Database switching is rarely a practical concern. If you ever need to switch, it's a massive undertaking that involves rewriting much more than just your ORM calls, and a simple repository pattern won't save you.

When to Embrace Eloquent Directly

For most CRUD operations and straightforward data retrieval, just use Eloquent in your controllers, jobs, or actions. Don't be afraid of User::where('status', 'active')->get() directly in a controller. Laravel is built for this. It's clean, readable, and idiomatic. Here's what I mean:

// In a Controller or Action
class UserController extends Controller
{
    public function index()
    {
        $users = User::where('is_active', true)
                     ->with('posts')
                     ->orderBy('name')
                     ->get();

        return view('users.index', compact('users'));
    }

    public function show(User $user)
    {
        return view('users.show', compact('user'));
    }
}

This is concise. Anyone familiar with Laravel knows exactly what's happening. You're not hiding complexity; you're expressing it clearly with the tool designed for the job. Your models become the primary interface to your data.

The Value of Abstraction: When It Makes Sense

Now, this isn't to say abstraction is always bad. There are specific scenarios where wrapping Eloquent query logic in a dedicated class, often a "Query Builder" or "Service" class (I prefer the former name for query-specific logic), provides real value:

  • Complex, Reusable Queries: If you have a query that involves multiple joins, conditional clauses, and eager loads that's used in several different parts of your application, extracting it makes sense. This isn't just a where clause; it's a complex, named query.
  • Domain-Specific Language: When you want to expose a more business-oriented API for retrieving data. Instead of User::where('status', 'pending_approval')->get(), you might have UserService::getPendingApprovalUsers(). This can improve readability for complex domain concepts.
  • Filtering/Sorting Logic: For applying dynamic filters and sorts based on user input, a dedicated class (often a Spatie Query Builder style or custom solution) is invaluable.
  • Testing Complex Scenarios: While you can test Eloquent directly, sometimes abstracting a very complex query makes it easier to mock and test its specific behavior in isolation from the database.

Practical Abstraction: Query Builders

Let's look at an example where abstraction adds clarity and reduces duplication. Imagine you frequently need to retrieve "active users with at least one published post, ordered by their most recent activity."

Instead of repeating this query everywhere, you can create a dedicated query builder:

// app/Queries/ActiveUsersQuery.php
namespace App\Queries;

use App\Models\User;
use Illuminate\Database\Eloquent\Builder;

class ActiveUsersQuery
{
    public function get(): Builder
    {
        return User::query()
            ->where('is_active', true)
            ->whereHas('posts', function ($query) {
                $query->where('status', 'published');
            })
            ->with('latestActivity') // Assuming a relationship for activity
            ->orderByDesc(function (Builder $query) {
                $query->select('created_at')
                    ->from('user_activities')
                    ->whereColumn('user_id', 'users.id')
                    ->latest() // Get the latest activity
                    ->limit(1);
            });
    }
}

// Usage in a Controller or Service
class DashboardController extends Controller
{
    public function index(ActiveUsersQuery $query)
    {
        $activeUsers = $query->get()->paginate(15);

        return view('dashboard', compact('activeUsers'));
    }
}

This ActiveUsersQuery class encapsulates a specific, non-trivial query. It uses Eloquent under the hood, but it provides a higher-level, named method for that specific data set. This is not a repository for *all* user operations; it's a specialized class for *a specific user query*.

The Line in the Sand

My rule of thumb is this: if the Eloquent query is simple and directly expresses what you need (e.g., fetching a record by ID, getting all active items), just use Eloquent directly. If the query logic is complex, involves multiple conditions, relationships, or custom ordering that you find yourself repeating, then abstract it into a dedicated Query class or a service method named after the specific data set it retrieves. Don't abstract for the sake of abstraction; abstract when it solves a real problem of duplication or complexity.

Eloquent is a fantastic tool. Use it as intended, and when its direct usage starts to obscure your intent or create repetition, that's your signal to consider a targeted abstraction.

Share

Building something and need a senior engineer?

Get in touch