naldi/l5-repository
Laravel package that implements the Repository pattern, giving you an abstraction layer over your data source. It ships with criteria, presenters, validators, caching, Fractal transformers, and Artisan generators to keep your app clean and testable.
Install and wire up (5 minutes)
composer require naldi/l5-repository
On Laravel 5.5+ the service provider is auto-discovered. On older versions add to config/app.php:
Prettus\Repository\Providers\RepositoryServiceProvider::class,
Publish the config so you can tune query-string params later:
php artisan vendor:publish --provider="Prettus\Repository\Providers\RepositoryServiceProvider"
Note: this package is a maintained fork of
prettus/l5-repositoryand keeps thePrettus\Repository\*namespace. That means it's a drop-in replacement — verify the namespace after install withcomposer show naldi/l5-repositoryand checkvendor/naldi/l5-repository/src.
Scaffold your first repository
php artisan make:entity Post
The command is interactive; it can generate the model, migration, repository interface, Eloquent repository, and a service-provider binding stub. If you prefer to stay manual:
php artisan make:model Post -m
php artisan make:repository Post
Then fill in the two generated classes:
// app/Repositories/PostRepository.php (interface)
namespace App\Repositories;
use Prettus\Repository\Contracts\RepositoryInterface;
interface PostRepository extends RepositoryInterface {}
// app/Repositories/Eloquent/PostRepositoryEloquent.php
namespace App\Repositories\Eloquent;
use App\Models\Post;
use App\Repositories\PostRepository;
use Prettus\Repository\Eloquent\BaseRepository;
class PostRepositoryEloquent extends BaseRepository implements PostRepository
{
protected $fieldSearchable = [
'title' => 'like',
'status' => '=',
];
public function model(): string
{
return Post::class;
}
public function boot(): void
{
$this->pushCriteria(app(\Prettus\Repository\Criteria\RequestCriteria::class));
}
}
Bind the interface in a service provider:
$this->app->bind(
\App\Repositories\PostRepository::class,
\App\Repositories\Eloquent\PostRepositoryEloquent::class
);
First use case — a controller that never touches Eloquent:
class PostController extends Controller
{
public function __construct(private PostRepository $posts) {}
public function index()
{
return $this->posts->paginate(15);
}
}
GET /posts?search=laravel&order=created_at|DESC now filters and sorts with zero controller code.
Where to look first
config/repository.php — controls search, filter, order, sort, include param names and the searchable-field conventions.vendor/naldi/l5-repository/src/Eloquent/BaseRepository.php — every public method; skim it once, it's the whole API surface.src/Criteria/RequestCriteria.php — the class you'll debug most often.Interface + Eloquent pair per aggregate. One repository per aggregate root (not per table). Keep the interface free of Eloquent types so you can swap in a cache decorator or an in-memory implementation for tests.
RequestCriteria as the default listing pipeline. Push it in boot() and every paginate() / all() picks up query-string filtering. Supported syntax out of the box:
?search=laravel # searches $fieldSearchable
?search=laravel&searchFields=title:like;body:like
?filter=status:published;author_id:3 # exact match (or per 'condition' in config)
?order=created_at|DESC&sort=title|asc
?include=author,comments # eager loads relations
Custom criteria for domain filters. Anything that isn't a naive column match belongs in a criteria class, not a controller:
class PublishedOnlyCriteria implements CriteriaInterface
{
public function apply($model, RepositoryInterface $repository)
{
return $model->where('status', 'published')
->whereNotNull('published_at');
}
}
// call site
$this->posts->pushCriteria(new PublishedOnlyCriteria)->paginate(15);
Criteria are cumulative and applied in push order — order matters when a criterion depends on a join or a select alias.
Scopes for one-off query shapes. scopeQuery lets you drop to the builder without losing the repository contract:
$this->posts->scopeQuery(function ($query) {
return $query->whereHas('author', fn ($q) => $q->where('active', 1));
})->paginate(20);
Presenters/transformers for API output. Return a Fractal presenter from presenter() and the repository will shape every collection/entity — including paginate(), which returns a data + meta structure instead of a raw paginator:
public function presenter(): string
{
return PostPresenter::class;
}
// when you need raw Eloquent (jobs, exports, internal service):
$this->posts->skipPresenter()->all();
Caching for read-heavy lookups. Add the trait and let the repository handle invalidation on create/update/delete:
use Prettus\Repository\Traits\CacheableRepository;
class PostRepositoryEloquent extends BaseRepository implements PostRepository
{
use CacheableRepository;
protected $cacheMinutes = 60;
protected $cacheExcept = ['paginate']; // be careful here
}
Cache keys are derived from the method name plus serialized arguments plus active criteria — so pushCriteria(new FooCriteria) produces a different key automatically.
Repository events for cross-cutting side effects. The base class dispatches RepositoryEvent::creating/created/updating/updated/saving/saved/deleting/deleted, which fire before/after the persistence call (unlike Eloquent observers, which can't see the repository's criteria or the pre-save payload):
$this->posts->create($data); // fires creating + created
Validation hooks. validator() integrates with prettus/laravel-validation; if you use it, add that package explicitly — it's not always pulled in transitively. Many teams skip it and validate in FormRequests instead, which keeps the repository free of HTTP concerns.
Testing tip. Because consumers type-hint the interface, a fake repository is trivial:
$this->app->instance(PostRepository::class, new InMemoryPostRepository());
No database, no RefreshDatabase for controller tests.
$fieldSearchable or searchableFields() is mandatory. If RequestCriteria is pushed and the model has no searchable fields defined, ?search=foo throws. Define the array in the repository, or override searchableFields() if the list needs to be dynamic.
scopeQuery is consumed and reset. You cannot chain two scopeQuery calls — each one applies and then resets the scope. Combine them into a single closure, or express one as a criteria.
Custom methods bypass criteria. Any method you write that touches $this->model directly must call $this->applyCriteria() and $this->applyScope() first:
public function activeCount(): int
{
$this->applyCriteria();
$this->applyScope();
$count = $this->model->count();
$this->resetModel();
return $count;
}
Forgetting resetModel() leaks state into the next call — the repository reuses a single model instance across the request.
model() must return a class-string. Returning $this or an instance causes "Class name must be a valid object or a string" at construction. Return Post::class.
Presenter changes the return shape. paginate() with a presenter no longer returns a LengthAwarePaginator — code doing ->links() in Blade will break. Use skipPresenter() for HTML views, presenters for JSON APIs.
Criteria ordering and leakage. pushCriteria() mutates the repository instance for the rest of the request. If a controller pushes a criterion and another controller call reuses the same singleton, the filter persists. Use resetCriteria() after a scoped query, or resolve a fresh instance.
Cache invalidation edge cases. The CacheableRepository trait flushes on repository events, but it does not know about writes that bypass the repository (raw DB::table() updates, queue workers on other connections, Eloquent calls in other services). If your app mixes access paths, either route all writes through the repository or drop caching for that model.
Case-insensitive search is a database concern. RequestCriteria uses LIKE %term%; on PostgreSQL this is case-sensitive. Switch the field config to ilike semantics with a custom criterion or use LOWER() manually — the package won't do it for you.
Generator namespaces are config-driven. If you move repositories out of App\Repositories, update repository.generator.rootNamespace and the paths section in config/repository.php, otherwise make:repository writes to the wrong place and autoloading fails silently.
Version pinning matters. This is a fork tracking a package with a long history against Laravel 5→11+. Pin the constraint in composer.json ("naldi/l5-repository": "^2.6" or whatever your installed major is) and re-read BaseRepository after upgrades — method signatures (e.g. paginate()'s third $method argument, model() return types) have changed across majors.
Don't over-repository. The pattern pays off when you have multiple consumers of the same query (API + console + queue) or need swappable data sources. For a CRUD controller over one table, a plain Eloquent scope is less code and less indirection. Keep repositories thin: query composition, eager loading, caching — not business rules, not HTTP validation, not response formatting.
How can I help you explore Laravel packages today?