yuriitatur/repository

A simple framework/db agnostic repository package

Maintainers

Package info

bitbucket.org/yurii_tatur/repository

pkg:composer/yuriitatur/repository

Statistics

Installs: 98

Dependents: 4

Suggesters: 0

dev-master 2026-05-17 09:56 UTC

This package is auto-updated.

Last update: 2026-05-17 09:57:03 UTC


README

Quality Gate Status Coverage

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:

InterfaceMethods
QueryBuilderInterfacewhere, orWhere, orderBy, limit, page, offset, withMeta, withoutOrder, withoutPagination, withNewBuilder, newQuery, getQueryBuilder
DataAccessorInterfaceget, getOne, getById, getWithTotalCount
EntityPersistenceInterfacesave, delete
BulkPersistenceInterfaceupdateWhere, deleteWhere
AggregatorInterfacecount, 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

MethodDescription
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 directly
  • fromPlainQuery(string $query) — parses a plain-text query (see query-builder docs)

Bulk operations

updateWhere and deleteWhere apply a write to every row matching the current query state and return the affected count. Dispatches RunningQueryEvent before the operation and BulkUpdatedEvent / BulkDeletedEvent after.

$affected = $repo->where(new FilterConditionNode('status', '=', 'pending'))->updateWhere(['status' => 'archived']);
$deleted  = $repo->where(new FilterConditionNode('age', '<', 18))->deleteWhere();

CachedRepository calls cleanCache() after either bulk operation (full prefix invalidation). ReadOnlyRepository throws RepositoryException on both.

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), bulkUpdate(QueryAST, array): int, bulkDelete(QueryAST): int.

Implementing AdvancedDatabaseDriverInterface additionally enables native getWithTotalCount() and exists() — useful when the database can resolve these in a single optimized query.

Built-in drivers:

DriverDescription
InMemoryPhpDriverPHP array — ideal for testing
DoctrineDbalDriverSQL databases via Doctrine DBAL
MeiliSearchDriverFull-text search via Meilisearch
EloquentModelDriverEloquent ORM — see yuriitatur/repository-laravel
TableDriverEloquent table abstraction — see yuriitatur/repository-laravel

ColumnMatcher

SQL-style drivers require a ColumnMatcherInterface to map entity field names to database column names (e.g., userIduser_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:

HydratorDescription
ReflectionArrayHydratorPure PHP reflection, no extra dependencies
JmsSerializerArrayEntityHydratorUses jms/serializer
SymfonySerializerArrayEntityHydratorUses 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.

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:

EventFired when
RunningQueryEventBefore any query hits the driver
EntityHydratedEventAfter a single entity is hydrated
CollectionHydratedEventAfter a collection of entities is hydrated
EntitySavedEventAfter save() completes
EntityDeletedEventAfter delete() completes
BulkUpdatedEventAfter updateWhere() completes
BulkDeletedEventAfter deleteWhere() 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.