Repository — самый спорный паттерн в мире Laravel. Одна половина сообщества считает его обязательным слоем чистой архитектуры, другая — бессмысленной обёрткой над Eloquent, который сам по себе уже реализует Active Record.
Правы обе стороны, но в разных проектах. Ниже — попытка провести границу и честно назвать, что вы теряете, вводя этот слой.
Нужен ли Repository в Laravel?
Споры о Repository в Laravel не утихают. Eloquent уже является паттерном Active Record и предоставляет удобный API. Зачем ещё один слой?
Аргументы ЗА:
- Абстракция от ORM (можно заменить Eloquent)
- Централизация сложных запросов
- Упрощение тестирования
- Единое место для кэширования
Аргументы ПРОТИВ:
- Дублирование Eloquent API
- Лишний слой абстракции
- Laravel и так тестируется легко
- Никто не меняет ORM в реальных проектах
Моё мнение: Repository оправдан для сложных запросов и кэширования. Для простого CRUD — избыточен.
Когда Repository нужен
// ❌ Плохо — сложный запрос размазан по контроллерам
// В 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();// ✅ Хорошо — запрос в одном месте
$orders = $orderRepository->getRecentVipOrders(
status: 'pending',
days: 7,
paginate: 20
);Аргумент «Repository позволит сменить Eloquent на что-то другое» на практике не работает. Он звучит убедительно, но: смена ORM не происходит примерно никогда, а если и происходит — интерфейс репозитория всё равно придётся переписывать, потому что он неизбежно протекает деталями Eloquent (коллекции, paginate(), ленивые связи в возвращаемых моделях).
Реальные причины ввести Repository приземлённее и их всего три: кэширование через декоратор без изменения вызывающего кода, подмена в тестах без обращения к базе, и сложные запросы, которым не место ни в контроллере, ни в модели. Если ни одна из трёх не про вас — слой не нужен, и Eloquent со скоупами будет честнее.
Простая реализация
Интерфейс
// 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;
}Реализация
// 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();
}
}Регистрация
// 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,
];
}Использование
// 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 + кэширование
// 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()
);
}
}Переключение на кэширующий репозиторий
// 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 — это не обязательно.
// 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 Decorator | Highload, частые одинаковые запросы |
Практические рекомендации.
Начинайте без репозитория. Eloquent плюс скоупы покрывают большинство задач, а ввести слой позже проще, чем убрать лишний.
Не дублируйте API Eloquent в интерфейсе. Репозиторий с методами where(), orderBy() и with() — это Eloquent с лишним шагом. Методы репозитория должны называться на языке предметной области: getActiveForCatalog(), findByVendorCode(). Если метод нельзя назвать без упоминания SQL, он не относится к репозиторию.
Возвращайте то, что не течёт. Модель Eloquent, возвращённая из репозитория, приносит с собой все ленивые связи и возможность сохранения — то есть вызывающий код может обойти ваш слой, даже не заметив. Строго это лечится DTO, но цена высока; практический компромисс — договорённость в команде, что модели из репозитория только читают.
Кэширующий декоратор — главная выгода. Возможность подменить реализацию в контейнере одной строкой и получить кэширование без правки контроллеров окупает весь слой. Если вы вводите Repository, вводите его ради этого.
Про тестирование — с оговоркой. Аргумент «репозиторий позволяет тестировать без базы» верен, но подмена репозитория мокой означает, что запросы не проверяются вообще: тест зелёный, а SQL сломан. В проектах на Laravel с их удобной работой с SQLite в памяти интеграционный тест с реальной базой часто и быстрее в написании, и полезнее. Мока репозитория оправдана там, где база действительно медленная или запрос тяжёлый.
Комментарии
Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.