Skip to content

Repository Pattern Guide

Overview

The Repository Pattern provides data access abstraction that isolates domain logic from infrastructure concerns. Repositories act as in-memory collections of aggregates, hiding database implementation details and providing a clean API for domain operations.

Core Concepts

What is a Repository?

A repository mediates between domain and data layers:

php
// ❌ Without Repository: Domain coupled to Eloquent
class ProductService
{
    public function activateProduct(int $id): void
    {
        $product = Product::findOrFail($id); // Coupled to Eloquent
        $product->status = 'active';
        $product->save();
    }
}

// ✅ With Repository: Clean domain layer
class ProductService
{
    public function __construct(
        private readonly ProductRepositoryInterface $repository
    ) {}

    public function activateProduct(int $id): void
    {
        $product = $this->repository->findOrFail($id);
        $product->activate();
        $this->repository->save($product);
    }
}

Benefits:

  • Domain logic independent of infrastructure
  • Easy to test (mock repository)
  • Centralized query logic
  • Can swap implementations (Eloquent, API, in-memory)

Repository Interface

php
<?php

namespace App\Modules\Operations\Domain\Contracts;

use App\Modules\Operations\Domain\Models\Product;
use Illuminate\Support\Collection;

interface ProductRepositoryInterface
{
    public function find(int $id): ?Product;
    public function findOrFail(int $id): Product;
    public function findBySKU(string $sku): ?Product;
    public function save(Product $product): void;
    public function delete(Product $product): void;
    public function all(): Collection;
    public function paginate(int $perPage = 15): \Illuminate\Pagination\LengthAwarePaginator;
}

Repository Implementation

Base Repository

php
<?php

namespace App\Shared\Infrastructure\Repositories;

use App\Shared\Domain\Models\BaseEntity;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Event;

abstract class EloquentRepository
{
    protected Model $model;

    public function find(int $id): ?BaseEntity
    {
        return $this->model->find($id);
    }

    public function findOrFail(int $id): BaseEntity
    {
        return $this->model->findOrFail($id);
    }

    public function save(BaseEntity $entity): void
    {
        $entity->save();

        // Dispatch domain events
        foreach ($entity->releaseEvents() as $event) {
            Event::dispatch($event);
        }
    }

    public function delete(BaseEntity $entity): void
    {
        $entity->delete();
    }

    public function all(): Collection
    {
        return $this->model->all();
    }

    public function paginate(int $perPage = 15)
    {
        return $this->model->paginate($perPage);
    }
}

Product Repository

php
<?php

namespace App\Modules\Operations\Infrastructure\Repositories;

use App\Modules\Operations\Domain\Contracts\ProductRepositoryInterface;
use App\Modules\Operations\Domain\Models\Product;
use App\Shared\Infrastructure\Repositories\EloquentRepository;
use Illuminate\Support\Collection;

final class EloquentProductRepository extends EloquentRepository implements ProductRepositoryInterface
{
    public function __construct(Product $model)
    {
        $this->model = $model;
    }

    public function findBySKU(string $sku): ?Product
    {
        return $this->model->where('sku', $sku)->first();
    }

    public function findActiveProducts(): Collection
    {
        return $this->model
            ->where('status', 'active')
            ->orderBy('name')
            ->get();
    }

    public function findLowStockProducts(int $warehouseId = null): Collection
    {
        $query = $this->model
            ->whereColumn('stock_quantity', '<=', 'reorder_point')
            ->where('stock_quantity', '>', 0);

        if ($warehouseId) {
            $query->where('warehouse_id', $warehouseId);
        }

        return $query->get();
    }

    public function search(string $term): Collection
    {
        return $this->model
            ->where('name', 'like', "%{$term}%")
            ->orWhere('sku', 'like', "%{$term}%")
            ->orWhere('description', 'like', "%{$term}%")
            ->get();
    }

    public function findByCategory(int $categoryId): Collection
    {
        return $this->model
            ->where('category_id', $categoryId)
            ->get();
    }

    public function existsBySKU(string $sku): bool
    {
        return $this->model->where('sku', $sku)->exists();
    }
}

Repository Registration

Service Provider Binding

php
<?php

namespace App\Modules\Operations\Providers;

use Illuminate\Support\ServiceProvider;
use App\Modules\Operations\Domain\Contracts\ProductRepositoryInterface;
use App\Modules\Operations\Infrastructure\Repositories\EloquentProductRepository;

final class OperationsServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(
            ProductRepositoryInterface::class,
            EloquentProductRepository::class
        );

        $this->app->bind(
            PurchaseOrderRepositoryInterface::class,
            EloquentPurchaseOrderRepository::class
        );

        // ... other repositories
    }
}

Using Repositories

In Command Handlers

php
<?php

namespace App\Modules\Operations\Application\Commands;

use App\Shared\Application\Commands\CommandHandler;
use App\Modules\Operations\Domain\Contracts\ProductRepositoryInterface;

final class ActivateProductCommandHandler implements CommandHandler
{
    public function __construct(
        private readonly ProductRepositoryInterface $productRepository,
    ) {}

    public function handle(Command $command): Product
    {
        \assert($command instanceof ActivateProductCommand);

        // Find via repository
        $product = $this->productRepository->findOrFail($command->productId);

        // Domain method
        $product->activate();

        // Save via repository (dispatches events)
        $this->productRepository->save($product);

        return $product;
    }
}

In Query Handlers

php
<?php

namespace App\Modules\Operations\Application\Queries;

use App\Shared\Application\Queries\QueryHandler;
use App\Modules\Operations\Domain\Contracts\ProductRepositoryInterface;

final class GetProductByIdQueryHandler implements QueryHandler
{
    public function __construct(
        private readonly ProductRepositoryInterface $productRepository,
    ) {}

    public function handle(Query $query): Product
    {
        \assert($query instanceof GetProductByIdQuery);

        return $this->productRepository->findOrFail($query->productId);
    }
}

Advanced Querying

Specification Pattern

php
<?php

namespace App\Modules\Operations\Domain\Specifications;

interface Specification
{
    public function isSatisfiedBy($candidate): bool;
    public function toQuery(): \Closure;
}

final class ActiveProductSpecification implements Specification
{
    public function isSatisfiedBy($product): bool
    {
        return $product->status === ProductStatus::ACTIVE;
    }

    public function toQuery(): \Closure
    {
        return fn($query) => $query->where('status', 'active');
    }
}

// Usage in repository
public function findBySpecification(Specification $spec): Collection
{
    return $this->model->where($spec->toQuery())->get();
}

Query Builder in Repository

php
public function findFiltered(ProductFilterDTO $filters): Collection
{
    $query = $this->model->query();

    if ($filters->status) {
        $query->where('status', $filters->status);
    }

    if ($filters->categoryId) {
        $query->where('category_id', $filters->categoryId);
    }

    if ($filters->minPrice) {
        $query->where('selling_price', '>=', $filters->minPrice);
    }

    if ($filters->maxPrice) {
        $query->where('selling_price', '<=', $filters->maxPrice);
    }

    if ($filters->searchTerm) {
        $query->where(function ($q) use ($filters) {
            $q->where('name', 'like', "%{$filters->searchTerm}%")
              ->orWhere('sku', 'like', "%{$filters->searchTerm}%");
        });
    }

    return $query
        ->orderBy($filters->sortBy ?? 'name', $filters->sortDirection ?? 'asc')
        ->get();
}

Event Dispatching

Automatic Event Dispatch

php
public function save(BaseEntity $entity): void
{
    // Save to database
    $entity->save();

    // Dispatch all recorded events
    foreach ($entity->releaseEvents() as $event) {
        Event::dispatch($event);
    }
}

Flow:

  1. Domain method records event: $product->recordEvent($event)
  2. Repository saves entity: $repository->save($product)
  3. Repository dispatches events automatically
  4. Event listeners handle events

Testing Repositories

Unit Testing (Mock Repository)

php
use Mockery;

public function test_activates_product_via_repository(): void
{
    $mockRepo = Mockery::mock(ProductRepositoryInterface::class);

    $product = Product::factory()->make(['status' => ProductStatus::DRAFT]);

    $mockRepo->shouldReceive('findOrFail')
        ->once()
        ->with(42)
        ->andReturn($product);

    $mockRepo->shouldReceive('save')
        ->once()
        ->with($product);

    $handler = new ActivateProductCommandHandler($mockRepo);
    $handler->handle(new ActivateProductCommand(productId: 42));

    $this->assertEquals(ProductStatus::ACTIVE, $product->status);
}

Integration Testing (Real Repository)

php
public function test_finds_product_by_sku(): void
{
    Product::factory()->create(['sku' => 'TEST-001']);

    $repository = app(ProductRepositoryInterface::class);
    $product = $repository->findBySKU('TEST-001');

    $this->assertNotNull($product);
    $this->assertEquals('TEST-001', $product->sku);
}

public function test_saves_product_and_dispatches_events(): void
{
    Event::fake();

    $repository = app(ProductRepositoryInterface::class);

    $product = Product::create(['name' => 'Test Product']);
    $product->activate();

    $repository->save($product);

    Event::assertDispatched(ProductActivated::class);
}

Repository Best Practices

DO:

  • ✅ Define repository interfaces in Domain layer
  • ✅ Implement repositories in Infrastructure layer
  • ✅ Use repositories in Application layer (handlers)
  • ✅ Dispatch domain events on save
  • ✅ Centralize complex queries in repository
  • ✅ Return domain entities, not DTOs

DON'T:

  • ❌ Use repositories in Domain layer (only interfaces)
  • ❌ Expose Eloquent query builder publicly
  • ❌ Put business logic in repositories
  • ❌ Create generic repositories (one per aggregate)
  • ❌ Skip event dispatching

Repository vs Query Builder

Use Repository When:

  • Working with aggregates
  • Need event dispatching
  • Domain operations (save, delete)
  • Want to hide persistence details

Use Query Builder When:

  • Read-only queries in Query handlers
  • Complex reporting
  • Aggregations and statistics
  • Performance-critical reads

Operations Module Repositories

app/Modules/Operations/Domain/Contracts/
├── ProductRepositoryInterface.php
├── InventoryRepositoryInterface.php
├── PurchaseOrderRepositoryInterface.php
└── StockMovementRepositoryInterface.php

app/Modules/Operations/Infrastructure/Repositories/
├── EloquentProductRepository.php
├── EloquentInventoryRepository.php
├── EloquentPurchaseOrderRepository.php
└── EloquentStockMovementRepository.php

Further Reading

  • Base Repository: app/Shared/Infrastructure/Repositories/EloquentRepository.php
  • Product Repository: app/Modules/Operations/Infrastructure/Repositories/EloquentProductRepository.php
  • Domain Contracts: app/Modules/Operations/Domain/Contracts/

Documentation for SynthesQ CRM/ERP Platform