Skip to content

Extending Repositories

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

Protected Methods

These methods are available for use in your repository subclasses:

MethodDescription
table()Returns QueryBuilder with table selected
queryBuilder()Returns fresh QueryBuilder
mapRowToModel(array $row)Converts database row to model
applyCriteria(QueryBuilder $qb, array $criteria)Applies criteria to query
applyOrderBy(QueryBuilder $qb, array $orderBy)Applies sorting

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 scopes:

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.