therockersenior/l5-repository
A Laravel repository pattern package that adds Criteria, Presenters, Fractal transformers, and caching to keep your controllers slim and your data layer reusable and testable across projects.
This package gives you a BaseRepository that wraps Eloquent, plus four composable layers around it: Criteria (query mutations), Presenters/Transformers (output shaping), Validators (write-time rules), and Cache (opt-in read caching). The day-to-day value is not "hiding SQL" — it's that ?search=, ?filter=, ?orderBy= and pagination become free on every list endpoint once a repository is wired up.
Install and publish config
composer require therockersenior/l5-repository
php artisan vendor:publish --provider="Prettus\Repository\Providers\RepositoryServiceProvider"
This is a fork of andersao/l5-repository (upstream: prettus/l5-repository). Before writing code, open the fork's composer.json and src/Providers/RepositoryServiceProvider.php — most forks keep the Prettus\ namespace and this provider name, but if it was renamed, every use statement below changes. Also check the illuminate/* constraints against your Laravel version; the 2.x line historically lagged new Laravel majors.
Then register the provider (skipped automatically on Laravel 5.5+ / 11+ package discovery) and add your binding provider:
// app/Providers/RepositoryServiceProvider.php
public function register()
{
$this->app->bind(
\App\Contracts\PostRepository::class,
\App\Repositories\PostRepositoryEloquent::class
);
}
Generate your first entity
php artisan make:entity Post --fillable="title,body,published_at"
It interactively asks which companions to build (migration, validator, presenter/transformer, controller) and prints the binding line to paste. If you prefer to build pieces individually: make:repository, make:criteria, make:presenter, make:transformer, make:validator.
First use case — a filterable index endpoint
// app/Repositories/PostRepositoryEloquent.php
class PostRepositoryEloquent extends BaseRepository implements PostRepository
{
protected $fieldSearchable = [
'title' => 'like',
'status',
'author.name', // relation-aware
];
public function boot()
{
parent::boot();
$this->pushCriteria(app(\Prettus\Repository\Criteria\RequestCriteria::class));
}
public function model(): string
{
return \App\Models\Post::class;
}
}
// app/Http/Controllers/PostController.php
public function index(PostRepository $posts)
{
return $posts->paginate(20);
}
You now get GET /posts?search=laravel&filter=status&orderBy=created_at&sortedBy=desc for free. That's the "aha" moment — start here, not with a full abstract domain layer.
The interface is intentionally broad and reads like Eloquent:
$posts->all(['id', 'title']);
$posts->find($id); // null on miss
$posts->findByField('slug', $slug); // Collection, not Model
$posts->findWhere(['status' => 'draft', 'author_id' => $userId]);
$posts->findWhereIn('id', $ids);
$posts->create($data);
$posts->update($data, $id);
$posts->updateOrCreate(['slug' => $slug], $data);
$posts->deleteWhere(['status' => 'spam']);
Inject the contract, never the Eloquent class. That is the whole point — swap the implementation, mock it in tests, or decorate it later.
Criteria are where business-specific query logic belongs, so it doesn't accumulate as if ($request->has(...)) in controllers.
class PublishedOnlyCriteria implements CriteriaInterface
{
public function apply($model, RepositoryInterface $repository)
{
return $model->whereNotNull('published_at')
->where('published_at', '<=', now());
}
}
Three ways to attach them:
// 1. Per call
$posts->pushCriteria(new PublishedOnlyCriteria)->paginate(15);
// 2. Always, from the repository's boot()
$this->pushCriteria(app(RequestCriteria::class));
$this->pushCriteria(app(PublishedOnlyCriteria::class));
// 3. Globally, via config/repository.php -> criteria.accepted
Because criteria stack, order matters. Compose from broad to narrow: tenant scope → authorization scope → RequestCriteria (user-driven) → business filters.
Wire it once and every index endpoint becomes queryable:
| Query string | Effect |
|---|---|
?search=foo |
LIKE across $fieldSearchable |
?searchFields=title:like;status:= |
Override field + operator |
?search=foo;bar&searchJoin=or |
OR between terms |
?filter=status;author_id |
Whitelist fields for equality filters |
?status=draft&author_id=3 |
Applied against the filter whitelist |
?orderBy=created_at&sortedBy=desc |
Sort |
?with=author,comments |
Eager load |
search also traverses relationships declared with dot notation (author.name) via whereHas. Note that filter is a whitelist — without it, arbitrary query params are ignored, which is a decent default but surprises people who expect all columns filterable.
Set a presenter on the repository and every return value gets transformed — including paginators.
class PostPresenter extends FractalPresenter
{
public function getTransformer(): PostTransformer
{
return new PostTransformer();
}
}
// in the repository
public function presenter(): string
{
return PostPresenter::class;
}
The TransformableTrait (transform()) is the lighter alternative if you don't want a full presenter stack. Escape hatch for internal callers that want raw models:
$posts->skipPresenter()->find($id);
BaseRepository calls beforeCreate / afterCreate / beforeUpdate / afterUpdate around writes, and runs the configured validator if one is present. Throw Prettus\Validator\Exceptions\ValidatorException (or let passesOrFail do it) with a MessageBag — your controller can catch it and return a 422 without any bespoke plumbing.
protected $rules = [
ValidatorInterface::RULE_CREATE => ['title' => 'required|max:255'],
ValidatorInterface::RULE_UPDATE => ['title' => 'sometimes|required|max:255'],
];
Caveat: validation support has moved between versions (older releases used the repository's own $rules). Check what your installed tag actually calls before relying on it — many teams disable it and use FormRequests instead.
use CacheableRepository; on the repository, flip cache.enabled in config, and reads go through the Laravel cache. Writes invalidate automatically when cache.clean.on.create/update/delete are true.
$posts->skipCache()->find($id); // bypass for one read
This is a "make the dashboard faster" tool, not a correctness tool. Don't reach for it on entities with write paths outside the repository.
RepositoryEntityCreated, RepositoryEntityUpdated, RepositoryEntityDeleted fire on writes with the repository and model attached. Good for activity logs, search-index sync, and webhook dispatch — and it keeps that logic out of the repository body.
Because everything goes through a contract, tests are straightforward:
$this->mock(PostRepository::class, function ($mock) {
$mock->shouldReceive('paginate')->once()->andReturn(collect());
});
For integration tests, point the repository at an in-memory SQLite connection via the model's $connection and assert on criteria behavior directly. Testing a criteria in isolation ($criteria->apply(Post::query(), $repo)) is faster and more precise than testing the HTTP endpoint.
The repository is stateful. with(), orderBy(), has(), whereHas() and pushCriteria() mutate the instance and persist across calls. A container-resolved repository is fine, but a singleton binding or a repository reused inside a loop will leak state between queries. Use resetModel() / resetCriteria() when in doubt, and never bind these as singletons.
findByField() returns a Collection. Always. Calling ->title on it returns nothing useful. If you expect one row, use ->first() on the result — or findWhere(...)->first().
find() returns null, not a 404. There is no automatic findOrFail semantics. Wrap it or use route-model binding in the controller and pass the ID down.
?search= silently does nothing when $fieldSearchable is empty. This is the single most common "the package is broken" report. Define getFieldsSearchable() (or the $fieldSearchable property) and retest.
?orderBy= collides with orderBy() in your repository. RequestCriteria appends its own orderBy; if you already set one in boot() or a criteria, you'll get ORDER BY x, y. Push RequestCriteria first, or drop your hardcoded ordering.
Presenters change return types. paginate() returns a LengthAwarePaginator normally, but a Fractal resource array once a presenter is attached. Code written before the presenter was added (e.g., a service that reads ->items()) breaks. Guard internal reads with skipPresenter().
Criteria order is execution order. A RequestCriteria pushed after an authorization scope will still see the scoped model — that's usually what you want — but pushing an orWhere-heavy criteria after a tenant filter can produce cross-tenant results. Audit any criteria that emits orWhere.
Cache key collisions. The cache key is derived from the method, arguments, and serialized criteria. If your criteria aren't serializable (closures, injected request objects), the key collapses and you'll serve the wrong data. skipUri in cache.params exists precisely because ?search= variants otherwise multiply entries without bound.
Model requirements. $fillable (or $guarded = []) must be set or writes silently no-op on protected attributes. model() must return a class-string, not an instance — returning new Post throws RepositoryException.
Generators write where the config says. If your app uses app/Models but the published config still says Models at the root, make:entity scatters files. Edit config/repository.php → generators.*.namespace and path first, then run config:clear.
Debugging "why is my query wrong". Dump the pipeline rather than guessing:
$repo->getCriteria(); // what's applied
$repo->resetCriteria(); // strip all and rebuild
DB::listen(fn ($q) => dump($q->sql, $q->bindings));
Criteria applied via boot() run in the constructor, so an early dd($repo) inside a service provider won't show them — resolve the repository fresh.
Extension points worth knowing: custom criteria (the main one), generator stub overrides in config/repository.php, overriding parserResult() to wrap responses, and subclassing BaseRepository as a project-wide AppRepository with your own boot() defaults (soft-delete scoping, tenant criteria, default ordering).
Maintenance reality check. Upstream has ~4.2k stars and a live MIT license, but this is a fork with zero registered dependents — the risk profile is "one maintainer, one composer constraint away from a Laravel major breaking you." Pin the version, keep an integration test that exercises BaseRepository end-to-end, and be prepared to swap to hand-rolled query objects (or spatie/laravel-query-builder for the RequestCriteria piece) if the fork stalls. Budget for that migration being a few days, not a rewrite, precisely because you injected contracts everywhere.
How can I help you explore Laravel packages today?