Yii 1.1: Custom Number Formatting or Decimal Separators and i18n

19 followers

By default, the decimal separator in php (also in mysql) is a dot (.). So when we work with floats in Yii (in calculations, validation, sql statements etc.), the decimal separator has to be a dot. If we want to use for example a comma (,) as the decimal separator, that is if we want to display numbers and enable users to enter numbers with a comma before the decimals, we have to...

  1. format the float value before printing/echoing : handle the output : 4.45 => "4,45"
  2. transform user input into a valid float value before using in calculations etc. : handle the input : "4,45" => 4.45

This article suggests where/when and how exactly to do this handling.

In the following we'll use the model 'Product' with the decimal attribute 'price' as an example.

THOUGHTS

1. The formatting of a number

HOW

In Yii, a number can be formatted using the formatNumber() function of the class CFormatter in the following way:

Yii::app()->format->formatNumber($value);
Yii::app()->format->number($value); // is the same as above, see details to CFormatter

By default, this will format the value without decimals, and with a comma (,) between every group of thousands. To change this default behavior we have to override the numberFormat attribute of the same class, which defines the format to be used. For example:

public $numberFormat=array('decimals'=>2, 'decimalSeparator'=>',', 'thousandSeparator'=>'');

After this, numbers will be formatted with 2 decimals, a comma (,) before the decimals and no separator between groups of thousands.

WHERE/WHEN

The question is: at what point in the application flow would it be best to format a numerical value? The answer depends...

---
If you 'always' send the price of a product directly to the output after reading it from the database, without using it in calculations, you could do the formatting in the afterFind() function of the model Product.php:

public function afterFind() {
    $this->price = Yii::app()->format->number($this->price);
    return parent::afterFind();
}

The advantage is that the formatting is done centrally in one place, and we don't need to change the views. The disatvantage is that $product->price contains the formatted number string, thus is not a valid numerical value and can not be used as such:

$product = Product::model()->findByPk($id); // ... when we select a model like this ...
echo $product->price; // "14,90" (if price stored in db is 14.90)
echo $product->price * 10; // 140 ( = "14,90" * 10) - not the expected result!

---
This wiki article suggests to do the formatting in the CHtml class, by overriding its two functions value() and resolveValue().
This seems to be a good approach. But don't forget that it does not cover the cases where CHtml is not used to generate the ouput. The following CGridView column for example will not be affected:

array(
    'name'=>'price',
    'value'=>'$data->price - $data->discount',
),

---
There doesn't seem to be a better way than to format each float value in the view file, direclty before printing/echoing it. At least none that makes sure every type of output is covered.

2. The 'unformatting' of a number

HOW

To transform the user input into a valid numerical value, we remove the thosund-seperators from the input string, and then replace the decimal-separator with a dot (.). In a way, we simply 'unformat' the number.

WHERE/WHEN

--
In this Yii extension, the 'unformatting' is done in the beforeSave() function of a model (more specifically, in the beforeSave() function of a behavior, that can be attached to any model).
This is not a good place, because the validating happens before the saving; and since the price is not a valid numerical value at that point, the validation will fail.

--
This wiki article suggests to do the 'unformatting' in the beforeValidate() function of a model.
This is a good place. But if you want to use the user input in calculations before saving the model, make sure that the price attribute is 'unformatted' beforehand, by validating the model ($product->validate()).

--
We usually assign the received user input (=POST variables) to the model before performing other operations (calculations, validaton, saving, etc.).
Therefore we sugges to do the 'unformatting' during the assignment process. This way, after one of the following lines is executed:

$product->attributes = $_POST['Product']; // or ...
$product->setAttributes(array('price'=>$_POST['Product']['price']));

$product->price will be a valid numerical value and available for further use.

THE CODE

1. The formatting of a number

Extend the CFormatter class, override its numberFormat attribute and optionally also its formatNumber() function. Add the new function unformatNumber() to the class, so it is available application wide.

For this, create the new file components/Formatter.php with the content:

class Formatter extends CFormatter
{
    /**
     * @var array the format used to format a number with PHP number_format() function.
     * Three elements may be specified: "decimals", "decimalSeparator" and 
     * "thousandSeparator". They correspond to the number of digits after 
     * the decimal point, the character displayed as the decimal point,
     * and the thousands separator character.
     * new: override default value: 2 decimals, a comma (,) before the decimals 
     * and no separator between groups of thousands
    */
    public $numberFormat=array('decimals'=>2, 'decimalSeparator'=>',', 'thousandSeparator'=>'');
 
    /**
     * Formats the value as a number using PHP number_format() function.
     * new: if the given $value is null/empty, return null/empty string
     * @param mixed $value the value to be formatted
     * @return string the formatted result
     * @see numberFormat
     */
    public function formatNumber($value) {
        if($value === null) return null;    // new
        if($value === '') return '';        // new
        return number_format($value, $this->numberFormat['decimals'], $this->numberFormat['decimalSeparator'], $this->numberFormat['thousandSeparator']);
        }
 
    /*
     * new function unformatNumber():
     * turns the given formatted number (string) into a float
     * @param string $formatted_number A formatted number 
     * (usually formatted with the formatNumber() function)
     * @return float the 'unformatted' number
     */
    public function unformatNumber($formatted_number) {
        if($formatted_number === null) return null;
        if($formatted_number === '') return '';
        if(is_float($formatted_number)) return $formatted_number; // only 'unformat' if parameter is not float already
 
        $value = str_replace($this->numberFormat['thousandSeparator'], '', $formatted_number);
        $value = str_replace($this->numberFormat['decimalSeparator'], '.', $value);
        return (float) $value;
    }
}

Adjust main config file config/main.php to ensure this new class is used instead of the CFormatter class:

'components'=>array( 
    //...
    'format'=>array(
        'class'=>'application.components.Formatter',
    ),
    //...
)

In a view file, or anywhere else for that matter, you can format a numerical value, or 'unformat' a formatted number string, simply by calling:

$formatted_number1 = Yii::app()->format->number($numerical_value); 
$formatted_number2 = Yii::app()->format->formatNumber($numerical_value);  // same as above
$numerical_value2 = Yii::app()->format->unformatNumber($formatted_number1);

NOTE: We declared the format centrally in one file (Formatter.php), so that it can be changed easily. It can also be configured in the main config file, which will override the value declared in the class file:

'components'=>array( 
    //...
    'format'=>array(
        'class'=>'application.components.Formatter',
        'numberFormat'=>array('decimals'=>3, 'decimalSeparator'=>',', 'thousandSeparator'=>'-'),
    ),
    //...
),
// ...

2. The 'unformatting' of a number

Extend the CActiveRecord class and override its function setAttributes() to add the 'unformatting' functionality.
For this, create the new file components/ActiveRecord.php with the following content:

class ActiveRecord extends CActiveRecord
{
  public function setAttributes($values,$safeOnly=true) {
    if(!is_array($values)) return;
    $attributes=array_flip($safeOnly ? $this->getSafeAttributeNames() : $this->attributeNames());
    foreach($values as $name=>$value) {
      if(isset($attributes[$name])) {
        $column = $this->getTableSchema()->getColumn($name); // new
        if (stripos($column->dbType, 'decimal') !== false) // new
          $value = Yii::app()->format->unformatNumber($value); // new
        $this->$name=$value;
      }
      else if($safeOnly)
        $this->onUnsafeAttribute($name,$value);
    }
  }
}

Derive all models from this new ActiveRecord class. For example in the Product model models/Product.php edit the following line:

class Product extends ActiveRecord  //old version: "class Product extends CActiveRecord"

In the controller, don't use the POST variables directly, but assign them to the model before further use. If there is a POST variable that is not to be assigned to any model and that contains a formatted number string, make sure to 'unformat' it before further use. That is:

$product->attributes = $_POST['Product'];   // This massively assigns all received values using the setAttributes() function.
$reduced_price = $product->price - 4.50;    // Correct, $product->price is valid numerical value
// DO NOT USE A POST VARIABLE DIRECTLY, SINCE IT IS NOT 'UNFORMATTED'!!
$reduced_price = $_POST['Product']['price']; // Incorrect! $_POST['Product']['price'] is not valid numerical value
// 'Unformat' a formatted number input manually if necessary:
$wish_price = Yii::app()->format->unformatNumber($_POST['wish_price']);

Related Links

That's it. If something is unclear, wrong or incomplete, please let me know. Any suggestions for improvements will be appreciated!

Total 6 comments

#13692 report it
jiihoo at 2013/06/17 05:39pm
Validation related issue

Very good instructions, helped me a lot.

However, I had small issues with validating incorrect numerical values. Inserting e.g. any random string of letters didn't cause validation errors, even though model's rules allowed only numerical values.

At the end I noticed reason to be this in unformatNumber():

return (float) $value;

For $value containing e.g. letters, the returned value is 0 which then validates OK.

I resolved this by adding one line to unformatNumber():

if(!preg_match('/^[0-9,]+$/i', $formatted_number)) return $formatted_number; // only unformat if parameter includes numbers or comma

Looks to be working as intended. Has anybody else seen same issue? If so, was some other solution used?

#13661 report it
tomvdp at 2013/06/14 03:59pm
Small error in unformatNumber

The 3rd line of the unformatNumber function in Formatter checks whether the number is already a float. But it fails, the correct way is:

    if(filter_var($formatted_number, FILTER_VALIDATE_FLOAT)!==false) return $formatted_number; // only 'unformat' if parameter is not float already

because is_float() will always return false if it is passed a string (which will be the case). It could be important if you also have the thousands seperator set: "99.99" does not pass as a float in is_float(), then the next step will remove the thousands seperator... hence the value becomes 9999. Woops.

#12397 report it
evince at 2013/03/18 08:15am
Great Guide. One more thing.

check whether the form page doesn't use clientValidation(java script).

beforeValidation() function doesn't not affect clientValidation. becasue validate done in client side.

So use ajax validation.

#11718 report it
c@cba at 2013/01/29 09:05am
@realtebo
'format'=>'customNumber'

in CGridView should work for the method formatCustomNumber. The problem in your case might be the capital first letter.

#11715 report it
realtebo at 2013/01/29 04:34am
How to use a custom formatter using 'format' option ?

Some widget, like cgridview, accept a 'format' option.

If I use

format => 'CustomNumber'

where 'CustomNumber' is the formatCustomNumber function, Yii doens't generate any error, but simply doesn't format this. How to ?

I don't want to use ->format->formatBlaBlaBla(..) if it's unnecessary

#9493 report it
Peter JK at 2012/08/17 12:50am
nice.. this is a good wiki

as a Yii beginner, this is a good practise.

I already use the really bad approach and make a new function on every numeric field. I know this is not a good practise until i found this wiki...

thank you..

Leave a comment

Please to leave your comment.

Write new article