Laravel Service Repository Pattern: Cách triển khai đúng
Hướng dẫn chi tiết cách implement Service Repository Pattern trong Laravel - Khi nào nên dùng, cấu trúc folder, best practices và ví dụ thực tế.
Giới thiệu
Khi Laravel project phát triển lớn, Fat Controllers và God Models trở thành vấn đề nghiêm trọng. Service Repository Pattern là giải pháp phổ biến để:
- Tách biệt business logic khỏi Controllers
- Abstract database operations
- Dễ test và maintain hơn
Bài viết này sẽ hướng dẫn cách implement đúng - không chỉ copy boilerplate code.
Khi nào cần Service Repository Pattern?
✅ Nên dùng khi:
- Project có >= 3 developers - Cần conventions rõ ràng
- Business logic phức tạp - Nhiều hơn CRUD đơn giản
- Nhiều data sources - API, database, cache
- Cần unit testing - Mock dependencies dễ dàng
❌ Không cần khi:
- MVP / Small projects - Over-engineering
- CRUD đơn giản - Laravel Eloquent là đủ
- Solo developer - Tự nhớ được code
Folder Structure
app/
├── Http/
│ └── Controllers/
│ └── Api/
│ └── ProductController.php
├── Services/
│ ├── ProductService.php
│ └── Contracts/
│ └── ProductServiceInterface.php
├── Repositories/
│ ├── ProductRepository.php
│ └── Contracts/
│ └── ProductRepositoryInterface.php
├── Models/
│ └── Product.php
└── Providers/
└── RepositoryServiceProvider.php
Step-by-step Implementation
Step 1: Define Repository Interface
<?php
// app/Repositories/Contracts/ProductRepositoryInterface.php
namespace App\Repositories\Contracts;
use App\Models\Product;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
interface ProductRepositoryInterface
{
public function all(): Collection;
public function paginate(int $perPage = 15): LengthAwarePaginator;
public function find(int $id): ?Product;
public function findOrFail(int $id): Product;
public function create(array $data): Product;
public function update(Product $product, array $data): Product;
public function delete(Product $product): bool;
public function findBySlug(string $slug): ?Product;
public function getActiveProducts(): Collection;
}
Step 2: Implement Repository
<?php
// app/Repositories/ProductRepository.php
namespace App\Repositories;
use App\Models\Product;
use App\Repositories\Contracts\ProductRepositoryInterface;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\LengthAwarePaginator;
class ProductRepository implements ProductRepositoryInterface
{
public function __construct(
protected Product $model
) {}
public function all(): Collection
{
return $this->model->all();
}
public function paginate(int $perPage = 15): LengthAwarePaginator
{
return $this->model
->with(['category', 'images'])
->latest()
->paginate($perPage);
}
public function find(int $id): ?Product
{
return $this->model->find($id);
}
public function findOrFail(int $id): Product
{
return $this->model->findOrFail($id);
}
public function create(array $data): Product
{
return $this->model->create($data);
}
public function update(Product $product, array $data): Product
{
$product->update($data);
return $product->fresh();
}
public function delete(Product $product): bool
{
return $product->delete();
}
public function findBySlug(string $slug): ?Product
{
return $this->model
->where('slug', $slug)
->with(['category', 'images', 'reviews'])
->first();
}
public function getActiveProducts(): Collection
{
return $this->model
->where('is_active', true)
->where('stock', '>', 0)
->get();
}
}
Step 3: Define Service Interface
<?php
// app/Services/Contracts/ProductServiceInterface.php
namespace App\Services\Contracts;
use App\Models\Product;
use Illuminate\Http\UploadedFile;
interface ProductServiceInterface
{
public function createProduct(array $data, ?UploadedFile $image = null): Product;
public function updateProduct(Product $product, array $data): Product;
public function deleteProduct(Product $product): void;
public function updateStock(Product $product, int $quantity): void;
public function applyDiscount(Product $product, float $percentage): Product;
}
Step 4: Implement Service
<?php
// app/Services/ProductService.php
namespace App\Services;
use App\Models\Product;
use App\Repositories\Contracts\ProductRepositoryInterface;
use App\Services\Contracts\ProductServiceInterface;
use App\Exceptions\InsufficientStockException;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class ProductService implements ProductServiceInterface
{
public function __construct(
protected ProductRepositoryInterface $productRepository
) {}
public function createProduct(array $data, ?UploadedFile $image = null): Product
{
return DB::transaction(function () use ($data, $image) {
// Generate slug
$data['slug'] = $this->generateUniqueSlug($data['name']);
// Handle image upload
if ($image) {
$data['image'] = $this->uploadImage($image);
}
// Create product
$product = $this->productRepository->create($data);
// Dispatch events
event(new ProductCreated($product));
return $product;
});
}
public function updateProduct(Product $product, array $data): Product
{
return DB::transaction(function () use ($product, $data) {
// Update slug if name changed
if (isset($data['name']) && $data['name'] !== $product->name) {
$data['slug'] = $this->generateUniqueSlug($data['name']);
}
return $this->productRepository->update($product, $data);
});
}
public function deleteProduct(Product $product): void
{
DB::transaction(function () use ($product) {
// Delete image
if ($product->image) {
Storage::disk('public')->delete($product->image);
}
// Soft delete related records if needed
$product->reviews()->delete();
// Delete product
$this->productRepository->delete($product);
});
}
public function updateStock(Product $product, int $quantity): void
{
$newStock = $product->stock + $quantity;
if ($newStock < 0) {
throw new InsufficientStockException(
"Insufficient stock. Available: {$product->stock}, Requested: " . abs($quantity)
);
}
$this->productRepository->update($product, ['stock' => $newStock]);
// Check low stock alert
if ($newStock <= 5) {
event(new LowStockAlert($product));
}
}
public function applyDiscount(Product $product, float $percentage): Product
{
$discountedPrice = $product->price * (1 - $percentage / 100);
return $this->productRepository->update($product, [
'discounted_price' => round($discountedPrice, 2),
'discount_percentage' => $percentage,
]);
}
protected function generateUniqueSlug(string $name): string
{
$slug = Str::slug($name);
$count = Product::where('slug', 'like', $slug . '%')->count();
return $count ? "{$slug}-{$count}" : $slug;
}
protected function uploadImage(UploadedFile $image): string
{
return $image->store('products', 'public');
}
}
Step 5: Bind Interfaces in Service Provider
<?php
// app/Providers/RepositoryServiceProvider.php
namespace App\Providers;
use App\Repositories\Contracts\ProductRepositoryInterface;
use App\Repositories\ProductRepository;
use App\Services\Contracts\ProductServiceInterface;
use App\Services\ProductService;
use Illuminate\Support\ServiceProvider;
class RepositoryServiceProvider extends ServiceProvider
{
public array $bindings = [
ProductRepositoryInterface::class => ProductRepository::class,
ProductServiceInterface::class => ProductService::class,
];
public function register(): void
{
foreach ($this->bindings as $interface => $implementation) {
$this->app->bind($interface, $implementation);
}
}
}
Đăng ký provider trong config/app.php:
'providers' => [
// ...
App\Providers\RepositoryServiceProvider::class,
],
Step 6: Use in Controller
<?php
// app/Http/Controllers/Api/ProductController.php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreProductRequest;
use App\Http\Requests\UpdateProductRequest;
use App\Http\Resources\ProductResource;
use App\Models\Product;
use App\Repositories\Contracts\ProductRepositoryInterface;
use App\Services\Contracts\ProductServiceInterface;
use Illuminate\Http\JsonResponse;
class ProductController extends Controller
{
public function __construct(
protected ProductRepositoryInterface $productRepository,
protected ProductServiceInterface $productService
) {}
public function index(): JsonResponse
{
$products = $this->productRepository->paginate(20);
return ProductResource::collection($products)->response();
}
public function store(StoreProductRequest $request): JsonResponse
{
$product = $this->productService->createProduct(
$request->validated(),
$request->file('image')
);
return ProductResource::make($product)
->response()
->setStatusCode(201);
}
public function show(Product $product): JsonResponse
{
return ProductResource::make($product)->response();
}
public function update(UpdateProductRequest $request, Product $product): JsonResponse
{
$product = $this->productService->updateProduct(
$product,
$request->validated()
);
return ProductResource::make($product)->response();
}
public function destroy(Product $product): JsonResponse
{
$this->productService->deleteProduct($product);
return response()->json(null, 204);
}
}
Testing
Unit Test cho Service
<?php
// tests/Unit/Services/ProductServiceTest.php
namespace Tests\Unit\Services;
use App\Models\Product;
use App\Repositories\Contracts\ProductRepositoryInterface;
use App\Services\ProductService;
use App\Exceptions\InsufficientStockException;
use Mockery;
use Tests\TestCase;
class ProductServiceTest extends TestCase
{
protected ProductRepositoryInterface $mockRepository;
protected ProductService $service;
protected function setUp(): void
{
parent::setUp();
$this->mockRepository = Mockery::mock(ProductRepositoryInterface::class);
$this->service = new ProductService($this->mockRepository);
}
/** @test */
public function it_updates_stock_correctly(): void
{
$product = new Product(['stock' => 10]);
$this->mockRepository
->shouldReceive('update')
->once()
->with($product, ['stock' => 15])
->andReturn($product);
$this->service->updateStock($product, 5);
}
/** @test */
public function it_throws_exception_for_insufficient_stock(): void
{
$product = new Product(['stock' => 5]);
$this->expectException(InsufficientStockException::class);
$this->service->updateStock($product, -10);
}
}
Common Mistakes
❌ Repository chứa business logic
// ❌ Bad - Business logic trong Repository
public function createWithDiscount(array $data, float $discount): Product
{
$data['price'] = $data['price'] * (1 - $discount);
return $this->model->create($data);
}
// ✅ Good - Repository chỉ data access
public function create(array $data): Product
{
return $this->model->create($data);
}
❌ Service gọi Eloquent trực tiếp
// ❌ Bad - Bypass repository
public function getActiveProducts()
{
return Product::where('is_active', true)->get();
}
// ✅ Good - Dùng repository
public function getActiveProducts()
{
return $this->productRepository->getActiveProducts();
}
❌ Quá nhiều interfaces cho small projects
// ❌ Over-engineering - Không cần interface cho mọi thứ
interface ProductRepositoryInterface
interface CategoryRepositoryInterface
interface UserRepositoryInterface
// ... 50 interfaces cho CRUD đơn giản
// ✅ Consider: Base interface + specific methods
interface RepositoryInterface {
public function all();
public function find(int $id);
public function create(array $data);
// ...
}
Kết luận
Service Repository Pattern là powerful tool cho Laravel projects lớn, nhưng không phải silver bullet:
✅ Benefits:
- Separation of concerns
- Testability
- Maintainability
- Team collaboration
❌ Trade-offs:
- More files, more boilerplate
- Learning curve
- Over-engineering risk
Rule of thumb: Nếu project có > 20 models và > 3 developers - strongly consider pattern này. Nếu không, Eloquent models + Form Requests có thể đủ.
Thử Thách Kiến Thức Lịch Sử?
Khám phá hàng trăm câu hỏi trắc nghiệm lịch sử thú vị tại HistoQuiz. Vừa học vừa chơi, nâng cao kiến thức ngay hôm nay!