Status: Active Pattern: Decorator + Repository Example Model:
Post
This guide details our robust Repository Pattern implementation, enhanced with the Decorator Pattern to handle Caching and Event dispatching transparently.
Click to expand/collapse
- π Repository Pattern with Caching & Events
- π Table of Contents
- 1. Architecture Overview
- 2. Why This Architecture?
- 3. Base Repository API Reference
- 4. Component Breakdown (The "Files")
- 5. Binding It All Together - π What is it? - π How to use it? - π Sample
- 6. Service Layer Integration
- 7. Usage Example
- 8. How to Create a New Repository
- 9. Directory Structure
- 10. Pros and Cons
- 11. Advanced Features
We use a Layered Decorator Stack to wrap the actual Eloquent Repository.
Used for find, getByIds, and custom read methods where caching is enabled.
graph TD
App[Controller/Service] -->|1. Call| Eventful[EventfulDecorator]
Eventful -->|2. Delegate| Cached[CachedDecorator]
subgraph "Read Operation"
Cached -->|3. Check| CacheStore[(Cache Store)]
CacheStore -- Hit --> Cached
CacheStore -- Miss --> Cached
Cached -->|4. Fetch| Real[EloquentRepository]
Real -->|5. Query| DB[(Database)]
Real -- Result --> Cached
Cached -->|6. Store| CacheStore
end
Cached -- Result --> Eventful
Eventful -- Result --> App
Used when:
- Writing:
create,update,delete - Locking:
lockForUpdate(),sharedLock() - Explicit Bypass: The repository logic purposely ignores cache.
In this flow, the Cache Decorator acts as a transparent pass-through or is explicitly disabled (via skipCache).
graph TD
App[Controller/Service] -->|1. Call| Eventful[EventfulDecorator]
Eventful -->|2. Pass-through| Cached[CachedDecorator]
subgraph "Bypass/Write Operation"
Cached -->|3. Skip Cache| Real[EloquentRepository]
Real -->|4. Query| DB[(Database)]
Real -- Result --> Cached
end
Cached -- Result --> Eventful
subgraph "Cache Invalidation"
Eventful -->|5. Dispatch| Event[RepositoryChanged]
Event -->|6. Listen| Listener[CacheInvalidator]
Listener -->|7. Flush| CacheStore[(Cache Store)]
end
Eventful -- Result --> App
- Separation of Concerns: Your Controller doesn't care about Caching. Your Eloquent model doesn't care about Events. Each layer does exactly one thing.
- Performance by Default: Read operations are cached automatically.
- Consistency: Every model follows the exact same flow.
- Testability: You can easily mock the
PostRepositoryInterfacein your unit tests, ignoring the database entirely.
Every repository extending BaseRepository inherits these methods.
| Method | Description | Usage |
|---|---|---|
find |
Find record by ID or null. | $repo->find($id, $cols, $relations) |
findOrFail |
Find by ID or throw 404. | $repo->findOrFail($id) |
findBy |
Find one by attribute. | $repo->findBy('slug', 'my-post') |
getByIds |
Fetch collection from array. | $repo->getByIds([1, 2, 3]) |
paginate |
Paginate with filters/sort. | $repo->paginate(15, ['*'], ['status' => 'active']) |
| Method | Description | Usage |
|---|---|---|
create |
Store new record. | $repo->create($data) |
update |
Update record by ID. | $repo->update($id, $data) |
delete |
Delete one record. | $repo->delete($id) |
deleteMany |
Bulk delete (Efficient). | $repo->deleteMany([1, 2, 3]) |
| Method | Description | Usage |
|---|---|---|
lockForUpdate |
SELECT ... FOR UPDATE | $repo->lockForUpdate()->find($id) |
sharedLock |
LOCK IN SHARE MODE | $repo->sharedLock()->find($id) |
| Method | Description | Usage |
|---|---|---|
restore |
Restore soft-deleted record. | $repo->restore($id) |
forceDelete |
Permanent removal. | $repo->forceDelete($id) |
We will use the Post model as our primary example.
File: app/Repositories/Contracts/PostRepositoryInterface.php
A PHP Interface that lists every public method your repository must support. It defines the "What", not the "How".
- Dependency Injection: We type-hint the interface, not the class. This lets Laravel swap the implementation (e.g., adding cache) without changing your controller code.
- Mocking: In tests, we can easily create a "FakePostRepository" that implements this interface, making tests 100x faster.
Extend BaseRepositoryInterface and add your custom method signatures.
interface PostRepositoryInterface extends BaseRepositoryInterface
{
// β
Good: Typed arguments and return types
public function getPublishedPosts(int $limit = 10): LengthAwarePaginator;
// β
Good: Specific business query
public function findBySlug(string $slug): ?Post;
}File: app/Repositories/Eloquent/PostRepository.php
The actual code that speaks to the Database. This is where you write Eloquent queries, scopes, and joins.
To keep complex SQL logic out of your Controllers and Services. If you need to change how "Published Posts" are found, you change it here, in one place.
Extend BaseRepository and implement your interface.
class PostRepository extends BaseRepository implements PostRepositoryInterface
{
// Mandatory: Define the model class
public function model(): string
{
return Post::class;
}
public function getPublishedPosts(int $limit = 10): LengthAwarePaginator
{
// π‘ Tip: Use scopes defined in your Model for cleaner code
return $this->query()
->with(['author', 'tags']) // Eager load relationships
->where('status', 'published')
->where('published_at', '<=', now())
->orderByDesc('published_at')
->paginate($limit);
}
}File: app/Repositories/Cache/CachedPostRepository.php
A wrapper class that intercepts calls to your repository. If the result is in the cache (Redis), it returns it immediately. If not, it calls the real repository and saves the result.
To drastically improve read performance. A complex query might take 100ms; pulling it from Redis takes 1ms.
Extend CachedRepository. Override only the Read methods you want to cache. Wrap the call in $this->remember().
class CachedPostRepository extends CachedRepository implements PostRepositoryInterface
{
public function getPublishedPosts(int $limit = 10): LengthAwarePaginator
{
// π Key Strategy: Include ALL variables that affect the result in the key parts.
// If we didn't include 'page', page 2 would show page 1's cached data!
return $this->remember(
method: 'getPublishedPosts',
parts: [$limit, request('page', 1)],
callback: fn() => $this->inner->getPublishedPosts($limit)
);
}
}File: app/Repositories/Decorators/EventfulPostRepository.php
A wrapper that triggers a RepositoryChanged event whenever a Write operation (Create, Update, Delete) occurs.
- Cache Clearing: When data changes, we must delete the old cache. This decorator does it automatically.
- Decoupling: You don't need to manually call
$cache->forget()in your controller.
Extend EventfulRepository.
- Usually, you leave this empty! The parent class automatically handles
create,update,delete. - Only add code here if you have a custom write method.
class EventfulPostRepository extends EventfulRepository implements PostRepositoryInterface
{
// Standard methods (create, update, delete) are ALREADY handled by the parent.
// π‘ Example: A custom write method needs to be wrapped manually
public function restore(int|string $id): bool
{
$result = $this->inner->restore($id);
// Dispatch event to clear cache
Event::dispatch(new RepositoryChanged($this->namespace));
return $result;
}
}File: app/Providers/RepositoryServiceProvider.php
The configuration file that tells Laravel how to wire these 4 files together.
Use the bindRepo helper in the register method.
public function register(): void
{
// This single line does the magic:
// Interface -> Eventful -> Cached -> Eloquent
$this->bindRepo(PostRepositoryInterface::class, PostRepository::class);
}While Repositories handle Data Access, Services handle Business Logic.
The Controller should generally talk to the Service, not the Repository directly (unless it's a simple read operation).
graph LR
User -->|HTTP Request| Controller
subgraph "Application Layer"
Controller -->|1. Validate & DTO| Service
Service -->|2. Business Logic| Service
Service -->|3. Call| Repository
end
subgraph "Data Layer"
Repository -->|4. Query| DB[(Database)]
DB -->|5. Result| Repository
end
Repository -->|6. Model| Service
Service -->|7. Transform/Return| Controller
Controller -->|8. JSON/View| User
- Multiple Repositories: A
CreateOrderServicemight need to talk toOrderRepository,ProductRepository, andUserRepository. The Service orchestrates this. - Transactions: The Service defines the transaction boundary (using
TransactionTrait). - Complex Validation: Business rules that go beyond simple field validation live here.
Example Service:
class PostService
{
use TransactionTrait;
public function __construct(
protected PostRepositoryInterface $postRepo,
protected TagRepositoryInterface $tagRepo
) {}
public function createWithTags(array $data, array $tags): Post
{
// π‘οΈ Transaction ensures atomic integrity
return $this->transaction(function() use ($data, $tags) {
// 1. Create Post (Write)
$post = $this->postRepo->create($data);
// 2. Sync Tags (Orchestration)
if (!empty($tags)) {
$post->tags()->sync($tags);
}
return $post;
});
}
}In your Controller:
class PostController extends Controller
{
public function __construct(
protected PostService $postService,
protected PostRepositoryInterface $postRepo // Optional: Direct read for simple lists
) {}
public function index()
{
// β
Read: Safe to use Repository directly for simple listing
return $this->postRepo->paginate();
}
public function store(Request $request)
{
// π‘οΈ Write: Always go through Service for business logic/transactions
$this->postService->createWithTags(
$request->validated(),
$request->input('tags', [])
);
}
}We have a dedicated Artisan command to generate all 4 files and register the binding for you.
# Standard Repository
php artisan make:repo Post
# With Soft Deletes support
php artisan make:repo Post --soft- Create
Contracts/PostRepositoryInterface.php - Create
Eloquent/PostRepository.php - Create
Cache/CachedPostRepository.php - Create
Decorators/EventfulPostRepository.php - Register in
RepositoryServiceProvider.php
Your app/Repositories folder will look like this:
app/Repositories/
βββ Contracts/ # 1. Interfaces
βββ Eloquent/ # 2. Database Logic
βββ Cache/ # 3. Cache Logic
βββ Decorators/ # 4. Event Logic
| Feature | Pros | Cons |
|---|---|---|
| Separation | Code is clean, focused, and follows SOLID principles. | More files to manage (4 files per model). |
| Caching | Automatic, robust, and centralized. | Developers must remember to add cache wrappers for custom methods. |
| Performance | Significant speedup for read-heavy apps. | Slight overhead in function calls (negligible). |
To keep your Services clean, we provide traits that handle common deletion patterns.
Usage in Service:
use App\Services\Traits\BulkDeleteServiceTrait;
use App\Services\Traits\SoftDeleteServiceTrait;
class PostService
{
use BulkDeleteServiceTrait; // Adds: bulkDelete(array $ids)
use SoftDeleteServiceTrait; // Adds: restore($id), bulkRestore($ids), forceDelete($id)...
}We support database locking with a fluent API that works seamlessly with our Decorator stack (Cache/Event).
Key Features:
- Dynamic Switching: You can switch on locking per query.
- Cache Safety: When you lock, the Cache is automatically bypassed for that request to ensure you get fresh data.
- Auto-Reset: The lock is consumed by the next query and immediately reset.
Examples:
// 1. Pessimistic Write Lock (SELECT ... FOR UPDATE)
// This will bypass Redis, lock the row in MySQL, and return the model.
$post = $this->repository->lockForUpdate()->find($id);
// 2. Shared Lock (SELECT ... LOCK IN SHARE MODE)
$post = $this->repository->sharedLock()->find($id);
// 3. Transaction with Lock
$this->transaction(function() use ($id) {
// Lock fresh data
$post = $this->repository->lockForUpdate()->find($id);
// ... modify ...
$post->save();
});