yuwuhsien/laravel-bcmath-cast
Eloquent casts for PHP 8.4's BCMath\Number object API. Turn model attributes into arbitrary-precision decimals and do natural operator-based arithmetic—add, subtract, multiply, divide—without bcadd/bcmul boilerplate. Requires PHP 8.4+ and Laravel 12+.
Confirm the two hard gates before anything else. The package requires PHP 8.4+ (for the native BCMath\Number class) and Laravel 12+ — Laravel 11 support was dropped in v1.2.2 with no shim. There is no degraded mode; Composer simply refuses to install. Check php -v and composer show laravel/framework in every app, worker, and reporting service that will hydrate the affected models.
composer require yuwuhsien/laravel-bcmath-cast
Read the one class first. The entire runtime surface is a single Eloquent cast: vendor/yuwuhsien/laravel-bcmath-cast/src/Casts/AsDecimal.php. There is no service provider, no config file, no migration, no container binding. Five minutes with that file tells you everything the package does — worth doing before you trust it with money.
Fix the schema before wiring the cast. The cast provides zero correctness benefit on FLOAT/DOUBLE; it only preserves precision that the column already stores. Convert to DECIMAL with explicit precision and scale first:
$table->decimal('price', 10, 2); // ✅
$table->decimal('tax_rate', 8, 6); // ✅
$table->float('amount'); // ❌ defeats the purpose
Pilot on one attribute. Pick a low-risk, high-visibility field (catalog price), declare the cast, and exercise it in Tinker:
use YuWuHsien\Decimal\Casts\AsDecimal;
protected function casts(): array
{
return ['price' => AsDecimal::class, 'cost' => AsDecimal::class];
}
$product = Product::create(['price' => '19.99', 'cost' => '12.50']);
$product->price instanceof \BCMath\Number; // true
$margin = $product->price - $product->cost; // Number('7.49')
(string) $margin; // "7.49"
That round trip — write a string, read a Number, compute, stringify — is your acceptance smoke test. Everything else is scale.
Always write strings, never floats. The cast accepts Number, numeric strings, and integers. Floats are accepted but emit a warning — and a warning is not a guardrail. Enforce at the request boundary, not in the cast:
'price' => ['required', 'decimal:0,4', 'max:999999.9999'],
Then assign (string) $request->validated('price') or the raw validated string. If you accept JSON payloads, cast the value to string at the edge rather than letting 29.99 become a float mid-flight.
Keep arithmetic in PHP, keep comparison in SQL. Use AsDecimal for computation; use plain string bindings for queries. Query builder calls bypass casts entirely:
Product::where('price', '>', (string) $threshold)->get(); // stringify
Product::whereBetween('price', ['10.00', '20.00']); // strings
Product::query()->update(['price' => (string) $newPrice]); // bypasses the cast
Sorting, filtering, and pagination belong in SQL. Only reduce over Number objects when you genuinely need PHP-side exactness.
Define a rounding policy per field and apply it before persisting. The cast yields a value; it does not impose a scale or rounding mode. Intermediate division can carry more digits than your DECIMAL(10,2) column, and the database will silently round (or error, under strict mode) on write:
$total = ($product->price * $qty) * (new Number('1') + $product->tax_rate);
$product->total = $total->round(2, \BCMath\RoundingMode::HalfEven);
$product->save();
Write that policy down once, in one place, and reuse it — don't let each call site invent its own.
Type-hint BCMath\Number, not vendor classes. The hydrated value is a PHP language built-in. Domain services that receive it depend on the runtime, not on yuwuhsien/*. That is the package's best property: if upstream is abandoned, you reimplement one cast class and nothing else changes.
final class OrderTotal
{
public function __construct(private Number $subtotal, private Number $taxRate) {}
public function gross(): Number
{
return $this->subtotal * (new Number('1') + $this->taxRate);
}
}
Test with string equality, not object equality. Number instances are values, not identities:
expect((string) $product->price)->toBe('19.99');
expect($margin->compare(new Number('7.49')))->toBe(0);
Build a round-trip suite per field and treat it as an acceptance gate: write '0.00' → read back; write null → read back; write ''; write a value exceeding column precision and assert it fails loudly rather than truncating. v1.2.2 shipped a fix for exactly this class of defect ("align set() input validation with what get() can parse back"), so assume field semantics are pre-stable relative to your edge cases.
Shape the API boundary deliberately. Decimal attributes serialize as JSON strings — semantically correct, and a breaking change for any client with numeric-typed fields. Decide per resource:
return [
'price' => (string) $this->price, // raw precision
'price_display'=> number_format((float) (string) $this->price, 2), // presentation only
'currency' => $this->currency, // keep separate
];
Treat that as an API versioning decision, not a side effect of a Composer update.
$product->price->add(new Number('1')) does nothing; reassign. And $product->price === $product->price is false — a fresh instance is built on each attribute read, so cache it in a local when used in loops.Number implicitly, others don't. Never pass a Number into where(), update(), insert(), or whereIn() — always (string) $n.null and '' are separate cases. Confirm your version's behavior for both before relying on either; the v1.2.2 changelog is a hint that this boundary has moved.DivisionByZeroError, not a silent INF.Number. Product::sum('price') returns whatever the driver hands back (usually a string). Pick SQL or PHP as the source of truth for totals and verify parity on representative data; divergence surfaces in reconciliation, not in tests.getRawOriginal('price') and getChanges() return strings — which is good news for audit logs. Just don't assume isDirty() compares Number objects.Number's serialize hooks surviving every driver and worker version.AsDecimal::class.':2' parameter syntax isn't documented — verify before designing around it, or write a thin cast that composes Number and applies your rounding policy. That's the only sanctioned extension point: subclass or compose the cast.dd($model->price::class, (string) $model->price, $model->getRawOriginal('price')); — distinguishes "cast returned a Number" from "the string that actually hit the column," which is where precision bugs live.How can I help you explore Laravel packages today?