yuriitatur/repository

A simple framework/db agnostic repository package

Maintainers

Package info

bitbucket.org/yurii_tatur/repository

pkg:composer/yuriitatur/repository

Statistics

Installs: 65

Dependents: 4

Suggesters: 0

dev-master 2026-04-07 20:12 UTC

This package is auto-updated.

Last update: 2026-04-07 20:13:04 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
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)

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:

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

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.