Authenticating...
Skip to main content

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:

  1. 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.
  2. Suppressed re-renders — Since Livewire no longer manages the DOM, server-initiated re-renders are wasted round-trips. This leads to passing false as the third argument to $wire.$set() to suppress them, which introduces race conditions.
  3. 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.
  4. Bypassed framework features — Filament's ->reactive(), ->afterStateUpdated(), ->live(), and relationship management all assume Livewire owns the DOM. Under wire: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 viewgoal-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() and buildGoalSchema() in sync.

Risks

RiskMitigation
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

Original Author

Benjamin Giese

Approval date

N/A — post-implementation ADR

Approved by

N/A — post-implementation ADR

Appendix

File Reference

FilePurpose
GoalEditorField.phpCustom Filament field extending Repeater — schema, columns, recalculation
GoalsFormBuilder.phpConfigures GoalEditorField with validation rules
SyncsGoalEvents.phpLivewire trait for goal-to-event persistence
goal-editor.blade.phpCustom table layout rendering Filament components in <td> cells
goal-editor.cssStyling for the goal editor table
GoalCalculationService.phpServer-side computed field logic
ValidGoalEvents.phpServer-side validation rule for goal events