Action classes — single-responsibility over fat services
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
// 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());