zerospam/laravel-cacheable-repository
zerospam/laravel-cacheable-repository adds transparent caching to your Laravel repositories with minimal code changes. Automatically cache query results, invalidate entries by tag, and keep your data layer clean, fast, and easy to maintain with any Laravel cache driver.
Install and sanity-check first. The package name (zerospam/laravel-cacheable-repository) and the linked repository (andersao/l5-repository) are not the same thing — the latter is the original Laravel 5 repository-abstraction package that prettus/l5-repository forked. Before you write a line of code, run composer show and confirm which tree you actually get (prettus/l5-repository vs. the zerospam wrapper) — the API is nearly identical, but the caching trait and Laravel version constraints differ.
composer require zerospam/laravel-cacheable-repository
php artisan vendor:publish --provider="Prettus\Repository\Providers\RepositoryServiceProvider"
That publishes config/repository.php (cache key, cache minutes, default criteria, presenter defaults). Read it once end-to-end — most “why isn’t this cached / why is this filtered” bugs trace back to a value in that file.
Where to look first. Two files: config/repository.php, and Prettus\Repository\Eloquent\BaseRepository. The base class defines the whole contract (all, find, findWhere, paginate, create, update, delete, with, scopeQuery, criteria stack). Everything else — criteria, presenters, caching — layers on top of those methods.
First use case. Take the messiest controller in your app and replace its inline Eloquent with a repository:
interface UserRepositoryInterface extends RepositoryInterface {}
class UserRepository extends BaseRepository implements UserRepositoryInterface
{
public function model() { return User::class; }
}
Bind it in a service provider:
$this->app->bind(UserRepositoryInterface::class, UserRepository::class);
Then in the controller: $this->users->paginate(20). That is the whole win — you now have one seam to add filters, caching, and eager loading without touching controllers. Do not try to build a criteria library on day one; get one resource working, then expand.
Interface-first binding, always. Inject UserRepositoryInterface, never UserRepository. The abstraction is what makes the repository worth having — if controllers depend on the concrete class you have added a layer of indirection with none of the testability payoff.
Criteria for cross-cutting filters. Criteria are the reason to pick this package over a bare repository class. Write one per filter, not one per feature:
class ActiveOnlyCriteria implements CriteriaInterface
{
public function apply($model, RepositoryInterface $repository)
{
return $model->where('status', 'active');
}
}
Apply them per-request, not in the constructor:
$this->users->pushCriteria(new ActiveOnlyCriteria())->paginate(20);
Constructor-pushed criteria are fine for a global scope-like default (e.g. tenant filtering), but anything request-dependent belongs at the call site so it is visible.
RequestCriteria for query-string driven listing endpoints. Map ?search=, ?filter=, ?orderBy= directly from the request. Configure searchable and filterable arrays on the repository rather than guarding in controllers — this is the highest-leverage piece of the package for admin CRUD screens, and it removes a pile of hand-written if ($request->has('q')) branches.
Caching: wrap reads, invalidate on writes. The cacheable layer typically caches by method + arguments. The pattern that works:
public function findActiveForTenant(int $tenantId)
{
return $this->skipCriteria()->scopeQuery(fn ($q) => $q->where('tenant_id', $tenantId))
->remember(10)
->all();
}
And in every mutating path — create, update, delete, and any custom write method — call $this->clearCache() or the wrapper’s equivalent. If your custom repository method writes and forgets to invalidate, you get stale reads for the full TTL and no error to point at it.
Presenters/Transformers for API output. setPresenter() at the repository level gives you a consistent response shape and keeps serialization out of controllers. Useful when the same model is exposed on three endpoints with different field sets.
Test against the interface. Because controllers type-hint the interface, you can bind a fake in tests and assert behaviour without a database. This is the practical payoff of the layering, not purity.
Criteria accumulate on the instance. pushCriteria() mutates the repository; if it is a singleton, filters leak into the next request in long-running workers (Octane, queue jobs). Use skipCriteria(), resetCriteria(), or bind the repository as non-singleton. This is the number one source of “why is my other controller filtering by status?”
Cache keys are argument-derived. Passing objects, closures, or timestamps into cached methods produces garbage keys and near-zero hit rates. Pass scalars, and cache only methods you call on hot read paths — blanket caching hides query problems rather than fixing them.
Invalidation is manual. There is no Eloquent observer wired for you. If anything else writes to the table (a job, an artisan command, a second service), the cache goes stale. Either route all writes through the repository or accept short TTLs.
findWhere and where behave differently. where sets the query and returns $this for chaining; findWhere executes. Mixing them produces silent empty results. Same caution for scopeQuery — it hands you the builder, and what you return matters.
Eager loading belongs at the call site, not the model. ->with(['posts', 'roles'])->paginate() — repositories make N+1 easy to fix but also easy to hide. Check with DB::listen or Telescope that your listing endpoints issue the queries you expect.
Don’t hand builders back to controllers. Returning $repository->makeModel() or a raw builder defeats the abstraction and makes caching impossible. If you need something bespoke, add a method to the interface.
Version drift is the real risk. The linked repo is a Laravel 5-era codebase; prettus/l5-repository has since been maintained separately, and newer Laravel releases (10/11/12) have diverged on container resolution and pagination. Verify the package’s composer.json constraints against your Laravel version before standardising on it internally, and check whether the zerospam fork is actually maintained — the future-dated release metadata here is a signal to look at the commit history rather than trust the tag.
Debugging recipe. When results are wrong: $repo->skipCriteria()->skipPresenter()->all() to see the unfiltered baseline, then re-add criteria one at a time. Also check config/repository.php — a default criteria or a cache-enabled flag there will make every call site look buggy simultaneously.
Extension points. Override model(), boot() (for global repository setup), presenter(), and validator() — those four cover most customisation. Anything deeper means subclassing BaseRepository, at which point ask whether the abstraction is still earning its keep.
How can I help you explore Laravel packages today?