lara-zeus/matrix-choice
A Laravel package for matrix-style multiple choice fields in Filament forms: let users pick answers across rows and columns in a grid, with a clean, intuitive interface for surveys, ratings, and structured selections.
Install and pin. The package is a Filament form field with no migrations, no services, and no data-model footprint.
composer require lara-zeus/matrix-choice:5.0.1
Pin the exact version rather than a caret range. This project has shipped a user-facing feature (feat: allow hiding specific options in 5.0.1) inside a patch release, so "patch = inert" is not a safe assumption here. Add the package to your grouped Dependabot/Renovate PR policy and read diffs before bumping.
Verify compatibility before writing any code.
composer why-not filament/filament $(php -r 'echo json_decode(file_get_contents("vendor/lara-zeus/matrix-choice/composer.json"))->require->{"filament/filament"};')
Filament's major version is the primary fault line for this plugin. Confirm php, laravel/framework, and filament/filament constraints from vendor/lara-zeus/matrix-choice/composer.json against your pins. There is no headless or Blade-only path — if the target surface isn't a Filament form, stop here.
Where to look first. In order: the package README, then vendor/lara-zeus/matrix-choice/src/ for the field class and its fluent methods (row/column builders, label customization, and the 5.0.1 option-hiding method). The fluent API is the contract you will live with; read the actual signatures rather than trusting snippets, including the ones below, which show the shape of usage rather than guaranteed method names.
First use case. Spend the first hour on a single non-critical form: a five-row satisfaction matrix with a shared Likert column set. Add the field, save a record, reload it. That end-to-end loop (render → select → validate → persist → re-render) is the entire integration risk envelope — budget half a day and you'll know whether the plugin fits.
use LaraZeus\MatrixChoice\...\MatrixChoice; // confirm exact namespace in src/
MatrixChoice::make('answers')
->label('How satisfied are you with each area?')
->rows([
'support' => 'Customer support',
'billing' => 'Billing accuracy',
'onboarding' => 'Onboarding experience',
])
->columns([
'very_poor' => 'Very poor',
'poor' => 'Poor',
'neutral' => 'Neutral',
'good' => 'Good',
'great' => 'Great',
])
->required()
->columnSpanFull();
Model and persistence. The field owns cell state; you own the schema. Keep the payload as a flat map of row key → column key and cast it.
// migration
$table->json('answers')->nullable();
// model
protected $casts = ['answers' => 'array'];
That single decision — a simple key/value map, not a bespoke relation — is what keeps the package replaceable. If you ever vendor the field or swap it out, a json column and an array cast are the only things you need to preserve.
Rows driven by data. For skills assessments or per-vendor comparisons, generate rows from the domain rather than hardcoding them:
->rows(Skill::query()->orderBy('name')->pluck('name', 'slug')->all())
Keep that query in a small helper or a static method on a schema class so the field definition stays declarative and one line long. This is the isolation boundary that makes replacement cheap.
Per-context option control (5.0.1). This is the feature that earns the upgrade: hide individual options per persona, plan, or row instead of duplicating field definitions for every variant.
->columns($this->availableColumnsFor(auth()->user())) // e.g. drop 'n/a' for plans where it's meaningless
Treat it as presentation. If an option must be rejected, encode that as validation, not visibility — see Gotchas.
Validation belongs to you. Two rules cover most matrices cheaply:
// 1. completeness — every row answered
->rules([
fn () => function (string $attribute, $value, Closure $fail) {
$missing = array_diff(array_keys($this->rows()), array_keys($value ?? []));
if ($missing) {
$fail('Please answer every row.');
}
},
])
// 2. allow-list — only permitted column keys accepted
// (use Rule::in() or an in: rule against the same source of truth that builds ->columns())
Derive the allow-list from the same function that builds the columns for the current context. That is the only reliable way to keep visibility and validation in sync as personas and plans change.
Form and table surfaces. On a table, don't try to render the grid — flatten it:
TextColumn::make('answers')
->formatStateUsing(fn (?array $state) => collect($state)
->map(fn ($col, $row) => "{$row}: {$col}")
->implode(' · '))
->wrap()
On a view page or infolist, render read-only rather than reusing the editable field; a matrix that looks editable but isn't is a common source of user confusion.
Testing pattern. Filament's Livewire test helpers cover this cleanly:
livewire(EditSurvey::class, ['record' => $response->id])
->fillForm(['answers' => ['support' => 'good', 'billing' => 'neutral']])
->call('save')
->assertHasNoFormErrors();
$this->assertSame('good', $response->fresh()->answers['support']);
Add one contract test asserting that a submitted hidden option is rejected server-side. It is the highest-value artifact this adoption produces and the guardrail against a future patch silently changing security-relevant behaviour.
Nested and repeated matrices. One field per matrix under distinct top-level state keys (satisfaction, priorities) beats a repeater. Repeaters multiply validation and state-shape complexity for no gain when the row set is fixed per context.
Hidden ≠ forbidden until you prove otherwise. The 5.0.1 option-hiding feature is the one place where a UI decision has data-integrity consequences. Before relying on it for anything data-bearing, answer three questions with tests, not a README read: does a tampered request posting a hidden option get rejected? does "hidden" remove the option from the DOM only? and what renders when a persisted record references an option you have since hidden? The graceful-degradation answer matters for historical reporting more than the security answer matters for new submissions.
Key discipline in the array state. Use non-numeric string keys for both rows and columns. Numeric-looking keys can arrive re-indexed through Livewire hydration, which will silently mis-map a 0-indexed row to the wrong column. If you generate keys from a pluck(), sanity-check the result.
Empty vs. null. An untouched matrix may persist as null, [], or a partial map depending on the field's default. Decide one canonical empty value, normalise on save in an observer or mutator, and assert it — otherwise every downstream report needs a three-way null check.
Don't let the field become the business logic. Scoring, weighting, NPS computation, and pass/fail thresholds belong in a service or a model accessor, never in the form schema. The moment rules accumulate in the field definition, the "cheap to remove" property that justified adoption is gone.
Width and mobile behaviour. Wide matrices (7+ columns) overflow on tablet and are painful on phone. Prefer 4–5 columns, or on small screens accept horizontal scroll inside a ->columnSpanFull() section rather than shrinking labels into illegibility. Also expect a completion-rate drop as row count grows — if a matrix has two rows, it probably wanted to be two radio groups.
Debugging state. When the saved JSON looks wrong, dump the Livewire state rather than guessing:
->afterStateUpdated(fn ($state) => logger()->debug('matrix', ['state' => $state]))
Most surprises here are state-shape surprises, not rendering bugs.
Upgrade discipline. Filament majors are where this plugin breaks first. On any Filament upgrade, exercise one matrix field end-to-end before merging — the package's compatibility follows the host's form API, and your upgrade calendar is therefore partly its calendar.
Single-maintainer reality. One primary maintainer, small community, 27 stars, zero dependents — little prior art for edge cases. That is acceptable for a form input and unacceptable for anything owning data or auth. Keep the field definition in one small class and note the vendoring cost now, so the fallback is a known number rather than a scramble.
How can I help you explore Laravel packages today?