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:
- Create an Attribute for the field
- Create a Custom Cast and cast that field to it.
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.
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?
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 storesint
and we have to transform1.5
into150
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
intoint
instead offloat
, 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 :)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?
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 doneOh, I get it, makes sense! Thank you for your quick reply :)
I was facing the same problem... Glad I found your posts @Rise Radiant & @Modestas ! Thanks!