Projeto Final Parte 3: Testes, Deploy e Encerramento

Projeto Final Parte 3: Testes, Deploy e Encerramento

A suíte de testes do Mercatto, o deploy por releases e symlink, e o checklist de go-live que encerra o curso. Traz o teste de feature cujo resultado dependia do driver da fila, o segredo de manutenção que estava versionado dentro do script, e por que um backup jamais restaurado não é um backup.
PHP

• • 24 min de leitura

Chegamos à última semana. O sistema está implementado. Agora fazemos o que distingue software amador de software profissional: cobrimos com testes, configuramos o deploy automatizado, e entregamos com confiança.

Esta semana é também um momento de reflexão. Um ano de estudo. Doze módulos. Você chegou aqui.

Suite de testes completa

<?php
// tests/Unit/Domain/DinheiroTest.php

use App\Domain\Produto\Dinheiro;

describe('Dinheiro', function () {

    it('cria valor em centavos', function () {
        $d = new Dinheiro(2990);
        expect($d->centavos())->toBe(2990);
        expect($d->reais())->toBe(29.90);
        expect($d->formatar())->toBe('R$ 29,90');
    });

    it('cria a partir de reais', function () {
        $d = Dinheiro::dereais(19.90);
        expect($d->centavos())->toBe(1990);
    });

    it('soma corretamente', function () {
        $r = (new Dinheiro(1000))->somar(new Dinheiro(990));
        expect($r->centavos())->toBe(1990);
    });

    it('subtrai corretamente', function () {
        $r = (new Dinheiro(2000))->subtrair(new Dinheiro(500));
        expect($r->centavos())->toBe(1500);
    });

    it('lança exceção em subtração negativa', function () {
        expect(fn() => (new Dinheiro(100))->subtrair(new Dinheiro(200)))
            ->toThrow(\DomainException::class);
    });

    it('aplica desconto percentual', function () {
        $r = (new Dinheiro(10000))->aplicarDesconto(10.0);
        expect($r->centavos())->toBe(9000);
    });

    it('é imutável', function () {
        $original = new Dinheiro(1000);
        $original->somar(new Dinheiro(500));
        expect($original->centavos())->toBe(1000);
    });

})->group('domain');
<?php
// tests/Unit/Domain/CarrinhoTest.php

use App\Domain\Carrinho\{Carrinho, ItemDoCarrinho};
use App\Domain\Produto\Dinheiro;

describe('Carrinho', function () {

    it('começa vazio', function () {
        $carrinho = new Carrinho();
        expect($carrinho->estaVazio())->toBeTrue();
        expect($carrinho->total()->centavos())->toBe(0);
    });

    it('adiciona item novo', function () {
        $carrinho = new Carrinho();
        $carrinho->adicionar(item(produtoId: 1, preco: 1000, qtd: 2));

        expect($carrinho->estaVazio())->toBeFalse();
        expect($carrinho->total()->centavos())->toBe(2000);
    });

    it('incrementa quantidade ao adicionar item existente', function () {
        $carrinho = new Carrinho();
        $carrinho->adicionar(item(produtoId: 1, preco: 1000, qtd: 1));
        $carrinho->adicionar(item(produtoId: 1, preco: 1000, qtd: 2));

        expect($carrinho->itens()[1]->quantidade())->toBe(3);
        expect($carrinho->total()->centavos())->toBe(3000);
    });

    it('remove item ao atualizar com quantidade zero', function () {
        $carrinho = new Carrinho();
        $carrinho->adicionar(item(produtoId: 1, preco: 1000, qtd: 2));
        $carrinho->atualizar(1, 0);

        expect($carrinho->estaVazio())->toBeTrue();
    });

    it('calcula total com múltiplos itens', function () {
        $carrinho = new Carrinho();
        $carrinho->adicionar(item(produtoId: 1, preco: 1000, qtd: 2)); // R$ 20,00
        $carrinho->adicionar(item(produtoId: 2, preco: 2500, qtd: 1)); // R$ 25,00
        $carrinho->adicionar(item(produtoId: 3, preco: 500, qtd: 3));  // R$ 15,00

        expect($carrinho->total()->centavos())->toBe(6000); // R$ 60,00
    });

})->group('domain');

// Helper para criar itens nos testes
function item(int $produtoId, int $preco, int $qtd): ItemDoCarrinho
{
    return new ItemDoCarrinho($produtoId, 'Produto Teste', new Dinheiro($preco), $qtd);
}
<?php
// tests/Unit/Domain/StatusDoPedidoTest.php

use App\Domain\Pedido\StatusDoPedido;

describe('StatusDoPedido — transições', function () {

    it('pendente pode ir para confirmado ou cancelado', function () {
        $s = StatusDoPedido::Pendente;
        expect($s->podeTransicionarPara(StatusDoPedido::Confirmado))->toBeTrue();
        expect($s->podeTransicionarPara(StatusDoPedido::Cancelado))->toBeTrue();
        expect($s->podeTransicionarPara(StatusDoPedido::Enviado))->toBeFalse();
    });

    it('entregue é estado final', function () {
        $s = StatusDoPedido::Entregue;
        foreach (StatusDoPedido::cases() as $outro) {
            expect($s->podeTransicionarPara($outro))->toBeFalse();
        }
    });

    it('cancelado é estado final', function () {
        $s = StatusDoPedido::Cancelado;
        foreach (StatusDoPedido::cases() as $outro) {
            expect($s->podeTransicionarPara($outro))->toBeFalse();
        }
    });

})->group('domain');
<?php
// tests/Feature/CheckoutTest.php

use App\Models\{User, Produto, Categoria, Endereco};
use App\Domain\Pedido\StatusDoPedido;
use App\Jobs\ProcessarPedido;
use Illuminate\Support\Facades\Queue;

describe('Checkout', function () {

    beforeEach(function () {
        // Sem esta linha o teste depende do QUEUE_CONNECTION: na fila
        // `sync` o ProcessarPedido roda na hora, o pedido já nasce
        // confirmado, e a asserção de "pendente" logo abaixo falha.
        Queue::fake();

        $this->usuario  = User::factory()->create();
        $categoria = Categoria::factory()->create();
        $this->produto  = Produto::factory()->create([
            'categoria_id' => $categoria->id,
            // A coluna `preco` do schema é NUMERIC(10,2) — reais, não
            // centavos como no value object Dinheiro. 5000 aqui cria um
            // produto de R$ 5.000,00, e o teste passa a afirmar outra coisa.
            'preco'        => 50.00,
            'estoque'      => 10,
            'ativo'        => true,
        ]);
        $this->endereco = Endereco::factory()->create(['usuario_id' => $this->usuario->id]);
    });

    it('cria pedido a partir do carrinho', function () {
        // Adiciona item ao carrinho via sessão
        $this->actingAs($this->usuario)
            ->post(route('carrinho.adicionar'), [
                'produto_id' => $this->produto->id,
                'quantidade' => 2,
            ]);

        // Realiza checkout
        $response = $this->post(route('checkout.store'), [
            'endereco_id' => $this->endereco->id,
        ]);

        $response->assertRedirect();

        $this->assertDatabaseHas('pedidos', [
            'usuario_id' => $this->usuario->id,
            'status'     => StatusDoPedido::Pendente->value,
        ]);

        $this->assertDatabaseHas('pedido_itens', [
            'produto_id' => $this->produto->id,
            'quantidade' => 2,
        ]);

        // Estoque foi decrementado
        expect($this->produto->fresh()->estoque)->toBe(8);

        // E o processamento foi enfileirado, em vez de acontecer aqui.
        Queue::assertPushed(ProcessarPedido::class);
    });

    it('rejeita checkout com carrinho vazio', function () {
        $response = $this->actingAs($this->usuario)
            ->post(route('checkout.store'), [
                'endereco_id' => $this->endereco->id,
            ]);

        $response->assertRedirect()->assertSessionHas('erro');
        $this->assertDatabaseCount('pedidos', 0);
    });

    it('rejeita checkout com estoque insuficiente', function () {
        $this->produto->update(['estoque' => 1]);

        $this->actingAs($this->usuario)
            ->post(route('carrinho.adicionar'), [
                'produto_id' => $this->produto->id,
                'quantidade' => 5,
            ]);

        $response = $this->actingAs($this->usuario)
            ->post(route('checkout.store'), ['endereco_id' => $this->endereco->id]);

        $response->assertRedirect()->assertSessionHas('erro');
        expect($this->produto->fresh()->estoque)->toBe(1); // não mudou
    });

})->group('feature');

Deploy final — configuração de produção

#!/bin/bash
# deploy-producao.sh

set -euo pipefail

RELEASES_DIR="/var/www/mercatto/releases"
SHARED_DIR="/var/www/mercatto/shared"
CURRENT_LINK="/var/www/mercatto/current"
REPO="git@github.com:empresa/mercatto.git"
BRANCH="${1:-main}"
TIMESTAMP=$(date +%Y%m%d%H%M%S)
RELEASE_DIR="${RELEASES_DIR}/${TIMESTAMP}"

echo "🚀 Deploy Mercatto — release: $TIMESTAMP"

# 1. Cria diretório do release
mkdir -p "$RELEASE_DIR"
git clone --depth=1 --branch="$BRANCH" "$REPO" "$RELEASE_DIR"
echo "✅ Código clonado"

# 2. Symlinks para diretórios compartilhados entre releases
ln -sfn "$SHARED_DIR/.env"     "$RELEASE_DIR/.env"
ln -sfn "$SHARED_DIR/storage"  "$RELEASE_DIR/storage"
echo "✅ Symlinks de shared configurados"

# 3. Dependências
cd "$RELEASE_DIR"
composer install --no-dev --optimize-autoloader --no-interaction
echo "✅ Dependências instaladas"

# 4. Ativa manutenção no release atual (se existir)
if [ -L "$CURRENT_LINK" ]; then
    # O segredo NÃO pode viver no script versionado: quem leu o repositório
    # uma vez entra no site em manutenção para sempre. Vem do ambiente,
    # e o ideal é que mude a cada deploy.
    php "$CURRENT_LINK/artisan" down \
        --secret="${DEPLOY_BYPASS_SECRET:?defina DEPLOY_BYPASS_SECRET no ambiente}" \
        2>/dev/null || true
fi

# 5. Migrations
php artisan migrate --force
echo "✅ Migrations executadas"

# 6. Cache e otimizações
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
echo "✅ Cache otimizado"

# 7. Ativa o novo release
ln -sfn "$RELEASE_DIR" "$CURRENT_LINK"
echo "✅ Release $TIMESTAMP ativado"

# 8. Reinicia serviços
php artisan queue:restart
# A versão sai do ambiente. Cravada aqui, o reload passa a falhar depois
# do primeiro upgrade do servidor — e o release novo segue servindo do
# OPcache antigo, sem erro nenhum aparecer no deploy.
sudo systemctl reload "php${PHP_VERSION:-8.4}-fpm"
echo "✅ Serviços reiniciados"

# 9. Desativa manutenção
php artisan up
echo "✅ Aplicação online"

# 10. Mantém apenas os últimos 5 releases
ls -dt "$RELEASES_DIR"/* | tail -n +6 | xargs rm -rf 2>/dev/null || true
echo "✅ Releases antigos removidos"

echo ""
echo "🎉 Deploy concluído — release: $TIMESTAMP"

Checklist de go-live

Antes de apontar o DNS para o servidor de produção, verifique cada item:

Segurança:

  • .env com APP_ENV=production, APP_DEBUG=false, e APP_KEY gerada
  • HTTPS configurado com certificado válido (Let's Encrypt)
  • Headers de segurança aplicados (CSP, HSTS, X-Frame-Options)
  • composer audit sem vulnerabilidades
  • Diretório storage/ e bootstrap/cache/ com permissões corretas (755, dono www-data)
  • Arquivo .env inacessível pelo servidor web

Performance:

  • OPcache ativado com validate_timestamps=0
  • Redis configurado para sessões e cache
  • Workers de fila rodando com Supervisor
  • php artisan route:cache, config:cache, view:cache executados

Observabilidade:

  • Logs configurados (diário + Slack para críticos)
  • Monitoramento de uptime (UptimeRobot, Betterstack)
  • Alertas de erro (Sentry ou similar)

Backup:

  • Backup automático do banco configurado (diário, 7 dias de retenção)
  • Backup testado — restore funciona?

Testes:

  • Suite de testes passando (CI verde)
  • Smoke test manual no ambiente de staging

Reflexão final — o que você construiu

No começo desta série, você aprendeu o que é o PHP e como exibir "Olá, Mundo!" no navegador. Hoje você sabe:

Fundamentos sólidos. Tipos, funções, arrays, strings, manipulação de datas, expressões regulares. A base que não muda independentemente do framework ou da versão da linguagem.

Orientação a objetos de verdade. Classes, herança, interfaces, traits, polimorfismo, design patterns. Não apenas a sintaxe — a filosofia de modelar o mundo em objetos com responsabilidades claras.

Bancos de dados. MySQL, PostgreSQL, SQLite e MongoDB. PDO com prepared statements, query builders, ORM com Eloquent, migrations, relacionamentos, índices e performance.

Desenvolvimento web completo. Formulários, sessões, cookies, upload de arquivos, autenticação, autorização. Os mecanismos que fazem a web funcionar.

PHP moderno. Composer, namespaces, PSR, boas práticas, tipagem estrita, análise estática com PHPStan.

Laravel profissional. Roteamento, controllers, Eloquent, Blade, autenticação, APIs com Sanctum, filas, eventos.

Segurança. SQL injection, XSS, CSRF, criptografia, headers HTTP, tokens seguros, LGPD.

Testes automatizados. PHPUnit e Pest, testes unitários, de integração e de feature, mocks, data providers, cobertura de código.

Deploy e produção. NGINX, PHP-FPM, OPcache, Docker, GitHub Actions, scripts de deploy com zero downtime, monitoramento, logs.

E o Mercatto — um sistema real, completo, com domínio modelado, arquitetura em camadas, testes, e pipeline de deploy.

O que vem depois

Dominar PHP em um ano é uma conquista real. Mas é também um começo. O ecossistema continua evoluindo, e há muito mais para explorar:

Aprofundar no Laravel. Livewire para interfaces reativas sem JavaScript pesado. Laravel Octane para performance extrema com Swoole. Laravel Telescope para debugging em desenvolvimento.

Explorar arquiteturas. Event Sourcing, CQRS, microsserviços. Quando aplicar cada padrão e quando não aplicar.

Outras linguagens. PHP é excelente, mas um desenvolvedor completo conhece mais de uma linguagem. TypeScript para o frontend, Go para serviços de alta performance, Python para análise de dados.

Contribuir com open source. O ecossistema PHP que você usou durante este ano foi construído por pessoas que dedicaram tempo. Reportar bugs, melhorar documentação, enviar pull requests — essas contribuições constroem reputação e aprofundam o aprendizado.

Exercício final

Não há exercício técnico nesta semana. O exercício é este:

Implemente o Mercatto completo. Não apenas os trechos deste artigo — o sistema inteiro, funcionando, testado, deployado em um servidor real (mesmo que gratuito — Railway, Fly.io, ou um VPS básico). Com CI/CD configurado. Com HTTPS. Com monitoramento.

Quando estiver no ar, compartilhe o link e o repositório. O melhor portfólio é um sistema em produção.

Se há uma linha que atravessa esta última parte, é a distância entre ter uma coisa e saber que ela funciona. A suíte de testes estava escrita, e um dos casos media o driver da fila em vez do checkout. O deploy guardava os cinco últimos releases e não tinha caminho de volta escrito em lugar nenhum. O checklist pedia backup testado, e testar de verdade era restaurar num banco descartável e conferir as contagens. Nos três, o que faltava não era mais código: era a pergunta de comprovação, feita uma vez, de propósito, antes de alguém precisar da resposta às três da manhã.

Programar é uma habilidade acumulativa. Cada problema resolvido torna o próximo mais fácil. Cada sistema construído revela padrões que se repetem. Cada bug encontrado ensina algo que nenhuma aula ensinaria.

Você tem agora as ferramentas. O que você vai construir com elas?

Fontes e leituras recomendadas

Exercícios

Exercício 1

O CheckoutTest deste artigo afirma que o pedido nasce com status pendente. Rode-o com QUEUE_CONNECTION=sync e depois com QUEUE_CONNECTION=array, e explique por que o resultado muda. Em seguida escreva os dois testes que faltam: um que prova que o processamento foi enfileirado e não executado, e outro que roda o job de propósito e prova a transição para confirmado.

Ver resposta

✓ Resposta: Com sync o job roda dentro do próprio post(), o ProcessarPedido confirma o pedido na hora e a asserção de pendente falha; com array o job é só empilhado e o teste passa. Um teste cujo resultado depende do .env não mede o sistema, mede o ambiente — e o pior é que ele costuma ser verde na máquina de quem escreveu. O conserto não é escolher o driver certo: é declarar a intenção no próprio teste. Queue::fake() separa as duas perguntas, que são mesmo diferentes: o checkout enfileira? e o job, quando roda, faz o que promete? A segunda merece executar o job explicitamente, porque é ali que a transição de status é exercida.

<?php
// tests/Feature/CheckoutTest.php

use App\Domain\Pedido\StatusDoPedido;
use App\Jobs\ProcessarPedido;
use App\Models\{User, Produto, Categoria, Endereco};
use Illuminate\Support\Facades\Queue;

describe('Checkout', function () {

    beforeEach(function () {
        Queue::fake();

        $this->usuario  = User::factory()->create();
        $categoria      = Categoria::factory()->create();
        $this->produto  = Produto::factory()->create([
            'categoria_id' => $categoria->id,
            'preco'        => 50.00,
            'estoque'      => 10,
            'ativo'        => true,
        ]);
        $this->endereco = Endereco::factory()->create(['usuario_id' => $this->usuario->id]);
    });

    it('enfileira o processamento em vez de executá-lo no request', function () {
        $this->actingAs($this->usuario)
            ->post(route('carrinho.adicionar'), [
                'produto_id' => $this->produto->id,
                'quantidade' => 2,
            ]);

        $this->post(route('checkout.store'), ['endereco_id' => $this->endereco->id])
            ->assertRedirect();

        $pedido = \App\Models\Pedido::firstOrFail();

        // O pedido nasce pendente PORQUE o job ainda não rodou. Sem o
        // Queue::fake() acima, esta linha vira uma aposta no QUEUE_CONNECTION.
        expect($pedido->status)->toBe(StatusDoPedido::Pendente);

        // Sem closure de propósito: o $pedidoId do job é `private readonly`,
        // e uma asserção sobre a carga exigiria torná-lo `public readonly` —
        // decisão de design que vale a pena, justamente para o job ser
        // testável por fora.
        Queue::assertPushed(ProcessarPedido::class);
    });

    it('confirma o pedido quando o job roda', function () {
        // Pedido::create() e não Pedido::factory(): o model do artigo não
        // usa a trait HasFactory.
        $pedido = \App\Models\Pedido::create([
            'usuario_id'  => $this->usuario->id,
            'endereco_id' => $this->endereco->id,
            'status'      => StatusDoPedido::Pendente,
            'subtotal'    => 100.00,
            'frete'       => 19.90,
            'desconto'    => 0,
            'total'       => 119.90,
        ]);

        // Aqui o job é executado de propósito: é ESTE teste que cobre a
        // transição de status, e não o do checkout.
        (new ProcessarPedido($pedido->id))->handle();

        expect($pedido->fresh()->status)->toBe(StatusDoPedido::Confirmado);
    });

    it('é idempotente: rodar duas vezes não quebra', function () {
        $pedido = \App\Models\Pedido::create([
            'usuario_id'  => $this->usuario->id,
            'endereco_id' => $this->endereco->id,
            'status'      => StatusDoPedido::Pendente,
            'subtotal'    => 100.00,
            'frete'       => 19.90,
            'desconto'    => 0,
            'total'       => 119.90,
        ]);

        $job = new ProcessarPedido($pedido->id);

        $job->handle();
        $job->handle();   // a re-tentativa que o $tries promete

        // Sem a guarda de idempotência no handle(), a segunda chamada pede
        // Confirmado -> Confirmado, que o enum recusa, e toda re-tentativa
        // morre em DomainException — nunca no erro que causou o retry.
        expect($pedido->fresh()->status)->toBe(StatusDoPedido::Confirmado);
    });

})->group('feature');

Exercício 2

O teste de aplicarDesconto() tem uma linha só — 10% de R$ 100,00 dá R$ 90,00 — e isso já produz 100% de cobertura de linha do método. Escreva a tabela de casos que mostra o que essa linha não viu. Inclua, no mínimo: um item de 1 centavo, dois descontos de 10% em sequência contra um único de 20%, e um desconto aplicado no total contra o mesmo desconto aplicado item a item. Para cada caso, diga qual decisão de negócio está em aberto — não qual é o bug.

Ver resposta

✓ Resposta: Os três casos foram medidos, e nenhum deles é bug no sentido de código errado: são decisões que ninguém tomou. Um centavo com 50% de desconto vira zero, porque o (int) round() arredonda o desconto e o resto se perde — é preciso decidir se a sobra fica com a loja ou com o cliente. Dez por cento duas vezes dá R$ 81,00, não R$ 80,00: desconto composto não soma, e a promoção que o marketing anuncia como "20% no total" precisa ser aplicada uma vez só. E três itens de R$ 3,33 com 10% dão R$ 8,99 pelo total contra R$ 9,00 item a item — um centavo de diferença que reaparece na conciliação com o gateway, que fecha pelo total. A regra prática: escolha um ponto de arredondamento, documente-o, e cubra a escolha com teste. Repare também no dereais(): dereais(1.005) devolve 100 centavos e não 101, porque 1,005 em binário é 1,00499…, enquanto dereais(8.615) acerta. Não dá para prever de cabeça qual float vai falhar — por isso dinheiro entra em centavos e sai em centavos.

<?php
// tests/Unit/Domain/DinheiroDescontoTest.php

use App\Domain\Produto\Dinheiro;

// Todos os valores abaixo foram MEDIDOS, não deduzidos.
// O nome de cada caso é a chave: é ele que aparece na saída quando falha.
dataset('descontos', [
    //  centavos, percentual, esperado
    'caso do artigo'      => [10000,  10.0,  9000],
    'metade de 1 centavo' => [    1,  50.0,     0],   // o item some no arredondamento
    'metade de R$ 9,99'   => [  999,  50.0,   499],   // arredonda o DESCONTO (500), não o resto
    'desconto zero'       => [10000,   0.0, 10000],   // borda inferior
    'desconto total'      => [10000, 100.0,     0],   // borda superior
]);

it('aplica o desconto', function (int $centavos, float $pct, int $esperado) {
    expect((new Dinheiro($centavos))->aplicarDesconto($pct)->centavos())->toBe($esperado);
})->with('descontos');

it('desconto composto não soma', function () {
    $emSequencia = (new Dinheiro(10000))->aplicarDesconto(10.0)->aplicarDesconto(10.0);
    $deUmaVez    = (new Dinheiro(10000))->aplicarDesconto(20.0);

    // 10% + 10% = 19% efetivo. Se a promoção diz "20%", ela precisa ser
    // aplicada UMA vez — e a regra tem de estar escrita em algum lugar.
    expect($emSequencia->centavos())->toBe(8100)
        ->and($deUmaVez->centavos())->toBe(8000);
});

it('o ponto de arredondamento muda o total', function () {
    $porTotal = (new Dinheiro(333))->multiplicar(3)->aplicarDesconto(10.0);
    $porItem  = (new Dinheiro(333))->aplicarDesconto(10.0)->multiplicar(3);

    // Um centavo de diferença. O gateway concilia pelo TOTAL, então é o
    // total que precisa ser a fonte de verdade — e a nota fiscal que se
    // ajusta, não o contrário.
    expect($porTotal->centavos())->toBe(899)
        ->and($porItem->centavos())->toBe(900);
});

it('dereais depende de como o float caiu', function () {
    // 1,005 em binário é 1,00499999...; 8,615 é 8,61500000...2.
    // Um erra para baixo, o outro acerta, e não há regra de bolso.
    expect(Dinheiro::dereais(1.005)->centavos())->toBe(100)
        ->and(Dinheiro::dereais(8.615)->centavos())->toBe(862);
})->group('domain');

Exercício 3

O script de deploy guarda os cinco últimos releases, mas não tem caminho de volta. Escreva o rollback.sh que repõe o release anterior, e explique por que ele resolve apenas metade do problema.

Ver resposta

✓ Resposta: O rollback do código é barato: o release anterior ainda está no disco, e trocar o symlink é uma operação atômica — o ln -sfn num diretório temporário seguido de mv -T evita até a janela de um milissegundo em que o link não existe. A outra metade é o banco, e essa não volta. Uma migration que renomeou uma coluna deixa o código antigo procurando um nome que não existe mais; um DROP COLUMN apagou dados que nenhum symlink traz de volta. Daí a regra que torna o rollback real: migrations em expand/contract — primeiro só adiciona (a versão nova usa o campo novo, a antiga ignora), e só depois que o release novo provou que aguenta é que um segundo deploy remove o que sobrou. Enquanto as duas versões puderem conviver no mesmo schema, o rollback é um comando; quando não puderem, ele é um incidente. Um detalhe do próprio script merece atenção, porque passa despercebido em revisão e só aparece no segundo uso: escolher o release anterior como "o segundo da lista por data" funciona uma vez e quebra na seguinte — depois do primeiro rollback o release em uso já não é o mais recente do diretório. É preciso caminhar a lista a partir do atual, e foi assim que este script foi testado: três releases, dois rollbacks, e a recusa correta na terceira tentativa.

#!/bin/bash
# rollback.sh — volta para o release imediatamente anterior
set -euo pipefail

RELEASES_DIR="/var/www/mercatto/releases"
CURRENT_LINK="/var/www/mercatto/current"

atual=$(readlink -f "$CURRENT_LINK")

# Percorre a lista por data e pega o que vem LOGO DEPOIS do atual.
# Pegar "o segundo da lista" parece equivalente e não é: funciona no
# primeiro rollback e quebra no segundo, porque a partir dali o release
# em uso já não é o mais recente do diretório.
anterior=""
achou_atual=0
while IFS= read -r dir; do
    dir="${dir%/}"
    if [ "$achou_atual" -eq 1 ]; then anterior="$dir"; break; fi
    [ "$(readlink -f "$dir")" = "$atual" ] && achou_atual=1
done < <(ls -dt "$RELEASES_DIR"/*/)

if [ -z "$anterior" ] || [ ! -d "$anterior" ]; then
    echo "Não há release anterior ao atual para onde voltar." >&2
    exit 1
fi

echo "Voltando de $(basename "$atual") para $(basename "$anterior")"

# Troca atômica: cria o link ao lado e move por cima. Um `ln -sfn` direto
# apaga e recria, e existe um instante em que /current não resolve.
ln -sfn "$anterior" "${CURRENT_LINK}.novo"
mv -T "${CURRENT_LINK}.novo" "$CURRENT_LINK"

php "$CURRENT_LINK/artisan" queue:restart
sudo systemctl reload "php${PHP_VERSION:-8.4}-fpm"

echo "Rollback concluído."
echo
echo "ATENÇÃO: o banco NÃO voltou. Confira se a última migration é"
echo "compatível com este release:"
php "$CURRENT_LINK/artisan" migrate:status | tail -n 5

Exercício 4

"Smoke test manual no ambiente de staging" é o último item do checklist e o primeiro a ser pulado numa sexta-feira. Automatize-o: escreva o endpoint de saúde e o trecho do deploy que recusa a troca do symlink quando o release novo não responde.

Ver resposta

✓ Resposta: A ordem é o que faz esse teste valer alguma coisa: ele precisa rodar contra o release novo, antes de o symlink apontar para ele. Rodar depois só descobre o problema com o site já no ar. Para isso o release novo sobe num socket próprio (ou num pool do PHP-FPM só dele) e o script conversa com esse socket. O endpoint precisa exercer o que costuma quebrar num deploy — conexão com o banco, Redis, e a pergunta que ninguém faz: sobrou migration pendente? Repare no que ele não faz: não expõe versão de dependência, não devolve stack trace, e responde 503 (não 500) quando algo falha, porque é assim que um balanceador entende "ainda não me mande tráfego". E o exit 1 é o ponto inteiro do exercício: um smoke test que não interrompe o deploy é um log que ninguém lê.

<?php
// routes/web.php  — fora de qualquer middleware de sessão ou auth
declare(strict_types=1);

use Illuminate\Support\Facades\{DB, Redis, Route};

Route::get('/saude', function () {
    $checagens = [];

    try {
        DB::select('select 1');
        $checagens['banco'] = 'ok';
    } catch (\Throwable $e) {
        $checagens['banco'] = 'falhou';
    }

    try {
        Redis::ping();
        $checagens['redis'] = 'ok';
    } catch (\Throwable $e) {
        $checagens['redis'] = 'falhou';
    }

    // A pergunta que ninguém faz e que derruba deploy: o código novo está
    // rodando contra um schema que ainda não recebeu a migration dele?
    $pendentes = collect(app('migrator')->getMigrationFiles(database_path('migrations')))
        ->diff(app('migrator')->getRepository()->getRan())
        ->count();

    $checagens['migrations_pendentes'] = $pendentes;

    $saudavel = $checagens['banco'] === 'ok'
        && $checagens['redis'] === 'ok'
        && $pendentes === 0;

    // 503 e não 500: é assim que balanceador e orquestrador entendem
    // "ainda não me mande tráfego" em vez de "esta aplicação quebrou".
    return response()->json([
        'status'    => $saudavel ? 'ok' : 'degradado',
        'release'   => config('app.release', 'desconhecido'),
        'checagens' => $checagens,
    ], $saudavel ? 200 : 503);
})->name('saude');

Exercício 5

Desafio: "Backup testado — restore funciona?" é o único item do checklist que ninguém consegue responder com honestidade, porque responder exige restaurar de verdade. Escreva o verificador que faz isso sozinho, num banco descartável, e que grita quando o resultado não bate. Rode-o no cron ou no CI, não à mão.

Ver resposta

✓ Resposta: Um backup que nunca foi restaurado não é um backup — é uma hipótese sobre um arquivo. E as formas de falhar são silenciosas: o dump trunca porque o disco encheu e o pg_dump saiu com erro que ninguém leu; a extensão que o schema usa não existe no servidor de restore; o arquivo tem 40 bytes e é só o cabeçalho. Por isso o verificador não pode olhar só o tamanho do arquivo: ele restaura num banco temporário, roda consultas de verdade e compara as contagens com a produção, aceitando uma diferença pequena porque a loja continuou vendendo entre o dump e a conferência. O trap ... EXIT garante que o banco descartável some mesmo quando o script morre no meio — sem isso, o servidor acumula bancos órfãos até alguém reparar. E o teste de verdade não é o script rodar: é ele falhar quando deve. Corrompa um dump de propósito, uma vez, e confirme que o alerta chega em quem está de plantão.

#!/bin/bash
# verificar-backup.sh — restaura o backup mais recente e confere o conteúdo.
# Roda no cron, num servidor que NÃO é o de produção.
set -euo pipefail

BACKUP_DIR="/var/backups/mercatto"
PROD_URL="${DATABASE_URL_PRODUCAO:?defina DATABASE_URL_PRODUCAO}"
BANCO_TESTE="verificacao_$(date +%s)"
TOLERANCIA_PCT=2   # a loja segue vendendo entre o dump e esta conferência

# O banco descartável some mesmo se o script morrer no meio.
trap 'dropdb --if-exists "$BANCO_TESTE" 2>/dev/null || true' EXIT

ultimo=$(ls -t "$BACKUP_DIR"/*.dump 2>/dev/null | head -n1)
[ -n "${ultimo:-}" ] || { echo "Nenhum backup encontrado em $BACKUP_DIR" >&2; exit 1; }

idade_h=$(( ( $(date +%s) - $(stat -c %Y "$ultimo") ) / 3600 ))
if [ "$idade_h" -gt 26 ]; then
    echo "Backup mais recente tem ${idade_h}h — o cron diário parou." >&2
    exit 1
fi

echo "Restaurando $(basename "$ultimo") (${idade_h}h) em $BANCO_TESTE"
createdb "$BANCO_TESTE"
pg_restore --dbname="$BANCO_TESTE" --no-owner --exit-on-error "$ultimo"

# Consultar é o que separa "o arquivo abriu" de "os dados estão lá".
falhas=0
for tabela in produtos pedidos pedido_itens usuarios; do
    restaurado=$(psql -tAX -d "$BANCO_TESTE" -c "SELECT count(*) FROM $tabela")
    producao=$(psql -tAX -d "$PROD_URL"     -c "SELECT count(*) FROM $tabela")

    if [ "$producao" -eq 0 ]; then
        limite=0
    else
        limite=$(( producao * TOLERANCIA_PCT / 100 + 1 ))
    fi

    diferenca=$(( producao - restaurado ))
    [ "$diferenca" -lt 0 ] && diferenca=$(( -diferenca ))

    if [ "$diferenca" -gt "$limite" ]; then
        echo "FALHA  $tabela: backup=$restaurado producao=$producao (limite $limite)" >&2
        falhas=$(( falhas + 1 ))
    else
        echo "ok     $tabela: backup=$restaurado producao=$producao"
    fi
done

# Uma leitura de conteúdo, não só de contagem: um dump truncado no meio
# pode ter a tabela criada e os dados pela metade.
amostra=$(psql -tAX -d "$BANCO_TESTE" -c \
    "SELECT count(*) FROM pedidos WHERE total IS NULL OR total < 0")
if [ "$amostra" -ne 0 ]; then
    echo "FALHA  $amostra pedido(s) com total nulo ou negativo no restore" >&2
    falhas=$(( falhas + 1 ))
fi

if [ "$falhas" -gt 0 ]; then
    echo "Backup NAO confiavel — $falhas verificacao(oes) falharam." >&2
    exit 1
fi

echo "Backup verificado: restore completo e contagens dentro da tolerancia."
Comentários

Mais em PHP

SQLite com PHP
SQLite com PHP

Um arquivo, zero configuração e uma busca textual com FTS5 que dispensa…

Arrays em Profundidade
Arrays em Profundidade

O array do PHP é uma estrutura só que faz o papel de lista, mapa e registro —…

Variáveis, Tipos de Dados e Operadores
Variáveis, Tipos de Dados e Operadores

Toda variável em PHP guarda um valor de algum tipo, e a linguagem converte…