recca0120/repository
Laravel repository pattern package that decouples business logic from Eloquent models. Ships criteria, presenters, cache support, and Artisan generators to scaffold repositories and interfaces quickly, keeping controllers thin and testable.
v3.0 is a compatibility major, not a redesign: the only documented change is the Laravel 12.x shift PR, which means the package version is now coupled to the framework major you run. Install accordingly:
# Laravel 12 / PHP 8.2+
composer require recca0120/laravel-repository:^3.0
# Laravel 10 or 11 — stay on the 2.x line
composer require recca0120/laravel-repository:^2.7
The service provider is auto-discovered, so there is nothing to register manually. Publish the config before writing any code, because caching, presenters and validation are all driven from it:
php artisan vendor:publish --provider="Recca0120\Repository\RepositoryServiceProvider"
Where to look first
config/repository.php — cache store/lifetime/key prefix, default presenter, default validator rules. Most "why is it behaving like that" questions are answered here.src/Repository.php in vendor/ — the base class is the real API surface (all, find, findBy, findWhere, create, update, delete, paginate, count, exists, scopeQuery, pushCriteria, skipCriteria, resetCriteria, present, skipPresenter, cache, flushCache). Skim the method list once; it is the fastest way to stop re-implementing helpers.php artisan list | grep -i repository — the package ships generators (make:repository and friends). Confirm the exact signature on your installed version rather than copying flags from a blog post.First use case — swap one noisy controller action, not the whole app.
namespace App\Repositories;
use App\Models\User;
use Recca0120\Repository\Repository;
class UserRepository extends Repository
{
// Return the class-string (or an instance) depending on your version's makeModel()
protected function model()
{
return User::class;
}
}
// Controller
public function index(UserRepository $users)
{
return UserResource::collection($users->paginate(20));
}
Keep the existing User::paginate(20) behaviour identical in the first commit. Prove parity, then add criteria, caching or presenters one at a time — each of those layers changes query shape, cache keys or response structure, and debugging all three at once is miserable.
Layering: Controller → Action/Service → Repository. The repository is a query/write boundary, not a business-logic layer. Controllers should never see a Builder; services orchestrate multiple repositories inside DB::transaction().
Criteria for request-driven filters. The main day-to-day win is composable filtering without a controller full of if ($request->has(...)):
class ActiveOnly implements CriteriaInterface
{
public function apply($model, RepositoryInterface $repository)
{
return $model->where('active', true); // return the builder, never ->get()
}
}
$users = $users->pushCriteria(app(ActiveOnly::class))->paginate(20);
Good criteria are single-purpose and composable (ActiveOnly, SearchByName, SortBy, WithRelations). Build them from a small mapper that translates query-string params into criteria, so adding a filter is one class plus one mapping entry.
Presenters for output shape. Use them when you want repository methods to return arrays/Jsonable consistently instead of raw models. Pick either presenters or API Resources for a given endpoint — running both produces double-wrapped payloads and confusing pagination metadata.
Caching read paths only.
$users = $users->cache(60)->findWhere(['active' => true]); // read
$users->create($data);
$users->flushCache(); // write
Cache the deterministic, parameterised read methods. Never cache all() behind user-specific criteria unless the cache key includes the criteria state.
Validation at the boundary.
$users->validator([
'email' => 'required|email|unique:users,email',
'name' => 'required|string',
])->create($request->validated());
Because it throws Laravel's ValidationException, JSON requests get a 422 for free — no extra handling in the controller. For updates use sometimes rules, since partial payloads are normal.
Magic __call for one-off scopes. Unknown methods forward to the underlying model/query builder, which is handy for $users->whereActive()->get(). Treat it as sugar: anything called from more than two places deserves a real repository method.
Testing. Unit-test criteria by asserting the SQL/behaviour, not the implementation:
$repo = new UserRepository();
$query = (new ActiveOnly())->apply($repo->getModel()->newQuery(), $repo);
$this->assertStringContainsString('"active" = ?', $query->toSql());
For integration tests use RefreshDatabase and assert results. Mock the repository interface in service/controller tests so those tests never touch the DB.
Transactions and events stay outside. Repositories don't manage transactions, and model observers (not repositories) are the right place for side effects — otherwise create() via the repository and create() via a factory behave differently.
v3.0 is a constraint bump with a major tag. Nothing in the release notes suggests an API redesign, but a major tag means Composer will refuse to resolve against L10/L11. Expect the usual upgrade friction (composer why-not recca0120/laravel-repository 3.0) and diff v2.7.0...v3.0 before merging — Shift-generated PRs sometimes carry unreleased signature/type changes that never made it into a changelog. If your app subclasses the package Repository and overrides model(), getCacheKey(), getCacheMinutes(), present() or boot(), verify each override still matches the parent signature after the bump; PHP will only tell you at runtime.
Criteria are stateful. pushCriteria()/skipCriteria() mutate the repository instance. If the repository is bound as a singleton (or you run Octane/queues), criteria leak between requests and you get results filtered by a previous user's criteria. Bind as a normal (non-singleton) binding, or call resetCriteria() at the top of every entry point. This is the single most common production bug with this pattern.
Return the builder from apply(). Returning ->get() or a collection from a criteria breaks paginate, count, exists and any subsequent criteria. Criteria compose queries; they don't execute them.
Cache keys must include criteria and arguments. If your criteria state isn't part of the key, cached page 1 will be served for page 5. Also: tag-based invalidation requires Redis/Memcached — the file and database stores throw on tags(), which surfaces as an exception only once caching is enabled in production.
Flush cache after writes, in the same request. Caching at the repository layer means an update() elsewhere in the codebase (a console command, an imported job) won't invalidate anything. Centralise writes through the repository, or add an observer that flushes explicitly.
Presenters + pagination. Presenting a LengthAwarePaginator requires handling the paginator explicitly; a presenter that assumes a collection will silently drop total/per_page. Assert your JSON shape in a feature test after enabling a presenter.
__call hides typos until runtime. There's no static check for $users->actve() — you get "Call to undefined method" at runtime, possibly in production. Prefer explicit repository methods for anything non-trivial and keep magic calls to genuinely one-off scopes.
Debugging. DB::listen() or ->toSql() on the builder returned by getModel()->newQuery() (or by applying criteria manually) tells you what the criteria chain actually produced. Dumping the repository with dd($repo) is useless because the interesting state is the underlying builder and the criteria stack.
Extension points worth using. A single App\Repositories\BaseRepository extends Recca0120\Repository\Repository gives you findOrFail, soft-delete helpers and project-wide defaults once, instead of in every concrete repository. Override model(), getCacheKey() and the presenter/cache getters there and keep concrete repositories to 10–20 lines.
Don't let it become a god object. One repository per aggregate root; no cross-aggregate queries inside a repository method. The moment a repository method calls another repository, you've moved business logic into the wrong layer.
How can I help you explore Laravel packages today?