sinemacula/laravel-repositories
| Install | |
|---|---|
composer require sinemacula/laravel-repositories |
|
| Latest Version: | v3.0.0 |
| PHP: | ^8.3 |
| License: | Apache-2.0 |
| Last Updated: | Sep 15, 2026 |
| Links: | GitHub · Packagist |
Laravel Repositories
This Laravel package provides a streamlined repository pattern layer for Eloquent models with criteria-driven query composition. It keeps the parts of l5-repository that are most useful in modern Laravel applications while removing unnecessary abstraction and maintenance overhead.
A big thanks to the creators of andersao/l5-repository for the original foundation this package builds on.
Features
- Repository Base Class: A container-resolved repository abstraction with explicit model validation, lifecycle reset
behavior, and a
boot()extension point for subclass initialization. - Criteria Lifecycle Controls: Persistent and one-shot criteria pipelines with runtime enable, disable, skip, and reset controls.
- Supplementary Capability Contracts: Opt-in interfaces that criteria can implement to declare eager-loading
(
DeclaresEagerLoading), field selection (DeclaresFieldSelection), relationship counts (DeclaresRelationshipCounts), and metadata (ContributesMetadata) alongside query modification. The repository collects these declarations at criteria application time and exposes them via dedicated accessors on the handle the query ran through. - Immutable Query Composition: Composing a query returns a copy rather than mutating the repository, so a composition abandoned part-way through cannot reach a later, unrelated query. Scopes that must apply to every query are registered separately.
- Model-Like Ergonomics: Explicit query entrypoints (
query()/newQuery()) plus magic forwarding for model-style usage such asRepository::find($id). - Opt-In Per-Query Caching: A transparent caching layer (
Cacheable) that serves repeated reads from a per-query cache and invalidates per table on writes, with whole-table reference mode, negative caching, and a size guard. Repositories that never use the trait pay nothing.
Installation
To install the Laravel Repositories package, run the following command in your project directory:
composer require sinemacula/laravel-repositories
Configuration
Publish the configuration file to customize the opt-in repository cache:
php artisan vendor:publish --provider="SineMacula\Repositories\RepositoryServiceProvider" --tag=config
Every option lives under repositories.cache.* and only affects repositories that opt into caching via the
Cacheable trait.
Usage
// Explicit query entrypoint
$users = $userRepository->query()->where('active', true)->get();
// Magic forwarding remains available for model-like usage
$user = UserRepository::find($id);
Composition Returns a Copy
Composing a query never mutates the repository. addScope(), withCriteria(), useCriteria(), skipCriteria(),
resetScopes() and withoutCache() each return a copy carrying the composition, so the returned value is the only
thing that has it:
$live = $credentials->scopeLive(); // $credentials is unchanged
$live->get(); // the copy carries the scope
This is what makes a half-built query harmless. If a scope method throws after composing, the partial composition is
garbage rather than state, so nothing reaches the next query made through the repository. Discarding the return value
therefore discards the composition, and addScope(), withCriteria(), useCriteria(), skipCriteria(),
resetScopes() and withoutCache() are marked pure so static analysis reports a discarded result as an error. A
consumer scope method that simply forwards to addScope() is only covered by that check if it is marked @phpstan-pure
too.
Configuration is the other half, and it does mutate. pushScope() and pushCriteria() register constraints that apply
to every query for the life of the instance, which is what boot() should use. pushCriteria() is public, while
pushScope() is protected, so a registered scope is declared by the repository itself rather than by a caller.
Container Lifecycle
A repository instance carries its registered configuration, so bind repositories as transient or scoped bindings
(bind or scoped) rather than singleton when that configuration is request-specific.
Caching
Caching is fully opt-in: add the Cacheable trait to a repository and its read verbs (get, all, find, first,
firstWhere, firstOrFail, findOrFail, sole, value, pluck) are served from a cache keyed by a fingerprint of
the executed query. A cache hit executes zero database queries. Because the fingerprint folds in the compiled SQL, the
bindings, the read verb, its arguments, and the registered eager loads, a filtered or by-id read never collides with
the full-table collection.
use SineMacula\Repositories\Concerns\Cacheable;
use SineMacula\Repositories\Repository;
final class UserRepository extends Repository
{
use Cacheable;
public function model(): string
{
return User::class;
}
}
$users = $repository->get(); // First read: one query, result cached
$users = $repository->get(); // Repeat read: zero queries
$fresh = $repository->withoutCache()->get(); // Bypass the cache for one read
$repository->flushCache(); // Drop every cached entry for the table
$status = $repository->getCacheStatus(); // isPopulated() / getAge() / getLastInvalidatedAt()
Invalidation
Write verbs forwarded through the repository (create, update, delete, firstOrCreate, updateOrCreate,
upsert, increment, decrement, restore, and friends) invalidate every cached entry for the repository's table
after the write executes. How the invalidation happens depends on the backing store:
- Taggable stores (e.g. Redis): every entry is tagged with its table, and a write flushes the tag.
- Non-taggable stores (e.g. file, database): every key embeds a generational table version, and a write bumps the
version with a single atomic increment. Invalidation is O(1) and race-free; orphaned old-version entries simply
expire by TTL. Setting
registry_enabledtofalsedisables the version bump, degrading invalidation to TTL expiry only.
A cache-store failure during the post-write flush is logged and swallowed: the write has already committed, so the safe degraded state is stale-until-TTL rather than surfacing an error the caller could retry into a duplicate write.
Writes performed outside the repository (directly on the model, query builder, or database) are invisible to the
cache and are served stale until the TTL expires or flushCache() is called.
Negative caching and the size guard
A read that returns nothing is cached as a miss marker under the shorter negative_ttl (10 seconds by default), so
repeated probes for a missing record do not hammer the database while a stale "not found" stays tightly bounded.
Results exceeding max_rows or max_bytes are still fetched and returned but never stored, preventing unbounded
cache growth.
Reference mode
For small, static tables read in full (countries, currencies, statuses), set protected bool $cacheReferenceTable = true to opt into whole-table reference mode: the table is loaded once, cached as a single snapshot, memoised on a
collaborator every copy of the repository shares, and indexed by primary key, so get, all, and find resolve
without touching the database. Other read verbs skip the cache entirely in this mode.
The snapshot always represents the unfiltered table, so reference reads only serve requests with no repository-level
composition pending: when criteria or scopes are active, get, all, and find execute a real (uncached) query so a
filtered read is never answered with the whole table. A scope registered with pushScope() counts as active for this
purpose, because the snapshot is built straight from the model and would otherwise answer a registered constraint with
unfiltered rows. Eloquent global scopes (such as soft deletes) are part of the
snapshot query and always apply.
Cache configuration
Every option may be overridden per repository via a property:
| Config key | Env variable | Property | Default |
|---|---|---|---|
prefix |
REPOSITORY_CACHE_PREFIX |
$cacheKeyPrefix |
table name |
store |
REPOSITORY_CACHE_STORE |
$cacheStoreName |
app default |
ttl |
REPOSITORY_CACHE_TTL |
$cacheTtl |
3600 |
reference_ttl |
REPOSITORY_CACHE_REFERENCE_TTL |
$cacheReferenceTtl |
3600 |
negative_ttl |
REPOSITORY_CACHE_NEGATIVE_TTL |
$cacheNegativeTtl |
10 |
max_rows |
REPOSITORY_CACHE_MAX_ROWS |
$cacheMaxRows |
1000 |
max_bytes |
REPOSITORY_CACHE_MAX_BYTES |
$cacheMaxBytes |
262144 |
registry_enabled |
REPOSITORY_CACHE_REGISTRY_ENABLED |
$cacheRegistryEnabled |
true |
Using a dedicated cache store (REPOSITORY_CACHE_STORE) is recommended so application-wide flushes of the default
store never evict repository caches, and vice versa.
Consistency notes
The per-query fingerprint folds in the connection name and database name, so multi-connection applications are
isolated automatically. Reference-mode snapshot keys, however, default to the table name alone: when two connections
expose the same table name (e.g. per-tenant databases), set a distinct $cacheKeyPrefix per repository so the
snapshots cannot collide.
Like any look-aside cache, a read that misses, queries the database, and stores its result can interleave with a concurrent write on taggable stores and in reference mode: the freshly stored entry may predate the write, and is served until the TTL expires or the next write flushes it. The staleness window is bounded and self-healing; on non-taggable stores the generational version is captured before the database read, so a late store lands under the old version and is never served.
Non-taggable store considerations
The generational version scheme depends on the version key outliving every entry it scopes, so a few things are worth knowing before picking a non-taggable store:
- Eviction risk. Under memory pressure an LRU store (e.g. Redis or Memcached without tags) can evict the version key like any other entry, resetting the counter to zero and un-orphaning entries that were meant to stay invalidated. Use a non-evicting store (or a dedicated one) for the cache store when relying on this scheme.
- Shared across connections. The version key is scoped by table alone, not by connection: a write on one connection bumps the same counter another connection's per-query keys embed, so it invalidates that connection's cache too. This is a coherence trade-off rather than a correctness bug - orphaned entries still expire by TTL.
- Database driver. Each version bump is a single locking increment transaction against one row, plus an extra round trip the first time the key is seeded. Prefer a taggable store for tables with a high write volume.
Testing Utilities
The package exports test utilities under the SineMacula\Repositories\Testing namespace for downstream packages that
extend Repository or implement CriteriaInterface. See docs/testing.md for the setup and the
documented testing patterns.
Extension Points
Every protected member of the repository is classified as Stable, Transitional, or Internal, with stability guarantees documented per member. The package adheres to Semantic Versioning; changes to stable extension points count as breaking. See docs/extension-points.md for the full classification.
Upgrading
See UPGRADE.md for version-by-version migration guides, including breaking changes and the steps required to move from 1.x to 2.x.
Requirements
- PHP ^8.3
- Laravel 12+
Testing
composer test # PHPUnit suite in parallel via Paratest
composer test:coverage # suite with Clover coverage output
composer test:mutation # Infection mutation gate (min MSI 90)
composer test:mutation:full # full mutation suite without thresholds
composer check # static analysis and lint via qlty
composer format # format via qlty
composer smells # duplication / complexity smells via qlty
Changelog
See CHANGELOG.md for a list of notable changes, and UPGRADE.md for version upgrade guides.
Contributing
Contributions are welcome. Please read CONTRIBUTING.md for guidelines on branching, commits, code quality, and pull requests.
Security
If you discover a security vulnerability, please report it responsibly. See SECURITY.md for the disclosure policy and contact details.
License
Licensed under the Apache License, Version 2.0.
Related Packages
An abstract repository class for your Eloquent repositories that requires minima...
Repository for Laravel (inspired by and indebted to Bosnadev/Repositories)