# Jobs disparados dentro de transação: o bug silencioso do afterCommit no Laravel

> Disparar um job dentro de DB::transaction parece inofensivo até o worker ler um registro que ainda não existe. Como trato isso em fluxos financeiros e onde o afterCommit não basta.

Por Lucas Roberto · Laravel · publicado em 2026-10-09 · 9 min de leitura
Versão web: https://lucasroberto.com/blog/jobs-disparados-dentro-de-transacao-o-bug-silencioso-do-aftercommit-no-laravel

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.

```php
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:

```php
// 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:

```php
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:

```php
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:

```php
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.

---

Lucas Roberto é desenvolvedor back-end e fundador da Granpag. Mais textos: https://lucasroberto.com/blog
