Working with decimals in PHP apps

DB Basics

Let’s talk about some of the basics first.

float vs decimal

In general, floating-point values are usually stored as float in the database.
In earlier versions of databases they also often had precision/scale (e.g. float(5,2) for values like 123.56).
As per standard this is not something that should be used anymore for Mysql and probably other DBs.
So we can say they represent float without the exact decimal places being relevant.

In case the scale (number of decimal places) is relevant, it is best to use a special type of float called decimal.
Here we can set precision and scale accordingly, e.g. decimal(5,2).

  • Precision: Precision refers to the total number of significant digits in a decimal number. It includes both the digits to the left and the right of the decimal point.
    For example, in the decimal number 123.456, the precision is 6 because there are six significant digits in total.
  • Scale: Scale specifically refers to the number of digits to the right of the decimal point in a decimal number.
    In the decimal number 123.456, the scale is 3 because there are three digits to the right of the decimal point.

Why not int?

Some people might ask.
Well, in some rare cases this might work, but for most other cases it usually never stays int.
It also makes it harder to add scale afterwards, lets say from 1.00 represented as 100 to 1.000 represented as 1000.
Same value for floating-point, but x10 in integer representation. With this change a huge risk of adding human error when
modifying your code and calculations etc.
Some more thoughts can be found on this old reddit page.

PHP

Now when we read those in PHP apps through ORMs or PDO, they are often transformed into their "float" type form.
For calculating and keeping the scale as well as other math and rounding issues it is however often not practical to do the same for decimal.
There are also issues when passing the float around or comparing them directly (see this).
With float you are also bound to the max value of that system (32 or 64 bit), strings don’t have that limit. This is, of course, only relevant for cases with very larger numbers.

So while some ORMs still use float by default others by default cast to string. CakePHP ORM, for example.

A string can be passed around more easily, including serialized and keeping the scale.
However, using it in normal calculations requires a cast to float and back to string afterwards.
Especially since with strict_types set it can easily break your app hard.
Also PHPStan would otherwise not be happy with the code – and we want to use here the highest level of protection for the code. So it sure makes sense for it to point out those missing casts where they appear. With every cast the scale can go boom or be set to a different way too long value.
A possible workaround is to handle it using bcmath functionality, those are not always so easy to work with, however.

A small example on even basic additions being problematic in float:

$floatAmount1 = 0.1;
$floatAmount2 = 0.2;

$sumFloat = $floatAmount1 + $floatAmount2;

echo "Sum using float: $sumFloat\n"; // Output: Sum using float: 0.30000000000000004

$stringAmount1 = '0.1';
$stringAmount2 = '0.2';

$sumString = bcadd($stringAmount1, $stringAmount2, 1);

echo "Sum using string: $sumString\n"; // Output: Sum using string: 0.3

As shown, it is not too user friendly to use bcmath API.
So in light of this it can make sense to use value objects here.

Value Object

What is a value object? See this explanation.

For monetary fields itself there can be reasons to use the moneyphp/money library.
It comes with quite a powerful harness and might be bit too much for some. For me at least – dealing with a simpler decimal type setup – I was looking for something else.
At least for non-monetary values it sure doesn’t seem like a good fit anyway, so lets assume those are height/weight values or alike we want to cover using decimals.

For this reason I would like to introduce php-collective/decimal-object which provides a simple API to work with decimals in a typehinted and autocomplete way.

$itemWeightString = '1.78';
$packageWeightString = '0.10';
$itemWeight = Decimal::create($itemWeightString);

$totalWeight = $itemWeight->multiply(4)->add($packageWeightString);
echo $totalWeight; // Output: 7.22

It will transform itself into the string representation once needed (echo, json_encode, …). Until then, internally, one can operate with it easily.

ORMs and Frameworks

Now, when working with ORMs, exchanging the existing strings with the value objects should be rather straightforward.
Usually, the casting is configured and can be switched out for this specific field type.

I am curious how "easy" this is across different ORMs and frameworks actually.
So if people want to comment or mail me their solution, I will add them below for the sake of completeness but also to compare notes.
Please chime in, would be awesome to have a concrete comparison on this.

CakePHP

Since I know most about this framework and ORM, I will present the way in the CakePHP (ORM) in more detail.

Let’s imagine we have a schema for migrations as follow:

$table = $this->table('articles');
$table->addColumn('weight', 'decimal', [
    'default' => null,
    'null' => false,
    'precision' => 7,
    'scale' => 3,
]);
$table->addColumn('height', 'decimal', [
    'default' => null,
    'null' => false,
    'precision' => 7,
    'scale' => 3,
]);
...
$table->update();

Here we should only need to switch out the type in the bootstrap config:

TypeFactory::map('decimal', \CakeDecimal\Database\Type\DecimalObjectType::class);

It uses dereuromark/cakephp-decimal plugin which provides the type class for the ORM.
The type class would now transform all "decimal" typed DB fields to a value object on read from the DB, and the floating-point string for storing as decimal value.

This can also be done on a per-field basis in the table classes. But switching them out type based seems more DRY at this point.

Now any form input sending a decimal value will be marshalled into the value object.

// Those could come from the POST form data
$data = [
    'weight' => '12.345',
    'height' => '1.678',
];
$article = $this->Articles->patchEntity($article, $data);

// Now both Decimal objects
// $article->weight;
// $article->height;

Same when reading the article from DB:

$article = $this->Articles->get($id);

// Now both Decimal objects
// $article->weight;
// $article->height;

The linked plugin also contains basic support for localization, which can be important for other languages/countries (, vs .).
But let’s keep things concise for now.

Symfony

In Doctrine one needs to switch out the entity mapping using the following config:

# app/config/packages/doctrine.yaml

doctrine:
    dbal:
        types:
            decimal: App\Doctrine\Type\DecimalType

The type class could maybe look like this (untested, though):

// src/Doctrine/Type/DecimalType.php

namespace App\Doctrine\Type;

use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\Type;
use PhpCollective\DecimalObject\Decimal;

class DecimalType extends Type
{
    public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform)
    {
        return $platform->getDecimalTypeDeclarationSQL($fieldDeclaration);
    }

    public function convertToPHPValue($value, AbstractPlatform $platform)
    {
        // Convert the database value to your application value
        return $value !== null ? Decimal::create($value) : null;
    }

    public function convertToDatabaseValue($value, AbstractPlatform $platform)
    {
        // Convert your application value to the database value
        return $value !== null ? (string)$value : null;
    }

    public function getName()
    {
        return 'decimal';
    }
}

Laravel

Should be along these lines (untested, though):

// app/Casts/DecimalCast.php

namespace App\Casts;

use Illuminate\Contracts\Database\Eloquent\CastsAttributes;

class DecimalCast implements CastsAttributes
{
    public function get($model, string $key, $value, array $attributes)
    {
        // Convert the database value to your application value
        return $value !== null ? Decimal::create($value) : null;
    }

    public function set($model, string $key, $value, array $attributes)
    {
        // Convert your application value to the database value
        return $value !== null ? (string)$value : null;
    }
}

Now, you can use this cast in your Eloquent model:

// app/Models/YourModel.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class YourModel extends Model
{
    protected $casts = [
        'your_decimal_column' => \App\Casts\DecimalCast::class,
    ];

However, this seems to be for specific fields, not all fields across all tables. Not sure if there is also the generic switch.

Others?

Waiting for some input here from the PHP community 🙂

Considerations

The concrete implementation is not quite as important as long as it does the job.

Immutability

When using value objects here it is important that they are immutable. Any time you modify the value the assignment makes sure that you didn’t accidentally modify a previous one that gets further passed around modified now.

$weight = $articleWeight->add($packageWeight);

If the decimal object were not immutable, $articleWeight would now have a different value (same as $weight), and any further usage would be off.
So $weight as result should be the only modified.

Localization/I18n

I personally don’t think those should be bundled into the value object itself, but rather be part of the presentation layer and the helpers there responsible for further displaying it in a specific way.
As such, frameworks like CakePHP offer helper classes for the templates to be passed to.
The linked one provides examples for currency(), format() and alike using PHP’s intl library.

For me the main task of the value object is a sane way to work with it internally as well as being able to transform back and forth from serialized form (APIs, DB, …).

Performance

I haven’t made any larger performance testing. So far normal usage of the object didn’t seem to indicate a slower page load compared to strings.
Would be interesting if someone has any insights on this.
Maybe also for different ORMs/frameworks, even though I doubt there will be much of a difference here between them.

Memory

Extra memory usage should be rather small with modern PHP versions. They often use value objects already, e.g. for datetime, enum, …
Prove my assumption wrong.

Conclusion

Any such value object (VO) approach seems to be quite more useful in most apps then just dealing with basic strings.
Especially the speaking API seems to improve readability.

Demo

If you want to play around with a live PHP demo, check out the examples in the sandbox.

Further Links

5.00 avg. rating (93% score) - 1 vote

3 Comments

  1. For most monetary uses I’d say there’s no need for any special library like BCMath or any other for dealing with decimal calculations. Using plain float in php is fine as long as you round after each calculation. So instead of doing "$sum += 10.5" you do "$sum = round($sum + 10.5, 2)" and so on. 64-bit float allows for very high precision and these small precision errors you get from floating point arithmetic don’t matter because for display or for saving in database they are rounded away. But you should not allow them to accumulate over many calculation statements – hence the need for rounding often.

    I’ve been creating shop and accounting applications in php for many years and not even once have I encountered a monetary value error due to using floats. Using special tools for decimal calculations make the formulas less readable and slower to execute. I think unless you are writing a banking application where you need precision for numbers with more than 15 significant digits, php float will work fine.

  2. With that logic you could still write plain PHP and forget nice abstractions 🙂
    The whole idea is to make things more smooth to work with, reduce (human) errors and more.
    I think the article goes quite into detail on what the benefits of not using plain PHP here are.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.