0

Action classes — single-responsibility over fat services

Intermediate5 min read·lv-27-003
solid

Concept

Repository pattern in Laravel is controversial. The argument FOR: abstracts data access, makes controllers/services independent of Eloquent, enables swapping persistence implementations, simplifies testing via mock repositories. The argument AGAINST: Eloquent is already an abstraction; wrapping it adds boilerplate without benefit for most apps.

When repositories make sense:

  • Large teams where different members specialize in service vs. data layers.
  • Applications that genuinely might switch ORMs (rare).
  • Complex domain objects where read model ≠ write model.
  • When queries are complex and scattered — a repository centralizes them.

When they're unnecessary overhead:

  • Standard CRUD apps where Eloquent handles it cleanly.
  • Small teams or solo projects.
  • When the "repository" just wraps Eloquent methods one-to-one with no added logic.

Practical middle ground: Don't create a full repository per model. Instead, extract complex or reused query logic into:

  • Eloquent scopes (on the model itself).
  • Query builder methods in a dedicated class only when needed.
  • Service methods that encapsulate data fetching.

The facade fake approach: For testing, Http::fake(), Queue::fake(), Mail::fake() cover most external dependencies. For database, just use RefreshDatabase — testing with real SQLite is fast and accurate. You rarely need mock repositories.

Code Example

php
<?php
// Practical approach: centralize complex queries without full repository pattern
namespace App\Queries;

use App\Models\Post;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;

class PostQueries
{
    public function published(int $perPage = 15): LengthAwarePaginator
    {
        return Post::with(['author:id,name', 'tags'])
            ->published()
            ->orderByDesc('published_at')
            ->paginate($perPage);
    }

    public function byAuthor(int $authorId, int $perPage = 15): LengthAwarePaginator
    {
        return Post::where('user_id', $authorId)
            ->with('tags')
            ->orderByDesc('created_at')
            ->paginate($perPage);
    }

    public function relatedTo(Post $post, int $limit = 5): Collection
    {
        return Post::whereHas('tags', fn($q) => $q->whereIn('id', $post->tags->pluck('id')))
            ->where('id', '!=', $post->id)
            ->published()
            ->limit($limit)
            ->get();
    }
}

// Full repository pattern (if you must)
interface PostRepositoryInterface
{
    public function find(int $id): ?Post;
    public function create(array $data): Post;
    public function update(Post $post, array $data): Post;
    public function delete(Post $post): void;
}

class EloquentPostRepository implements PostRepositoryInterface
{
    public function find(int $id): ?Post { return Post::find($id); }
    public function create(array $data): Post { return Post::create($data); }
    public function update(Post $post, array $data): Post { $post->update($data); return $post; }
    public function delete(Post $post): void { $post->delete(); }
}

// Bind in AppServiceProvider
// $this->app->bind(PostRepositoryInterface::class, EloquentPostRepository::class);

// Test with a mock (only worthwhile if you have the interface)
// $mock = Mockery::mock(PostRepositoryInterface::class);
// $this->app->instance(PostRepositoryInterface::class, $mock);
// $mock->shouldReceive('create')->once()->andReturn(Post::factory()->make());