Tem um bug que aparece em quase todo sistema Laravel que cresce. Em fluxo financeiro, ele custa caro. O código parece certo, passa nos testes e roda bem em desenvolvimento. Em produção, sob carga, começa a gerar ModelNotFoundException esporádico nos workers ou, pior, jobs que rodam sem erro e leem um estado desatualizado.

A causa é simples: um job foi para a fila antes de a transação que o criou fazer commit.

O cenário típico

Pense num webhook de pagamento confirmado. O gateway avisa que a cobrança foi paga. Você trava a cobrança, marca como paga, cria o lançamento no extrato e dispara dois jobs: um para processar o split entre os recebedores e outro para notificar o cliente.

public function handle(PaymentConfirmed $payload): void
{
    DB::transaction(function () use ($payload) {
        $charge = Charge::lockForUpdate()->findOrFail($payload->chargeId);
        $charge->markAsPaid($payload->paidAt);

        $entry = LedgerEntry::create([
            'charge_id' => $charge->id,
            'amount' => $charge->amount,
            'type' => 'credit',
        ]);

        SettleSplit::dispatch($entry);
        NotifyCustomer::dispatch($charge);
    });
}

Parece correto e, na maior parte do tempo, funciona. O problema é a palavra "maior".

Por que quebra

Quando SettleSplit::dispatch($entry) é chamado, o job é serializado e empurrado para o Redis (ou SQS, ou o que for) na hora. Com SerializesModels, o payload guarda só a classe e o ID do model. O worker, ao pegar o job, faz uma nova consulta para reidratar o LedgerEntry.

Só que a transação ainda está aberta. Ela pode estar no meio de outras escritas, esperando um lock ou fazendo mais um insert. Se o worker for mais rápido que o commit, e com Redis e worker ocioso ele costuma ser, a consulta não encontra o registro. Resultado: ModelNotFoundException. O job falha, entra no retry e, com sorte, na segunda tentativa o commit já aconteceu.

O caso da Charge é mais perigoso. O registro existe, então o worker encontra. Só que, fora da transação, ele vê o status antigo, pending. Se o NotifyCustomer tiver uma guarda do tipo "só notifica se estiver paga", ele simplesmente não faz nada e termina com sucesso. Não aparece exceção nem alerta. O cliente apenas não recebe a mensagem.

Existe ainda o cenário de rollback. Se qualquer coisa depois do dispatch lançar exceção, a transação é desfeita, mas o job já está na fila. Você acabou de mandar processar o split de um lançamento que nunca existiu.

A correção básica: afterCommit

O Laravel resolve boa parte disso nativamente. A forma mais ampla é ligar after_commit na conexão de fila:

// config/queue.php
'redis' => [
    'driver' => 'redis',
    'connection' => 'default',
    'queue' => env('REDIS_QUEUE', 'default'),
    'retry_after' => 90,
    'block_for' => null,
    'after_commit' => true,
],

Com isso, todo job disparado dentro de uma transação aberta fica retido em memória e só é enviado depois do commit. Se houver rollback, o job é descartado. Fora de transação, o dispatch continua imediato.

Se você não quer mudar o comportamento global, dá para fazer por dispatch:

SettleSplit::dispatch($entry)->afterCommit();

E o inverso também existe: com after_commit global ligado, ->beforeCommit() força o envio imediato. Isso vai ser útil mais adiante.

O mesmo problema aparece em eventos, listeners e observers, e cada um tem seu mecanismo:

  • Eventos: a classe do evento implementa ShouldDispatchAfterCommit, e o próprio dispatch do evento espera o commit.
  • Listeners enfileirados: public $afterCommit = true; no listener, ou a interface ShouldQueueAfterCommit.
  • Observers: a interface ShouldHandleEventsAfterCommit faz os handlers (created, updated etc.) rodarem só depois do commit.
  • Callbacks avulsos: DB::afterCommit(fn () => ...) registra qualquer closure para depois do commit, ou executa na hora se não houver transação aberta.

O caso dos observers merece atenção. Muita gente dispara job ou chama API externa dentro de created() sem perceber que o model foi criado dentro de uma transação de outro service. O observer roda no meio dela, e o efeito colateral sai antes de os dados existirem de fato.

Sobre transações aninhadas: o Laravel usa savepoints e só executa os callbacks de after commit quando a transação mais externa faz commit. É o comportamento que você quer, mas convém saber que um DB::transaction interno não "libera" nada sozinho.

O que o afterCommit não resolve

Aqui está o trade-off que pouca gente discute. O after_commit troca um problema por outro menor, mas não elimina a janela de falha.

Antes, a sequência era "enfileira, depois commita". Agora é "commita, depois enfileira". Se o processo morrer entre essas duas etapas, os dados estão no banco e o job não existe. Isso pode acontecer por um deploy que mata o PHP-FPM, um OOM, um Redis indisponível naquele segundo ou um timeout. O pagamento está marcado como pago e o split nunca roda.

Para notificação de WhatsApp, isso costuma ser aceitável: a janela é pequena e o impacto é baixo. Para split, liberação de saldo ou qualquer coisa que mexa em dinheiro de terceiros, não é. Nesses casos eu não confio em "quase sempre".

Outbox quando o efeito colateral não pode se perder

A saída clássica é o transactional outbox. Em vez de enfileirar o job, você grava a intenção numa tabela, dentro da mesma transação dos dados de negócio:

DB::transaction(function () use ($payload) {
    $charge = Charge::lockForUpdate()->findOrFail($payload->chargeId);
    $charge->markAsPaid($payload->paidAt);

    $entry = LedgerEntry::create([/* ... */]);

    OutboxMessage::create([
        'type' => 'settle_split',
        'payload' => ['ledger_entry_id' => $entry->id],
    ]);
});

Agora é atômico: ou existe o lançamento e a mensagem, ou não existe nenhum dos dois. Um relay, que pode ser um comando agendado a cada minuto ou um processo em loop supervisionado, lê as mensagens pendentes e as despacha:

DB::transaction(function () {
    $messages = OutboxMessage::whereNull('dispatched_at')
        ->orderBy('id')
        ->limit(100)
        ->lockForUpdate()
        ->skipLocked()
        ->get();

    foreach ($messages as $message) {
        dispatch($message->toJob())->beforeCommit();
        $message->update(['dispatched_at' => now()]);
    }
});

Dois detalhes importam aqui.

O skipLocked() permite rodar mais de uma instância do relay sem que elas peguem a mesma mensagem. Ele funciona em Postgres e MySQL 8.

O beforeCommit() é proposital. Se after_commit estiver ligado globalmente, o dispatch seria adiado para depois do commit, e você voltaria à mesma janela: mensagem marcada como enviada e job perdido. Enfileirando antes do commit, o pior caso passa a ser o contrário. O job vai para a fila, o processo morre antes do commit, a mensagem continua pendente e é enviada de novo no próximo ciclo.

Ou seja, o outbox te dá entrega at-least-once, não exactly-once. O job precisa ser idempotente. No caso do split, eu verifico se já existe liquidação para aquele ledger_entry_id, de preferência com uma unique constraint no banco, e não só com um if no código. Sem idempotência, o outbox só troca perda por duplicidade.

O custo é real: mais uma tabela, um processo a mais para monitorar, latência de até um ciclo do relay e limpeza periódica das mensagens antigas. Por isso não uso para tudo. Uso nos efeitos que, se perdidos, geram divergência financeira ou exigem conciliação manual.

Outros detalhes que mordem

Réplica de leitura. Mesmo com o commit feito, se o worker lê de uma réplica, o lag de replicação reabre a mesma janela. A opção sticky da conexão vale só para o mesmo request, não para o worker. Em jobs críticos, eu leio explicitamente da conexão de escrita com Model::onWriteConnection(), em vez de depender do SerializesModels reidratar da réplica.

Chamada externa dentro da transação. É o primo desse bug. Chamar a API do gateway dentro de DB::transaction segura locks durante toda a latência de rede. Se a transação fizer rollback depois, a chamada externa já aconteceu e não volta atrás. A regra que sigo é: dentro da transação, só banco. Efeito externo vai para depois do commit ou para o outbox.

Testes. Queue::fake() não reproduz esse bug, porque nada é executado. Com RefreshDatabase, tudo roda dentro de uma transação que nunca faz commit de verdade, e o comportamento de after commit nos testes depende da versão do framework. Se esse fluxo é crítico, vale um teste de integração que rode o job de fato contra dados commitados, ou ao menos uma asserção de que o dispatch foi marcado como afterCommit.

Passar dados em vez de model. Uma mitigação parcial é passar para o job os valores de que ele precisa, em vez do model. Isso evita o ModelNotFoundException, mas não resolve rollback nem estado inconsistente. Uso quando faz sentido semântico, não como correção.

Como deixo isso nos projetos hoje

Na prática, meu padrão virou este:

  1. after_commit => true em todas as conexões de fila, desde o primeiro dia. É mais barato começar assim do que caçar dispatches depois.
  2. Observers que disparam qualquer efeito colateral implementam ShouldHandleEventsAfterCommit. Eventos de domínio implementam ShouldDispatchAfterCommit.
  3. Efeitos que mexem em dinheiro, como split, liberação de saldo ou repasse, passam pelo outbox, com job idempotente garantido por constraint no banco.
  4. Jobs críticos leem da conexão de escrita.
  5. Nada de HTTP para terceiros dentro de DB::transaction.

Se você tem um sistema em produção e nunca olhou para isso, faça uma busca por dispatch e ::dispatch( dentro de closures de DB::transaction e dentro de observers. Em seguida, cruze com os ModelNotFoundException intermitentes no log de jobs falhos. Há uma boa chance de aqueles erros "aleatórios" que somem no retry serem exatamente isso.