MongoDB com PHP

MongoDB com PHP

Documentos em vez de linhas, consultas por notação de ponto e um pipeline de agregação que faz o papel do GROUP BY e do JOIN. Inclui onde a ausência de schema cobra o seu preço e por que atualizar um campo dentro de um array pede o operador posicional.
PHP

• • 27 min de leitura

MongoDB representa uma abordagem radicalmente diferente ao armazenamento de dados. Enquanto bancos relacionais organizam dados em tabelas com linhas e colunas de schema fixo, o MongoDB organiza dados em coleções de documentos no formato BSON (Binary JSON), sem schema obrigatório. Cada documento pode ter campos diferentes dos demais na mesma coleção.

Isso não significa que o MongoDB é "melhor" que o MySQL — é diferente. Ele brilha em cenários específicos: dados com estrutura variável ou que mudam com frequência, catálogos de produtos com atributos heterogêneos, logs e eventos, e aplicações onde a estrutura de dados evolui rápido. Entender quando usá-lo (e quando não usá-lo) é tão importante quanto saber usá-lo.

Instalação

MongoDB no Linux:

# Instalar MongoDB.
# O pacote NÃO está nos repositórios do Debian nem do Ubuntu: o MongoDB
# saiu deles ao trocar de licença para a SSPL, em 2018, e o comando abaixo
# responde "Unable to locate package" numa instalação limpa. É preciso
# adicionar o repositório oficial primeiro; o procedimento muda por
# distribuição e está em mongodb.com/docs/manual/administration/install-on-linux/
sudo apt install mongodb-org

# Instalar a extensão PHP. Não adianta chutar o caminho do php.ini — o
# "8.x" acima é literal e não existe, e escrever só no php.ini do CLI
# deixa o PHP-FPM sem a extensão. Em Debian e Ubuntu, o phpenmod habilita
# em todos os SAPIs de uma vez.
sudo pecl install mongodb
sudo phpenmod mongodb
php -m | grep mongodb          # confirma que carregou

# Instalar a biblioteca via Composer
composer require mongodb/mongodb

Conceitos fundamentais

Antes de escrever código, alinhe o vocabulário:

Relacional MongoDB
Banco de dados Database
Tabela Collection
Linha Document
Coluna Field
JOIN $lookup (aggregation)
PRIMARY KEY _id (ObjectId automático)
INDEX Index (mesma ideia)
Schema fixo Schema flexível

Conexão e operações básicas

<?php
declare(strict_types=1);

require 'vendor/autoload.php';

use MongoDB\Client;
use MongoDB\BSON\ObjectId;
use MongoDB\BSON\UTCDateTime;

$client = new Client('mongodb://localhost:27017');

// Seleciona o banco e a coleção
// Se não existirem, são criados automaticamente na primeira operação
$db       = $client->loja;
$produtos = $db->produtos;

// INSERT — insertOne() retorna InsertOneResult
$resultado = $produtos->insertOne([
    'nome'       => 'Teclado Mecânico Keychron K2',
    'preco'      => 299.90,
    'estoque'    => 15,
    'ativo'      => true,
    'tags'       => ['periférico', 'teclado', 'mecânico'],
    'fabricante' => [           // documento embutido (embedded document)
        'nome'    => 'Keychron',
        'pais'    => 'China',
        'site'    => 'keychron.com',
    ],
    'variacoes' => [            // array de documentos embutidos
        ['cor' => 'preto',  'sku' => 'K2-BLK', 'estoque' => 8],
        ['cor' => 'cinza',  'sku' => 'K2-GRY', 'estoque' => 7],
    ],
    'criado_em'  => new UTCDateTime(),
]);

$id = $resultado->getInsertedId(); // ObjectId
echo "Inserido com ID: {$id}\n";

// INSERT em lote — insertMany()
$produtos->insertMany([
    ['nome' => 'Mouse Logitech MX Master 3', 'preco' => 499.90, 'tags' => ['periférico', 'mouse']],
    ['nome' => 'Monitor LG 27" 4K',          'preco' => 2899.00, 'tags' => ['monitor', 'display']],
]);

Consultas — find e findOne

<?php

// findOne() retorna um documento ou null
$produto = $produtos->findOne(['nome' => 'Teclado Mecânico Keychron K2']);
echo $produto['nome'] . ": R$ " . $produto['preco'] . "\n";

// Acessando documento embutido
echo "Fabricante: " . $produto['fabricante']['nome'] . "\n";

// find() retorna um Cursor — itere normalmente
$cursor = $produtos->find(['ativo' => true]);
foreach ($cursor as $p) {
    echo "- {$p['nome']}: R$ {$p['preco']}\n";
}

// Operadores de comparação
$caros = $produtos->find([
    'preco' => ['$gte' => 500],   // maior ou igual a 500
    'ativo' => true,
]);

// Múltiplas condições — AND implícito
$cursor = $produtos->find([
    'preco'   => ['$gte' => 100, '$lte' => 1000],
    'estoque' => ['$gt' => 0],
]);

// OR explícito
$cursor = $produtos->find([
    '$or' => [
        ['tags' => 'teclado'],
        ['tags' => 'mouse'],
    ]
]);

// Busca em array — o MongoDB verifica se o array contém o valor
$cursor = $produtos->find(['tags' => 'mecânico']);

// Busca em documento embutido com notação de ponto
$cursor = $produtos->find(['fabricante.nome' => 'Keychron']);

// Projeção — escolher quais campos retornar (1 = incluir, 0 = excluir)
$cursor = $produtos->find(
    filter:  ['ativo' => true],
    options: [
        'projection' => ['nome' => 1, 'preco' => 1, '_id' => 0],
        'sort'       => ['preco' => -1],   // -1 = decrescente
        'limit'      => 10,
        'skip'       => 0,
    ]
);

Atualização e deleção

<?php

// updateOne() — atualiza o primeiro documento que corresponde ao filtro
$produtos->updateOne(
    ['_id' => new ObjectId($id)],
    ['$set' => ['preco' => 279.90, 'atualizado_em' => new UTCDateTime()]]
);

// $inc — incrementa um valor
$produtos->updateOne(
    ['_id' => new ObjectId($id)],
    ['$inc' => ['estoque' => -1]]   // decrementa estoque em 1
);

// $push — adiciona ao array
$produtos->updateOne(
    ['_id' => new ObjectId($id)],
    ['$push' => ['tags' => 'bluetooth']]
);

// $pull — remove do array
$produtos->updateOne(
    ['_id' => new ObjectId($id)],
    ['$pull' => ['tags' => 'bluetooth']]
);

// updateMany() — atualiza todos que correspondem
$produtos->updateMany(
    ['estoque' => 0],
    ['$set' => ['ativo' => false]]
);

// findOneAndUpdate() — atualiza e retorna o documento.
// Repare no caminho dos campos. O sku não mora na raiz do produto, e sim
// dentro do array "variacoes": filtrar por ['sku' => ...] não encontra
// documento nenhum, e um $inc em 'estoque' mexeria no estoque do produto
// inteiro, não no daquela cor. O operador posicional $ aponta para o
// elemento do array que casou com o filtro.
$atualizado = $produtos->findOneAndUpdate(
    ['variacoes.sku' => 'K2-BLK'],
    ['$inc' => ['variacoes.$.estoque' => -1]],
    ['returnDocument' => \MongoDB\Operation\FindOneAndUpdate::RETURN_DOCUMENT_AFTER]
);

// deleteOne() e deleteMany()
$produtos->deleteOne(['_id' => new ObjectId($id)]);
$produtos->deleteMany(['ativo' => false, 'estoque' => 0]);

Aggregation Pipeline — o poder do MongoDB

O framework de agregação do MongoDB é equivalente a GROUP BY, JOIN e funções de janela do SQL, mas com uma sintaxe de pipeline:

<?php

$pipeline = [
    // $match — filtra documentos (como WHERE)
    ['$match' => ['ativo' => true]],

    // $unwind — "explode" um array em documentos separados
    ['$unwind' => '$tags'],

    // $group — agrupa e acumula (como GROUP BY)
    ['$group' => [
        '_id'           => '$tags',
        'total_produtos'=> ['$sum' => 1],
        'preco_medio'   => ['$avg' => '$preco'],
        'preco_min'     => ['$min' => '$preco'],
        'preco_max'     => ['$max' => '$preco'],
    ]],

    // $sort — ordena o resultado
    ['$sort' => ['total_produtos' => -1]],

    // $limit
    ['$limit' => 10],
];

$resultado = $produtos->aggregate($pipeline);
foreach ($resultado as $item) {
    echo "Tag: {$item['_id']}, Produtos: {$item['total_produtos']}, "
       . "Preço médio: R$ " . number_format($item['preco_medio'], 2, ',', '.') . "\n";
}

Índices

<?php

// Índice simples
$produtos->createIndex(['nome' => 1]);

// Índice composto
$produtos->createIndex(['ativo' => 1, 'preco' => -1]);

// Índice de texto para busca full-text
$produtos->createIndex(['nome' => 'text', 'descricao' => 'text']);

// Busca textual
$cursor = $produtos->find(['$text' => ['$search' => 'teclado mecânico']]);

// Índice em campo de documento embutido
$produtos->createIndex(['fabricante.nome' => 1]);

// TTL index — documentos expiram automaticamente (ótimo para logs/sessões).
// Duas condições que costumam passar despercebidas: o campo indexado precisa
// ser uma data BSON de verdade (UTCDateTime), porque em string o índice é
// criado e simplesmente nunca apaga nada; e a remoção fica a cargo de uma
// tarefa que roda a cada 60 segundos, então o documento sobrevive um pouco
// além do prazo. Para expiração exata, confira também na leitura.
$db->sessoes->createIndex(
    ['criado_em' => 1],
    ['expireAfterSeconds' => 3600]  // expira após 1 hora
);

Quando usar MongoDB vs banco relacional

Use MongoDB quando... Use banco relacional quando...
Schema varia entre documentos Schema é bem definido e estável
Dados hierárquicos naturais Dados com muitos relacionamentos
Escrita e leitura em alta escala Consistência ACID é crítica
Prototipagem rápida Relatórios complexos e JOINs
Catálogos com atributos variáveis Transações financeiras

A liberdade de não ter schema é a primeira coisa que encanta no MongoDB e a primeira que cobra. Sem estrutura declarada, nada impede que metade da coleção use preco e a outra metade use valor, ou que o mesmo campo seja número num documento e string no seguinte — e o banco não reclama em nenhum dos casos. Por isso, na prática, todo projeto sério acaba reconstruindo alguma disciplina de schema, seja na aplicação, seja com as regras de validação do próprio MongoDB.

A decisão de modelagem que mais pesa é onde parar de embutir. Documento embutido é lido de uma vez, sem $lookup nenhum, e é isso que torna a leitura tão rápida; mas aquilo que você embutiu passa a exigir caminho completo em todo filtro e todo update — e um campo que mora dentro de um array só se deixa atualizar com o operador posicional. Errar esse caminho não gera erro: gera um update que não encontra nada.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Crie uma coleção artigos de um blog: título, conteúdo, autor (documento embutido com nome e email), tags (array), publicado_em, rascunho (boolean). Insira 5 artigos.

Ver resposta

✓ Resposta: Embutir o autor elimina o JOIN, mas cria o problema do outro lado: se o e-mail dele mudar, é preciso atualizar todos os artigos. A regra prática é o padrão de acesso — embuta o que é sempre lido junto e muda pouco; referencie o que muda com frequência ou cresce sem limite.

<?php

declare(strict_types=1);

// composer require mongodb/mongodb   (+ extensão: pecl install mongodb)

use MongoDB\BSON\UTCDateTime;
use MongoDB\Client;

$cliente = new Client($_ENV['MONGO_DSN'] ?? 'mongodb://127.0.0.1:27017');
$colecao = $cliente->blog->artigos;

/** Converte DateTimeImmutable para o tipo de data do BSON. */
function bsonData(string $expressao): UTCDateTime
{
    return new UTCDateTime(new DateTimeImmutable($expressao));
}

$artigos = [
    [
        'titulo'   => 'Introdução ao MongoDB com PHP',
        'slug'     => 'introducao-mongodb-php',
        'conteudo' => str_repeat('Documento é a unidade de armazenamento. ', 40),
        // Documento EMBUTIDO: o autor vive dentro do artigo. Sem JOIN para
        // exibir a lista — é a troca clássica do modelo documental.
        'autor'    => ['nome' => 'Ana Souza', 'email' => 'ana@exemplo.com'],
        'tags'     => ['mongodb', 'php', 'nosql'],
        'publicado_em' => bsonData('2026-03-10 09:00'),
        'rascunho' => false,
        'palavras' => 320,
    ],
    [
        'titulo'   => 'Agregações no MongoDB',
        'slug'     => 'agregacoes-mongodb',
        'conteudo' => str_repeat('O pipeline processa em estágios. ', 90),
        'autor'    => ['nome' => 'Ana Souza', 'email' => 'ana@exemplo.com'],
        'tags'     => ['mongodb', 'agregacao', 'performance'],
        'publicado_em' => bsonData('2026-04-02 14:30'),
        'rascunho' => false,
        'palavras' => 720,
    ],
    [
        'titulo'   => 'Índices e performance',
        'slug'     => 'indices-performance',
        'conteudo' => str_repeat('Índice composto segue a regra ESR. ', 70),
        'autor'    => ['nome' => 'Bruno Lima', 'email' => 'bruno@exemplo.com'],
        'tags'     => ['mongodb', 'performance', 'indices'],
        'publicado_em' => bsonData('2026-04-18 08:15'),
        'rascunho' => false,
        'palavras' => 560,
    ],
    [
        'titulo'   => 'Transações em réplica set',
        'slug'     => 'transacoes-replica-set',
        'conteudo' => str_repeat('Transações exigem réplica set. ', 30),
        'autor'    => ['nome' => 'Bruno Lima', 'email' => 'bruno@exemplo.com'],
        'tags'     => ['mongodb', 'transacoes'],
        'publicado_em' => null,
        'rascunho' => true,
        'palavras' => 210,
    ],
    [
        'titulo'   => 'Modelagem: embutir ou referenciar?',
        'slug'     => 'modelagem-embutir-referenciar',
        'conteudo' => str_repeat('A regra é o padrão de acesso. ', 110),
        'autor'    => ['nome' => 'Ana Souza', 'email' => 'ana@exemplo.com'],
        'tags'     => ['mongodb', 'modelagem', 'nosql'],
        'publicado_em' => bsonData('2026-05-05 19:45'),
        'rascunho' => false,
        'palavras' => 880,
    ],
];

$resultado = $colecao->insertMany($artigos);

printf("%d artigos inseridos.%s", $resultado->getInsertedCount(), PHP_EOL);

foreach ($resultado->getInsertedIds() as $indice => $id) {
    printf("  [%d] %s%s", $indice, (string) $id, PHP_EOL);
}

// Índices: a "notação de ponto" alcança o documento embutido.
$colecao->createIndex(['slug' => 1], ['unique' => true]);
$colecao->createIndex(['autor.email' => 1, 'publicado_em' => -1]);
$colecao->createIndex(['tags' => 1]);          // multikey, para arrays
$colecao->createIndex(['rascunho' => 1, 'publicado_em' => -1]);

// Índice de texto: um por coleção, e pode cobrir vários campos.
$colecao->createIndex(
    ['titulo' => 'text', 'conteudo' => 'text'],
    ['weights' => ['titulo' => 10, 'conteudo' => 1], 'default_language' => 'portuguese'],
);

Exercício 2

Escreva queries para: buscar todos os artigos publicados de um autor específico, buscar artigos por tag, buscar artigos com mais de 500 palavras no conteúdo (usando $where com $expr).

Ver resposta

✓ Resposta: Evite $where: ele roda JavaScript no servidor, não usa índice e costuma vir desabilitado por segurança. $expr faz o mesmo dentro do motor de agregação. Melhor ainda é materializar o dado (o campo palavras) — comparar inteiro indexado ganha de qualquer expressão calculada.

<?php

declare(strict_types=1);

use MongoDB\BSON\UTCDateTime;
use MongoDB\Collection;

final class ConsultaArtigos
{
    public function __construct(private readonly Collection $colecao) {}

    /** Publicados de um autor — notação de ponto para o campo embutido. */
    public function publicadosDoAutor(string $email): array
    {
        return $this->colecao->find(
            [
                'autor.email'  => $email,
                'rascunho'     => false,
                'publicado_em' => ['$ne' => null, '$lte' => new UTCDateTime()],
            ],
            [
                'sort'       => ['publicado_em' => -1],
                // projeção: traz só o necessário. O conteúdo pode ser enorme.
                'projection' => ['titulo' => 1, 'slug' => 1, 'publicado_em' => 1, 'tags' => 1],
            ],
        )->toArray();
    }

    /** Em array, a igualdade simples já testa "contém". */
    public function porTag(string $tag): array
    {
        return $this->colecao->find(
            ['tags' => $tag, 'rascunho' => false],
            ['sort' => ['publicado_em' => -1]],
        )->toArray();
    }

    /** $all exige TODAS; $in aceita QUALQUER uma. */
    public function comTodasAsTags(array $tags): array
    {
        return $this->colecao->find(['tags' => ['$all' => $tags]])->toArray();
    }

    public function comAlgumaTag(array $tags): array
    {
        return $this->colecao->find(['tags' => ['$in' => $tags]])->toArray();
    }

    /**
     * Artigos com mais de N palavras.
     *
     * $expr permite comparar CAMPOS entre si e usar operadores de agregação
     * dentro do find. É a alternativa moderna ao $where — que executa
     * JavaScript no servidor, ignora índices e é desabilitado por padrão
     * em muitos ambientes. Prefira $expr sempre.
     */
    public function comMaisDe(int $palavras): array
    {
        return $this->colecao->find([
            '$expr' => [
                '$gt' => [
                    // Conta palavras a partir do próprio conteúdo, sem
                    // depender do campo pré-calculado.
                    ['$size' => ['$split' => [['$trim' => ['input' => '$conteudo']], ' ']]],
                    $palavras,
                ],
            ],
        ], ['projection' => ['titulo' => 1, 'palavras' => 1]])->toArray();
    }

    /** A versão barata: usar o contador já materializado. */
    public function comMaisDeRapido(int $palavras): array
    {
        return $this->colecao->find(
            ['palavras' => ['$gt' => $palavras], 'rascunho' => false],
            ['sort' => ['palavras' => -1]],
        )->toArray();
    }

    /** Comparação entre dois campos do MESMO documento — só com $expr. */
    public function atualizadosDepoisDePublicados(): array
    {
        return $this->colecao->find([
            '$expr' => ['$gt' => ['$atualizado_em', '$publicado_em']],
        ])->toArray();
    }

    /** Busca textual usando o índice de texto criado no exercício 1. */
    public function buscarTexto(string $termo, int $limite = 10): array
    {
        return $this->colecao->find(
            ['$text' => ['$search' => $termo], 'rascunho' => false],
            [
                'projection' => ['titulo' => 1, 'score' => ['$meta' => 'textScore']],
                'sort'       => ['score' => ['$meta' => 'textScore']],
                'limit'      => $limite,
            ],
        )->toArray();
    }
}

$consulta = new ConsultaArtigos($colecao);

foreach ($consulta->publicadosDoAutor('ana@exemplo.com') as $artigo) {
    printf("  %s (%s)%s", $artigo['titulo'],
           $artigo['publicado_em']->toDateTime()->format('d/m/Y'), PHP_EOL);
}

print_r($consulta->porTag('performance'));
print_r($consulta->comMaisDe(500));
print_r($consulta->buscarTexto('índice performance'));

Exercício 3

Crie um pipeline de agregação que retorne: por autor, o número de artigos publicados, a data do artigo mais recente e as 3 tags mais usadas.

Ver resposta

✓ Resposta: Duas regras que decidem a performance de um pipeline: $match o mais cedo possível (é o único estágio que aproveita índice, e só antes de qualquer $group/$unwind), e ordenar antes do $push quando o resultado precisa de um "top N" — o $push preserva a ordem em que os documentos chegam.

<?php

declare(strict_types=1);

use MongoDB\Collection;

final class RelatorioAutores
{
    public function __construct(private readonly Collection $colecao) {}

    public function porAutor(): array
    {
        $pipeline = [
            // 1) FILTRAR PRIMEIRO. $match no topo aproveita índice e reduz o
            //    volume que os estágios seguintes precisam processar.
            ['$match' => ['rascunho' => false, 'publicado_em' => ['$ne' => null]]],

            // 2) Agrupar por autor, acumulando o que interessa.
            ['$group' => [
                '_id'           => '$autor.email',
                'nome'          => ['$first' => '$autor.nome'],
                'total'         => ['$sum' => 1],
                'mais_recente'  => ['$max' => '$publicado_em'],
                'primeiro'      => ['$min' => '$publicado_em'],
                'media_palavras'=> ['$avg' => '$palavras'],
                // Junta todos os arrays de tags num array de arrays.
                'tags'          => ['$push' => '$tags'],
            ]],

            // 3) Achatar o array de arrays em uma lista simples.
            ['$addFields' => [
                'todas_tags' => ['$reduce' => [
                    'input'        => '$tags',
                    'initialValue' => [],
                    'in'           => ['$concatArrays' => ['$$value', '$$this']],
                ]],
            ]],

            // 4) Uma linha por tag, para poder contá-las.
            ['$unwind' => '$todas_tags'],

            // 5) Contar cada par (autor, tag).
            ['$group' => [
                '_id' => ['autor' => '$_id', 'tag' => '$todas_tags'],
                'nome'          => ['$first' => '$nome'],
                'total'         => ['$first' => '$total'],
                'mais_recente'  => ['$first' => '$mais_recente'],
                'primeiro'      => ['$first' => '$primeiro'],
                'media_palavras'=> ['$first' => '$media_palavras'],
                'usos'          => ['$sum' => 1],
            ]],

            // 6) Ordenar por uso antes de reagrupar: o $push preserva a
            //    ordem, e é isso que faz o "top 3" ficar correto.
            ['$sort' => ['usos' => -1]],

            // 7) Voltar a uma linha por autor, com as tags já ordenadas.
            ['$group' => [
                '_id'           => '$_id.autor',
                'nome'          => ['$first' => '$nome'],
                'total'         => ['$first' => '$total'],
                'mais_recente'  => ['$first' => '$mais_recente'],
                'primeiro'      => ['$first' => '$primeiro'],
                'media_palavras'=> ['$first' => '$media_palavras'],
                'tags'          => ['$push' => ['tag' => '$_id.tag', 'usos' => '$usos']],
            ]],

            // 8) Só as 3 primeiras e formatação final.
            ['$project' => [
                '_id'            => 0,
                'email'          => '$_id',
                'nome'           => 1,
                'artigos'        => '$total',
                'mais_recente'   => 1,
                'dias_ativo'     => ['$dateDiff' => [
                    'startDate' => '$primeiro', 'endDate' => '$mais_recente', 'unit' => 'day',
                ]],
                'media_palavras' => ['$round' => ['$media_palavras', 0]],
                'top_tags'       => ['$slice' => ['$tags', 3]],
            ]],

            ['$sort' => ['artigos' => -1]],
        ];

        return $this->colecao->aggregate($pipeline)->toArray();
    }

    /** $facet: vários relatórios numa varredura só. */
    public function painel(): array
    {
        return $this->colecao->aggregate([
            ['$match' => ['rascunho' => false]],
            ['$facet' => [
                'por_autor' => [
                    ['$group' => ['_id' => '$autor.nome', 'total' => ['$sum' => 1]]],
                    ['$sort' => ['total' => -1]],
                ],
                'por_mes' => [
                    ['$group' => [
                        '_id' => ['$dateToString' => ['format' => '%Y-%m', 'date' => '$publicado_em']],
                        'total' => ['$sum' => 1],
                    ]],
                    ['$sort' => ['_id' => 1]],
                ],
                'tags_populares' => [
                    ['$unwind' => '$tags'],
                    ['$group' => ['_id' => '$tags', 'usos' => ['$sum' => 1]]],
                    ['$sort' => ['usos' => -1]],
                    ['$limit' => 5],
                ],
                'resumo' => [
                    ['$group' => [
                        '_id' => null,
                        'artigos'  => ['$sum' => 1],
                        'palavras' => ['$sum' => '$palavras'],
                    ]],
                ],
            ]],
        ])->toArray()[0];
    }
}

foreach ((new RelatorioAutores($colecao))->porAutor() as $autor) {
    printf("%s <%s>%s", $autor['nome'], $autor['email'], PHP_EOL);
    printf("  %d artigos · média de %d palavras · ativo por %d dias%s",
           $autor['artigos'], $autor['media_palavras'], $autor['dias_ativo'], PHP_EOL);

    foreach ($autor['top_tags'] as $tag) {
        printf("    %-14s %dx%s", $tag['tag'], $tag['usos'], PHP_EOL);
    }
}

Exercício 4

Implemente um TTL index na coleção tokens_recuperacao onde cada token expira em 2 horas (expireAfterSeconds: 7200).

Ver resposta

✓ Resposta: Duas armadilhas do TTL index: ele só funciona sobre campo de data (com string ou timestamp numérico, é ignorado sem aviso), e o coletor roda a cada ~60 s — o documento vencido continua legível nesse intervalo. Para credencial, sempre compare a data na própria consulta.

<?php

declare(strict_types=1);

use MongoDB\BSON\UTCDateTime;
use MongoDB\Client;

final class TokensRecuperacao
{
    private const EXPIRA_EM = 7200;   // 2 horas

    private \MongoDB\Collection $colecao;

    public function __construct(Client $cliente)
    {
        $this->colecao = $cliente->auth->tokens_recuperacao;

        $this->garantirIndices();
    }

    private function garantirIndices(): void
    {
        // TTL index: o Mongo apaga o documento sozinho quando
        // criado_em + expireAfterSeconds passa. Só funciona sobre campo de
        // DATA (ou array de datas) — em string ou int, é ignorado em silêncio.
        $this->colecao->createIndex(
            ['criado_em' => 1],
            ['expireAfterSeconds' => self::EXPIRA_EM, 'name' => 'ttl_criado_em'],
        );

        // O token é guardado como HASH; a busca é por ele.
        $this->colecao->createIndex(['token_hash' => 1], ['unique' => true]);
        $this->colecao->createIndex(['usuario_id' => 1]);
    }

    /** @return string o token em texto puro — só existe aqui e no e-mail */
    public function gerar(int $usuarioId, string $ip = ''): string
    {
        // Um token ativo por usuário: gerar outro invalida o anterior.
        $this->colecao->deleteMany(['usuario_id' => $usuarioId]);

        $token = bin2hex(random_bytes(32));

        $this->colecao->insertOne([
            'token_hash' => hash('sha256', $token),
            'usuario_id' => $usuarioId,
            'criado_em'  => new UTCDateTime(),
            'expira_em'  => new UTCDateTime((time() + self::EXPIRA_EM) * 1000),
            'ip'         => $ip,
            'usado'      => false,
        ]);

        return $token;
    }

    public function validar(string $token): ?int
    {
        $documento = $this->colecao->findOne([
            'token_hash' => hash('sha256', $token),
            'usado'      => false,
            // ⚠️ NÃO confie só no TTL: o coletor roda a cada ~60 s, então um
            // documento vencido pode continuar visível por até um minuto.
            // Para segurança, compare a data explicitamente.
            'expira_em'  => ['$gt' => new UTCDateTime()],
        ]);

        return $documento !== null ? (int) $documento['usuario_id'] : null;
    }

    /** Consome o token: uso único, de forma atômica. */
    public function consumir(string $token): ?int
    {
        // findOneAndUpdate é atômico: duas requisições simultâneas com o
        // mesmo token — só uma consegue marcá-lo como usado.
        $documento = $this->colecao->findOneAndUpdate(
            [
                'token_hash' => hash('sha256', $token),
                'usado'      => false,
                'expira_em'  => ['$gt' => new UTCDateTime()],
            ],
            ['$set' => ['usado' => true, 'usado_em' => new UTCDateTime()]],
            ['returnDocument' => \MongoDB\Operation\FindOneAndUpdate::RETURN_DOCUMENT_BEFORE],
        );

        return $documento !== null ? (int) $documento['usuario_id'] : null;
    }

    /** Muda o prazo sem recriar o índice. */
    public function ajustarPrazo(int $segundos): void
    {
        $this->colecao->database->command([
            'collMod' => 'tokens_recuperacao',
            'index'   => ['name' => 'ttl_criado_em', 'expireAfterSeconds' => $segundos],
        ]);
    }

    public function diagnostico(): array
    {
        $agora = new UTCDateTime();

        return [
            'total'      => $this->colecao->countDocuments(),
            'validos'    => $this->colecao->countDocuments(
                ['usado' => false, 'expira_em' => ['$gt' => $agora]]
            ),
            // Vencidos que o coletor ainda não removeu — normal ver alguns.
            'aguardando_coleta' => $this->colecao->countDocuments(
                ['expira_em' => ['$lte' => $agora]]
            ),
        ];
    }
}

$tokens = new TokensRecuperacao($cliente);

$token = $tokens->gerar(usuarioId: 7, ip: $_SERVER['REMOTE_ADDR'] ?? '');
// Enviar por e-mail: https://site.com/recuperar?token={$token}

var_dump($tokens->validar($token));   // int(7)
var_dump($tokens->consumir($token));  // int(7)
var_dump($tokens->consumir($token));  // NULL — uso único

print_r($tokens->diagnostico());

Exercício 5

Desafio: implemente um ArticleRepository com interface idêntica ao ArticleRepositoryPDO que usa bancos relacionais. Ambos devem implementar ArticleRepositoryInterface. Escreva testes que rodem contra ambas as implementações.

Ver resposta

✓ Resposta: A normalização em cada implementação é o que realmente entrega a troca: o Mongo devolve ObjectId e UTCDateTime, o PDO devolve string e int. Sem converter para uma forma comum, a interface é só aparência — o código de cima ainda precisaria saber qual banco está atrás.

<?php

declare(strict_types=1);

interface ArticleRepositoryInterface
{
    public function salvar(array $artigo): string;

    public function buscarPorSlug(string $slug): ?array;

    /** @return array<int, array> */
    public function publicados(int $limite = 20, int $pulo = 0): array;

    /** @return array<int, array> */
    public function porTag(string $tag): array;

    public function remover(string $id): bool;

    public function contar(bool $apenasPublicados = true): int;
}

// ---------------------------------------- MongoDB
final class ArticleRepositoryMongo implements ArticleRepositoryInterface
{
    public function __construct(private readonly \MongoDB\Collection $colecao) {}

    public function salvar(array $artigo): string
    {
        $documento = $this->paraDocumento($artigo);

        // upsert pelo slug: salvar duas vezes não duplica.
        $resultado = $this->colecao->updateOne(
            ['slug' => $artigo['slug']],
            ['$set' => $documento],
            ['upsert' => true],
        );

        $id = $resultado->getUpsertedId();

        return (string) ($id ?? $this->colecao->findOne(['slug' => $artigo['slug']])['_id']);
    }

    public function buscarPorSlug(string $slug): ?array
    {
        $documento = $this->colecao->findOne(['slug' => $slug]);

        return $documento !== null ? $this->paraArray($documento) : null;
    }

    public function publicados(int $limite = 20, int $pulo = 0): array
    {
        $cursor = $this->colecao->find(
            ['rascunho' => false],
            ['sort' => ['publicado_em' => -1], 'limit' => $limite, 'skip' => $pulo],
        );

        return array_map($this->paraArray(...), $cursor->toArray());
    }

    public function porTag(string $tag): array
    {
        // Aqui é uma consulta em array indexado — nada de JOIN.
        $cursor = $this->colecao->find(['tags' => $tag, 'rascunho' => false]);

        return array_map($this->paraArray(...), $cursor->toArray());
    }

    public function remover(string $id): bool
    {
        return $this->colecao->deleteOne(
            ['_id' => new \MongoDB\BSON\ObjectId($id)]
        )->getDeletedCount() > 0;
    }

    public function contar(bool $apenasPublicados = true): int
    {
        return $this->colecao->countDocuments($apenasPublicados ? ['rascunho' => false] : []);
    }

    private function paraDocumento(array $artigo): array
    {
        return [
            'titulo'   => $artigo['titulo'],
            'slug'     => $artigo['slug'],
            'conteudo' => $artigo['conteudo'],
            'autor'    => ['nome' => $artigo['autor_nome'], 'email' => $artigo['autor_email']],
            'tags'     => $artigo['tags'] ?? [],
            'rascunho' => (bool) ($artigo['rascunho'] ?? true),
            'publicado_em' => isset($artigo['publicado_em'])
                ? new \MongoDB\BSON\UTCDateTime(new \DateTimeImmutable($artigo['publicado_em']))
                : null,
        ];
    }

    /** A normalização é o que torna as duas implementações intercambiáveis. */
    private function paraArray(object|array $doc): array
    {
        $doc = (array) $doc;

        return [
            'id'          => (string) $doc['_id'],
            'titulo'      => $doc['titulo'],
            'slug'        => $doc['slug'],
            'conteudo'    => $doc['conteudo'],
            'autor_nome'  => $doc['autor']['nome'],
            'autor_email' => $doc['autor']['email'],
            'tags'        => (array) ($doc['tags'] ?? []),
            'rascunho'    => (bool) $doc['rascunho'],
            'publicado_em'=> isset($doc['publicado_em'])
                ? $doc['publicado_em']->toDateTime()->format('Y-m-d H:i:s') : null,
        ];
    }
}

// ---------------------------------------- PDO (relacional)
final class ArticleRepositoryPDO implements ArticleRepositoryInterface
{
    public function __construct(private readonly \PDO $pdo) {}

    public function salvar(array $artigo): string
    {
        $this->pdo->beginTransaction();

        try {
            $stmt = $this->pdo->prepare(
                'INSERT INTO artigos (titulo, slug, conteudo, autor_nome, autor_email,
                                      rascunho, publicado_em)
                 VALUES (:titulo, :slug, :conteudo, :nome, :email, :rascunho, :publicado)
                 ON DUPLICATE KEY UPDATE
                     titulo = VALUES(titulo), conteudo = VALUES(conteudo),
                     rascunho = VALUES(rascunho), publicado_em = VALUES(publicado_em)'
            );

            $stmt->execute([
                ':titulo'   => $artigo['titulo'],
                ':slug'     => $artigo['slug'],
                ':conteudo' => $artigo['conteudo'],
                ':nome'     => $artigo['autor_nome'],
                ':email'    => $artigo['autor_email'],
                ':rascunho' => (int) ($artigo['rascunho'] ?? true),
                ':publicado'=> $artigo['publicado_em'] ?? null,
            ]);

            $id = (int) $this->pdo->lastInsertId();

            if ($id === 0) {
                $busca = $this->pdo->prepare('SELECT id FROM artigos WHERE slug = :slug');
                $busca->execute([':slug' => $artigo['slug']]);
                $id = (int) $busca->fetchColumn();
            }

            // O que no Mongo é um array dentro do documento, aqui exige
            // duas tabelas e um DELETE + INSERT.
            $this->pdo->prepare('DELETE FROM artigo_tags WHERE artigo_id = :id')
                      ->execute([':id' => $id]);

            $vincular = $this->pdo->prepare(
                'INSERT INTO artigo_tags (artigo_id, tag) VALUES (:id, :tag)'
            );

            foreach ($artigo['tags'] ?? [] as $tag) {
                $vincular->execute([':id' => $id, ':tag' => $tag]);
            }

            $this->pdo->commit();

        } catch (\Throwable $e) {
            $this->pdo->rollBack();
            throw $e;
        }

        return (string) $id;
    }

    public function buscarPorSlug(string $slug): ?array
    {
        $stmt = $this->pdo->prepare('SELECT * FROM artigos WHERE slug = :slug');
        $stmt->execute([':slug' => $slug]);

        $linha = $stmt->fetch(\PDO::FETCH_ASSOC);

        return $linha === false ? null : $this->normalizar($linha);
    }

    public function publicados(int $limite = 20, int $pulo = 0): array
    {
        $stmt = $this->pdo->prepare(
            'SELECT * FROM artigos WHERE rascunho = 0
              ORDER BY publicado_em DESC LIMIT :limite OFFSET :pulo'
        );
        $stmt->bindValue(':limite', $limite, \PDO::PARAM_INT);
        $stmt->bindValue(':pulo', $pulo, \PDO::PARAM_INT);
        $stmt->execute();

        return array_map($this->normalizar(...), $stmt->fetchAll(\PDO::FETCH_ASSOC));
    }

    public function porTag(string $tag): array
    {
        $stmt = $this->pdo->prepare(
            'SELECT a.* FROM artigos a
               JOIN artigo_tags t ON t.artigo_id = a.id
              WHERE t.tag = :tag AND a.rascunho = 0
              ORDER BY a.publicado_em DESC'
        );
        $stmt->execute([':tag' => $tag]);

        return array_map($this->normalizar(...), $stmt->fetchAll(\PDO::FETCH_ASSOC));
    }

    public function remover(string $id): bool
    {
        $stmt = $this->pdo->prepare('DELETE FROM artigos WHERE id = :id');
        $stmt->execute([':id' => (int) $id]);

        return $stmt->rowCount() > 0;
    }

    public function contar(bool $apenasPublicados = true): int
    {
        $sql = 'SELECT COUNT(*) FROM artigos' . ($apenasPublicados ? ' WHERE rascunho = 0' : '');

        return (int) $this->pdo->query($sql)->fetchColumn();
    }

    private function normalizar(array $linha): array
    {
        $tags = $this->pdo->prepare('SELECT tag FROM artigo_tags WHERE artigo_id = :id');
        $tags->execute([':id' => $linha['id']]);

        return [
            'id'          => (string) $linha['id'],
            'titulo'      => $linha['titulo'],
            'slug'        => $linha['slug'],
            'conteudo'    => $linha['conteudo'],
            'autor_nome'  => $linha['autor_nome'],
            'autor_email' => $linha['autor_email'],
            'tags'        => $tags->fetchAll(\PDO::FETCH_COLUMN),
            'rascunho'    => (bool) $linha['rascunho'],
            'publicado_em'=> $linha['publicado_em'],
        ];
    }
}

// ---------------------------------------- Tests: um contrato, dois bancos
declare(strict_types=1);

namespace Tests;

use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

abstract class ArticleRepositoryTestCase extends TestCase
{
    abstract protected function repositorio(): ArticleRepositoryInterface;

    private function artigoDeExemplo(array $sobrescrever = []): array
    {
        return array_merge([
            'titulo'      => 'Modelagem de dados',
            'slug'        => 'modelagem-de-dados',
            'conteudo'    => 'Conteúdo do artigo.',
            'autor_nome'  => 'Ana Souza',
            'autor_email' => 'ana@exemplo.com',
            'tags'        => ['modelagem', 'banco'],
            'rascunho'    => false,
            'publicado_em'=> '2026-05-01 10:00:00',
        ], $sobrescrever);
    }

    #[Test]
    public function salvarEBuscarPorSlug(): void
    {
        $repo = $this->repositorio();
        $repo->salvar($this->artigoDeExemplo());

        $artigo = $repo->buscarPorSlug('modelagem-de-dados');

        self::assertNotNull($artigo);
        self::assertSame('Modelagem de dados', $artigo['titulo']);
        self::assertSame('ana@exemplo.com', $artigo['autor_email']);
        self::assertEqualsCanonicalizing(['modelagem', 'banco'], $artigo['tags']);
        self::assertFalse($artigo['rascunho']);
    }

    #[Test]
    public function slugInexistenteDevolveNull(): void
    {
        self::assertNull($this->repositorio()->buscarPorSlug('nao-existe'));
    }

    #[Test]
    public function rascunhoNaoApareceEmPublicados(): void
    {
        $repo = $this->repositorio();

        $repo->salvar($this->artigoDeExemplo());
        $repo->salvar($this->artigoDeExemplo(['slug' => 'rascunho', 'rascunho' => true]));

        self::assertCount(1, $repo->publicados());
        self::assertSame(1, $repo->contar());
        self::assertSame(2, $repo->contar(apenasPublicados: false));
    }

    #[Test]
    public function porTagFiltraCorretamente(): void
    {
        $repo = $this->repositorio();

        $repo->salvar($this->artigoDeExemplo());
        $repo->salvar($this->artigoDeExemplo([
            'slug' => 'outro', 'tags' => ['php'],
        ]));

        self::assertCount(1, $repo->porTag('modelagem'));
        self::assertCount(1, $repo->porTag('php'));
        self::assertCount(0, $repo->porTag('inexistente'));
    }

    #[Test]
    public function salvarDuasVezesComOMesmoSlugAtualiza(): void
    {
        $repo = $this->repositorio();

        $repo->salvar($this->artigoDeExemplo());
        $repo->salvar($this->artigoDeExemplo(['titulo' => 'Título novo']));

        self::assertSame(1, $repo->contar(apenasPublicados: false));
        self::assertSame('Título novo', $repo->buscarPorSlug('modelagem-de-dados')['titulo']);
    }
}
Comentários

Mais em PHP

Projeto Final Parte 1: Planejamento e Arquitetura
Projeto Final Parte 1: Planejamento e Arquitetura

Escopo, decisões de arquitetura com justificativa, o schema completo em…

Testes Automatizados com PHPUnit e Pest
Testes Automatizados com PHPUnit e Pest

PHPUnit e Pest lado a lado, mocks, testes de integração com banco e testes de…

Projeto Final Parte 2: Implementação
Projeto Final Parte 2: Implementação

Sem duas constantes de timestamp, nenhum model deste projeto grava uma linha…