Skip to content

Extending Repositories ​

Add domain-specific methods to your repositories for cleaner application code.

Public Utility Methods ​

These methods are available on any repository instance:

MethodReturnsDescription
getTableName()stringThe database table name
getPrimaryKeyName()stringThe primary key column name
getConnection()ConnectionThe Doctrine DBAL connection instance
getTranslationConfig()?arrayThe translation table config, or null if not configured
php
// Access connection for raw queries
$conn = $repo->getConnection();
$row = $conn->fetchAssociative('SELECT * FROM users WHERE id = ?', [1]);

To map a raw row to a model from inside your subclass, use the protected mapRowToModel() — see the "Using QueryBuilder" examples below.


Protected Methods ​

These methods are available for use in your repository subclasses:

MethodDescription
table()Returns QueryBuilder with table selected (LEFT JOINs the translation table when a locale is active)
queryBuilder()Returns fresh QueryBuilder
mapRowToModel(array $row)Converts database row to model
applyCriteria(QueryBuilder $qb, array $criteria, bool $useAlias = true)Compiles criteria → WHERE expression and binds parameters on $qb. Pass useAlias: false for UPDATE/DELETE builders
applyOrderBy(QueryBuilder $qb, array $orderBy)Applies sorting (qualifies bare columns with the table alias)
afterWrite(): voidOverride to invalidate local memoized values after each successful SQL write to this repository's table

Invalidating Memoized Values After Writes ​

Override afterWrite() when your repository memoizes values derived from its own table. This avoids overriding every write method just to clear the same cache:

Available since v3.1.0. If a subclass already declares afterWrite(), ensure it is compatible with protected function afterWrite(): void and its new lifecycle behavior, or rename the existing method.

php
/** @extends BaseRepository<Organization> */
final class OrganizationRepository extends BaseRepository
{
    /** @var array<int, string> */
    private array $timezones = [];

    public function __construct(Connection $connection)
    {
        parent::__construct($connection, Organization::class, 'organizations');
    }

    public function timezone(int $id): string
    {
        // Read directly during transactions, without using or filling the memo.
        if ($this->inTransaction()) {
            return $this->readTimezone($id);
        }

        return $this->timezones[$id] ??= $this->readTimezone($id);
    }

    private function readTimezone(int $id): string
    {
        return $this->find($id)?->timezone
            ?? throw new \RuntimeException('Organization not found');
    }

    protected function afterWrite(): void
    {
        $this->timezones = [];
    }
}

The hook runs after successful statements from insert(), create(), insertMany(), update(), updateBy(), forceDelete() and forceDeleteBy(). delete(), deleteBy() and soft-delete restore() reach it through those methods, without an extra call. It runs even when a statement affects zero rows, but not when the statement throws. Calls that execute no statement, such as insertMany([]) or restore() without soft deletes, do not trigger it.

insertMany() calls the hook once per SQL statement, including each chunk and column group. If a later statement fails, earlier successful writes have already invalidated the memo. Keep the hook idempotent and non-throwing: the write has already succeeded. In create() and update(), it runs before the follow-up read, even if that read subsequently fails.

The hook runs immediately inside transactions, without waiting for commit. In the example, writes clear the old memo and transactional reads never refill it. After either commit or rollback, the next read outside the transaction memoizes the current database value. This also works for rollback caused by an exception in withTransaction(). The hook itself is not a commit or rollback notification; a subclass that fills its memo during transactions must handle rollback separately.

Only writes through this repository instance are covered. attach(), detach(), sync() and seedTranslations() write other tables and do not trigger the hook. Raw SQL through queryBuilder() or getConnection(), writes through another repository instance, and external database changes also bypass it. After a custom SQL write in your subclass, call $this->afterWrite() yourself if it changes this table.


Basic Extension ​

php
final class UserRepository extends BaseRepository
{
    protected ?string $deletedAtColumn = 'deleted_at';

    public function __construct(Connection $connection)
    {
        parent::__construct($connection, User::class, 'users');
    }

    /**
     * Find users by email domain
     */
    public function findByEmailDomain(string $domain): array
    {
        return $this->findBy([
            'email' => ['LIKE' => '%@' . $domain]
        ]);
    }

    /**
     * Find active users with minimum balance
     */
    public function findActiveWithMinBalance(float $minBalance): array
    {
        return $this->findBy([
            'status' => 'active',
            'balance' => ['>=' => $minBalance]
        ]);
    }
}

Using QueryBuilder ​

For complex queries, use table() which returns a QueryBuilder:

php
final class UserRepository extends BaseRepository
{
    /**
     * Find top users by score
     */
    public function findTopByScore(int $limit = 10): array
    {
        $rows = $this->table()
            ->andWhere('status = :status')
            ->setParameter('status', 'active')
            ->orderBy('score', 'DESC')
            ->setMaxResults($limit)
            ->executeQuery()
            ->fetchAllAssociative();

        return array_map(
            fn(array $row) => $this->mapRowToModel($row),
            $rows
        );
    }

    /**
     * Find users registered in date range
     */
    public function findRegisteredBetween(
        \DateTimeInterface $from,
        \DateTimeInterface $to
    ): array {
        $rows = $this->table()
            ->andWhere('created_at >= :from')
            ->andWhere('created_at <= :to')
            ->setParameter('from', $from->format('Y-m-d H:i:s'))
            ->setParameter('to', $to->format('Y-m-d H:i:s'))
            ->orderBy('created_at', 'ASC')
            ->executeQuery()
            ->fetchAllAssociative();

        return array_map(
            fn(array $row) => $this->mapRowToModel($row),
            $rows
        );
    }
}

Complex Queries with Joins ​

php
final class OrderRepository extends BaseRepository
{
    /**
     * Find orders with user info (manual join)
     */
    public function findOrdersWithUserEmail(string $email): array
    {
        $rows = $this->queryBuilder()
            ->select('o.*')
            ->from('orders', 'o')
            ->innerJoin('o', 'users', 'u', 'o.user_id = u.id')
            ->where('u.email = :email')
            ->setParameter('email', $email)
            ->orderBy('o.created_at', 'DESC')
            ->executeQuery()
            ->fetchAllAssociative();

        return array_map(
            fn(array $row) => $this->mapRowToModel($row),
            $rows
        );
    }

    /**
     * Get order statistics by status
     */
    public function getStatsByStatus(): array
    {
        return $this->queryBuilder()
            ->select('status', 'COUNT(*) as count', 'SUM(total) as total')
            ->from('orders')
            ->groupBy('status')
            ->executeQuery()
            ->fetchAllAssociative();
    }
}

Scoped Queries ​

Create reusable query logic as repository methods:

Methods vs. named scopes

This section covers query helpers exposed as methods ($repo->active()). For virtual criteria keys expanded inside findBy()/count()/etc. — e.g. mapping HTTP filter params — see the Scopes feature; the two are independent mechanisms.

php
final class ProductRepository extends BaseRepository
{
    /**
     * Only active products
     */
    public function active(): array
    {
        return $this->findBy(['status' => 'active']);
    }

    /**
     * Only in-stock products
     */
    public function inStock(): array
    {
        return $this->findBy([
            'status' => 'active',
            'quantity' => ['>' => 0]
        ]);
    }

    /**
     * Products in price range using BETWEEN
     */
    public function inPriceRange(float $min, float $max): array
    {
        return $this->findBy([
            'price' => ['BETWEEN' => [$min, $max]]
        ]);
    }
}

Business Logic Methods ​

php
final class OrderRepository extends BaseRepository
{
    protected ?string $deletedAtColumn = 'deleted_at';

    /**
     * Create order with items
     */
    public function createWithItems(
        int $userId,
        array $items,
        OrderItemRepository $itemRepo
    ): Order {
        return $this->withTransaction(function () use ($userId, $items, $itemRepo) {
            // Create order
            $order = $this->create([
                'user_id' => $userId,
                'status' => 'pending',
                'total' => 0
            ]);

            // Create items and calculate total
            $total = 0;
            foreach ($items as $item) {
                $itemRepo->create([
                    'order_id' => $order->id,
                    'product_id' => $item['product_id'],
                    'quantity' => $item['quantity'],
                    'price' => $item['price']
                ]);
                $total += $item['quantity'] * $item['price'];
            }

            // Update total
            return $this->update($order->id, ['total' => $total]);
        });
    }

    /**
     * Cancel order with reason
     */
    public function cancel(int $orderId, string $reason): Order
    {
        $order = $this->find($orderId);
        
        if ($order === null) {
            throw new \RuntimeException('Order not found');
        }

        if ($order->status === 'shipped') {
            throw new \RuntimeException('Cannot cancel shipped order');
        }

        return $this->update($orderId, [
            'status' => 'cancelled',
            'cancelled_at' => date('Y-m-d H:i:s'),
            'cancel_reason' => $reason
        ]);
    }
}

Full Example ​

php
final class ArticleRepository extends BaseRepository
{
    protected ?string $deletedAtColumn = 'deleted_at';
    protected array $relationConfig = [];

    public UserRepository $userRepository;
    public TagRepository $tagRepository;

    public function __construct(
        Connection $connection,
        UserRepository $userRepository,
        TagRepository $tagRepository
    ) {
        $this->userRepository = $userRepository;
        $this->tagRepository = $tagRepository;

        $this->relationConfig = [
            'author' => new BelongsTo(
                repository: 'userRepository',
                foreignKey: 'author_id',
                setter: 'setAuthor',
            ),
            'tags' => new BelongsToMany(
                repository: 'tagRepository',
                pivot: 'article_tag',
                foreignPivotKey: 'article_id',
                relatedPivotKey: 'tag_id',
                setter: 'setTags',
            ),
        ];

        parent::__construct($connection, Article::class, 'articles');
    }

    // === Query Methods ===

    public function findPublished(): array
    {
        return $this->with(['author', 'tags'])
            ->findBy(
                ['status' => 'published'],
                ['published_at' => 'DESC']
            );
    }

    public function findBySlug(string $slug): ?Article
    {
        return $this->with(['author', 'tags'])
            ->findOneBy(['slug' => $slug]);
    }

    public function findByTag(string $tagSlug): array
    {
        return $this->findBy(['tags.slug' => $tagSlug]);
    }

    public function findPopular(int $limit = 10): array
    {
        $rows = $this->table()
            ->andWhere('status = :status')
            ->setParameter('status', 'published')
            ->orderBy('views', 'DESC')
            ->setMaxResults($limit)
            ->executeQuery()
            ->fetchAllAssociative();

        return array_map(
            fn(array $row) => $this->mapRowToModel($row),
            $rows
        );
    }

    // === Business Logic ===

    public function publish(int $id): Article
    {
        return $this->update($id, [
            'status' => 'published',
            'published_at' => date('Y-m-d H:i:s')
        ]);
    }

    public function incrementViews(int $id): void
    {
        $this->queryBuilder()
            ->update('articles')
            ->set('views', 'views + 1')
            ->where('id = :id')
            ->setParameter('id', $id)
            ->executeStatement();
    }

    // === Statistics ===

    public function getMonthlyStats(int $year, int $month): array
    {
        $startDate = sprintf('%04d-%02d-01', $year, $month);
        $endDate = date('Y-m-t', strtotime($startDate));

        return [
            'published' => $this->count([
                'status' => 'published',
                'published_at' => ['>=' => $startDate],
                'published_at' => ['<=' => $endDate . ' 23:59:59'],
            ]),
            'total_views' => $this->sum('views', [
                'published_at' => ['>=' => $startDate],
            ]),
        ];
    }
}

Tips ​

Use mapRowToModel()

Always use mapRowToModel() to convert database rows to models. This ensures consistent object creation.

Keep Repositories Focused

Repositories should handle data access, not business logic. Consider service classes for complex operations.

Avoid Exposing QueryBuilder

Don't return QueryBuilder from public methods. Always return models or arrays.

Released under the MIT License.