Forms
Extending Forms
Wire Forms is built to be extended. Most apps never need to — the built-in field set covers the common cases — but when you need a field that does not ship with the package, you write a small PHP class plus a Blade view and use it exactly like a first-party field.
On this page
- Quick Custom View (ViewField)
- How a Field Is Put Together
- Building a Custom Field
- 1. The PHP class
- 2. The Blade view
- 3. Use it
- Display-Only Components
- Reusable Presets
- Shaping the Value a Field Stores
- Hooking Into the Save Lifecycle
- Packaging Fields Into a Plugin
- What "registering a field" really means
- 1. Package layout
- 2. The field, pointed at a namespaced view
- 3. Register the view namespace in the service provider
- 4. The plugin: presets, hooks, and config
- 5. Consumers install it
- Checklist
- JS-Backed Fields
- Testing Custom Fields
- See Also
Wire Forms is built to be extended. Most apps never need to — the built-in field set covers the common cases — but when you need a field that does not ship with the package, you write a small PHP class plus a Blade view and use it exactly like a first-party field.
This page covers, from least to most involved:
| You need… | Use |
|---|---|
| A one-off custom view inside a form | ViewField |
| A static display block (no input) | Display component |
| A reusable input with its own API | Custom field |
| The same preset applied across many forms | Form macros |
| A field that stores something other than what it shows | State transforms |
| Logic around every save | Save hooks |
| A field shipped in a package | Packaging fields into a plugin |
Prefer the fluent API first. Reach for a custom field only when the same behaviour should be reused, or when no built-in field can be configured to do the job.
Quick Custom View (ViewField)
When you only need to drop custom markup into a form — without a reusable API —
use ViewField. It renders any Blade view and passes
through the form state path, so you avoid writing a class at all.
use NyonCode\WireForms\Components\Display\ViewField; ViewField::make('avatar_preview') ->view('forms.partials.avatar-preview') ->viewData(fn () => ['url' => $this->user->avatar_url]);
{{-- resources/views/forms/partials/avatar-preview.blade.php --}}<img src="{{ $url }}" class="h-16 w-16 rounded-full" alt="">
viewData() accepts an array or a closure (evaluated at render time). Use
ViewField for previews, callouts, or bespoke widgets that do not need to be a
shared, named component.
How a Field Is Put Together
Every form component extends
NyonCode\WireCore\Foundation\Components\Component. That base class gives you,
for free:
make(string $name)factory and a$name-only constructor- label, hint, helper text, id, size, column span, visibility
extraAttributes(),default(), and closure evaluation viaevaluate()render(), which calls yourviewName()with the component available in the view as$field
Input fields extend NyonCode\WireForms\Components\Field, which adds the parts
that make a field interactive:
| Concern | What it adds |
|---|---|
HasState |
getStatePath(), getWireModelAttribute() |
CanBeLive |
->live(), getWireModelModifier() |
HasDebounce |
->debounce(), getDebounceModifier() |
CanBeReadOnly |
->disabled(), isReadOnly() |
HasFormValidation |
->rules(), ->required(), rule collection |
HasPlaceholder, HasPrefixAndSuffix, HasTooltip, CanBeAutofocused |
optional affordances |
A field also declares its state type with getStateType() (default
'string'). The state hydrator uses it to cast raw request values before they
reach the form state — return 'int', 'float', 'bool', or 'array' when
your value is not a string.
getStateType() shapes the value on the way in. When your field also needs to
shape it on the way out — to store something other than what the widget holds —
implement DehydratesState rather than
pushing the job onto every form that uses the field.
The only abstract method you must implement is viewName().
Building a Custom Field
We will build a MoneyInput field that stores an integer number of cents and
renders a currency-aware text input.
1. The PHP class
<?php namespace App\Forms\Components; use NyonCode\WireForms\Components\Field; class MoneyInput extends Field{ protected string $currency = 'USD'; protected int $decimals = 2; public function currency(string $currency): static { $this->currency = $currency; return $this; } public function decimals(int $decimals): static { $this->decimals = $decimals; return $this; } public function getCurrency(): string { return $this->currency; } public function getDecimals(): int { return $this->decimals; } // Value is stored as an integer (cents). public function getStateType(): string { return 'int'; } protected function viewName(): string { return 'forms.components.money-input'; } }
Conventions worth following, because the built-in fields all do:
- Fluent setters return
staticso calls chain. - State is set through protected properties with matching getters; the Blade view reads getters, never properties.
- Setters accept
Closurewhere it makes sense, and getters resolve them with$this->evaluate(...). That is what makes->label(fn () => ...)work.
2. The Blade view
Wrap the input in the shared field-wrapper partials. They render the label, hint, required marker, helper text, and validation error for you — so a custom field looks identical to a built-in one and needs no extra markup for those.
{{-- resources/views/forms/components/money-input.blade.php --}}@php use App\Forms\Components\MoneyInput; assert($field instanceof MoneyInput); $wireModifier = $field->getWireModelModifier(); $debounceModifier = $field->getDebounceModifier(); $wireAttr = 'wire:model' . ($wireModifier ? ".{$wireModifier}" : '') . $debounceModifier;@endphp @include('wire-forms::partials.field-wrapper-start') <div class="flex rounded-md shadow-sm"> <span class="inline-flex items-center rounded-l-md border border-r-0 border-gray-300 bg-gray-50 px-3 text-sm text-gray-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-400"> {{ $field->getCurrency() }} </span> <input type="number" step="{{ 1 / (10 ** $field->getDecimals()) }}" id="{{ $field->getId() }}" {{ $wireAttr }}="{{ $field->getWireModelAttribute() }}" @if($field->isReadOnly()) readonly @endif @if($field->isRequired()) required @endif class="block w-full rounded-r-md border-gray-300 text-sm shadow-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white" /></div> @include('wire-forms::partials.field-wrapper-end')
The key wiring is the wire:model attribute. getWireModelAttribute() returns
the correct dotted state path (for example data.price), and the modifier
helpers add .live / .debounce.Nms when the field opts into them. This is the
same pattern every built-in field uses — see
packages/forms/resources/views/components/text-input.blade.php for the full
reference.
3. Use it
A custom field is a normal component. Add it to any schema:
use App\Forms\Components\MoneyInput; $form->schema([ MoneyInput::make('price') ->currency('EUR') ->decimals(2) ->required() ->helperText('Stored in cents.'),]);
No registration step is required for use in your own app: the field resolves its own view, so listing it in a schema is enough.
Display-Only Components
For output that has no input value — callouts, summaries, computed previews —
extend NyonCode\WireCore\Foundation\Components\ViewComponent instead of
Field. It is the same base, minus the input/validation concerns, and is what
Placeholder, Alert, and Html use.
<?php namespace App\Forms\Components; use Closure;use NyonCode\WireCore\Foundation\Components\ViewComponent; class StatBlock extends ViewComponent{ protected string|Closure $value = ''; public function value(string|Closure $value): static { $this->value = $value; return $this; } public function getValue(): string { return (string) $this->evaluate($this->value); } protected function viewName(): string { return 'forms.components.stat-block'; }}
Reusable Presets
When you do not need a new component, only a preset of existing fluent calls,
you have two accurate options. (Form fields are not Macroable — unlike Table
and Action, which support ::macro(); see
Core Plugins → Adding Buttons And Actions
for table/action macros.)
A static factory keeps the preset in one place and reads cleanly at the call site:
namespace App\Forms; use NyonCode\WireForms\Components\TextInput; class Fields{ public static function slug(string $name): TextInput { return TextInput::make($name) ->helperText('Lowercase, dash-separated.') ->rules(['regex:/^[a-z0-9-]+$/']) ->live(); }}
use App\Forms\Fields; $form->schema([ Fields::slug('slug'),]);
A subclass that pre-configures make() is the right choice when you also
want a distinct type you can reference elsewhere:
use NyonCode\WireForms\Components\TextInput; class SlugInput extends TextInput{ public static function make(string $name): static { return parent::make($name) ->rules(['regex:/^[a-z0-9-]+$/']) ->live(); }}
Use a factory or subclass when you are composing existing field methods, and a full custom field when you need new state, a new view, or new markup.
Shaping the Value a Field Stores
A field's state is whatever its widget produced. Usually that is exactly what
should be persisted — but not always. A date picker's input parses only its own
format while the column wants another; an upload holds a temporary path while the
column wants the stored one; a money field shows 1 234,50 and stores cents.
Two contracts in NyonCode\WireCore\Foundation\Contracts cover the two
directions. They are independent — implement only the one you need:
| Contract | Method | Runs |
|---|---|---|
HydratesState |
hydrateState($value, ?Model $record) |
model value → state, after the getStateType() cast |
DehydratesState |
dehydrateState($state, ?Model $record) |
state → stored value, during save |
Note that the MoneyInput above needs neither: its
state is already the integer it stores, which getStateType(): 'int' is enough to
express. The contracts earn their keep only when state and stored value genuinely
differ — as here, where the input shows plaintext and the column holds ciphertext:
<?php namespace App\Forms\Components; use Illuminate\Database\Eloquent\Model;use NyonCode\WireCore\Foundation\Contracts\DehydratesState;use NyonCode\WireCore\Foundation\Contracts\HydratesState;use NyonCode\WireForms\Components\Field; class EncryptedInput extends Field implements DehydratesState, HydratesState{ // Stored → shown. public function hydrateState(mixed $value, ?Model $record = null): mixed { return $value === null || $value === '' ? $value : decrypt($value); } // Shown → stored. public function dehydrateState(mixed $state, ?Model $record = null): mixed { return $state === null || $state === '' ? $state : encrypt($state); } protected function viewName(): string { return 'forms.components.encrypted-input'; }}
The same two contracts drive editable table columns —
TextInputColumn uses them for its trim/case/number pipeline — so a component
that implements them behaves the same in a form and in an inline-edited cell.
Both directions, or neither. If a transform moves the value (a timezone conversion, a unit change), implementing only
hydrateState()means the shifted state gets written straight back on save, moving the value a little further on every round trip. A one-sided transform is worse than none.
dehydrateState() must be a pure function of its arguments. A host may call
it more than once per save — the table dehydrates once without a record to
validate before opening its transaction, then again with the locked record. Hosts
always pass the original state, never the result of an earlier call, so a
transform that would break if applied twice is still safe. The $record is
null when the host has none (a create form); a table cell always has one.
Hooking Into the Save Lifecycle
The contracts above belong to a field — they travel with it into every form.
The two layers here belong to a form or to the app. Reach for them when
the knowledge is not the field's: use DehydratesState for "this field always
stores cents", a hook for "every form stamps a tenant".
Two layers exist, and they compose:
Per-form callbacks — for logic local to one form. Defined on the Form
instance and documented in Save Lifecycle:
$form ->mutateDataBeforeSave(fn (array $data) => [...$data, 'updated_by' => auth()->id()]) ->beforeSave(fn (array $data) => /* … */) ->afterSave(fn ($record) => $record->notifySubscribers());
Plugin hooks — for logic that should run for every form across the app,
without touching each component. The runtime emits form.saving (before
persistence, can modify the data) and form.saved (after persistence,
observational):
app(PluginManager::class)->hook('form.saving', function (array $payload): array { $payload['data']['tenant_id'] ??= auth()->user()->tenant_id; return $payload;}, priority: -100);
See Core Plugins → Hook System for priorities, typed hooks, and the full payload shape. Use a per-form callback for one form; use a hook for a cross-cutting rule.
Packaging Fields Into a Plugin
The sections above build a field inside an app, where a class plus a Blade view
is enough — the field resolves its own view, so no registration step is needed.
This section covers the next step: shipping custom fields as a reusable,
installable unit — a companion package others can composer require, or a
shared module inside a larger app.
What "registering a field" really means
There is no field type registry. Unlike table columns, filters, and actions
— which have addColumnType() / addFilterType() / addActionType() metadata
registries — form fields are never looked up by name. A field is just a class,
and render() calls its viewName() directly through Laravel's view()
helper. So making a packaged field usable comes down to exactly two things:
- The field's class is autoloadable — handled by your package's Composer
autoloadblock, like any PHP class. - The field's view resolves — its
viewName()must point at a view Laravel can find. In a package that means registering a view namespace.
The core plugin is the layer on top: it is where you install the cross-cutting extras — presets (macros), save hooks, and default configuration — so consumers get them by registering one class. The plugin is optional for a plain field, and required only once you ship macros or hooks.
Do not register a field with
addColumnType()(or the other type registries). Those are metadata registries for table columns/filters/actions and are never consulted to render a form field. Registering a field there does nothing useful and is misleading.
1. Package layout
A minimal field package looks like this:
acme/wire-money-fields/├── composer.json├── src/│ ├── AcmeMoneyServiceProvider.php│ ├── AcmeMoneyPlugin.php│ └── Components/│ └── MoneyInput.php└── resources/ └── views/ └── components/ └── money-input.blade.php
composer.json autoloads the namespace and registers the service provider so
the package boots automatically:
{ "name": "acme/wire-money-fields", "require": { "nyoncode/wire-forms": "^0.1" }, "autoload": { "psr-4": { "Acme\\WireMoney\\": "src/" } }, "extra": { "laravel": { "providers": [ "Acme\\WireMoney\\AcmeMoneyServiceProvider" ] } }}
2. The field, pointed at a namespaced view
The field class is identical to the app version, except viewName() returns a
namespaced view (namespace::path) so it resolves no matter where the
package is installed:
<?php namespace Acme\WireMoney\Components; use NyonCode\WireForms\Components\Field; class MoneyInput extends Field{ protected string $currency = 'USD'; public function currency(string $currency): static { $this->currency = $currency; return $this; } public function getCurrency(): string { return $this->currency; } public function getStateType(): string { return 'int'; } protected function viewName(): string { // "acme-money" is the namespace registered by the service provider. return 'acme-money::components.money-input'; }}
3. Register the view namespace in the service provider
The service provider's one required job is to make acme-money:: resolvable.
You can register it manually with loadViewsFrom(), and publishes() the views
so consumers can override your markup:
<?php namespace Acme\WireMoney; use Illuminate\Support\ServiceProvider;use NyonCode\WireCore\Core\Plugin\PluginManager; final class AcmeMoneyServiceProvider extends ServiceProvider{ public function register(): void { // Register the plugin (macros, hooks, config) once the manager resolves. $this->app->resolving(PluginManager::class, function (PluginManager $manager) { if (! $manager->has('acme-money')) { $manager->register($this->app->make(AcmeMoneyPlugin::class)); } }); } public function boot(): void { // Makes acme-money::components.money-input resolvable. $this->loadViewsFrom(__DIR__ . '/../resources/views', 'acme-money'); // Let consumers override the markup with vendor:publish. $this->publishes([ __DIR__ . '/../resources/views' => resource_path('views/vendor/acme-money'), ], 'acme-money::views'); }}
If your package is built on
spatie/laravel-package-toolkit(the same toolkit Wire packages use), calling->hasViews()in your package configuration registers the namespace and the publish tag for you, derived from the package short name — exactly howwire-forms::partials.field-wrapper-startis exposed.
Once the namespace is registered, the field is fully usable — MoneyInput::make('price')
works in any schema with no further setup.
4. The plugin: presets, hooks, and config
Add a plugin when you want to ship more than the field class — for example a
reusable preset macro, a save hook, or default configuration. Form
fields are not Macroable, so field presets ship as a static factory or a
subclass (see Reusable Presets); the plugin is where you
register Table/Action macros, hooks, and config.
<?php namespace Acme\WireMoney; use NyonCode\WireCore\Core\Plugin\Contracts\HasConfiguration;use NyonCode\WireCore\Core\Plugin\Contracts\Plugin;use NyonCode\WireCore\Core\Plugin\PluginManager; final class AcmeMoneyPlugin implements HasConfiguration, Plugin{ public function getId(): string { return 'acme-money'; } public function defaultConfig(): array { return ['default_currency' => 'USD']; } public function register(PluginManager $manager): void { // A cross-cutting save hook: round any money field to whole cents. $manager->hook('form.saving', function (array $payload): array { foreach ($payload['data'] as $key => $value) { if (str_ends_with($key, '_cents') && is_numeric($value)) { $payload['data'][$key] = (int) round($value); } } return $payload; }); } public function boot(PluginManager $manager): void { // Config-driven defaults are available here if you need them: // $manager->getPluginConfig($this->getId())['default_currency'] }}
The plugin is wired up automatically by the service provider's resolving()
callback in step 3, so consumers get the field, its views, and its hooks just by
installing the package. See
Core Plugins → Register Plugins From A Package
for the registration pattern and the has() guard.
5. Consumers install it
From the consuming app, installation is just Composer plus optional view overrides:
composer require acme/wire-money-fields # Optional: override the field markupphp artisan vendor:publish --tag=acme-money::views
use Acme\WireMoney\Components\MoneyInput; $form->schema([ MoneyInput::make('price_cents')->currency('EUR'),]);
Plugin config tags use
::—acme-money::views,acme-money::config— not a dash. A dashed tag likeacme-money-viewsresolves to nothing.
Checklist
| Step | Required for | Mechanism |
|---|---|---|
| Autoload field class | Always | Composer psr-4 |
| Register view namespace | Always (in a package) | loadViewsFrom() / ->hasViews() |
viewName() returns namespace::path |
Always (in a package) | Field class |
| Publish views | Optional (override support) | publishes(..., 'tag::views') |
| Register the plugin | Only for macros/hooks/config | resolving(PluginManager::class) |
| Default config | Only for configurable plugins | HasConfiguration |
JS-Backed Fields
There are two levels of client-side behaviour in the built-in fields:
-
Inline Alpine. Lightweight interactivity needs no separate bundle.
SliderandRating, for example, drive everything from anx-datablock and@entanglethe field's state path, with any CSS inlined once via@once. For most custom fields this is all you need — seepackages/forms/resources/views/components/slider.blade.php. -
Pre-bundled script via
@assets. Heavier fields (likeTiptapEditor) ship a pre-built JS bundle that the provider serves from a route (/wire-forms/assets/{asset}.js). The field view injects it with Livewire's@assetsdirective so the script runs even when the field is opened inside a modal — where a plain<script>tag injected through DOM morphing would never execute. Seepackages/forms/resources/views/components/tiptap-editor.blade.php:@assets<script src="{{ route('wire-forms.asset', ['asset' => 'tiptap']) }}"></script>@endassets
If you build a heavier JS-backed field in your own package, follow the same
pattern: bundle the script, expose it on a route, and inject it with @assets
from the field view so it is present whenever the field renders.
Testing Custom Fields
Fields render to HTML, so the fastest tests assert on output and configuration.
use App\Forms\Components\MoneyInput; it('renders the currency symbol', function () { $field = MoneyInput::make('price')->currency('EUR'); expect($field->getCurrency())->toBe('EUR') ->and($field->getStateType())->toBe('int') ->and((string) $field->toHtml())->toContain('EUR');});
For end-to-end behaviour (state binding, validation, save), exercise the field
inside a Livewire form component with Livewire's testing helpers, the same way the
package tests the built-in fields. Run them with composer test:forms.
See Also
- Form Fields reference — every built-in field
- Save Lifecycle — per-form save callbacks
- Validation — rule collection and messages
- Core Plugins — hooks, macros, type registries, packaging