yuriitatur / repository
A simple framework/db agnostic repository package
Requires
- php: >=8.4
- illuminate/collections: ^12.28 || ^13.0
- psr/container: ^2.0
- psr/log: ^3.0
- symfony/cache-contracts: ^3.6
- symfony/event-dispatcher: ^7.3
- yuriitatur/exceptions: ^1.0
- yuriitatur/query-builder: dev-master
Requires (Dev)
- dg/bypass-finals: ^1.9
- doctrine/dbal: ^4.3
- elasticsearch/elasticsearch: ^9.0
- jms/serializer: ^3.32
- kint-php/kint: ^6.1
- meilisearch/meilisearch-php: ^1.0
- mongodb/mongodb: ^2.1
- phpunit/phpunit: ^12.0
- ramsey/uuid: ^4.1
- symfony/serializer: ^7.3
Suggests
- doctrine/dbal: Allows you to use Dbal driver
- elasticsearch/elasticsearch: Allows you to use ElasticSearch driver
- glopgar/monolog-timer-processor: Adds timers to measure query durarions
- jms/serializer: Adds ability to use JmsSerializerEntityMapper
- meilisearch/meilisearch-php: If you want to use meilisearch db driver
- mongodb/mongodb: Allows you to use MongoDB driver
- symfony/event-dispatcher: Allows to use events
- symfony/serializer: Allows to use SymfonySerializerArrayEntityHydrator
This package is auto-updated.
Last update: 2026-04-07 20:13:04 UTC
README
Repository
A framework and database-agnostic repository and query builder library to speed up development with your own entities. It provides a stateless repository pattern with pluggable database drivers, so you can use filters, ordering, pagination, and aggregations uniformly regardless of the underlying storage.
Installation
composer require yuriitatur/repository
Core Concepts
The two foundational contracts are Entity and RepositoryInterface.
To mark your class as an entity, implement YuriiTatur\Repository\Contracts\Entity:
class Product implements Entity
{
use HasId; // provides getId()/setId() boilerplate
}
RepositoryInterface composes four sub-interfaces:
| Interface | Methods |
|---|---|
QueryBuilderInterface | where, orWhere, orderBy, limit, page, offset, withMeta, withoutOrder, withoutPagination, withNewBuilder, newQuery, getQueryBuilder |
DataAccessorInterface | get, getOne, getById, getWithTotalCount |
EntityPersistenceInterface | save, delete |
AggregatorInterface | count, exists, min, max, avg, sum |
Usage
Repository
Instantiate EntityRepository with a driver, a hydrator, and an event dispatcher:
$repo = new EntityRepository(
persistence: $dbDriver,
hydrator: $hydrator,
events: $eventDispatcher,
);
Query methods are stateless — each call returns a clone with the updated state, so the original $repo is never mutated:
$activeUsers = $repo
->where(new FilterConditionNode('status', '=', 'active'))
->orWhere(new InNode('userId', [1, 2, 3]))
->orderBy('createdAt', 'desc')
->limit(10)
->page(3)
->get(); // returns Illuminate\Support\Collection
$total = $repo->where(new FilterConditionNode('status', '=', 'active'))->count();
Use newQuery() to explicitly reset state and start a fresh query from any repository instance.
Query builder methods
| Method | Description |
|---|---|
where($filters, $operator) | Accepts a filter node, an array of nodes (combined with $operator), or a callable receiving the QueryBuilder |
orWhere($filters) | Same as where but always applies OR |
orderBy($field, $direction) | Column and sort direction |
limit(int $limit) | Page size |
page(int $page) | Page number (offset takes precedence if both are set) |
offset(int $offset) | Raw row offset |
withMeta(array $meta) | Driver-specific metadata |
withoutOrder() | Strips any applied ordering |
withoutPagination() | Strips limit/offset/page |
withNewBuilder(QueryBuilder $b) | Replaces the entire query builder instance |
getQueryBuilder() | Returns the current QueryBuilder |
The underlying QueryBuilder also exposes:
withQueryAst(QueryAST $ast)— replaces the full query AST directlyfromPlainQuery(string $query)— parses a plain-text query (see query-builder docs)
Aggregations
$repo->where(...)->count(); // int
$repo->where(...)->exists(); // bool
$repo->where(...)->min('price'); // mixed
$repo->where(...)->max('price'); // mixed
$repo->where(...)->avg('price'); // mixed
$repo->where(...)->sum('quantity'); // mixed
$repo->where(...)->getWithTotalCount(); // ['items' => Collection, 'total' => int]
Repository Decorators
Wrap any RepositoryInterface via AbstractRepositoryDecorator. Included decorators:
CachedRepository
Caches read queries using Symfony's TagAwareCacheInterface:
$repo = new CachedRepository(
inner: $entityRepository,
cache: $tagAwareCache,
cachePrefix: 'products',
ttl: 3600, // seconds, default 3600
);
Cache key format: cached-repository-{prefix}-{md5(queryBuilder)}.
Invalidation tags:
{prefix}— all collection queries for this prefix{prefix}_id_{id}— per-entity queries{prefix}-not-found— negative lookups (TTL capped at 60s){prefix}_aggs— aggregation queries
Cache invalidation happens automatically on save() and delete(). For manual control, CachedRepositoryInterface provides:
$repo->cleanCache(); // invalidate all tags for this prefix
$repo->cleanEntityCache($entity); // invalidate only that entity's tags
ReadOnlyRepository
Throws a RepositoryException on any save() or delete() call — useful for enforcing read-only access at a boundary:
$repo = new ReadOnlyRepository($entityRepository);
Drivers
A driver implements DatabaseDriverInterface and handles the raw persistence operations: get(QueryAST), save(array), delete(array), aggregate(QueryAST).
Implementing AdvancedDatabaseDriverInterface additionally enables native getWithTotalCount() and exists() — useful when the database can resolve these in a single optimized query.
Built-in drivers:
| Driver | Description |
|---|---|
InMemoryPhpDriver | PHP array — ideal for testing |
DoctrineDbalDriver | SQL databases via Doctrine DBAL |
MeiliSearchDriver | Full-text search via Meilisearch |
EloquentModelDriver | Eloquent ORM — see yuriitatur/repository-laravel |
TableDriver | Eloquent table abstraction — see yuriitatur/repository-laravel |
ColumnMatcher
SQL-style drivers require a ColumnMatcherInterface to map entity field names to database column names (e.g., userId → user_id).
interface ColumnMatcherInterface
{
public function getFilterableColumnOption(string $entityField): string|callable|null;
public function getOrderableColumnOption(string $entityField): string|callable|null;
public function getAggregateColumnOption(string $entityField): string|null;
}
The default implementation is ArrayColumnMatcher, which resolves names from a simple map. For complex cases, a callable can be used to intercept and modify the underlying query directly:
$matcher = new ArrayColumnMatcher([
'discount' => function (Builder $builder, $node) {
$builder->whereHas('discount', function (Builder $builder) use ($node) {
$builder->where('amount', $node->operator, $node->operand);
});
},
]);
Hydration
EntityHydratorInterface converts between raw database arrays and Entity objects.
Built-in hydrators:
| Hydrator | Description |
|---|---|
ReflectionArrayHydrator | Pure PHP reflection, no extra dependencies |
JmsSerializerArrayEntityHydrator | Uses jms/serializer |
SymfonySerializerArrayEntityHydrator | Uses symfony/serializer |
EntityUpdater uses reflection to copy database-generated values (e.g. auto-increment IDs, timestamps) back onto the original entity reference after save().
Transactions
The library provides contracts and a default implementation for transactions without mandating a specific approach.
TransactionRunnerInterface—run(\Closure $task): mixedTransactionDriverInterface— driver-level begin/commit/rollbackDriverTransactionRunner— default implementation that delegates to aTransactionDriverInterface
For Laravel projects, see yuriitatur/repository-laravel for EloquentTransactionDriver and TransactionMiddleware.
Lazy Loading
Entities using the HasReferences trait can lazily load related objects on demand:
class User implements Entity
{
use HasId;
use HasReferences;
private ?Address $address = null; // must default to null
public function getAddress(): ?Address
{
return $this->loadReference('address');
}
}
$user->injectReference('address', function (User $user) use ($addressRepo) {
return $addressRepo->where(new FilterConditionNode('id', '=', $user->getAddressId()))->getOne();
});
Beware of the N+1 problem when loading references inside collection loops.
Automatic injection via events
If you dispatch hydration events, use the #[LazyReference] attribute to automatically inject references when an entity is hydrated. The attribute value is the container service name of a callable that accepts the entity:
class User implements Entity
{
use HasId;
use HasReferences;
#[LazyReference('MyAddressLoaderService')]
private ?Address $address = null;
public function getAddress(): ?Address
{
return $this->loadReference('address');
}
}
class MyAddressLoaderService
{
public function __invoke(User $user): void
{
$user->injectReference('address', fn () => /* load address */);
}
}
Events
EntityRepository dispatches PSR EventDispatcherInterface events throughout the entity lifecycle:
| Event | Fired when |
|---|---|
RunningQueryEvent | Before any query hits the driver |
EntityHydratedEvent | After a single entity is hydrated |
CollectionHydratedEvent | After a collection of entities is hydrated |
EntitySavedEvent | After save() completes |
EntityDeletedEvent | After delete() completes |
LogAwareDatabaseDriverDecorator wraps any driver and logs all queries via PSR-3.
Coming next
- MultiEntityRepository — repository facade over multiple entity types
- MongoDB driver
- Elasticsearch driver
Testing
composer test
License
This code is under the MIT license, read more in the LICENSE file.