Repository Pattern in Laravel: Clean Up Your Messy Code
Source: Dev.to

The Problem
Ever seen controllers like this?
public class OrderController extends Controller
{
public function show($id)
{
$order = Order::with(['customer', 'items.product'])
->where('id', $id)
->first();
return response()->json($order);
}
public function getUserOrders($userId)
{
// Same query duplicated! 😱
$orders = Order::with(['customer', 'items.product'])
->where('customer_id', $userId)
->get();
return response()->json($orders);
}
}
Problems
- 🔴 Duplicated queries everywhere
- 🔴 Controllers tightly coupled to Eloquent
- 🔴 Impossible to test without database
- 🔴 Business logic mixed with data access
The Solution: Repository Pattern
Step 1 – Create Interface
interface OrderRepositoryInterface
{
public function find(int $id): ?Order;
public function findWithRelations(int $id): ?Order;
public function findByCustomer(int $customerId): Collection;
}
Step 2 – Implement Repository
class OrderRepository implements OrderRepositoryInterface
{
protected $model;
public function __construct(Order $model)
{
$this->model = $model;
}
public function findWithRelations(int $id): ?Order
{
return $this->model
->with(['customer', 'items.product'])
->find($id);
}
public function findByCustomer(int $customerId): Collection
{
return $this->model
->with(['customer', 'items.product'])
->where('customer_id', $customerId)
->orderBy('created_at', 'desc')
->get();
}
}
Step 3 – Register in Service Provider
class RepositoryServiceProvider extends ServiceProvider
{
public function register()
{
$this->app->bind(
OrderRepositoryInterface::class,
OrderRepository::class
);
}
}
Step 4 – Clean Controller
class OrderController extends Controller
{
protected $orderRepository;
public function __construct(OrderRepositoryInterface $orderRepository)
{
$this->orderRepository = $orderRepository;
}
public function show(int $id)
{
$order = $this->orderRepository->findWithRelations($id);
if (! $order) {
return response()->json(['message' => 'Not found'], 404);
}
return response()->json($order);
}
public function getUserOrders(int $userId)
{
$orders = $this->orderRepository->findByCustomer($userId);
return response()->json($orders);
}
}
Benefits
- ✅ No Duplication – Query logic lives in one place
- ✅ Easy Testing – Mock repositories instead of the database
- ✅ Flexibility – Switch data sources without touching business logic
- ✅ Clean Code – Controllers focus on HTTP concerns only
- ✅ Reusability – Same repository can be used in controllers, jobs, commands, etc.
Advanced: Base Repository
abstract class BaseRepository
{
protected $model;
public function all(): Collection
{
return $this->model->all();
}
public function find(int $id): ?Model
{
return $this->model->find($id);
}
public function create(array $data): Model
{
return $this->model->create($data);
}
public function update(int $id, array $data): bool
{
return $this->model->find($id)?->update($data) ?? false;
}
public function delete(int $id): bool
{
return $this->model->find($id)?->delete() ?? false;
}
}
Extending the Base Repository
class ProductRepository extends BaseRepository
{
public function __construct(Product $model)
{
parent::__construct($model);
}
public function getFeatured(int $limit = 10): Collection
{
return $this->model
->where('is_featured', true)
->where('stock', '>', 0)
->limit($limit)
->get();
}
public function searchAndFilter(array $filters)
{
$query = $this->model->query();
if (!empty($filters['search'])) {
$query->where('name', 'like', "%{$filters['search']}%");
}
if (!empty($filters['min_price'])) {
$query->where('price', '>=', $filters['min_price']);
}
return $query->paginate(15);
}
}
Testing Made Easy
Without Repository
// Must set up entire database
$order = Order::factory()->hasItems(3)->create();
$response = $this->getJson("/api/orders/{$order->id}");
With Repository
// Just mock the repository!
$orderRepo = Mockery::mock(OrderRepositoryInterface::class);
$orderRepo->shouldReceive('find')
->with(1)
->andReturn($mockOrder);
$this->app->instance(OrderRepositoryInterface::class, $orderRepo);
Common Pitfalls
❌ Don’t Return Query Builder
// Bad
public function getActive()
{
return $this->model->where('active', true); // Query builder!
}
✅ Do Return Concrete Results
// Good
public function getActive(): Collection
{
return $this->model->where('active', true)->get();
}
✅ Keep Repository for Data Access Only
// Good – Repository only handles data
public function create(array $data): Order
{
return $this->model->create($data);
}
// Business logic in Service
class OrderService
{
public function placeOrder(array $data): Order
{
$order = $this->orderRepository->create($data);
Mail::to($order->customer)->send(new OrderCreated($order));
return $order;
}
}
Quick Checklist
Before implementing the Repository Pattern, ask yourself:
- Is my controller doing database queries directly?
- Am I duplicating the same queries in multiple places?
- Is testing my code difficult without a database?
- Do I want to switch between Eloquent / Query Builder / Raw SQL easily?
- Am I building more than a simple CRUD app?
If you answered yes to 2+ questions, the Repository Pattern will help you!
Conclusion
The Repository Pattern isn’t always necessary for simple CRUD apps, but once your application grows, it becomes invaluable. It gives you:
- Clean, testable code
- Centralized data access logic
- Flexibility to change data sources
- Better separation of concerns
Start small – implement it for your most complex models first, then expand as needed.
Want the Full Deep Dive?
This is a condensed version! For the complete guide with:
- ✨ More advanced examples (caching, service‑layer integration)
- ✨ Real‑world blog system implementation
- ✨ Complete testing strategies
- ✨ E‑commerce order management example
Read the full article on Medium:
👉 Repository Pattern in Laravel: From Problem to Solution
Follow me for more Laravel tips:
What’s your experience with the Repository Pattern? Love it? Hate it? Let’s discuss in the comments! 💬
