Repository pattern em PHP

1. Introdução ao Repository Pattern

O Repository Pattern é um padrão de design que atua como uma camada de abstração entre a lógica de negócio e a persistência de dados. Seu propósito principal é isolar o domínio da aplicação dos detalhes de implementação do armazenamento, seja ele um banco relacional, um cache, uma API externa ou até mesmo um arquivo.

Os problemas que o Repository resolve são significativos:

  • Acoplamento com ORM/banco: Sem ele, o código de negócio frequentemente contém chamadas diretas ao Eloquent, Doctrine ou PDO, dificultando mudanças futuras.
  • Testabilidade: Repositories permitem substituir a implementação real por mocks durante testes unitários.
  • Centralização da lógica de consulta: Toda a complexidade de queries fica encapsulada em um único local.

É importante diferenciar Repository de DAO (Data Access Object). Enquanto o DAO foca em operações CRUD genéricas e expõe a estrutura do banco, o Repository trabalha com conceitos do domínio, retornando entidades ou agregados completos. O Repository esconde a complexidade de como os dados são montados.

2. Estrutura Básica de um Repository em PHP

A base do padrão é uma interface que define os contratos:

<?php

namespace App\Domain\Repository;

use App\Domain\Entity\User;
use App\Domain\ValueObject\UserId;

interface UserRepositoryInterface
{
    public function find(UserId $id): ?User;
    public function findAll(): array;
    public function save(User $user): void;
    public function delete(UserId $id): void;
}

Implementação com PDO

<?php

namespace App\Infrastructure\Persistence;

use App\Domain\Entity\User;
use App\Domain\Repository\UserRepositoryInterface;
use App\Domain\ValueObject\UserId;
use PDO;

class PdoUserRepository implements UserRepositoryInterface
{
    public function __construct(private PDO $pdo) {}

    public function find(UserId $id): ?User
    {
        $stmt = $this->pdo->prepare('SELECT * FROM users WHERE id = :id');
        $stmt->execute(['id' => $id->value()]);
        $data = $stmt->fetch(PDO::FETCH_ASSOC);

        if (!$data) {
            return null;
        }

        return User::fromArray($data);
    }

    public function findAll(): array
    {
        $stmt = $this->pdo->query('SELECT * FROM users');
        $users = [];

        while ($data = $stmt->fetch(PDO::FETCH_ASSOC)) {
            $users[] = User::fromArray($data);
        }

        return $users;
    }

    public function save(User $user): void
    {
        $stmt = $this->pdo->prepare(
            'INSERT INTO users (id, name, email) VALUES (:id, :name, :email)
             ON DUPLICATE KEY UPDATE name = :name2, email = :email2'
        );

        $stmt->execute([
            'id' => $user->id()->value(),
            'name' => $user->name(),
            'email' => $user->email()->value(),
            'name2' => $user->name(),
            'email2' => $user->email()->value(),
        ]);
    }

    public function delete(UserId $id): void
    {
        $stmt = $this->pdo->prepare('DELETE FROM users WHERE id = :id');
        $stmt->execute(['id' => $id->value()]);
    }
}

Implementação com Eloquent

<?php

namespace App\Infrastructure\Persistence;

use App\Domain\Entity\User;
use App\Domain\Repository\UserRepositoryInterface;
use App\Domain\ValueObject\UserId;
use App\Models\User as EloquentUser;

class EloquentUserRepository implements UserRepositoryInterface
{
    public function find(UserId $id): ?User
    {
        $eloquentUser = EloquentUser::find($id->value());

        return $eloquentUser ? User::fromArray($eloquentUser->toArray()) : null;
    }

    public function findAll(): array
    {
        return EloquentUser::all()
            ->map(fn(EloquentUser $user) => User::fromArray($user->toArray()))
            ->toArray();
    }

    public function save(User $user): void
    {
        EloquentUser::updateOrCreate(
            ['id' => $user->id()->value()],
            [
                'name' => $user->name(),
                'email' => $user->email()->value(),
            ]
        );
    }

    public function delete(UserId $id): void
    {
        EloquentUser::destroy($id->value());
    }
}

3. Repository com Tipagem Forte e Value Objects

Value Objects trazem segurança e expressividade ao código. Exemplos:

<?php

namespace App\Domain\ValueObject;

class UserId
{
    public function __construct(private string $uuid)
    {
        if (!uuid_is_valid($uuid)) {
            throw new \InvalidArgumentException('UUID inválido');
        }
    }

    public function value(): string
    {
        return $this->uuid;
    }

    public function equals(UserId $other): bool
    {
        return $this->uuid === $other->uuid;
    }
}

class Email
{
    public function __construct(private string $email)
    {
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new \InvalidArgumentException('Email inválido');
        }
    }

    public function value(): string
    {
        return $this->email;
    }
}

Métodos de busca especializados e uso de DTOs:

<?php

namespace App\Domain\Repository;

use App\Domain\DTO\UserSummaryDTO;
use App\Domain\ValueObject\Email;
use App\Domain\ValueObject\UserId;

interface UserRepositoryInterface
{
    // Métodos existentes...

    public function findByEmail(Email $email): ?User;
    public function findActiveUsers(int $limit = 10): array;
    public function findSummary(UserId $id): ?UserSummaryDTO;
}

4. Injeção de Dependência e Service Layer

A Service Layer utiliza o Repository através de sua interface:

<?php

namespace App\Application\Service;

use App\Domain\Entity\User;
use App\Domain\Repository\UserRepositoryInterface;
use App\Domain\ValueObject\Email;
use App\Domain\ValueObject\UserId;

class UserService
{
    public function __construct(
        private UserRepositoryInterface $userRepository
    ) {}

    public function registerUser(string $name, string $email): User
    {
        $emailVo = new Email($email);

        if ($this->userRepository->findByEmail($emailVo)) {
            throw new \DomainException('Email já cadastrado');
        }

        $user = User::create($name, $emailVo);
        $this->userRepository->save($user);

        return $user;
    }

    public function getUserSummary(string $userId): array
    {
        $userIdVo = new UserId($userId);
        $summary = $this->userRepository->findSummary($userIdVo);

        if (!$summary) {
            throw new \DomainException('Usuário não encontrado');
        }

        return $summary->toArray();
    }
}

No Laravel, a injeção pode ser configurada no AppServiceProvider:

<?php

namespace App\Providers;

use App\Domain\Repository\UserRepositoryInterface;
use App\Infrastructure\Persistence\EloquentUserRepository;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(
            UserRepositoryInterface::class,
            EloquentUserRepository::class
        );
    }
}

5. Testabilidade e Mocks

Testes unitários com mocks do Repository:

<?php

namespace Tests\Unit\Application\Service;

use App\Application\Service\UserService;
use App\Domain\Entity\User;
use App\Domain\Repository\UserRepositoryInterface;
use App\Domain\ValueObject\Email;
use PHPUnit\Framework\TestCase;

class UserServiceTest extends TestCase
{
    public function testRegisterUserCreatesNewUser(): void
    {
        $repository = $this->createMock(UserRepositoryInterface::class);

        $repository->expects($this->once())
            ->method('findByEmail')
            ->willReturn(null);

        $repository->expects($this->once())
            ->method('save')
            ->with($this->isInstanceOf(User::class));

        $service = new UserService($repository);
        $user = $service->registerUser('João', 'joao@email.com');

        $this->assertInstanceOf(User::class, $user);
    }
}

Testes de integração com SQLite:

<?php

namespace Tests\Integration\Infrastructure\Persistence;

use App\Domain\Entity\User;
use App\Domain\ValueObject\Email;
use App\Domain\ValueObject\UserId;
use App\Infrastructure\Persistence\PdoUserRepository;
use PHPUnit\Framework\TestCase;

class PdoUserRepositoryTest extends TestCase
{
    private PdoUserRepository $repository;
    private \PDO $pdo;

    protected function setUp(): void
    {
        $this->pdo = new \PDO('sqlite::memory:');
        $this->pdo->exec('CREATE TABLE users (id TEXT PRIMARY KEY, name TEXT, email TEXT)');
        $this->repository = new PdoUserRepository($this->pdo);
    }

    public function testSaveAndFindUser(): void
    {
        $user = User::create('Maria', new Email('maria@email.com'));
        $this->repository->save($user);

        $found = $this->repository->find($user->id());
        $this->assertNotNull($found);
        $this->assertEquals('Maria', $found->name());
    }
}

6. Repository com Cache e Performance

Um decorator para cache com Redis:

<?php

namespace App\Infrastructure\Cache;

use App\Domain\Entity\User;
use App\Domain\Repository\UserRepositoryInterface;
use App\Domain\ValueObject\UserId;
use Predis\Client as Redis;

class CachedUserRepository implements UserRepositoryInterface
{
    private const TTL = 3600;

    public function __construct(
        private UserRepositoryInterface $repository,
        private Redis $redis
    ) {}

    public function find(UserId $id): ?User
    {
        $cacheKey = "user:{$id->value()}";

        $cached = $this->redis->get($cacheKey);
        if ($cached) {
            return User::fromJson($cached);
        }

        $user = $this->repository->find($id);

        if ($user) {
            $this->redis->setex($cacheKey, self::TTL, $user->toJson());
        }

        return $user;
    }

    public function save(User $user): void
    {
        $this->repository->save($user);
        $this->redis->del("user:{$user->id()->value()}");
    }

    // Outros métodos delegam para o repositório real...
}

7. Repository e Dados em Tempo Real

Integração com WebSockets ao salvar:

<?php

namespace App\Infrastructure\Persistence;

use App\Domain\Entity\User;
use App\Domain\Repository\UserRepositoryInterface;
use App\Infrastructure\WebSocket\WebSocketBroadcaster;

class WebSocketAwareUserRepository implements UserRepositoryInterface
{
    public function __construct(
        private UserRepositoryInterface $repository,
        private WebSocketBroadcaster $broadcaster
    ) {}

    public function save(User $user): void
    {
        $this->repository->save($user);
        $this->broadcaster->broadcast('user.saved', $user->toArray());
    }

    // Demais métodos delegados...
}

8. Boas Práticas e Armadilhas Comuns

Evite o "God Repository": Não crie um repositório com dezenas de métodos. Separe por agregados ou contextos.

Repository vs. Query Objects: Para consultas complexas com muitos filtros, considere criar Query Objects separados:

<?php

namespace App\Domain\Repository\Query;

class ActiveUsersQuery
{
    public function __construct(
        public readonly ?int $minAge = null,
        public readonly ?string $city = null,
        public readonly string $orderBy = 'created_at',
        public readonly int $limit = 20
    ) {}
}

Transações: Gerencie transações no nível do Service, não do Repository:

<?php

namespace App\Application\Service;

class OrderService
{
    public function placeOrder(OrderData $data): void
    {
        $this->entityManager->beginTransaction();
        try {
            $order = Order::create($data);
            $this->orderRepository->save($order);

            foreach ($data->items as $item) {
                $this->inventoryRepository->decrementStock($item->productId, $item->quantity);
            }

            $this->entityManager->commit();
        } catch (\Exception $e) {
            $this->entityManager->rollback();
            throw $e;
        }
    }
}

O Repository Pattern, quando bem aplicado, traz desacoplamento, testabilidade e manutenibilidade para aplicações PHP de qualquer porte. A chave está em manter a interface focada no domínio e a implementação flexível para mudanças.

Referências