DEV3 Technologies home

Filament order items relation manager with totals

A Filament 4 relation manager for order lines: product select fills the price, a footer sums the column, and the order total is recalculated.

FilamentTablesfilamentrelation-managerorderssummarizer

Code

<?php

namespace App\Filament\Resources\Orders\RelationManagers;

use App\Models\Product;
use Filament\Actions\CreateAction;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\Summarizers\Sum;
use Filament\Tables\Table;

class ItemsRelationManager extends RelationManager
{
    protected static string $relationship = 'items';

    protected static ?string $title = 'Order items';

    // Lines are locked once the order has left "pending".
    public function isReadOnly(): bool
    {
        return $this->getOwnerRecord()->status !== 'pending';
    }

    public function form(Schema $schema): Schema
    {
        return $schema->components([
            Select::make('product_id')
                ->relationship('product', 'name')
                ->searchable()
                ->preload()
                ->required()
                ->live()
                ->afterStateUpdated(fn ($state, Set $set) => $set('unit_price', Product::find($state)?->price ?? 0)),
            TextInput::make('quantity')->numeric()->minValue(1)->default(1)->required()->live(onBlur: true),
            TextInput::make('unit_price')
                ->label('Unit price (cents)')
                ->numeric()
                ->required()
                ->helperText(fn (Get $get) => 'Line total: '.number_format(((int) $get('quantity') * (int) $get('unit_price')) / 100, 2)),
        ]);
    }

    public function table(Table $table): Table
    {
        $recalculate = fn () => $this->getOwnerRecord()->recalculateTotal();

        return $table
            ->recordTitleAttribute('product.name')
            ->columns([
                TextColumn::make('product.name')->label('Product'),
                TextColumn::make('quantity')->summarize(Sum::make()->label('Units')),
                TextColumn::make('unit_price')->money('usd', divideBy: 100),
                TextColumn::make('line_total')
                    ->state(fn ($record) => $record->quantity * $record->unit_price)
                    ->money('usd', divideBy: 100),
            ])
            ->headerActions([CreateAction::make()->after($recalculate)])
            ->recordActions([
                EditAction::make()->after($recalculate),
                DeleteAction::make()->after($recalculate),
            ]);
    }
}

Needs: filament/filament@^4.0

How it works

What it solves

An order is only useful if staff can fix its lines: add a product, change a quantity, remove a mistake. A relation manager gives you that inline on the order page, and this one keeps the stored total in sync and prevents edits after fulfilment.

How it works

Register the class on the order resource's getRelations(). The items relationship drives the table, so create and edit actions automatically set the foreign key.

In the form, the product Select is live(); its afterStateUpdated hook copies the product price into unit_price through the Set utility. The quantity is live(onBlur: true) so the helper text on the price field (which reads sibling state with Get) updates when the user leaves the field, not on each keystroke.

The table shows a computed line total from state(), and Sum adds a footer summary for units. A single $recalculate closure is passed to after() on create, edit and delete, so the parent order's recalculateTotal() runs after every change.

isReadOnly() hides all actions when the order status is not pending.

Adapting it

Implement recalculateTotal() on your order model (sum of quantity times unit price, written to a total column). Use a different status rule in isReadOnly() or tie it to a policy.

Pitfalls

  • Storing the price on the line is deliberate, a later product price change must not rewrite old orders.
  • Wrap recalculateTotal() in a transaction if orders are edited concurrently.
  • recordTitleAttribute with a dotted path only affects labels in modals, it is not a column.

How to use it

  1. Make sure Order has an items() hasMany relation and OrderItem has a product() belongsTo.
  2. Add recalculateTotal() to Order, summing quantity * unit_price into total.
  3. Place the manager in the resource's RelationManagers folder.
  4. Register it: public static function getRelations(): array { return [ItemsRelationManager::class]; }.
  5. Open an order in the panel; the items tab appears below the form.

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets