Introdução ao Doctrine ORM

1. O que é o Doctrine ORM e por que usá-lo?

Tradicionalmente, ao trabalhar com bancos de dados em PHP, utilizamos SQL puro ou PDO para executar consultas. Essa abordagem, embora funcional, apresenta diversos problemas à medida que o projeto cresce:

  • Código repetitivo: tarefas comuns como CRUD exigem consultas SQL manuais e repetitivas
  • Acoplamento ao banco: mudar de SGBD (ex: MySQL para PostgreSQL) exige reescrever consultas
  • Falta de orientação a objetos: trabalhamos com arrays associativos, perdendo os benefícios da POO
  • Manutenção complexa: consultas espalhadas por toda a aplicação dificultam alterações

O Doctrine ORM (Object-Relational Mapping) resolve esses problemas ao mapear tabelas do banco de dados para classes PHP, permitindo que você trabalhe com objetos em vez de SQL. Ele implementa o padrão Data Mapper, onde uma camada intermediária (EntityManager) gerencia a persistência dos objetos.

Principais vantagens:
- Produtividade: operações comuns são feitas com poucas linhas de código
- Manutenibilidade: regras de negócio ficam centralizadas nas entidades
- Independência de banco: abstrai diferenças entre SGBDs
- Recursos avançados: cache, lazy loading, identity map, unit of work

A arquitetura do Doctrine é composta por:
- EntityManager: ponto central de acesso ao ORM
- Repositories: classes responsáveis por consultas personalizadas
- Unit of Work: rastreia mudanças nas entidades durante uma requisição

2. Instalação e configuração inicial

Para instalar o Doctrine ORM via Composer, execute:

composer require doctrine/orm doctrine/dbal symfony/cache

Agora, crie o arquivo bootstrap.php para configurar a conexão:

<?php
// bootstrap.php

use Doctrine\ORM\EntityManager;
use Doctrine\ORM\ORMSetup;

require_once __DIR__ . '/vendor/autoload.php';

// Configuração dos diretórios de entidades
$paths = [__DIR__ . '/src/Entity'];
$isDevMode = true;

// Configuração do cache (usando array para desenvolvimento)
$cache = new Doctrine\Common\Cache\ArrayCache();

// Configuração do ORM
$config = ORMSetup::createAttributeMetadataConfiguration(
    $paths,
    $isDevMode,
    null,
    $cache
);

// Configuração da conexão com banco (exemplo com SQLite)
$connectionParams = [
    'driver'   => 'pdo_sqlite',
    'path'     => __DIR__ . '/data/database.sqlite',
];

// Para MySQL, use:
// $connectionParams = [
//     'driver'   => 'pdo_mysql',
//     'host'     => 'localhost',
//     'dbname'   => 'meu_banco',
//     'user'     => 'root',
//     'password' => 'senha',
// ];

// Criação do EntityManager
$entityManager = EntityManager::create($connectionParams, $config);

Para usar as ferramentas de linha de comando, crie o arquivo cli-config.php:

<?php
// cli-config.php
require_once 'bootstrap.php';

return Doctrine\ORM\Tools\Console\ConsoleRunner::createHelperSet($entityManager);

3. Mapeamento de entidades (Entity Mapping)

Vamos criar nossa primeira entidade utilizando PHP 8 attributes (recomendado):

<?php
// src/Entity/Usuario.php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\Table(name: 'usuarios')]
class Usuario
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'integer')]
    private int $id;

    #[ORM\Column(type: 'string', length: 100)]
    private string $nome;

    #[ORM\Column(type: 'string', length: 180, unique: true)]
    private string $email;

    #[ORM\Column(type: 'datetime', nullable: true)]
    private ?\DateTimeInterface $dataNascimento = null;

    #[ORM\Column(type: 'boolean', options: ['default' => true])]
    private bool $ativo = true;

    // Getters e Setters
    public function getId(): int { return $this->id; }
    public function getNome(): string { return $this->nome; }
    public function setNome(string $nome): self { $this->nome = $nome; return $this; }
    public function getEmail(): string { return $this->email; }
    public function setEmail(string $email): self { $this->email = $email; return $this; }
    public function getDataNascimento(): ?\DateTimeInterface { return $this->dataNascimento; }
    public function setDataNascimento(?\DateTimeInterface $dataNascimento): self { $this->dataNascimento = $dataNascimento; return $this; }
    public function isAtivo(): bool { return $this->ativo; }
    public function setAtivo(bool $ativo): self { $this->ativo = $ativo; return $this; }
}

Para gerar as tabelas no banco, execute:

php vendor/bin/doctrine orm:schema-tool:create

4. Relacionamentos entre entidades

OneToOne (Usuário e Perfil)

// src/Entity/Perfil.php
#[ORM\Entity]
class Perfil
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
    private int $id;

    #[ORM\Column(type: 'text')]
    private string $biografia;

    #[ORM\OneToOne(inversedBy: 'perfil')]
    #[ORM\JoinColumn(nullable: false)]
    private Usuario $usuario;
}

Na entidade Usuario, adicione:

#[ORM\OneToOne(mappedBy: 'usuario', cascade: ['persist', 'remove'])]
private ?Perfil $perfil = null;

OneToMany / ManyToOne (Categoria e Produto)

// src/Entity/Categoria.php
#[ORM\Entity]
class Categoria
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
    private int $id;

    #[ORM\Column(type: 'string', length: 100)]
    private string $nome;

    #[ORM\OneToMany(mappedBy: 'categoria', targetEntity: Produto::class, cascade: ['persist'])]
    private \Doctrine\Common\Collections\Collection $produtos;

    public function __construct()
    {
        $this->produtos = new \Doctrine\Common\Collections\ArrayCollection();
    }
}

// src/Entity/Produto.php
#[ORM\Entity]
class Produto
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
    private int $id;

    #[ORM\Column(type: 'string', length: 150)]
    private string $nome;

    #[ORM\ManyToOne(inversedBy: 'produtos')]
    #[ORM\JoinColumn(nullable: false)]
    private Categoria $categoria;
}

ManyToMany (Aluno e Curso)

// src/Entity/Aluno.php
#[ORM\Entity]
class Aluno
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
    private int $id;

    #[ORM\Column(type: 'string', length: 100)]
    private string $nome;

    #[ORM\ManyToMany(targetEntity: Curso::class, inversedBy: 'alunos')]
    #[ORM\JoinTable(name: 'alunos_cursos')]
    private \Doctrine\Common\Collections\Collection $cursos;

    public function __construct()
    {
        $this->cursos = new \Doctrine\Common\Collections\ArrayCollection();
    }
}

// src/Entity/Curso.php
#[ORM\Entity]
class Curso
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
    private int $id;

    #[ORM\Column(type: 'string', length: 150)]
    private string $titulo;

    #[ORM\ManyToMany(targetEntity: Aluno::class, mappedBy: 'cursos')]
    private \Doctrine\Common\Collections\Collection $alunos;

    public function __construct()
    {
        $this->alunos = new \Doctrine\Common\Collections\ArrayCollection();
    }
}

5. EntityManager e o ciclo de vida das entidades

O EntityManager gerencia o ciclo de vida das entidades através do padrão Unit of Work:

<?php
require_once 'bootstrap.php';

use App\Entity\Usuario;

$em = require 'bootstrap.php';

// Criando e persistindo um novo usuário
$usuario = new Usuario();
$usuario->setNome('João Silva');
$usuario->setEmail('joao@email.com');
$usuario->setDataNascimento(new \DateTime('1990-05-15'));

$em->persist($usuario); // Apenas agenda para inserção
$em->flush();           // Executa todas as operações pendentes

echo "Usuário criado com ID: " . $usuario->getId() . PHP_EOL;

// Recuperando entidades
$usuario = $em->find(Usuario::class, 1);
$usuarios = $em->getRepository(Usuario::class)->findBy(['ativo' => true]);
$usuario = $em->getRepository(Usuario::class)->findOneBy(['email' => 'joao@email.com']);

// Atualizando uma entidade
$usuario->setNome('João Santos');
$em->flush(); // Detecta mudanças automaticamente

// Removendo uma entidade
$em->remove($usuario);
$em->flush();

O Identity Map garante que cada entidade seja carregada apenas uma vez por requisição:

$usuario1 = $em->find(Usuario::class, 1);
$usuario2 = $em->find(Usuario::class, 1);

var_dump($usuario1 === $usuario2); // true - mesmo objeto

6. Repositórios (Repositories)

Repositórios centralizam consultas personalizadas:

<?php
// src/Repository/UsuarioRepository.php

namespace App\Repository;

use App\Entity\Usuario;
use Doctrine\ORM\EntityRepository;

class UsuarioRepository extends EntityRepository
{
    public function findAtivosComPerfil(): array
    {
        return $this->createQueryBuilder('u')
            ->innerJoin('u.perfil', 'p')
            ->where('u.ativo = :ativo')
            ->setParameter('ativo', true)
            ->getQuery()
            ->getResult();
    }

    public function countByMes(int $ano): array
    {
        return $this->createQueryBuilder('u')
            ->select('MONTH(u.dataCriacao) as mes, COUNT(u.id) as total')
            ->where('YEAR(u.dataCriacao) = :ano')
            ->setParameter('ano', $ano)
            ->groupBy('mes')
            ->orderBy('mes')
            ->getQuery()
            ->getScalarResult();
    }
}

Para usar o repositório customizado, registre no mapeamento:

#[ORM\Entity(repositoryClass: UsuarioRepository::class)]
class Usuario { ... }

7. Consultas com DQL e QueryBuilder

DQL (Doctrine Query Language) é uma linguagem de consulta orientada a objetos:

// DQL básica
$dql = "SELECT u FROM App\Entity\Usuario u WHERE u.ativo = :ativo ORDER BY u.nome ASC";
$query = $em->createQuery($dql);
$query->setParameter('ativo', true);
$usuarios = $query->getResult();

// DQL com JOIN
$dql = "SELECT u, p FROM App\Entity\Usuario u JOIN u.perfil p WHERE u.ativo = :ativo";
$usuarios = $em->createQuery($dql)
    ->setParameter('ativo', true)
    ->getResult();

// DQL com funções agregadas
$dql = "SELECT c.nome, COUNT(p.id) as total FROM App\Entity\Categoria c JOIN c.produtos p GROUP BY c.id";
$resultados = $em->createQuery($dql)->getScalarResult();

QueryBuilder oferece uma API fluente para construir consultas dinâmicas:

$qb = $em->createQueryBuilder();

$qb->select('u')
   ->from(Usuario::class, 'u')
   ->leftJoin('u.perfil', 'p')
   ->where($qb->expr()->andX(
       $qb->expr()->eq('u.ativo', ':ativo'),
       $qb->expr()->orX(
           $qb->expr()->like('u.nome', ':busca'),
           $qb->expr()->like('u.email', ':busca')
       )
   ))
   ->setParameter('ativo', true)
   ->setParameter('busca', '%' . $termo . '%')
   ->orderBy('u.nome', 'ASC')
   ->setMaxResults(10);

$usuarios = $qb->getQuery()->getResult();

Hidratação de resultados:

// Array de objetos (padrão)
$usuarios = $query->getResult(); // array de Usuario

// Array escalar
$dados = $query->getScalarResult(); // array de arrays simples

// Array simples de uma coluna
$nomes = $query->getSingleColumnResult(); // array de strings

// Um único resultado
$usuario = $query->getOneOrNullResult(); // Usuario ou null

8. Considerações finais e boas práticas

Migrations com Doctrine Migrations:

composer require doctrine/migrations

Crie e execute migrations para versionar o schema do banco:

php vendor/bin/doctrine migrations:diff
php vendor/bin/doctrine migrations:migrate

Performance: evitar N+1 queries:

// Ruim - N+1 queries
$categorias = $em->getRepository(Categoria::class)->findAll();
foreach ($categorias as $categoria) {
    foreach ($categoria->getProdutos() as $produto) { // Nova query a cada iteração
        echo $produto->getNome();
    }
}

// Bom - eager loading com JOIN
$categorias = $em->createQueryBuilder()
    ->select('c, p')
    ->from(Categoria::class, 'c')
    ->leftJoin('c.produtos', 'p')
    ->getQuery()
    ->getResult();

Validação de dados: valide antes de persistir:

if (empty($usuario->getNome())) {
    throw new \InvalidArgumentException('Nome é obrigatório');
}
if (!filter_var($usuario->getEmail(), FILTER_VALIDATE_EMAIL)) {
    throw new \InvalidArgumentException('Email inválido');
}
$em->persist($usuario);
$em->flush();

Organização de código:
- Mantenha entidades anêmicas (apenas getters/setters)
- Coloque lógica de negócio em serviços ou Domain Services
- Use repositórios para consultas complexas
- Evite chamar flush() dentro de loops; chame uma vez no final

O Doctrine ORM é uma ferramenta poderosa que, quando bem utilizada, acelera significativamente o desenvolvimento e melhora a qualidade do código. Comece com projetos pequenos, entenda o ciclo de vida das entidades e, gradualmente, explore recursos avançados como eventos de ciclo de vida, filtros e extensões.

Referências