Главная/Статьи/Repository Pattern в Laravel — когда нужен и как внедрять

Repository Pattern в Laravel — когда нужен и как внедрять

Repository абстрагирует работу с данными. Разбираем, когда паттерн оправдан, а когда Eloquent достаточно. Практические примеры без оверинжиниринга.

ДМ
Дмитрий Мещеряков
📅 13 июля 2026 г.📖 7 мин чтения

Repository — самый спорный паттерн в мире Laravel. Одна половина сообщества считает его обязательным слоем чистой архитектуры, другая — бессмысленной обёрткой над Eloquent, который сам по себе уже реализует Active Record.

Правы обе стороны, но в разных проектах. Ниже — попытка провести границу и честно назвать, что вы теряете, вводя этот слой.

Нужен ли Repository в Laravel?

Споры о Repository в Laravel не утихают. Eloquent уже является паттерном Active Record и предоставляет удобный API. Зачем ещё один слой?

Аргументы ЗА:

  • Абстракция от ORM (можно заменить Eloquent)
  • Централизация сложных запросов
  • Упрощение тестирования
  • Единое место для кэширования

Аргументы ПРОТИВ:

  • Дублирование Eloquent API
  • Лишний слой абстракции
  • Laravel и так тестируется легко
  • Никто не меняет ORM в реальных проектах
💡 Совет

Моё мнение: Repository оправдан для сложных запросов и кэширования. Для простого CRUD — избыточен.

Когда Repository нужен

php
// ❌ Плохо — сложный запрос размазан по контроллерам
// В OrderController
$orders = Order::with(['items.product', 'user'])
    ->where('status', 'pending')
    ->where('created_at', '>=', now()->subDays(7))
    ->whereHas('user', fn($q) => $q->where('is_vip', true))
    ->orderBy('total', 'desc')
    ->paginate(20);
// В DashboardController — тот же запрос с небольшими изменениями
$orders = Order::with(['items.product', 'user'])
    ->where('status', 'pending')
    ->where('created_at', '>=', now()->subDays(7))
    ->whereHas('user', fn($q) => $q->where('is_vip', true))
    ->orderBy('total', 'desc')
    ->limit(5)
    ->get();
php
// ✅ Хорошо — запрос в одном месте
$orders = $orderRepository->getRecentVipOrders(
    status: 'pending',
    days: 7,
    paginate: 20
);
⚠️ Важно

Аргумент «Repository позволит сменить Eloquent на что-то другое» на практике не работает. Он звучит убедительно, но: смена ORM не происходит примерно никогда, а если и происходит — интерфейс репозитория всё равно придётся переписывать, потому что он неизбежно протекает деталями Eloquent (коллекции, paginate(), ленивые связи в возвращаемых моделях).

Реальные причины ввести Repository приземлённее и их всего три: кэширование через декоратор без изменения вызывающего кода, подмена в тестах без обращения к базе, и сложные запросы, которым не место ни в контроллере, ни в модели. Если ни одна из трёх не про вас — слой не нужен, и Eloquent со скоупами будет честнее.

Простая реализация

Интерфейс

php
// app/Repositories/Contracts/OrderRepositoryInterface.php
namespace App\Repositories\Contracts;
use App\Models\Order;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
interface OrderRepositoryInterface
{
    public function find(int $id): ?Order;
    public function findOrFail(int $id): Order;
    public function create(array $data): Order;
    public function update(Order $order, array $data): Order;
    public function delete(Order $order): bool;
    public function getByUser(int $userId, int $perPage = 15): LengthAwarePaginator;
    public function getRecentVipOrders(string $status, int $days, ?int $paginate = null): Collection|LengthAwarePaginator;
    public function getTotalsByStatus(): array;
}

Реализация

php
// app/Repositories/EloquentOrderRepository.php
namespace App\Repositories;
use App\Models\Order;
use App\Repositories\Contracts\OrderRepositoryInterface;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
class EloquentOrderRepository implements OrderRepositoryInterface
{
    public function __construct(
        private Order $model
    ) {}
    public function find(int $id): ?Order
    {
        return $this->model->find($id);
    }
    public function findOrFail(int $id): Order
    {
        return $this->model->findOrFail($id);
    }
    public function create(array $data): Order
    {
        return $this->model->create($data);
    }
    public function update(Order $order, array $data): Order
    {
        $order->update($data);
        return $order->fresh();
    }
    public function delete(Order $order): bool
    {
        return $order->delete();
    }
    public function getByUser(int $userId, int $perPage = 15): LengthAwarePaginator
    {
        return $this->model
            ->where('user_id', $userId)
            ->with(['items.product'])
            ->orderBy('created_at', 'desc')
            ->paginate($perPage);
    }
    public function getRecentVipOrders(
        string $status,
        int $days,
        ?int $paginate = null
    ): Collection|LengthAwarePaginator {
        $query = $this->model
            ->with(['items.product', 'user'])
            ->where('status', $status)
            ->where('created_at', '>=', now()->subDays($days))
            ->whereHas('user', fn($q) => $q->where('is_vip', true))
            ->orderBy('total', 'desc');
        return $paginate
            ? $query->paginate($paginate)
            : $query->get();
    }
    public function getTotalsByStatus(): array
    {
        return $this->model
            ->selectRaw('status, COUNT(*) as count, SUM(total) as sum')
            ->groupBy('status')
            ->pluck('sum', 'status')
            ->toArray();
    }
}

Регистрация

php
// app/Providers/RepositoryServiceProvider.php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use App\Repositories\Contracts\OrderRepositoryInterface;
use App\Repositories\EloquentOrderRepository;
class RepositoryServiceProvider extends ServiceProvider
{
    public array $bindings = [
        OrderRepositoryInterface::class => EloquentOrderRepository::class,
    ];
}

Использование

php
// app/Http/Controllers/OrderController.php
namespace App\Http\Controllers;
use App\Repositories\Contracts\OrderRepositoryInterface;
class OrderController extends Controller
{
    public function __construct(
        private OrderRepositoryInterface $orders
    ) {}
    public function index()
    {
        $orders = $this->orders->getByUser(auth()->id());
        return view('orders.index', compact('orders'));
    }
    public function show(int $id)
    {
        $order = $this->orders->findOrFail($id);
        $this->authorize('view', $order);
        return view('orders.show', compact('order'));
    }
}

Repository + кэширование

php
// app/Repositories/CachedOrderRepository.php
namespace App\Repositories;
use App\Models\Order;
use App\Repositories\Contracts\OrderRepositoryInterface;
use Illuminate\Support\Facades\Cache;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
class CachedOrderRepository implements OrderRepositoryInterface
{
    public function __construct(
        private EloquentOrderRepository $repository,
        private int $ttl = 3600
    ) {}
    public function find(int $id): ?Order
    {
        return Cache::tags(['orders'])->remember(
            "order.{$id}",
            $this->ttl,
            fn() => $this->repository->find($id)
        );
    }
    public function findOrFail(int $id): Order
    {
        return Cache::tags(['orders'])->remember(
            "order.{$id}",
            $this->ttl,
            fn() => $this->repository->findOrFail($id)
        );
    }
    public function create(array $data): Order
    {
        $order = $this->repository->create($data);
        // Сбрасываем кэш списков
        Cache::tags(['orders', 'order-lists'])->flush();
        return $order;
    }
    public function update(Order $order, array $data): Order
    {
        $result = $this->repository->update($order, $data);
        // Сбрасываем кэш этого заказа
        Cache::tags(['orders'])->forget("order.{$order->id}");
        Cache::tags(['order-lists'])->flush();
        return $result;
    }
    public function delete(Order $order): bool
    {
        $id = $order->id;
        $result = $this->repository->delete($order);
        Cache::tags(['orders'])->forget("order.{$id}");
        Cache::tags(['order-lists'])->flush();
        return $result;
    }
    public function getByUser(int $userId, int $perPage = 15): LengthAwarePaginator
    {
        // Пагинацию обычно не кэшируем
        return $this->repository->getByUser($userId, $perPage);
    }
    public function getRecentVipOrders(
        string $status,
        int $days,
        ?int $paginate = null
    ): Collection|LengthAwarePaginator {
        if ($paginate) {
            return $this->repository->getRecentVipOrders($status, $days, $paginate);
        }
        $cacheKey = "orders.vip.{$status}.{$days}";
        return Cache::tags(['orders', 'order-lists'])->remember(
            $cacheKey,
            $this->ttl,
            fn() => $this->repository->getRecentVipOrders($status, $days)
        );
    }
    public function getTotalsByStatus(): array
    {
        return Cache::tags(['orders', 'order-stats'])->remember(
            'orders.totals_by_status',
            $this->ttl,
            fn() => $this->repository->getTotalsByStatus()
        );
    }
}

Переключение на кэширующий репозиторий

php
// app/Providers/RepositoryServiceProvider.php
public function register(): void
{
    $this->app->singleton(OrderRepositoryInterface::class, function ($app) {
        $eloquentRepo = new EloquentOrderRepository(new Order());
        // В production используем кэширующий декоратор
        if (app()->environment('production')) {
            return new CachedOrderRepository($eloquentRepo);
        }
        return $eloquentRepo;
    });
}

Упрощённый подход: Query Scopes + Service

⚠️ Важно

Альтернатива Repository: Для многих проектов достаточно Query Scopes в модели + Service Layer. Repository — это не обязательно.

php
// app/Models/Order.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;
class Order extends Model
{
    // Скоупы вместо Repository
    public function scopePending(Builder $query): Builder
    {
        return $query->where('status', 'pending');
    }
    public function scopeVipCustomers(Builder $query): Builder
    {
        return $query->whereHas('user', fn($q) => $q->where('is_vip', true));
    }
    public function scopeRecentDays(Builder $query, int $days): Builder
    {
        return $query->where('created_at', '>=', now()->subDays($days));
    }
    public function scopeWithDetails(Builder $query): Builder
    {
        return $query->with(['items.product', 'user']);
    }
}
// Использование
$orders = Order::query()
    ->pending()
    ->vipCustomers()
    ->recentDays(7)
    ->withDetails()
    ->orderBy('total', 'desc')
    ->paginate(20);

Итоги

ПодходКогда использовать
Eloquent напрямуюПростые запросы, небольшие проекты
Query ScopesПереиспользуемые условия фильтрации
RepositoryСложные запросы, кэширование, абстракция
Repository + Cache DecoratorHighload, частые одинаковые запросы

Практические рекомендации.

Начинайте без репозитория. Eloquent плюс скоупы покрывают большинство задач, а ввести слой позже проще, чем убрать лишний.

Не дублируйте API Eloquent в интерфейсе. Репозиторий с методами where(), orderBy() и with() — это Eloquent с лишним шагом. Методы репозитория должны называться на языке предметной области: getActiveForCatalog(), findByVendorCode(). Если метод нельзя назвать без упоминания SQL, он не относится к репозиторию.

Возвращайте то, что не течёт. Модель Eloquent, возвращённая из репозитория, приносит с собой все ленивые связи и возможность сохранения — то есть вызывающий код может обойти ваш слой, даже не заметив. Строго это лечится DTO, но цена высока; практический компромисс — договорённость в команде, что модели из репозитория только читают.

Кэширующий декоратор — главная выгода. Возможность подменить реализацию в контейнере одной строкой и получить кэширование без правки контроллеров окупает весь слой. Если вы вводите Repository, вводите его ради этого.

💡 Совет

Про тестирование — с оговоркой. Аргумент «репозиторий позволяет тестировать без базы» верен, но подмена репозитория мокой означает, что запросы не проверяются вообще: тест зелёный, а SQL сломан. В проектах на Laravel с их удобной работой с SQLite в памяти интеграционный тест с реальной базой часто и быстрее в написании, и полезнее. Мока репозитория оправдана там, где база действительно медленная или запрос тяжёлый.

🚀

Хотите такое же решение?

Настрою окружение под ваш проект, учту специфику инфраструктуры и обучу команду.

Обсудить проект →
Бесплатная консультация · Ответ в течение дня

Комментарии

Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.