# Dinheiro no Laravel: centavos em inteiro, cast de Money e split sem perder centavo

> Como guardar valores em centavos no PostgreSQL, encapsular em um value object Money com cast do Eloquent e dividir split e parcelas sem criar nem sumir com centavos.

Por Lucas Roberto · Laravel · publicado em 2026-10-26 · 9 min de leitura
Versão web: https://lucasroberto.com/blog/dinheiro-no-laravel-centavos-em-inteiro-cast-de-money-e-split-sem-perder-centavo

Para lidar com dinheiro no Laravel sem erro de centavo, guarde valores como inteiro em centavos (`bigint` no PostgreSQL) e embrulhe esse inteiro em um value object `Money` imutável, exposto por um cast customizado do Eloquent. Toda conta (taxa, split e parcelamento) é feita em aritmética inteira, com uma regra explícita de arredondamento. Na divisão de valores entre várias partes, use o método do maior resto, que garante que a soma das partes é sempre igual ao total.

Parece detalhe, mas é o tipo de bug que não aparece no teste com R$ 100,00 e aparece na conciliação do fim do mês: um centavo a mais no repasse de um recebedor, um centavo a menos na soma das parcelas, um relatório que não bate com o extrato do adquirente. Em fintech, esse centavo vira ticket de suporte e, pior, vira diferença no ledger. Vou mostrar como estruturo isso nos meus projetos em Laravel.

## Por que float quebra conta de dinheiro em PHP?

`float` em PHP é ponto flutuante IEEE 754 de 64 bits: ele representa números em base 2, e a maioria das frações decimais (como 0,10 ou 19,99) não tem representação exata em base 2. O resultado é sempre uma aproximação.

```php
var_dump(0.1 + 0.2 === 0.3);       // bool(false)
var_dump((int) (19.99 * 100));     // int(1998)
var_dump(floor(0.29 * 100));       // float(28)
```

O segundo caso é o clássico: alguém recebe `19.99` do front ou de um gateway, multiplica por 100 e faz cast para `int`. O cast trunca, e `19.99 * 100` vale `1998.9999999999998`. Sumiu um centavo e nenhum teste com valor redondo pega isso.

Usar `bcmath` com strings resolve a precisão, mas espalha `bcadd`, `bcmul` e escala de casas decimais pelo código inteiro. Funciona, mas é fácil esquecer a escala em um lugar e truncar sem perceber.

## Centavos em inteiro ou DECIMAL no PostgreSQL?

As duas opções são corretas no banco. A diferença está no que acontece quando o valor chega ao PHP.

| Critério | `bigint` em centavos | `numeric(15,2)` |
|---|---|---|
| Precisão no banco | Exata | Exata |
| Tipo que chega no PHP via PDO | `int` | `string` |
| Aritmética no PHP | Nativa, inteira | Exige bcmath ou conversão |
| Leitura em SQL cru | Precisa dividir por 100 | Legível direto |
| Compatível com Stripe e Pagar.me | Direto (já usam centavos) | Precisa converter |
| Frações de centavo | Não representa | Representa até a escala definida |

Eu fico com `bigint` em centavos para valores monetários e sufixo `_cents` no nome da coluna (`amount_cents`, `fee_cents`). O sufixo parece redundante até o dia em que alguém roda um `SELECT` no console de produção e lê 1999 como mil novecentos e noventa e nove reais.

Para taxas percentuais, como o MDR (a taxa que o adquirente cobra sobre cada venda no cartão), uso inteiro em basis points: 1 basis point é 0,01%, então 2,39% vira `239`. Mesma ideia: nada de float, nem na taxa.

```php
Schema::create('sales', function (Blueprint $table) {
    $table->id();
    $table->bigInteger('amount_cents');
    $table->bigInteger('fee_cents');
    $table->integer('mdr_bps');
    $table->timestamps();
});
```

## Como criar um value object Money imutável?

Value object é um objeto definido pelo valor que carrega, não por identidade, e que não muda depois de criado. Toda operação devolve uma instância nova. Isso evita o bug de alterar um valor que outro trecho de código ainda estava usando.

```php
namespace App\Support;

use InvalidArgumentException;
use JsonSerializable;

final class Money implements JsonSerializable
{
    private function __construct(public readonly int $cents) {}

    public static function cents(int $cents): self
    {
        return new self($cents);
    }

    public static function zero(): self
    {
        return new self(0);
    }

    public static function fromDecimal(string $value): self
    {
        if (! preg_match('/^(-)?(\d+)(?:[.,](\d{1,2}))?$/', trim($value), $m)) {
            throw new InvalidArgumentException("Valor monetário inválido: {$value}");
        }

        $cents = (int) $m[2] * 100 + (int) str_pad($m[3] ?? '', 2, '0');

        return new self($m[1] === '-' ? -$cents : $cents);
    }

    public function add(self $other): self
    {
        return new self($this->cents + $other->cents);
    }

    public function subtract(self $other): self
    {
        return new self($this->cents - $other->cents);
    }

    public function equals(self $other): bool
    {
        return $this->cents === $other->cents;
    }

    public function isNegative(): bool
    {
        return $this->cents < 0;
    }

    public function jsonSerialize(): int
    {
        return $this->cents;
    }
}
```

O `fromDecimal` recebe string de propósito e parseia sem passar por float. Ele rejeita separador de milhar (`1.234,56`) intencionalmente: se esse formato chegou no back-end, o problema está em quem mandou, e prefiro uma exceção a um palpite.

Trabalho só com real na maioria dos produtos, então o `Money` não carrega moeda. Se o seu caso tem várias moedas, não reinvente: `brick/money` e `moneyphp/money` são bibliotecas maduras, tratam moeda, escala e arredondamento. O trade-off é peso e API mais verbosa. Para um sistema monomoeda, umas 100 linhas próprias que eu entendo por completo me servem melhor.

## Como fazer um cast customizado de Money no Eloquent?

Cast customizado é uma classe que implementa `CastsAttributes` e diz ao Eloquent como transformar o valor da coluna em objeto na leitura, e de volta em valor primitivo na escrita.

```php
namespace App\Casts;

use App\Support\Money;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;

final class MoneyCast implements CastsAttributes
{
    public function get(Model $model, string $key, mixed $value, array $attributes): ?Money
    {
        return $value === null ? null : Money::cents((int) $value);
    }

    public function set(Model $model, string $key, mixed $value, array $attributes): ?int
    {
        if ($value === null) {
            return null;
        }

        if (! $value instanceof Money) {
            throw new InvalidArgumentException(
                "{$key} precisa ser Money, recebeu " . get_debug_type($value)
            );
        }

        return $value->cents;
    }
}
```

```php
class Sale extends Model
{
    protected function casts(): array
    {
        return [
            'amount_cents' => MoneyCast::class,
            'fee_cents' => MoneyCast::class,
        ];
    }
}
```

A decisão importante está no `set`: ele recusa `int`, `float` e `string`. Isso é chato nas primeiras semanas, porque todo `$sale->amount_cents = 1999` quebra. E é exatamente esse o objetivo. Quem escreve o código precisa decidir conscientemente se aquele número é centavo ou real, chamando `Money::cents()` ou `Money::fromDecimal()`. O cast vira uma barreira de tipo na fronteira com o banco.

Um detalhe que pega muita gente: agregações não passam pelo cast. `Sale::sum('amount_cents')` no PostgreSQL devolve `numeric` (a soma de `bigint` sobe de tipo), que chega ao PHP como string. Embrulhe na hora: `Money::cents((int) Sale::sum('amount_cents'))`.

## Como calcular taxa percentual sem erro de arredondamento?

Taxa percentual quase nunca dá centavo inteiro. Uma venda de R$ 10,00 com MDR de 2,39% dá R$ 0,239 de taxa. Alguém vai ter que arredondar, e a regra precisa estar escrita no código, não implícita no comportamento de uma função.

```php
public function percentage(int $basisPoints): self
{
    $product = $this->cents * $basisPoints;
    $quotient = intdiv($product, 10_000);
    $remainder = $product % 10_000;

    // half-up, afastando do zero
    if (abs($remainder) * 2 >= 10_000) {
        $quotient += $product <=> 0;
    }

    return new self($quotient);
}
```

`Money::cents(1000)->percentage(239)` dá 24 centavos. Tudo inteiro, sem float em nenhum passo. Em `int` de 64 bits, `cents * bps` só estoura com valores na casa de quatrilhões de reais, então não me preocupo.

O trade-off aqui não é técnico, é de negócio: arredondar a taxa para cima favorece a plataforma, para baixo favorece o lojista. Half-up é o padrão mais defensável, mas o ponto é escolher, documentar e aplicar em um lugar só. O valor cobrado pelo adquirente também vem arredondado por regra dele; a conciliação precisa comparar com o que ele reporta, não com o que você recalculou. Esse cuidado de onde cada centavo é registrado é o mesmo que aparece quando você [modela o saldo com um ledger de partidas dobradas](/blog/ledger-de-partidas-dobradas-no-laravel-e-postgresql-como-modelar-o-saldo-de-uma-conta-digital): taxa é um lançamento próprio, não uma subtração escondida no valor líquido.

## Como dividir um valor no split sem perder ou sobrar centavo?

Split de pagamento é a divisão de uma mesma transação entre vários recebedores, como lojista e parceiro. O erro mais comum é calcular cada parte como percentual do total e arredondar cada uma separadamente.

Veja: um líquido de R$ 97,61 dividido 50/50. Cada metade dá 4880,5 centavos. Arredondando cada uma com half-up, as duas viram 4881, e a soma dá 9762. O sistema acabou de criar um centavo do nada. Com três partes iguais de R$ 1,00, o efeito é o inverso: três vezes 33 dá 99 e some um centavo.

A solução é o método do maior resto (largest remainder):

1. Calcule a parte inteira de cada recebedor com divisão inteira (arredondando sempre para baixo).
2. Some as partes e veja quantos centavos sobraram em relação ao total.
3. Ordene os recebedores pelo resto da divisão, do maior para o menor.
4. Distribua um centavo para cada um, nessa ordem, até a sobra zerar.

```php
/**
 * @param  list<int>  $ratios
 * @return list<Money>
 */
public function allocate(array $ratios): array
{
    if ($this->cents < 0) {
        throw new InvalidArgumentException('allocate não aceita valor negativo');
    }

    $total = array_sum($ratios);

    if ($ratios === [] || $total <= 0 || min($ratios) < 0) {
        throw new InvalidArgumentException('Proporções inválidas');
    }

    $shares = [];
    $remainders = [];

    foreach ($ratios as $i => $ratio) {
        $shares[$i] = intdiv($this->cents * $ratio, $total);
        $remainders[$i] = ($this->cents * $ratio) % $total;
    }

    $leftover = $this->cents - array_sum($shares);

    // arsort é estável no PHP 8: empate mantém a ordem original
    arsort($remainders);

    foreach (array_keys($remainders) as $i) {
        if ($leftover === 0) {
            break;
        }
        $shares[$i]++;
        $leftover--;
    }

    return array_map(fn (int $c) => new self($c), $shares);
}
```

Com isso, os 9761 centavos divididos 50/50 viram 4881 e 4880. A soma sempre fecha, por construção. A ordem dos recebedores importa no desempate, então é parte da regra: eu coloco o lojista primeiro, o que faz o centavo de diferença ir para ele.

O fluxo completo de uma venda fica assim:

```php
$amount = Money::cents(10_000);
$fee = $amount->percentage($sale->mdr_bps);   // 239
$net = $amount->subtract($fee);               // 9761

[$merchant, $partner] = $net->allocate([70, 30]);

assert($fee->add($merchant)->add($partner)->equals($amount));
```

O mesmo `allocate` resolve parcelamento: `Money::cents(10_000)->allocate([1, 1, 1])` dá 3334, 3333 e 3333. Uma convenção comum é a primeira parcela absorver a diferença, que é justamente o que o desempate pela ordem produz. Mas confirme na documentação do adquirente ou do gateway como ele distribui, porque a sua conciliação vai comparar parcela a parcela.

## Como converter valores na fronteira com o gateway de pagamento?

Cada gateway representa valor de um jeito. Stripe e Pagar.me trabalham com inteiro na menor unidade da moeda, ou seja, centavos. O Asaas usa valor decimal (`100.50`). Essa conversão precisa ficar no adapter do gateway, nunca no domínio. É o mesmo princípio de [integrar vários gateways sem amarrar o domínio a um provedor](/blog/como-integrar-varios-gateways-de-pagamento-no-laravel-sem-amarrar-o-dominio-a-um-provedor): o domínio só conhece `Money`.

Quando o gateway devolve JSON com número decimal, o `json_decode` já entrega `float`. Nesse ponto específico, com no máximo duas casas, `round()` antes do cast é seguro, porque corrige o `1998.9999...`. O erro é usar `(int)` direto, que trunca.

```php
// saída para o gateway que usa decimal
$payload['value'] = number_format($money->cents / 100, 2, '.', '');

// entrada vinda do gateway que usa decimal
$money = Money::cents((int) round($response['value'] * 100));
```

No sentido inverso, se o gateway aceitar string decimal, mande string formatada com `number_format`. E no seu próprio front-end, defina o contrato da API como inteiro em centavos:

```php
public function rules(): array
{
    return [
        'amount_cents' => ['required', 'integer', 'min:1', 'max:100000000'],
    ];
}
```

Isso empurra a conversão para quem tem o campo de texto com máscara, e o back-end nunca precisa adivinhar se `19.9` era R$ 19,90 ou um erro de digitação.

## Como formatar Money em reais para exibir?

Formatação é a única hora em que dividir por 100 em float é aceitável, porque o resultado não volta para nenhuma conta. O Laravel tem `Number::currency`, que usa a extensão `intl`:

```php
use Illuminate\Support\Number;

public function format(): string
{
    return Number::currency($this->cents / 100, in: 'BRL', locale: 'pt_BR');
}
```

Saída: `R$ 19,99`. Atenção nos testes de snapshot: o espaço entre `R$` e o número é um espaço não separável (`U+00A0`), não um espaço comum, e o `assertSame` com string digitada à mão falha sem motivo aparente.

## Como testar que nenhum centavo some?

Teste com exemplos fixos é necessário, mas o que me dá confiança é um teste de propriedade: para qualquer total e qualquer conjunto de proporções, a soma das partes é igual ao total.

```php
it('nunca cria nem perde centavo no allocate', function () {
    foreach (range(1, 1_000) as $_) {
        $total = Money::cents(random_int(0, 10_000_000));
        $ratios = array_map(fn () => random_int(1, 100), range(1, random_int(1, 6)));

        $sum = array_sum(array_map(
            fn (Money $m) => $m->cents,
            $total->allocate($ratios),
        ));

        expect($sum)->toBe($total->cents);
    }
});

it('converte decimal sem passar por float', function () {
    expect(Money::fromDecimal('19.99')->cents)->toBe(1999)
        ->and(Money::fromDecimal('0,29')->cents)->toBe(29)
        ->and(Money::fromDecimal('7.5')->cents)->toBe(750);
});
```

Rodar mil combinações aleatórias leva milissegundos e pega exatamente o tipo de caso de borda que ninguém lembra de escrever. Se você usa Pest, dá para trocar o laço por um dataset gerado; o princípio é o mesmo.

No code review, a regra que sigo é simples: qualquer `float`, `/ 100` ou `* 100` fora da classe `Money` e dos adapters de gateway é motivo para pedir mudança. Esse item está no [checklist que uso para revisar PRs que mexem com dinheiro](/blog/como-revisar-um-pr-que-mexe-com-dinheiro-o-checklist-de-code-review-que-uso-em-fintech), junto com idempotência e transações.

## O que eu faria hoje em um projeto Laravel novo

1. Colunas monetárias como `bigint` com sufixo `_cents`; taxas como `integer` em basis points.
2. Um `Money` imutável com `percentage()` e `allocate()`, e a regra de arredondamento comentada no próprio método.
3. `MoneyCast` em todos os models, recusando primitivos no `set`.
4. Conversão de e para decimal só nos adapters de gateway, com `round()` e nunca `(int)` direto.
5. API pública recebendo e devolvendo centavos inteiros.
6. Teste de propriedade garantindo que split e parcelamento sempre fecham com o total.

Se o projeto já existe e usa float, não tente migrar tudo de uma vez. Comece pelo `Money` e pelo `allocate`, aplique primeiro no split e no parcelamento, que são onde o centavo some de verdade, e vá trocando as colunas uma a uma. Cada coluna migrada para `_cents` com cast é um lugar a menos onde a conciliação do fim do mês pode te surpreender.

---

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