0063: Custom Component Approach for Goals
STATUS
Accepted
CONTEXT
AdSuite's goal editor requires capabilities beyond what Filament's built-in Repeater provides out of the box: a spreadsheet-like table layout with inline editing, computed fields (expected cost, actual cost, conversion rates), drag-and-drop reordering, CSV import, and footer totals. As goal complexity grew — particularly with the addition of SDK event mapping and the upcoming Tune integration — we needed an approach that supported these features without introducing architectural complexity that would slow future development.
The initial implementation used a custom Alpine.js component with wire:ignore, handing the entire goal editor off to client-side JavaScript. While this worked, it created two sources of truth (Alpine state and Livewire's server-side snapshot) and required a parallel client-side validation system that had to stay in sync with the server-side rules. The result was ~1,400 lines of custom TypeScript, manual state synchronization via $wire.$set(path, value, false), and occasional race conditions from suppressed re-renders.
Why wire:ignore Should Be Avoided
The wire:ignore directive tells Livewire to stop managing a section of the DOM entirely. While this sounds like a clean hand-off to Alpine, it triggers a cascade of architectural consequences:
- Two sources of truth — Alpine owns the DOM and its own reactive state, while Livewire maintains a separate server-side snapshot. Keeping them in sync requires manual bridging on every state change.
- Suppressed re-renders — Since Livewire no longer manages the DOM, server-initiated re-renders are wasted round-trips. This leads to passing
falseas the third argument to$wire.$set()to suppress them, which introduces race conditions. - Manual validation — Filament's validation pipeline operates on Livewire state, but the user interacts with Alpine state. This forces a parallel client-side validation layer (amber/red ring tiers, snapshot-based error extraction) that must mirror every server-side rule.
- Bypassed framework features — Filament's
->reactive(),->afterStateUpdated(),->live(), and relationship management all assume Livewire owns the DOM. Underwire:ignore, none of these work, forcing manual reimplementation.
The core insight: every feature in the goal editor can be built with Livewire-native Filament. Nothing requires Alpine to own the DOM.
Considered Options
-
Option 1 — Custom Alpine.js component with
wire:ignore— Alpine manages all rendering and state client-side. Livewire is used only for persistence. Provides instant client-side reactivity but creates dual state management, parallel validation, and ~1,400 lines of custom TypeScript. -
Option 2 — Native Filament Repeater (no customization) — Use Filament's Repeater as-is with card-based layout. Simple and maintainable but doesn't support the spreadsheet-like table layout or computed display fields without server round-trips on every keystroke.
-
Option 3 — Custom Filament component extending Repeater with table layout (chosen) — Extend Filament's Repeater to render in a
<table>layout via a custom Blade view while keeping all state management, validation, and reactivity within Filament's native pipeline. Use->live(onBlur: true)for computed field recalculation.
DECISION
We chose Option 3 — a custom Filament component that extends Repeater, using a custom Blade view for table-based rendering while staying fully within Filament's state management and validation pipeline.
Architecture
Filament GoalEditorField (extends Repeater)
-> Custom Blade view (table layout with <tr> rows)
-> Filament schema components rendered natively in <td> cells
-> Alpine.js used minimally (chevron expand/collapse only)
-> Server-side recalculation via GoalCalculationService
-> Filament's native validation pipeline
-> SyncsGoalEvents trait for event persistence
Key Implementation Details
GoalEditorField extends Repeater — Configures ->table(), ->reorderable(), and ->deleteAction() using Filament's native APIs. Provides buildGoalSchema() for field definitions and buildTableColumns() for column headers. A single recalculateAllComputedFields() method handles all derived values.
Custom Blade view — goal-editor.blade.php iterates Filament Repeater items and renders each schema component in <td> cells. The Blade template maps columns to schema fields by index, so buildTableColumns() and buildGoalSchema() must have matching counts and order.
Computed fields via ->live(onBlur: true) — Expected cost, actual conversion rate, and actual cost are recalculated server-side through ->afterStateUpdated() callbacks backed by GoalCalculationService. This fires on blur rather than on every keystroke — an intentional trade-off of live-as-you-type reactivity for architectural simplicity and a single source of truth.
Event persistence via SyncsGoalEvents trait — Goal-to-event mapping is stored as a Filament state array. The SyncsGoalEvents trait captures events in beforeValidate() (before Filament strips non-model fields) and syncs to the database via $goal->events()->sync() in afterSave().
CSV import — Sets Repeater state directly via Filament's state management. No Alpine event dispatch or client-side parsing needed.
CONSEQUENCES
Positive
- Single source of truth — All state lives in Livewire/Filament. No manual synchronization between client and server.
- Standard validation — Uses Filament's native validation pipeline. No parallel client-side validation system to maintain.
- Dramatically less code — Deleted ~1,400 lines of custom TypeScript (Alpine component, validation engine, type declarations, unit tests, build config). The goal editor is now primarily PHP and Blade.
- Framework alignment —
->reactive(),->afterStateUpdated(),->live(), relationship helpers, and future Filament features all work as expected. - Extensible pattern — The approach of extending Repeater with a custom Blade view establishes a reusable pattern for other AdSuite components that need table-based editing beyond Filament's default card layout.
Negative
- Footer totals update on blur, not live-as-you-type — Server round-trips are required for recalculation. Users see updated totals after tabbing out of a field rather than instantly as they type.
- Column/schema coupling — The Blade template maps columns to schema fields by index. Adding or reordering fields requires updating both
buildTableColumns()andbuildGoalSchema()in sync.
Risks
| Risk | Mitigation |
|---|---|
| Livewire latency on recalculation — Blur-triggered server calls could feel slow on high-latency connections. | Acceptable for internal tool. If needed, targeted Alpine sprinkles (small x-data blocks reading from $wire.data) can add instant client-side computation for specific fields without wire:ignore. |
| Filament Repeater limitations — Future requirements may exceed what the extended Repeater supports. | The custom Blade view provides an escape hatch for layout. Core state management stays in Filament regardless of how the UI is rendered. |
NOTES
- This is a post-implementation ADR documenting a decision that has already been built and deployed.
- The Tune integration (which motivated the draft iterations prerequisite) will be covered in a separate ADR. This ADR covers only the goal editor's component architecture.
References
- Ben's original architecture proposal
- CAM-789: Refactor Goal Form based on new architecture direction
- CAM-791: Write ADR: Custom component approach for Goals
- PR #130: docs(ADR): Custom Component Approach for Goals
Original Author
Benjamin Giese
Approval date
N/A — post-implementation ADR
Approved by
N/A — post-implementation ADR
Appendix
File Reference
| File | Purpose |
|---|---|
GoalEditorField.php | Custom Filament field extending Repeater — schema, columns, recalculation |
GoalsFormBuilder.php | Configures GoalEditorField with validation rules |
SyncsGoalEvents.php | Livewire trait for goal-to-event persistence |
goal-editor.blade.php | Custom table layout rendering Filament components in <td> cells |
goal-editor.css | Styling for the goal editor table |
GoalCalculationService.php | Server-side computed field logic |
ValidGoalEvents.php | Server-side validation rule for goal events |