giordanolima/eloquent-repository
A clean repository layer for Laravel Eloquent models. Wrap common query logic behind a simple, testable interface, keep controllers and services lean, and reuse filtering, pagination, and CRUD operations across your app. Swap implementations and mock queries easily in tests.
1.0.0 is the first tagged release, so treat it as a foundation rather than a mature API. Install it into a Laravel app and immediately locate three things before writing any code:
BaseRepository class — this is the contract you will extend for every model-backed repository.perPage and orderBy properties — these are the package's two opinionated knobs and they will silently shape every query you run.GlobalScope implementation — because a global scope touches every query on a model, including ones you did not write (auth, relationships, admin panels). Read it before you trust it.The minimal first use case is a read-heavy index endpoint. Create a repository extending BaseRepository, point it at a model, set $perPage to something sane, and let the base class handle pagination and default ordering:
class PostRepository extends BaseRepository
{
protected int $perPage = 25;
protected string $orderBy = 'published_at';
protected string $orderDirection = 'desc';
public function __construct(Post $model)
{
parent::__construct($model);
}
}
Then bind it in a service provider and inject it into a controller. The value of this package is that pagination and ordering stop being copy-pasted across twenty controllers — verify that payoff on one endpoint before rolling it out.
Where to look first: the BaseRepository constructor and any all() / paginate() style methods, because that is where perPage and orderBy are actually consumed. If the constructor signature is not obvious from the source, that is your first friction point and worth resolving before onboarding the rest of the team.
Repository-per-aggregate, not per-table. Extend BaseRepository once per meaningful domain concept. Keep custom query methods on the concrete repository (findActiveByAuthor(), withTrashedForAdmin()) and keep the base class untouched. The base class is shared by every repository you create, so a change there is a change everywhere.
Constructor injection with interface binding. Bind PostRepositoryInterface to PostRepository in a service provider and type-hint the interface in controllers and jobs. This keeps the package swappable and makes tests trivial to fake. Do not resolve repositories via app() inside models or traits — it hides dependencies and makes the global scope's behaviour harder to reason about.
Override ordering per call, not per class. The orderBy property is a sensible default, but index screens and exports usually need different ordering. Confirm whether the base class exposes a fluent override (something like ->orderBy('created_at') returning $this) — if it does, prefer that over subclassing or mutating the property at runtime. Mutating a public property on a shared, container-resolved instance is a classic source of "why is my export sorted wrong" bugs.
Pagination as the default, get() as the exception. With perPage baked in, make paginated responses the happy path in your controllers and reserve unpaginated queries for exports and internal jobs. This pairs naturally with Resource::collection($repo->paginate()) and keeps API responses consistent.
Cache driver: wrap reads, never writes. Use the cache driver as a read-through layer around expensive, low-churn queries — dashboard counters, category trees, published-content listings. Invalidate explicitly on write in the repository's create/update/delete methods rather than relying on short TTLs. Keep cache keys namespaced by model and by the query parameters that affect results; a cache keyed only on Post::class will serve the wrong page of results the moment you add filters.
Treat GlobalScope as an opt-in escape hatch. If the package's scope applies default ordering or a soft-delete-style filter, document it in your team's README and add an explicit bypass (withoutGlobalScope(...)) in the places that legitimately need it — admin panels, reporting, queue workers. Every bypass should have a comment explaining why. If you cannot articulate why a query needs the bypass, you probably should not have it.
Integration tip: register the cache driver and any global scope configuration in a dedicated RepositoryServiceProvider rather than AppServiceProvider. It keeps the package's footprint in one file and makes it obvious what to remove if you outgrow it.
exists() checks in ways that surface far from the cause. If the scope is applied automatically on boot, test your login flow and any whereHas relationships on day one, before you build features on top.perPage and orderBy will be inherited by every repository you write. If the package defaults to something like 15 items or id ascending, your endpoints will silently behave that way until someone overrides it. Set both explicitly in every concrete repository, even when the default happens to be what you want — future readers should not have to open vendor code.orderBy is ever populated from request input (?sort=), validate it against an allowlist. Ordering by an unvalidated column name is a straightforward SQL injection vector, and this pattern invites exactly that mistake.Model::create() in a tinker session, direct DB imports, other packages. Establish a rule: all writes go through the repository, or accept stale reads. Add a cache-busting artisan command early for operations.BaseRepository is to push filtering, sorting, and search into it until it is a query builder with extra steps. Keep the base class thin; put domain queries on concrete repositories.static calls or resolves dependencies internally, your tests will hit the database when you did not intend them to.1.0.0 with sparse release notes means the API will likely shift in 1.x minors. Pin to 1.0.* in composer.json and read the changelog before every bump. Wrap the package behind your own interface layer so a breaking change costs you one file, not fifty.$orderBy on the concrete repository, $orderBy on the base class, then the global scope. Scope-applied ordering wins or loses depending on reorder() semantics, so log the final SQL with DB::listen() once and confirm.query() / newQuery() accessor returning the underlying builder? Can you compose scopes inside repository methods? If not, you will end up adding them yourself in a base subclass — do that in one place on day one rather than in each repository.How can I help you explore Laravel packages today?