laravel-money
LaravelAn immutable money value object for Laravel with safe arithmetic, allocation and Eloquent casting.
composer require webrek/laravel-moneyLaravel Money
Un objeto de valor inmutable para representar dinero en Laravel. Los montos se almacenan como un entero de unidades menores (centavos), la aritmética es exacta y el redondeo solo ocurre donde tú lo pides explícitamente.
Inicio rápido
composer require webrek/laravel-money
use Webrek\Money\Money;
$price = Money::of('19.99', 'USD'); // desde unidades mayores
$tax = $price->times('0.16'); // 16% — redondeado HALF_UP a 3.20
$total = $price->plus($tax); // USD 23.19
$total->format(); // "USD 23.19"
$total->minorAmount; // 2319
¿Por qué no usar simplemente una columna decimal y flotantes?
Porque el dinero y los flotantes binarios no se llevan. 0.1 + 0.2 no es 0.3,
y un centavo que se desvía dentro de un bucle se convierte en un ticket de
conciliación. El enfoque robusto —usado por todo sistema de pagos serio— es
almacenar el dinero como un conteo entero de la unidad más pequeña (centavos,
fils, yenes) y nunca dejar que un flotante se acerque.
Este paquete te da eso como un tipo de primera clase:
- Exacto con enteros. Cada monto es un
intde unidades menores más unaCurrency. La suma y la resta no pueden perder precisión porque no hay precisión que perder. - El redondeo es explícito. Las únicas operaciones que pueden producir una
fracción de centavo —la multiplicación y la división— reciben un
RoundingMode, conHALF_UPpor defecto. Nada se redondea a tus espaldas. - No se crea ni se destruye dinero al repartir.
allocate()ysplit()distribuyen hasta la última unidad menor. - Consciente de la moneda. Las operaciones entre monedas lanzan una excepción en lugar de producir resultados sin sentido en silencio, y cada moneda conoce su propia escala (USD tiene 2 decimales, JPY tiene 0, BHD tiene 3).
No depende de moneyphp/money — es un tipo enfocado y nativo de Laravel.
Construir dinero
Money::of('10.99', 'USD'); // unidades mayores (el string es lo más seguro)
Money::of(10.99, 'USD'); // se acepta float, redondeado a la escala de la moneda
Money::ofMinor(1099, 'USD'); // unidades menores directamente
Money::zero('USD');
// La escala de la moneda se respeta automáticamente:
Money::of('1000', 'JPY')->minorAmount; // 1000 (JPY no tiene unidad menor)
Money::of('1.234', 'BHD')->minorAmount; // 1234 (BHD tiene 3)
Aritmética
$a = Money::of('10.00', 'USD');
$b = Money::of('2.50', 'USD');
$a->plus($b); // 12.50
$a->minus($b); // 7.50
$a->times(3); // 30.00
$a->dividedBy(3); // 3.33 (HALF_UP)
$a->negated(); // -10.00
$a->abs();
$a->plus(Money::of('1', 'EUR')); // lanza CurrencyMismatchException
Modos de redondeo
times() y dividedBy() aceptan cualquier RoundingMode:
UP, DOWN, CEILING, FLOOR, HALF_UP (por defecto), HALF_DOWN, HALF_EVEN
(redondeo bancario).
use Webrek\Money\RoundingMode;
Money::ofMinor(1099, 'USD')->times('1.5', RoundingMode::DOWN); // 16.48
Money::ofMinor(1099, 'USD')->times('1.5', RoundingMode::UP); // 16.49
Conversión de moneda
Convierte con una tasa explícita (cuántas unidades de la moneda destino equivalen a una unidad de la moneda origen), con la escala y el redondeo resueltos por ti:
Money::of('10.00', 'USD')->convertTo('EUR', '0.92'); // EUR 9.20
Money::of('10.00', 'USD')->convertTo('JPY', '150'); // JPY 1500 (0 decimales)
Money::of('1000', 'JPY')->convertTo('USD', '0.0067'); // USD 6.70
O resuelve la tasa desde un ExchangeRateProvider. El ArrayExchangeRateProvider
incluido recibe un mapa de moneda => tasa relativa a una base común y calcula las
tasas cruzadas por ti:
use Webrek\Money\ArrayExchangeRateProvider;
$rates = new ArrayExchangeRateProvider(['USD' => 1, 'EUR' => 0.92, 'MXN' => 17.5]);
Money::of('100', 'USD')->convert('EUR', $rates); // EUR 92.00
Money::of('175', 'MXN')->convert('USD', $rates); // USD 10.00 (tasa cruzada)
Configura el proveedor por defecto en config/money.php y resuélvelo desde el
contenedor:
'exchange' => ['rates' => ['USD' => 1, 'EUR' => 0.92, 'MXN' => 17.5]],
use Webrek\Money\Contracts\ExchangeRateProvider;
$eur = $price->convert('EUR', app(ExchangeRateProvider::class));
Conecta tasas en vivo implementando tú mismo ExchangeRateProvider (por ejemplo,
respaldado por una API y caché) y vinculándolo al contrato.
Repartir sin perder centavos
// Divide una cuenta en tres — el centavo sobrante se reparte, nada se pierde.
$shares = Money::ofMinor(100, 'USD')->split(3);
// [USD 0.34, USD 0.33, USD 0.33] (suma de vuelta exactamente 1.00)
// Reparte por proporción (por ejemplo, un reparto de ingresos 70/30):
Money::ofMinor(100, 'USD')->allocate(7, 3);
// [USD 0.70, USD 0.30]
El residuo se entrega primero a las proporciones más grandes, de modo que los
repartos son estables y justos, y array_sum de las partes siempre es igual al
original.
Agregados y porcentajes
Money::sum([$a, $b, $c]); // total (todas la misma moneda)
Money::min([$a, $b, $c]);
Money::max([$a, $b, $c]);
$price->percentage(16); // 16% — por ejemplo, impuesto
$price->percentage('8.25'); // las tasas fraccionarias son bienvenidas
Suma una colección directamente, opcionalmente por clave:
$orders->sumMoney('total'); // Money|null
collect([$a, $b])->sumMoney(); // Money|null
sumMoney() devuelve null para una colección vacía; Money::sum() lanza una
excepción con un conjunto vacío (no hay moneda que devolver).
Comparación
$a->isEqualTo($b);
$a->isGreaterThan($b);
$a->isGreaterThanOrEqualTo($b);
$a->isLessThan($b);
$a->isLessThanOrEqualTo($b);
$a->compareTo($b); // -1 | 0 | 1
$a->isZero();
$a->isPositive();
$a->isNegative();
Casting con Eloquent
Almacena las unidades menores en una columna entera y conviértela (cast) a Money.
Moneda única — la columna contiene las unidades menores; la moneda es fija en el cast:
use Webrek\Money\Casts\MoneyCast;
protected function casts(): array
{
return ['price' => MoneyCast::class . ':USD'];
}
$product->price = Money::of('19.99', 'USD');
$product->save(); // almacena 1999 en `price`
$product->price->format(); // "USD 19.99"
Multimoneda — agrega una columna string acompañante {column}_currency y
omite el código:
protected function casts(): array
{
return ['cost' => MoneyCast::class]; // lee/escribe `cost` y `cost_currency`
}
$product->cost = Money::of('15.50', 'EUR'); // almacena 1550 + "EUR"
Asignar una moneda que no coincide con una columna de moneda fija lanza una
CurrencyMismatchException.
Validación
use Webrek\Money\Rules\CurrencyCode;
$request->validate([
'currency' => ['required', new CurrencyCode],
]);
Formato y serialización
$money = Money::ofMinor(123456, 'USD');
$money->format(); // "USD 1,234.56" (independiente del locale)
$money->formatTo('en_US'); // "$1,234.56" (requiere ext-intl)
$money->toDecimal(); // "1234.56"
(string) $money; // "1234.56 USD"
json_encode($money);
// {"amount":"1234.56","minorAmount":123456,"currency":"USD"}
formatTo() usa la extensión intl; sin ella, recae en format().
Requisitos
| Componente | Versión |
|---|---|
| PHP | 8.2+ |
| Laravel | 12.x / 13.x |
| ext-intl | opcional, para formatTo() |
| ext-bcmath | opcional, para multiplicación/división exacta a gran escala |
Pruebas
composer install
composer test
Contribuir
Consulta CONTRIBUTING.md.
Seguridad
Por favor revisa la política de seguridad antes de reportar una vulnerabilidad.
Licencia
La Licencia MIT (MIT). Consulta LICENSE.