# Como evitar saldo negativo com débitos simultâneos no Laravel e PostgreSQL

> Checar o saldo no PHP e depois salvar abre espaço para saldo negativo. Veja como usar UPDATE condicional, lockForUpdate, ordem de locks e CHECK no PostgreSQL.

Por Lucas Roberto · Laravel · publicado em 2026-10-30 · 10 min de leitura
Versão web: https://lucasroberto.com/blog/como-evitar-saldo-negativo-com-debitos-simultaneos-no-laravel-e-postgresql

Para impedir que dois débitos simultâneos deixem uma conta com saldo negativo no Laravel, a verificação do saldo e o débito precisam acontecer numa única operação atômica dentro do banco. Existem dois caminhos: um `UPDATE` condicional (`where balance >= ?`), ou um `SELECT ... FOR UPDATE` com `lockForUpdate()` dentro de `DB::transaction`. Ler o saldo no PHP, comparar e depois chamar `save()` é uma condição de corrida, e em produção ela aparece mais cedo do que se imagina.

Em conta digital isso não é teórico. Saque via Pix, pagamento de boleto, tarifa automática e split de uma venda podem mexer no mesmo saldo no mesmo segundo, vindos de processos diferentes: uma requisição HTTP, um job do Horizon e um comando agendado. A seguir estão as decisões que eu tomo nesse tipo de código e o custo de cada uma.

## Por que checar o saldo no PHP e depois salvar dá errado?

Uma condição de corrida (race condition) acontece quando o resultado depende da ordem em que dois processos concorrentes executam. O código abaixo parece correto e passa em qualquer teste sequencial:

```php
$account = Account::find($accountId);

if ($account->balance < $amount) {
    throw new SaldoInsuficiente();
}

$account->balance -= $amount;
$account->save();
```

O problema está na janela entre o `find` e o `save`. Imagine uma conta com R$ 100,00 e dois saques de R$ 80,00 chegando juntos:

1. O processo A lê `balance = 10000` (em centavos).
2. O processo B lê `balance = 10000`.
3. A verifica que 10000 >= 8000 e grava `2000`.
4. B verifica que 10000 >= 8000 e grava `2000`.

O resultado são dois saques aprovados, R$ 160,00 saindo e um saldo final de R$ 20,00. Isso é um *lost update*, ou atualização perdida: a escrita de A foi sobrescrita por B, que calculou em cima de um valor velho. O dinheiro saiu duas vezes e o saldo não mostra isso.

Trocar o `save()` por `$account->decrement('balance', $amount)` resolve só metade. O `decrement` gera `balance = balance - 8000`, então nenhuma escrita se perde. Mas a checagem continua no PHP, e o saldo termina em -6000. Você trocou um saldo errado por um saldo negativo, que pelo menos é visível.

## Como fazer o débito atômico com UPDATE condicional?

O jeito mais simples e mais rápido é levar a condição para dentro do `UPDATE`. O PostgreSQL trava a linha durante o update. Se outro processo já alterou a linha, ele reavalia o `WHERE` com o valor novo antes de aplicar. Ou o débito cabe e é aplicado, ou nenhuma linha é afetada.

```php
final class DebitoAtomico
{
    public function executar(int $accountId, int $amount): int
    {
        $row = DB::connection()->selectOne(
            'update accounts
                set balance = balance - ?, updated_at = now()
              where id = ? and balance >= ?
          returning balance',
            [$amount, $accountId, $amount],
            useReadPdo: false,
        );

        if ($row === null) {
            throw new SaldoInsuficiente($accountId, $amount);
        }

        return (int) $row->balance;
    }
}
```

Alguns detalhes que importam:

- **`RETURNING balance`** devolve o saldo novo sem uma segunda query, e esse valor é coerente com o débito que você acabou de fazer. Ler o saldo depois, numa query separada, pode trazer um valor que já inclui o débito de outro processo.
- **`useReadPdo: false`** é obrigatório se você usa separação de leitura e escrita. `selectOne` vai para a conexão de leitura por padrão, e um `UPDATE` mandado para a réplica falha (ou, pior, você acaba descobrindo que a réplica não estava realmente em modo somente leitura). Escrevi sobre essa armadilha em [como lidar com o replication lag usando réplica de leitura no PostgreSQL com Laravel](/blog/replica-de-leitura-no-postgresql-com-laravel-como-lidar-com-o-replication-lag).
- **O saldo é inteiro em centavos.** Comparação com float em `WHERE balance >= ?` é pedir problema. Se ainda não migrou, veja [como tratar dinheiro no Laravel com centavos em inteiro e cast de Money](/blog/dinheiro-no-laravel-centavos-em-inteiro-cast-de-money-e-split-sem-perder-centavo).

Se você não precisa do saldo de volta, o query builder resolve, porque `decrement` retorna o número de linhas afetadas:

```php
$afetadas = DB::table('accounts')
    ->where('id', $accountId)
    ->where('balance', '>=', $amount)
    ->decrement('balance', $amount);

if ($afetadas === 0) {
    throw new SaldoInsuficiente($accountId, $amount);
}
```

O trade-off é que o UPDATE condicional só resolve regras que cabem num `WHERE` de uma linha. Quando a decisão depende de várias linhas, como limite diário somado de várias transações ou duas contas numa transferência, você precisa de lock explícito.

## Quando usar lockForUpdate no Laravel?

O lock pessimista consiste em travar a linha antes de ler, para que ninguém mais escreva nela até sua transação terminar. No Laravel, `lockForUpdate()` gera `SELECT ... FOR UPDATE`. O ponto que mais vejo errado em code review é travar depois de já ter lido. O lock precisa estar na leitura que alimenta a decisão:

```php
DB::transaction(function () use ($accountId, $amount) {
    $account = Account::whereKey($accountId)->lockForUpdate()->firstOrFail();

    $sacadoHoje = $account->ledgerEntries()
        ->where('type', 'pix_out')
        ->where('created_at', '>=', today())
        ->sum('amount');

    if ($account->balance < $amount || $sacadoHoje + $amount > $account->daily_limit) {
        throw new DebitoRecusado();
    }

    $account->decrement('balance', $amount);
    $account->ledgerEntries()->create(['type' => 'pix_out', 'amount' => $amount]);
});
```

Repare que o lock na linha da conta serializa todos os débitos dessa conta. É isso que torna segura a soma do limite diário: nenhum outro débito da mesma conta consegue inserir lançamento enquanto você está decidindo. Isso só vale se todo código que debita passar pelo mesmo lock. Um caminho alternativo que faz `decrement` direto, sem travar, quebra a garantia.

O custo é contenção. Enquanto a transação está aberta, qualquer outro débito da mesma conta fica esperando. Por isso tenho uma regra que não negocio: **nada de HTTP dentro de transação com lock**. Chamar o gateway para enviar o Pix com a linha travada significa segurar o lock pelo tempo de resposta de um terceiro. Se ele tiver um timeout de 30 segundos, todos os débitos daquela conta ficam presos por 30 segundos. O fluxo certo é reservar o saldo na transação, fazer commit e só então chamar o gateway num job.

## Como evitar deadlock ao travar duas contas numa transferência interna?

Deadlock é quando duas transações esperam uma pela outra em ciclo e nenhuma consegue avançar. Numa transferência interna entre contas, ele aparece assim: A transfere para B e trava A primeiro; ao mesmo tempo, B transfere para A e trava B primeiro. Cada uma fica esperando o lock que a outra segura. O PostgreSQL detecta o ciclo e aborta uma das duas com o erro `40P01`.

A correção clássica é travar sempre na mesma ordem, por exemplo pelo `id` crescente:

```php
public function transferir(int $fromId, int $toId, int $amount): void
{
    DB::transaction(function () use ($fromId, $toId, $amount) {
        DB::statement("SET LOCAL lock_timeout = '3s'");

        $contas = Account::whereIn('id', [$fromId, $toId])
            ->orderBy('id')
            ->lockForUpdate()
            ->get()
            ->keyBy('id');

        $origem = $contas[$fromId];
        $destino = $contas[$toId];

        if ($origem->balance < $amount) {
            throw new SaldoInsuficiente($fromId, $amount);
        }

        $origem->decrement('balance', $amount);
        $destino->increment('balance', $amount);

        LedgerEntry::insert([
            ['account_id' => $fromId, 'amount' => -$amount, 'type' => 'transfer_out', 'created_at' => now()],
            ['account_id' => $toId, 'amount' => $amount, 'type' => 'transfer_in', 'created_at' => now()],
        ]);
    }, attempts: 3);
}
```

No PostgreSQL, com `ORDER BY` e `FOR UPDATE` na mesma query, as linhas são travadas na ordem em que saem da ordenação. Assim, duas transferências cruzadas sempre disputam primeiro o menor `id`, e não existe ciclo.

Dois detalhes nesse código:

1. **`attempts: 3`**: o `DB::transaction` do Laravel refaz o callback quando a exceção é de concorrência, como deadlock ou falha de serialização. Isso funciona como rede de segurança para deadlocks que você não previu, como os causados por índices ou foreign keys. Já o `SaldoInsuficiente` não é repetido, só faz rollback.
2. **`SET LOCAL lock_timeout`**: limita quanto tempo a transação espera por um lock. Sem isso, uma transação travada por um processo lento fica pendurada até o timeout do worker. Com `SET LOCAL`, a configuração vale só para a transação corrente e não vaza para a conexão.

Como o callback pode rodar mais de uma vez, ele não pode ter efeito colateral fora do banco. Disparar job, mandar notificação ou publicar evento lá dentro gera duplicata no retry e publicação fantasma no rollback. Ou você usa `afterCommit`, ou grava o evento numa tabela na mesma transação, como descrevi no post sobre [transactional outbox no Laravel](/blog/transactional-outbox-no-laravel-como-publicar-eventos-sem-perder-nenhum-quando-a-fila-cai).

## Lock otimista com coluna version vale a pena para saldo?

No lock otimista, você não trava nada. Lê a linha junto com uma coluna `version`, e no update exige que a versão continue a mesma:

```php
$afetadas = Account::whereKey($account->id)
    ->where('version', $account->version)
    ->update([
        'balance' => $account->balance - $amount,
        'version' => $account->version + 1,
    ]);

if ($afetadas === 0) {
    // alguém mexeu antes: recarregar e tentar de novo
}
```

Funciona bem quando o conflito é raro, como na edição de cadastro ou na configuração de split de um lojista. Para saldo de conta movimentada, é a escolha errada: em momentos de pico, como o fechamento de lote de vendas da maquininha, os conflitos viram regra e você troca espera em lock por um loop de retries no PHP, que é pior.

| Estratégia | Quando uso | Custo |
|---|---|---|
| UPDATE condicional | Débito simples, regra cabe no WHERE | Quase nenhum; não cobre regras com várias linhas |
| `lockForUpdate` | Limites somados, transferência entre contas | Contenção por conta; exige ordem de locks |
| Lock otimista (`version`) | Dados com pouca disputa | Retries em pico; ruim para saldo quente |
| `pg_advisory_xact_lock` | Travar algo que ainda não tem linha | Fácil de esquecer em algum caminho do código |

O advisory lock do PostgreSQL (`select pg_advisory_xact_lock(?)`) é um lock por número, liberado automaticamente no fim da transação. Uso quando o recurso disputado ainda não existe como linha, por exemplo ao criar a conta-saldo de um lojista na primeira venda, quando dois webhooks chegam juntos.

## Cache::lock no Redis não resolve?

Não como garantia de saldo. `Cache::lock` é útil para evitar trabalho duplicado, como dois workers processando o mesmo arquivo de conciliação, mas ele tem dois furos quando o assunto é dinheiro:

1. **Ele é voluntário.** Protege só o código que lembra de pedir o lock. O comando que alguém escreve às pressas para estornar tarifas passa direto pelo banco.
2. **Ele expira.** Se a transação demorar mais que o TTL, por causa de pausa de GC, banco lento ou lock_wait, o lock some e um segundo processo entra com a transação do primeiro ainda aberta.

A fonte da verdade do saldo é o PostgreSQL, então a garantia tem que estar no PostgreSQL. Redis pode ficar na frente como otimização, nunca como única proteção.

## O que colocar no banco como última linha de defesa?

Mesmo com tudo acima, eu coloco uma constraint. Ela não deixa o bug passar em silêncio:

```php
public function up(): void
{
    DB::statement(
        'ALTER TABLE accounts ADD CONSTRAINT accounts_balance_non_negative CHECK (balance >= 0)'
    );
}
```

Se algum caminho esquecido tentar deixar o saldo negativo, o `UPDATE` falha com violação de constraint, a transação faz rollback e você recebe um erro no Sentry em vez de descobrir o problema na conciliação do mês. Se a conta tem limite ou cheque especial, a regra vira `balance >= -credit_limit`, com o limite na mesma linha.

A outra defesa é o ledger, a tabela de lançamentos imutáveis em que cada movimentação vira uma linha. O saldo na tabela `accounts` é uma materialização que serve para consultar rápido. O ledger é o que explica o saldo. Gravar o lançamento na mesma transação do débito, como nos exemplos acima, permite rodar periodicamente um `SUM(amount)` por conta e comparar com `balance`. Divergência ali quer dizer que algum código está mexendo no saldo por fora.

## Como testar concorrência de débito no Laravel?

Teste sequencial não pega race condition. É preciso processos de verdade batendo no mesmo banco ao mesmo tempo. Com o `Process::pool` do Laravel dá para fazer isso num teste Pest, desde que você observe duas coisas: o banco precisa ser PostgreSQL real (o SQLite em memória serializa tudo e esconde o problema) e você não pode usar `RefreshDatabase`, porque os processos filhos não enxergam dados de uma transação que nunca recebeu commit. Use `DatabaseTruncation`.

```php
use Illuminate\Process\Pool;
use Illuminate\Support\Facades\Process;

it('não deixa o saldo negativo com saques simultâneos', function () {
    $conta = Account::factory()->create(['balance' => 10_000]);

    Process::pool(function (Pool $pool) use ($conta) {
        foreach (range(1, 20) as $i) {
            $pool->path(base_path())
                ->command(['php', 'artisan', 'testing:debitar', $conta->id, '1000']);
        }
    })->start()->wait();

    expect($conta->fresh()->balance)->toBe(0)
        ->and(LedgerEntry::where('account_id', $conta->id)->count())->toBe(10);
});
```

O comando `testing:debitar` é registrado só no ambiente de teste e chama o mesmo serviço de débito da aplicação. Vinte processos tentam sacar R$ 10,00 de uma conta com R$ 100,00. O esperado é saldo zero e exatamente dez lançamentos. Rode esse teste contra a versão ingênua do primeiro exemplo e ele falha com frequência. Contra o UPDATE condicional, passa sempre. É o tipo de teste que vale manter no CI só para os serviços que mexem em saldo.

## Conclusão: o que eu faço em todo serviço que debita saldo

Na prática, o checklist fica assim:

1. Débito simples usa `UPDATE ... WHERE balance >= ? RETURNING balance`, sempre pela conexão de escrita.
2. Regra que depende de várias linhas usa `lockForUpdate()` na leitura que alimenta a decisão, dentro de `DB::transaction` com `attempts`.
3. Quando há mais de uma conta, trava em ordem de `id` e usa `SET LOCAL lock_timeout`.
4. Nada de HTTP nem dispatch de job dentro da transação; efeito colateral vai para depois do commit ou para o outbox.
5. Toda tabela de saldo tem `CHECK (balance >= 0)` e lançamento no ledger gravado na mesma transação.
6. Existe pelo menos um teste com processos paralelos contra PostgreSQL real.

Se for para escolher uma coisa só para fazer hoje, adicione a constraint `CHECK`. Ela não corrige a race condition, mas faz com que o pior caso seja um erro na tela em vez de dinheiro saindo de uma conta que não tinha saldo.

---

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