Repository Pattern Explorer

Understand when and how to implement the Repository Pattern in Laravel

The Repository Pattern adds an abstraction layer between your business logic and data access.

When to Use:
• Complex applications with long lifespans
• Multiple data sources (database + API + cache)
• Heavy business logic that needs isolated testing
• When you might switch ORMs or databases

When NOT to Use:
• Simple CRUD applications
• Small projects with straightforward data access
• When Eloquent's features are sufficient

Real-World Scenario:
You're building a product catalog that pulls from:
• Your database (primary inventory)
• Supplier API (real-time stock levels)
• Redis cache (frequently accessed items)

A Repository can unify these sources behind a single interface.

Code Examples

Without Repository Pattern
✗ Avoid This php
<?php
// Controller is tightly coupled to Eloquent
class ProductController extends Controller
{
    public function index()
    {
        // Direct Eloquent queries in controller
        $products = Product::with('category')
            ->where('active', true)
            ->where('stock', '>', 0)
            ->orderBy('name')
            ->paginate(20);
            
        return view('products.index', compact('products'));
    }
    
    public function show(Product $product)
    {
        // More Eloquent in controller
        $product->load('reviews', 'images');
        
        // Check supplier API for real-time stock
        $supplierStock = Http::get("api.supplier.com/stock/{$product->sku}")
            ->json('quantity');
            
        $product->realtime_stock = $supplierStock;
        
        return view('products.show', compact('product'));
    }
    
    public function search(Request $request)
    {
        $query = Product::query();
        
        if ($request->category) {
            $query->where('category_id', $request->category);
        }
        
        if ($request->min_price) {
            $query->where('price', '>=', $request->min_price);
        }
        
        // Complex search logic in controller
        if ($request->search) {
            $query->where(function($q) use ($request) {
                $q->where('name', 'like', "%{$request->search}%")
                  ->orWhere('description', 'like', "%{$request->search}%");
            });
        }
        
        return $query->paginate(20);
    }
}

// Problems:
// ❌ Controller is bloated with query logic
// ❌ Hard to test (requires database)
// ❌ Can't easily switch data sources
// ❌ Duplicated query logic across controllers
// ❌ Violates Single Responsibility Principle

💡 The controller is tightly coupled to Eloquent. Query logic is scattered, making it hard to test and maintain. If you need to add caching or fetch from an API, you must modify the controller.

With Repository Pattern
✓ Best Practice php
<?php
// 1. Define the interface
namespace App\Repositories\Contracts;

interface ProductRepositoryInterface
{
    public function getAllActive(int $perPage = 20);
    public function findWithDetails(int $id);
    public function search(array $filters);
    public function getRealtimeStock(string $sku): int;
}

// 2. Implement the repository
namespace App\Repositories;

use App\Models\Product;
use App\Repositories\Contracts\ProductRepositoryInterface;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;

class ProductRepository implements ProductRepositoryInterface
{
    public function getAllActive(int $perPage = 20)
    {
        return Cache::remember('products.active', 3600, function() use ($perPage) {
            return Product::with('category')
                ->where('active', true)
                ->where('stock', '>', 0)
                ->orderBy('name')
                ->paginate($perPage);
        });
    }
    
    public function findWithDetails(int $id)
    {
        return Product::with('reviews', 'images')
            ->findOrFail($id);
    }
    
    public function search(array $filters)
    {
        $query = Product::query();
        
        if (!empty($filters['category'])) {
            $query->where('category_id', $filters['category']);
        }
        
        if (!empty($filters['min_price'])) {
            $query->where('price', '>=', $filters['min_price']);
        }
        
        if (!empty($filters['search'])) {
            $query->where(function($q) use ($filters) {
                $q->where('name', 'like', "%{$filters['search']}%")
                  ->orWhere('description', 'like', "%{$filters['search']}%");
            });
        }
        
        return $query->paginate(20);
    }
    
    public function getRealtimeStock(string $sku): int
    {
        return Cache::remember("stock.{$sku}", 300, function() use ($sku) {
            return Http::get("api.supplier.com/stock/{$sku}")
                ->json('quantity', 0);
        });
    }
}

// 3. Clean controller
class ProductController extends Controller
{
    public function __construct(
        protected ProductRepositoryInterface $products
    ) {}
    
    public function index()
    {
        $products = $this->products->getAllActive();
        return view('products.index', compact('products'));
    }
    
    public function show(int $id)
    {
        $product = $this->products->findWithDetails($id);
        $product->realtime_stock = $this->products->getRealtimeStock($product->sku);
        
        return view('products.show', compact('product'));
    }
    
    public function search(Request $request)
    {
        $products = $this->products->search($request->all());
        return view('products.index', compact('products'));
    }
}

// Benefits:
// ✅ Controller is clean and focused
// ✅ Easy to test with mock repository
// ✅ Centralized data access logic
// ✅ Can swap implementations easily
// ✅ Caching logic in one place

💡 The Repository abstracts all data access. The controller is thin and testable. Query logic is centralized. You can easily add caching, switch to an API, or change ORMs without touching the controller.

Bind Repository in Service Provider
💡 Solution php
<?php
// In AppServiceProvider or RepositoryServiceProvider
namespace App\Providers;

use App\Repositories\Contracts\ProductRepositoryInterface;
use App\Repositories\ProductRepository;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // Bind the interface to the implementation
        $this->app->bind(
            ProductRepositoryInterface::class,
            ProductRepository::class
        );
    }
}

// Now you can type-hint the interface anywhere:
// - Controllers
// - Jobs
// - Commands
// - Services

// For testing, easily swap with a mock:
$this->mock(ProductRepositoryInterface::class, function($mock) {
    $mock->shouldReceive('getAllActive')
         ->once()
         ->andReturn(collect([/* fake products */]));
});

💡 Bind the repository interface to its implementation in a service provider. Now Laravel will automatically inject the concrete repository wherever the interface is type-hinted. This makes testing incredibly easy.