Filament: Repeater With Live Calculations - Total Price Example

When building the Repeater field in Filament forms, you may want to perform some action or calculation whenever any of the repeater columns is changed. Let me show you how we did it in one of our Filament Examples projects of invoices.

The main logic lives in our InvoiceResource and is mainly contained within the form() method:

  • Using a Repeater field to allow adding multiple items
  • Using the live() method to calculate the subtotal and total in real time
  • Using a custom function to trigger calculations on items list change, quantity change (see afterStateUpdated and afterStateHydrated)
  • Disabling options in real-time to prevent duplicate items from being added

Here's how it looks in the code:

public static function form(Form $form): Form
{
$products = Product::get();
 
return $form
->schema([
Section::make()
->columns(1)
->schema([
// Repeatable field for invoice items
Forms\Components\Repeater::make('invoiceProducts')
// Defined as a relationship to the InvoiceProduct model
->relationship()
->schema([
// Two fields in each row: product and quantity
Forms\Components\Select::make('product_id')
->relationship('product', 'name')
// Options are all products, but we have modified the display to show the price as well
->options(
$products->mapWithKeys(function (Product $product) {
return [$product->id => sprintf('%s ($%s)', $product->name, $product->price)];
})
)
// Disable options that are already selected in other rows
->disableOptionWhen(function ($value, $state, Get $get) {
return collect($get('../*.product_id'))
->reject(fn($id) => $id == $state)
->filter()
->contains($value);
})
->required(),
Forms\Components\TextInput::make('quantity')
->integer()
->default(1)
->required()
])
// Repeatable field is live so that it will trigger the state update on each change
->live()
// After adding a new row, we need to update the totals
->afterStateUpdated(function (Get $get, Set $set) {
self::updateTotals($get, $set);
})
// After deleting a row, we need to update the totals
->deleteAction(
fn(Action $action) => $action->after(fn(Get $get, Set $set) => self::updateTotals($get, $set)),
)
// Disable reordering
->reorderable(false)
->columns(2)
]),
Section::make()
->columns(1)
->maxWidth('1/2')
->schema([
Forms\Components\TextInput::make('subtotal')
->numeric()
// Read-only, because it's calculated
->readOnly()
->prefix('$')
// This enables us to display the subtotal on the edit page load
->afterStateHydrated(function (Get $get, Set $set) {
self::updateTotals($get, $set);
}),
Forms\Components\TextInput::make('taxes')
->suffix('%')
->required()
->numeric()
->default(20)
// Live field, as we need to re-calculate the total on each change
->live(true)
// This enables us to display the subtotal on the edit page load
->afterStateUpdated(function (Get $get, Set $set) {
self::updateTotals($get, $set);
}),
Forms\Components\TextInput::make('total')
->numeric()
// Read-only, because it's calculated
->readOnly()
->prefix('$')
])
]);
}
 
// This function updates totals based on the selected products and quantities
public static function updateTotals(Get $get, Set $set): void
{
// Retrieve all selected products and remove empty rows
$selectedProducts = collect($get('invoiceProducts'))->filter(fn($item) => !empty($item['product_id']) && !empty($item['quantity']));
 
// Retrieve prices for all selected products
$prices = Product::find($selectedProducts->pluck('product_id'))->pluck('price', 'id');
 
// Calculate subtotal based on the selected products and quantities
$subtotal = $selectedProducts->reduce(function ($subtotal, $product) use ($prices) {
return $subtotal + ($prices[$product['product_id']] * $product['quantity']);
}, 0);
 
// Update the state with the new values
$set('subtotal', number_format($subtotal, 2, '.', ''));
$set('total', number_format($subtotal + ($subtotal * ($get('taxes') / 100)), 2, '.', ''));
}

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

avatar
Ivan Stojmenovic Ivke

Thanks alot friend. I waste two days to slove this problem.

avatar

I am making something similar and what I have is slightly different. This would not be good calculation if items are sold in 2019 for example AND invoices is loaded now with latest prizes pulled from latest prizelist. Second issue with approach is for example selling Coca-Cola cans on Pallets. Then picking number of (palets * number of cans per palet) * prize per can. Another and the biggest at least in my project is that total prize isn't re-calculatig on changing quantity or adding new item to the list. Totals refreshes only after saving and refreshing the form.

I've set afterStateUpdated on my field but then $get('invoiceProducts') returns null like it's reset on field value change.


->afterStateUpdated(function (Forms\Get $get, Forms\Set $set) {
			ray("Welll.... Seeing this value changed");
			self::updateTotals($get, $set);
	}),

avatar

Hm, it can be due to a few things:

  1. Something changed in Filament with latest releases (unlikely, but possible!)
  2. There is a different structure you have and some paths for $get and $set are needed.

If the #2 is your case, you need to debug $get('*') and see what paths you have. Maybe it is different and does not want to update as it is listed here. In that case, modifications are required to make the paths work again.

Or if you can, add some more information - maybe I'll spot an issue from experience with this

avatar

Hi Medestas, As you have mentioned it's more likely due paths on $get and $set˙. Here is short stripped down example that I have. I have this orderingItems collection for repeating items in repeater. There is a Placeholder just as representation with live calculation (per repeater item) that gets updated when I change quantity.

It's bit harder to explain as I am getting mixed results. Here is a better explanation.

1.) If I use collect($get('orderingItems')) then on each page refresh $set('subtotal',.. is calculated presented properly and Ray will show $totalAmount properly. If I go ahead and change value of pallets_quantity, subtotal value never gets updated and ray("Total: " . $totalAmount); will show 0.

2.) On other hand, if I use collect($get('../*')), subtotal $set('subtotal',.. always shows 0 no matter if I change values in the fields or I reload the Form. Ray ray("Total: " . $totalAmount); will show 0 on page refresh but will show calculated values if I start changing pallets_quantity.

public static function form(Form $form): Form
    {
        return $form
            ->schema([
                // Some other fields ...
                // ...
                Forms\Components\Section::make('Add items to order')
                ->compact()
                ->schema([
                    static::getItemsRepeater(),
                ]),


                // footer
                Forms\Components\Section::make("")
                    ->schema(static::getFooterDetailsAndActions())
                    ->compact()
                    ->columns(1),
            ])->columns(1);
    }

    
    // @ Repeater
    public static function getItemsRepeater(): Repeater
    {
        return TableRepeater::make('orderingItems')
        ->relationship('orderingItems')
        ->schema([
            /// ... other fields
            TextInput::make('pallets_quantity')
                ->numeric()
                ->live(true)
                ->minValue(1)
                ->maxValue(100)
                ->label(__('Quantity of pallets'))
                ->afterStateUpdated(function (Get $get, Set $set) {
                    self::updateTotals($get, $set);
                }),

            Placeholder::make('amount')
                ->content(function ($get) {
                    return ($get('pallets_quantity') * $get('on_pallet')) * $get('price');
            })->label(__('Price total per item')),

        ])
        ->defaultItems(0)
        ->hiddenLabel()
        ->columnSpan('full');
    }



    // @ Footer
    public static function getFooterDetailsAndActions(Get $get, Set $set): array
    {
        return [
            Forms\Components\TextInput::make('subtotal')
                ->numeric()
                ->readOnly()
                ->prefix('$')
                ->afterStateHydrated(function (Get $get, Set $set) {
                    self::updateTotals($get, $set);
                }),
            ])->verticalAlignment(VerticalAlignment::End)->alignment(Alignment::End),
        ];
     }

    public static function updateTotals(Get $get, Set $set): void
    {

        ray($get('../*'))->color('orange'); // This prints all items from repeater even with updated values
        ray($get('orderingItems'))->color('blue'); // this prints null after change of value in the fields

        // Worsk only of Refresh/First load
        $selectedProducts = collect($get('orderingItems'))->filter(function ($item) {
            return !empty($item['price']);
        });

        // Works only when changing the value in the field
        $selectedProducts = collect($get('../*'))->filter(function ($item) {
            return !empty($item['price'
avatar

Looks like I've hit the limit on lenght. Here is updateTotals function.


public static function updateTotals(Get $get, Set $set): void
    {

        ray($get('../*'))->color('orange'); // This prints all items from repeater even with updated values
        ray($get('orderingItems'))->color('blue'); // this prints null after change of value in the fields

        // Worsk only of Refresh/First load
        $selectedProducts = collect($get('orderingItems'))->filter(function ($item) {
            return !empty($item['price']);
        });

        // Works only when changing the value in the field
        $selectedProducts = collect($get('../*'))->filter(function ($item) {
            return !empty($item['price']);
        });

        // Calculate total price
        $totalAmount = 0;
        foreach ($selectedProducts as $key => $value) {
            $totalAmount += ($value['pallets_quantity'] * $value['on_pallet']) * $value['price'];
        }

        ray("Total: " . $totalAmount);
        $set('subtotal', number_format($totalAmount, 2, '.', '')); // it does not update if $get('../*') is used on top
    }

avatar

Okay, so a few things:

  1. It would have been better to use pastebin.com or something similar for the code :)
  2. Yep, as I suspected, you have more fields in there, than originally (correct me if I'm wrong on this!), so this makes our code example non-functional indeed. You do have to modify it, as it conflicts with the paths. Here's what I did for another example:
    public static function updateTotals(Get $get, $livewire): void
    {
        // Retrieve the state path of the form. Most likely it's `data` but it could be something else.
        $statePath = $livewire->getFormStatePath();

        $products = data_get($livewire, $statePath . '.quoteProducts');
        if (collect($products)->isEmpty()) {
            return;
        }
        $selectedProducts = collect($products)->filter(fn($item) => !empty($item['product_id']) && !empty($item['quantity']));

        $prices = collect($products)->pluck('price', 'product_id');

        $subtotal = $selectedProducts->reduce(function ($subtotal, $product) use ($prices) {
            return $subtotal + ($prices[$product['product_id']] * $product['quantity']);
        }, 0);

        data_set($livewire, $statePath . '.subtotal', number_format($subtotal, 2, '.', ''));
        data_set($livewire, $statePath . '.total', number_format($subtotal + ($subtotal * (data_get($livewire, $statePath . '.taxes') / 100)), 2, '.', ''));
    }

This uses $statePath from the livewire itself, so maybe try to play around with this calculation. And of course, you have to modify the code to pass $get and $livewire there :)

avatar

First to thank you for looking into this and investing time to elaborate. True, I should use pastebin, appology on long posts. True I have multiple fields and different implementation. I've used Livewire to get around it but will use your example to combine and make it cleaner. Whar order actually looks like: https://pasteboard.co/wS6YEzmvcJmi.png Super nasty but working example is below. I'll work around and improove it.

public static function updateTotals(Get $get, Set $set, \Livewire\Component $livewire): void
    {
        $pallet_count = 0;
        $pieces_count = 0;
        $selectedProducts = [];
        $collection = $get('orderingItems'); // default 
        
        if (gettype($collection) !== 'array') {
            $collection = $get('../*');
            $keys = array_map(function($index) {
                return "item-" . ($index + 1);
            }, array_keys($collection));
            // Combine the keys with the original array
            $collection = array_combine($keys, $collection);
        }

        $selectedProducts = collect($collection)->filter(function ($item) {
            return !empty($item['price']);
        });

        $totalAmount = 0;
        foreach ($selectedProducts as $key => $value) {
            $totalAmount += ($value['pallets_quantity'] * $value['on_pallet']) * $value['price'];
            $pallet_count += $value['pallets_quantity'];
            $pieces_count += $value['on_pallet'];
        }

        $set('subtotal', number_format($totalAmount, 2, '.', ','));  // <- this is not updating the subtotal after any modification

        // This does update and its's reactive
        $livewire->order_price_total = number_format($totalAmount, 2, '.', ','); 
        $livewire->order_pallet_total = $pallet_count;
        $livewire->order_pieces_total = $pieces_count;
    }
avatar

I will look at your comment in depth tomorrow, but as a quick comment before sleep:

take a look at the other example that I have added here. it uses a different approach that I implemented with quantity field along others. Had similar issue and the latest example I shared - solved it!

ps. Your image did not load :(

avatar

That was quick reply :) Implementing what you've suggested now and will see how it goes. New image URL is here https://snipboard.io/nWz5Yg.jpg

I am using repeater as shown on image with Select list, just if you get a chance and point me in better direction. I need to slightly modify what's displayed on Select drop down. I've managed to add few product details in few lines and that works from searching and saving perspective. Just when form is reloaded/Edit it displays Article code in select list instead of article name. https://snipboard.io/ZtInC1.jpg I've opened discussion on Discord but still could not figure out ways around it.

Maybe some sort of Select field with HTML formatted options. Sorry for double question but related issue.

Thanks in advance!

avatar

Okay, looked at the example image that you have and... I have done something similar here: https://demo-crm.filamentexamples.com/admin/quotes/create - which also uses a repeater and a calculation based on a few fields. My latest example should work for your case, but it does indeed require quite a bit of modifications and a different approach (removing $set, $get and using state directly). At this point, I can't really give much more code, as it is a paid product (https://filamentexamples.com/project/crm-customer-management) but it should still work out as long as you change the approach :)

As for your second issue - I honestly have no idea. I did not make such a case myself, or I don't remember it :) But there's definitely some parameter that is not correct (I'd assume!)

avatar

Thank you so much for the article! Works as a charm. I have a question what if each product has an in-stock-number which I want to reduce after the order executed, how do I do that?

avatar

This can be done in multiple ways, so here's a quick overview:

  • You can use observers for your model. This means that once model is created, you can then take all quantities and update the stock field (subract it).
  • You can use filament data mutation (like this https://filamentphp.com/docs/3.x/panels/resources/creating-records#customizing-data-before-saving ) - which allows you to instantly walk over each of the items and update the stock accordingly.
  • There is also events and listeners that can be used

In all cases, the idea is pretty simple - take the form data, do a foreach for each items and update stock value :)

Hope that helps!

avatar

Got it! Thanks alot !!

avatar

Hi Modestas, I have a question and maybe you will have suggestion on best (or better) approach. My form has repeater which is hidden until value in "Category" select field is picked. Basically, Select field and I need to select "Category" before I can fill any repeater items.

Now what I am looking for is to "lock" select field so that it can't be changed once Repeater has at least one itme added. Bigger problem is "locking" select input nad I can't just use "readOnly" or "disabled()" as it's required field. I could create another field (and hide it using css and setting scale to 0.01) which would take a value of Select field and act as a "proxy" but that would not be best or proper approach.

Would you have any suggestion maybe?

UPDATE: I have implemented it like this:

<?php
    Select::make('category_select_only')
    ->label(__('Category'))
    ->default(fn (callable $get) => $get('category'))
    ->options(static::optionsCategories())
    ->afterStateUpdated(function (?string $state, $set, $livewire) {
        $set('category', $state);
        // ray($livewire->data['orderingItems']); // check ordering items
    })
    ->afterStateHydrated(function ($state, callable $set, callable $get) {
        $category = $get('category');
        $set('category_select_only', $category);
    })
    ->disabled(fn (callable $get, $livewire) => (count($livewire->data['orderingItems']) != 0 && $get('category') !== null))
    ->live()
    ->required(),


    Hidden::make('category')
        ->default(fn (callable $get) => $get('category'))
        ->dehydrated(fn (callable $get) => $get('category') !== null),

Using Hidden field and liwevire to check if repeater has items. I don't check if repeater's items are null, that would not matter much.

avatar

Hi, this is exactly how I would have done it - just create a hidden field and fill the value!

Like our articles?

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

Recent New Courses