Filament: Money Columns and Storing Value in Cents

When working with money fields such as price, it's common to save values as cents in the database. So, the value of 9.98 would be 998 in the DB. In this tutorial, I will show you how to deal with this scenario in Filament.

Notice: if you want to dive deeper into why the data is saved in cents, we have a separate tutorial: Dealing With Money in Laravel/PHP: Best Practices


Conversion from/to Cents with Eloquent

First, let's cover the Eloquent level of transforming the data before saving/getting it from the DB.

To perform the conversion automatically, there are a couple of ways:

The cast could be similar to this:

php artisan make:cast MoneyCast

app/Casts/MoneyCast.php:

class MoneyCast implements CastsAttributes
{
public function get(Model $model, string $key, mixed $value, array $attributes): float
{
// Transform the integer stored in the database into a float.
return round(floatval($value) / 100, precision: 2);
}
 
public function set(Model $model, string $key, mixed $value, array $attributes): float {
// Transform the float into an integer for storage.
return round(floatval($value) * 100);
}
}

app/Models/Product.php:

use App\Casts\MoneyCast;
 
class Product extends Model
{
// ...
 
protected function casts(): array
{
return [
'price' => MoneyCast::class,
];
}
 
// ...
}

But then, how can you filter by price in the Filament table?


Show Price in Filament Table

Then, in the Filament Resource, we show a list of products.

class ProductResource extends Resource
{
// ...
 
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('code'),
Tables\Columns\TextColumn::make('name'),
Tables\Columns\TextColumn::make('category.name'),
Tables\Columns\TextColumn::make('country.name'),
Tables\Columns\TextColumn::make('brand.name'),
Tables\Columns\TextColumn::make('price')
->money(),
])
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
])
->emptyStateActions([
Tables\Actions\CreateAction::make(),
]);
}
 
// ...
}


Filament: Custom Filter for Price

We must use the custom filter so that products can be filtered from and to price.

class ProductResource extends Resource
{
// ...
 
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('code'),
Tables\Columns\TextColumn::make('name'),
Tables\Columns\TextColumn::make('category.name'),
Tables\Columns\TextColumn::make('country.name'),
Tables\Columns\TextColumn::make('brand.name'),
Tables\Columns\TextColumn::make('price')
->money(),
])
->filters([
Tables\Filters\Filter::make('price')
->form([
Forms\Components\TextInput::make('from')
->debounce(),
Forms\Components\TextInput::make('to')
->debounce(),
])
->query(function (Builder $query, array $data) {
return $query
->when($data['from'], function (Builder $query, ?string $from) {
$query->where('price', '>=', $from);
})
->when($data['to'], function (Builder $query, ?string $to) {
$query->where('price', '<=', $to);
});
}),
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
])
->emptyStateActions([
Tables\Actions\CreateAction::make(),
]);
}
 
// ...
}

However, the query would be wrong if the user tries to filter the prices with cents. The values won't be converted to cents.

Luckily, it's easy to fix. To convert to cents, we only need to multiply the value by 100.

class ProductResource extends Resource
{
// ...
 
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('code'),
Tables\Columns\TextColumn::make('name'),
Tables\Columns\TextColumn::make('category.name'),
Tables\Columns\TextColumn::make('country.name'),
Tables\Columns\TextColumn::make('brand.name'),
Tables\Columns\TextColumn::make('price')
->money(),
])
->filters([
Tables\Filters\Filter::make('price')
->form([
Forms\Components\TextInput::make('from')
->debounce(),
Forms\Components\TextInput::make('to')
->debounce(),
])
->query(function (Builder $query, array $data) {
return $query
->when($data['from'], function (Builder $query, ?string $from) {
$query->where('price', '>=', $from);
$query->where('price', '>=', $from * 100);
})
->when($data['to'], function (Builder $query, ?string $to) {
$query->where('price', '<=', $to);
$query->where('price', '<=', $to * 100);
});
}),
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
])
->emptyStateActions([
Tables\Actions\CreateAction::make(),
]);
}
 
// ...
}

Now the query is correct!


If you want more Filament examples, you can find more real-life projects on our FilamentExamples.com.

avatar

Hi, Povilas! wouldnt it be cleaner to cast to a Money Value Object? This way we can ensure avoiding any pesky rounding errors. In your example you use functions to convert from cents into dollar value and in the Filament table you are just multiplying the value by 100. With a Money VO you woud ensure that the same code runs in both instances minimizing the chance of errors?

avatar

Hey! Thanks for bringing this to our attention, we looked at this and discussed internally. Here's the conclusion:

Money Value Object (further MVO) is great, but this technically repeats what we have done using a cast. We do cast into a specific MVO object, but of course, with some limitations. So in theory, this part is something that you are correct about - MVO could be another option.

Now, if we look at the idea of * 100 - this becomes tricky. Normally, you would not have to do this, but in this case - context is important. And context here is - database! Our database has no idea about money or MVO. It just stores int and we have to transform 1.5 into 150 for our database to understand that these values should be corrected. So in here, adding a MVO (instead of a cast) would not make a difference. Maybe to transform the $from and $to into int instead of float, but for that, we could use casting too.

So I guess, the only valid point is - we should do something about the * 100 and make it be somewhere else. But, the sad part is - we will not minimize the chance of errors as people will still forget to add it :)

avatar

Hello, thank you for this tutorial! I've created a MoneyCast and it works just fine. However, in one of my Relation Managers, I've added a summarizer and, while the row total is correct, the summarizer needs the "divideBy: 100" argument to display the correct grand total value. For example, if I have a single row with a 'total_price' of €196.00 (saved in the database as 19600), the summarizer will show €19,600.00, unless I use the "divideBy: 100". Am I doing something wrong?

class PurchaseOrdersRelationManager extends RelationManager 
{
	// ...
	public function table(Table $table): Table
	{
		return $table
			->recordTitleAttribute('purchase_order_id')
				->columns([
					// ... 
						TextColumn::make('total_price')
							->label('Total Price')
							->money('EUR')
							->summarize(Sum::make()->money('EUR', divideBy: 100)->label('Grand Total')),
				])
	// ...
avatar

You are doing everything correct!

The problem here is from the fact that summary comes from database. Due to efficiency and to prevent loading too many models - it makes a separate database query. This means that your model cast is not being applied.

So in that case, you do need to have the / 100 on it. It never touched the model to have this cast done

avatar

Oh, I get it, makes sense! Thank you for your quick reply :)

Like our articles?

Become a Premium Member for $129/year or $29/month
What else you will get:
  • 60 courses (1084 lessons, total 42 h 44 min)
  • 80 long-form tutorials (one new every week)
  • access to project repositories
  • access to private Discord

Recent Premium Tutorials