DEV Community

Cover image for Handling Decimal Calculations in PHP 8.4: Integrating BCMath Object API with Laravel
Takeshi Yu
Takeshi Yu

Posted on • Edited on

Handling Decimal Calculations in PHP 8.4: Integrating BCMath Object API with Laravel

Originally published at Takeshi Yu's Blog ( https://takeshiyu.me/posts/bcmath-object-api-in-php-84 )


When developing enterprise applications, particularly those handling financial transactions, accounting systems, or inventory management, precise numerical calculations are non-negotiable. A small rounding error could lead to significant discrepancies and debugging nightmares. Let's dive into how PHP 8.4's new BCMath Object API makes handling these calculations both precise and elegant.


If you've been working with PHP for a while, you've probably encountered this classic floating-point precision issue:

$a = 0.1;
$b = 0.2;
var_dump($a + $b);  // Outputs: 0.30000000000000004
Enter fullscreen mode Exit fullscreen mode

This kind of imprecision is unacceptable in financial calculations. It's not just about aesthetics – these small errors can compound and lead to real-world discrepancies in your applications.

The Foundation: Database Structure

The first step in handling precise decimal calculations starts at the database level. Using the DECIMAL type is crucial:

// In Laravel Migration
Schema::create('items', function (Blueprint $table) {
    $table->id();
    $table->decimal('quantity', 10, 3);  // Total length 10, 3 decimal places
    $table->decimal('price', 10, 3);     // Total length 10, 3 decimal places
    $table->decimal('discount', 10, 3);  // Total length 10, 3 decimal places
    $table->decimal('tax', 10, 3);       // Total length 10, 3 decimal places
    // other columns ...
});
Enter fullscreen mode Exit fullscreen mode

The DECIMAL type ensures:

  • Exact decimal point precision
  • Configurable scale and precision
  • Suitable for financial calculations

While DECIMAL might be slightly slower than FLOAT, the trade-off for precision is well worth it in business-critical applications.

Laravel's Decimal Casting

If you're using Laravel, you can leverage its casting system to handle decimal values:

class Item extends Model
{
    protected function casts(): array
    {
        return [
            'quantity' => 'decimal:3',
            'price' => 'decimal:3',
            'discount' => 'decimal:3',
            'tax' => 'decimal:3',
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

However, it's important to understand that Laravel's casting primarily handles:

  • Data formatting
  • Consistent value representation

The Hidden Type Conversion Trap

Even with proper database types and Laravel casting, you can still run into precision issues when performing calculations:

// Values from database
$item1 = Item::find(1);  // price: "99.99"
$item2 = Item::find(2);  // price: "149.99"

// Calculation without BCMath
$subtotal = $item1->price + $item2->price;
$tax = $subtotal * 0.05;  // 5% tax

var_dump($tax);  // Outputs: float(12.499000000000002) instead of 12.499
Enter fullscreen mode Exit fullscreen mode

This behavior occurs because PHP automatically converts strings to numbers when you perform arithmetic operations:

// String values from database
$price1 = "99.99";
$price2 = "149.99";
echo gettype($price1);  // string

// PHP automatically converts to float during arithmetic
$total = $price1 + $price2;
echo gettype($total);   // double (float)
Enter fullscreen mode Exit fullscreen mode

BCMath Before PHP 8.4: Precise But Verbose

The solution uses PHP's BCMath extension:

// Database values
$item1 = Item::find(1);  // price: "99.99"
$item2 = Item::find(2);  // price: "149.99"

// Using BCMath functions
$subtotal = bcadd($item1->price, $item2->price, 3);
$tax = bcmul($subtotal, $item2->tax, 3);

var_dump($tax);  // Precisely outputs: string(5) "12.499"
Enter fullscreen mode Exit fullscreen mode

However, when calculations become more complex, the code becomes harder to read and maintain:

// Complex order calculation
$subtotal = bcmul($item1->price, $item1->quantity, 3);  // Calculate quantity
$discount = bcmul($subtotal, $item1->discount, 3);      // 10% discount
$afterDiscount = bcsub($subtotal, $discount, 3);        // Apply discount
$tax = bcmul($afterDiscount, $item1->tax, 3);           // Add 5% tax
$total = bcadd($afterDiscount, $tax, 3);                // Final amount
Enter fullscreen mode Exit fullscreen mode

PHP 8.4's BCMath Object API

PHP 8.4 introduces a new object-oriented API for BCMath, making precise calculations both elegant and intuitive. When using BCMath\Number, values must be passed as strings to maintain precision - any float values will be converted to integers, leading to precision loss:

$num = new Number(3.5);    // Output: "3"
$num = new Number('4.1');  // Output: "4.1"
Enter fullscreen mode Exit fullscreen mode

This is why we properly configure our database columns as DECIMAL and ensure proper PHP PDO settings or explicit casting - to maintain string representation of decimal values. Here's how we use the new API to perform precise calculations:

use BCMath\Number;

$item1 = Item::find(1);
$price = new Number($item1->price);            // price: "99.99"
$quantity = new Number($item1->quantity);      // quantity: "2"
$discountRate = new Number($item1->discount);  // 10% discount
$taxRate = new Number($item1->tax);            // 5% tax

// Calculations become natural and readable
$subtotal = $price * $quantity;
$discount = $subtotal * $discountRate;
$afterDiscount = $subtotal - $discount;
$tax = $afterDiscount * $taxRate;
$total = $afterDiscount + $tax;

var_dump($total);  // Automatically converts to string
Enter fullscreen mode Exit fullscreen mode

Key benefits of the new API:

  • Intuitive object-oriented interface
  • Support for standard mathematical operators
  • Immutable objects ensuring value safety
  • Implementation of the Stringable interface

Elegant Integration with Laravel

We can make this even more elegant using Laravel's accessor pattern:

use BCMath\Number;

class Item extends Model
{
    protected function quantity(): Attribute
    {
        return Attribute::make(
            get: fn (string $value) => new Number($value),
        );
    }

    protected function price(): Attribute
    {
        return Attribute::make(
            get: fn (string $value) => new Number($value),
        );
    }

    protected function discount(): Attribute
    {
        return Attribute::make(
            get: fn (string $value) => new Number($value),
        );
    }

    protected function tax(): Attribute
    {
        return Attribute::make(
            get: fn (string $value) => new Number($value),
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Or with a custom cast:

use BCMath\Number;

class DecimalCast implements CastsAttributes
{
    public function get(Model $model, string $key, mixed $value, array $attributes): mixed
    {
        return new Number($value);
    }

    public function set(Model $model, string $key, mixed $value, array $attributes): mixed
    {
        if ($value instanceof Number) {
            return (string) $value;
        }
        return (string) new Number($value);
    }
}
Enter fullscreen mode Exit fullscreen mode
class Item extends Model
{
    protected function casts(): array
    {
        return [
            'quantity' => DecimalCast::class,
            'price' => DecimalCast::class,
            'discount' => DecimalCast::class,
            'tax' => DecimalCast::class,
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

Then:

$item1 = Item::find(1);

$subtotal = $item1->price * $item1->quantity;
$discount = $subtotal * $item1->discount;
$afterDiscount = $subtotal - $discount;
$tax = $afterDiscount * $item1->tax;
$total = $afterDiscount + $tax;
Enter fullscreen mode Exit fullscreen mode

Conclusion

In my work developing healthcare inventory systems, precise decimal calculations are crucial. Whether it's converting medication units or calculating exact costs, even small rounding errors could have serious implications. PHP 8.4's BCMath Object API, combined with Laravel's elegant model layer, has transformed how we handle these critical calculations.

This integration brings multiple benefits:

  • The precision we require for medical calculations
  • Improved code readability through object-oriented syntax
  • Better maintainability with Laravel's elegant abstraction
  • Type safety through PHP's static typing

While the traditional BCMath functions served us well for many years, this new approach significantly improves our daily development work.

Top comments (4)

Collapse
 
xwero profile image
david duymelinck • Edited

There is one caveat when you use BCMath\Number, if it is not a string it will convert a float to an integer. So make sure the input is a string.

When I execute

$subtotal = new BCMath\Number('99.99') + new BCMath\Number('149.99');
$tax = $subtotal * new BCMath\Number('0.05'); // 5% tax

var_dump($subtotal, $tax); 
Enter fullscreen mode Exit fullscreen mode

I get the results 249.98 and 12.4990. So if you don't want rounding errors you have to do $tax = $subtotal->mul('0.05', 3);, to get the correct precision.

Why do you want the precision to be 3 when all the numbers in the examples have max precision 2?

Collapse
 
takeshiyu profile image
Takeshi Yu

Thank you for pointing this out! You're right about being careful with string inputs.

About the precision: I used 2 decimal places in the examples to match common financial calculations (cents). Using 3 decimal places in intermediate calculations can help avoid rounding errors in complex calculations. Would you suggest keeping consistent precision throughout?

Collapse
 
xwero profile image
david duymelinck

You mentioned 12.499000000000002 is a problem. So I was thinking would 12.499 not be a problem when the actual numbers are only two decimals?
If you do $tax = $subtotal->mul('0.05', 2); the tax is 12.49.
Maybe you know scenarios where there are rounding errors?

I seen solutions where prices are stored as an integer. But then you have to use the floor or ceil functions to prevent rounding errors. So I guess maybe you wanting to prevent rounding errors came from that experience?

Thread Thread
 
takeshiyu profile image
Takeshi Yu

Thank you for this thoughtful feedback! You make a great point about 2 decimal precision being sufficient for standard financial calculations.

You're also correct about my approach being influenced by past experiences with integer storage and rounding issues.