osama-dev / filament-calculator-action
Dynamic real-time calculator action for Filament v3 & v4 — instant client-side arithmetic, zero Livewire round-trips
Package info
github.com/osamaatef1/filament-calculator-action
pkg:composer/osama-dev/filament-calculator-action
Requires
- php: ^8.1
- filament/filament: ^3.0|^4.0
- illuminate/support: ^10.0|^11.0|^12.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0
- pestphp/pest: ^2.0
- pestphp/pest-plugin-laravel: ^2.0
README
A plug-and-play real-time calculator action for Filament v3 & v4 — instant client-side arithmetic with zero Livewire round-trips.
Works in both table row actions (CalculatorAction) and page header actions (CalculatorPageAction).
Why this package?
Using ->live() on Filament form fields triggers a Livewire server round-trip on every keystroke. For a simple numeric calculator (subtotal + fees − discount = total), that's a server call every time the user types a digit — sluggish and unnecessary.
This package generates lightweight inline JavaScript (onkeyup / onchange) that performs all arithmetic directly in the browser. No server call. No debounce. No waiting. The result field updates the instant a key is released.
Server-side recomputation via computeResult() is still performed inside ->action() to ensure the stored value is always trustworthy.
Installation
composer require osama-dev/filament-calculator-action
No extra configuration needed — the service provider is auto-discovered.
Two action classes
| Class | Extends | Use in |
|---|---|---|
CalculatorAction |
Filament\Tables\Actions\Action (v3) / Filament\Actions\Action (v4) |
Table row actions |
CalculatorPageAction |
Filament\Actions\Action |
Page header actions (getHeaderActions()) |
Both share the exact same API via the HasCalculation trait.
Basic Usage — Table Action
use OsamaDev\FilamentCalculatorAction\CalculatorAction; use OsamaDev\FilamentCalculatorAction\CalcField; use Filament\Forms\Components\Section; use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; CalculatorAction::make('issue_receipt') ->label('Mark Fulfilled & Issue Receipt') ->icon('heroicon-o-document-text') ->color('success') ->visible(fn ($record): bool => $record->status === 'in_progress') ->calcSectionHeading('Receipt Details') ->calcColumns(2) ->calcPrefix('EGP') ->calcFields([ CalcField::make('subtotal') ->label('Subtotal') ->adds() ->required() ->helperText('including taxes') ->default(fn ($record) => (float) ($record->sub_total ?? 0)) ->columnSpan(1), CalcField::make('extra_fees') ->label('Extra Fees') ->adds() ->default(0) ->columnSpan(1), CalcField::make('discount') ->label('Discount') ->subtracts() ->default(0) ->columnSpan(1), CalcField::make('total') ->label('Total') ->result(), ]) ->form([ Section::make('Booking Context') ->schema([ TextInput::make('user_name') ->label('User') ->default(fn ($record) => $record->user?->full_name ?? 'Deleted User') ->disabled(), TextInput::make('reference') ->label('Reference') ->default(fn ($record) => $record->reference) ->disabled(), ])->columns(2), Textarea::make('notes') ->label('Notes') ->rows(3) ->columnSpanFull(), ]) ->action(function (array $data, $record) { $subtotal = (float) ($data['subtotal'] ?? 0); $discount = (float) ($data['discount'] ?? 0); $extraFees = (float) ($data['extra_fees'] ?? 0); $total = $subtotal - $discount + $extraFees; // Or use the helper: $total = $this->computeResult($data); $record->invoice()->create([ 'subtotal' => $subtotal, 'discount' => $discount, 'extra_fees' => $extraFees, 'total' => $total, 'notes' => $data['notes'] ?? null, ]); }) ->modalHeading('Issue Receipt') ->modalSubmitActionLabel('Create Receipt')
Note:
->form([...])(v3) or->schema([...])(v4) defines your custom fields. The calculator section is always appended after your form fields automatically. Both methods work in both versions.
Page Header Action
Use CalculatorPageAction when placing the action in getHeaderActions() on a resource page (View, Edit, etc.):
use OsamaDev\FilamentCalculatorAction\CalculatorPageAction; use OsamaDev\FilamentCalculatorAction\CalcField; protected function getHeaderActions(): array { return [ CalculatorPageAction::make('issue_receipt') ->label('Mark Fulfilled & Issue Receipt') ->icon('heroicon-o-document-text') ->color('success') ->visible(fn ($record): bool => $record->status === 'in_progress') ->calcSectionHeading('Receipt Details') ->calcPrefix('EGP') ->calcColumns(2) ->calcFields([ CalcField::make('subtotal')->label('Subtotal')->adds()->required()->default(fn ($record) => (float) ($record->sub_total ?? 0))->columnSpan(1), CalcField::make('extra_fees')->label('Extra Fees')->adds()->default(0)->columnSpan(1), CalcField::make('discount')->label('Discount')->subtracts()->default(0)->columnSpan(1), CalcField::make('total')->label('Total')->result(), ]) ->action(function (array $data, $record) { $total = $this->computeResult($data); // persist invoice... }), ]; }
Multiply & Divide Example — Quote Builder
CalculatorAction::make('generate_quote') ->label('Generate Quote') ->calcSectionHeading('Quote Breakdown') ->calcColumns(2) ->calcPrefix('EGP') ->calcFields([ CalcField::make('unit_price') ->label('Unit Price') ->adds() ->required() ->columnSpan(1), CalcField::make('quantity') ->label('Quantity') ->multiplies() ->default(1) ->columnSpan(1), CalcField::make('discount') ->label('Discount') ->subtracts() ->default(0) ->columnSpan(1), CalcField::make('total') ->label('Total') ->result(), ]) ->action(function (array $data, $record) { $total = $this->computeResult($data); // formula: (unit_price - discount) * quantity })
Order of operations: adds and subtracts are applied first, then multiplies, then divides. So
(unit_price - discount) * quantity / installmentsworks as expected.
Another Example — Payroll Calculator
CalculatorAction::make('process_payroll') ->label('Process Payroll') ->calcSectionHeading('Payroll Breakdown') ->calcColumns(2) ->calcPrefix('USD') ->calcFields([ CalcField::make('base_salary') ->label('Base Salary') ->adds() ->required() ->default(5000) ->columnSpan(1), CalcField::make('bonus') ->label('Bonus') ->adds() ->default(0) ->columnSpan(1), CalcField::make('deductions') ->label('Deductions') ->subtracts() ->default(0) ->columnSpan(1), CalcField::make('tax_withholding') ->label('Tax Withholding') ->subtracts() ->default(0) ->columnSpan(1), CalcField::make('net_salary') ->label('Net Salary') ->result(), ]) ->action(function (array $data, $record) { $net = $this->computeResult($data); $record->payroll()->create([ 'base_salary' => $data['base_salary'], 'bonus' => $data['bonus'], 'deductions' => $data['deductions'], 'tax_withholding' => $data['tax_withholding'], 'net_salary' => $net, ]); })
CalcField API
| Method | Description |
|---|---|
CalcField::make(string $name) |
Create a field with the given key name |
->adds() |
Field value is added to the running total |
->subtracts() |
Field value is subtracted from the running total |
->multiplies() |
Running total is multiplied by this field's value |
->divides() |
Running total is divided by this field's value (zero-safe) |
->result() |
Marks this as the read-only result display field |
->label(string $label) |
Label shown above the input |
->prefix(string $prefix) |
Currency/unit prefix (e.g. 'EGP', '$') — falls back to calcPrefix() |
->required(bool $required = true) |
Makes the field required on submit |
->default(float|Closure $value) |
Default value; Closure receives $record |
->columnSpan(int $span) |
Grid column span within the calc section (default: 1) |
->helperText(string $text) |
Small hint text displayed below the input |
CalculatorAction / CalculatorPageAction API
| Method | Description |
|---|---|
->calcFields(array $fields) |
Array of CalcField instances |
->calcSectionHeading(string $heading) |
Section heading for the calculator (default: 'Calculation') |
->calcColumns(int $columns) |
Column count for the calc section grid (default: 2) |
->calcPrefix(string $prefix) |
Global prefix applied to all fields that don't define their own |
->calcFlash(bool $flash = true) |
Enable/disable the yellow highlight animation (default: true) |
->calcFlashColor(string $color) |
Highlight color as any CSS value (default: '#fef9c3') |
->calcFlashDuration(int $ms) |
Flash animation duration in milliseconds (default: 400) |
->computeResult(array $data) |
Server-side recalculation — use inside ->action() |
How it Works
Each non-result CalcField gets two HTML event attributes: onkeyup and onchange. Both run the same small inline script that reads all field values via document.querySelector('[data-calc-field="name"]'), computes the result, and updates the result field:
// Generated JS (simplified) var __r = ((unit_price) - (discount)) * (quantity); var __t = document.querySelector('[data-calc-field="total"]'); if (__t) { __t.value = __r.toFixed(2); // Negative warning — red outline + red text if (__r < 0) { __t.style.color = '#dc2626'; __t.style.outline = '2px solid #dc2626'; } else { __t.style.color = ''; __t.style.outline = ''; } // Flash animation — yellow highlight fades out clearTimeout(window.__ct); __t.style.transition = 'background-color 0.4s'; __t.style.backgroundColor = '#fef9c3'; window.__ct = setTimeout(function () { __t.style.backgroundColor = ''; }, 400); }
No framework dependency, no reactivity system, no round-trips. The result field is readOnly with dehydrated(false), so Livewire does not include it in submitted form data.
Negative total warning
When the computed result goes below zero, the result field turns red (color + outline) to signal the user to fix the inputs before submitting. The raw negative value is shown so the user understands what's wrong.
Flash animation
Every time a value changes and a new result is computed, the result field briefly flashes yellow then fades back — giving clear visual feedback that the calculation fired.
The flash is enabled by default and fully customisable:
->calcFlash(false) // disable entirely ->calcFlashColor('#d1fae5') // change to green ->calcFlashDuration(600) // slow it down to 600ms
Server-Side Safety
Always recompute the total server-side inside ->action() — never trust $data['total'].
Because the result field is dehydrated(false), $data['total'] will not be present in $data. Use the computeResult() helper or compute manually from the individual field values:
->action(function (array $data) { // Option A — helper method $total = $this->computeResult($data); // Option B — manual (same result) $total = (float) ($data['subtotal'] ?? 0) + (float) ($data['extra_fees'] ?? 0) - (float) ($data['discount'] ?? 0); })
Both options apply max(0, ...) clamping, preventing negative totals.
License
MIT — see LICENSE.md