wotz/filament-placeholder-input
wotz/filament-placeholder-input adds placeholder text support to Filament form inputs, helping Laravel developers improve form usability by displaying helpful hints inside fields. Lightweight, easy to install, and designed to integrate smoothly with existing Filament applications.
Install into an existing Filament app:
composer require wotz/filament-placeholder-input
The package is now on the v2 line (v2.1.1). Older blog posts, gists, and any v1 README snippets you find will not map cleanly onto v2 — treat the source as the reference. Check the resolved constraint (composer show wotz/filament-placeholder-input) and confirm it matches your Filament major version; a v2 major bump almost certainly tracks a Filament major (v4 Filament\Schemas\Components) rather than shipping parallel branches. Verify before adopting.
Read the source before the README. With packages this small the class itself is the documentation. Start at:
composer.json → autoload / extra.laravel (any service provider, config publish, views, assets)src/ → the field class (PlaceholderInput.php or Forms/Components/PlaceholderInput.php) for the public API: how it targets another field, whether it appends/prepends/replaces, which state hooks it uses, and getVariables() — the token-resolution method touched in 2.1.1, which tells you how placeholder variables are declared and evaluatedresources/views/ if present, to see whether the interaction is server-side (Livewire round trip) or Alpine-sideREADME.md last, for a copy-pasteable schema snippetFirst use case: a RichEditor (or Textarea) plus a small input that inserts reusable snippets/tokens into it. Sketch:
use Wotz\FilamentPlaceholderInput\Forms\Components\PlaceholderInput;
RichEditor::make('content')->live(),
PlaceholderInput::make('placeholder')
->target('content'), // exact method name comes from the source
Register both fields in the same form schema, open the resource create page, type into the placeholder input, and confirm the target field updates in the browser and survives a save. If the second part fails, the package is only doing client-side insertion and you'll need your own mutateFormDataBeforeSave.
Decide quickly whether it fits. If the source is <200 lines and uses afterStateUpdated + $set(), it's a thin convenience wrapper you could reimplement in an hour — worth knowing before you build a workflow on it. Note the package does now have CI (GitHub Actions), Dependabot, and outside contributors (@jyrkidn), so it's maintained better than the star count suggests — but adoption is still low enough that you are the upstream triage.
Targeting a sibling field. The core pattern is a field whose state is not what you persist — it exists to push content into another field's state. Treat it as a controller, not a data column:
PlaceholderInput::make('snippet_picker')
->target('content')
->dehydrated(false), // if the package doesn't already do this
If dehydrated(false) isn't set by the package and your model has no snippet_picker column, you'll get a "column not found" error on save. Verify with dd($this->form->getState()) in a handleRecordCreation override or via Livewire devtools.
Variables and tokens. getVariables() is the heart of the package's placeholder resolution — it builds the map of tokens available for insertion. Read it before wiring anything up: it tells you whether variables are hardcoded, pulled from config, or exposed as a fluent schema method (->variables([...])). If it's hardcoded, your realistic options are (a) config-driven if a config key exists, or (b) subclass and override. If it's fluent, define the variable set once in a small helper and reuse it across resources so token names stay consistent between the editor and any downstream renderer you build.
Reactive two-way flow. The reliable Filament pattern for cross-field writes is server-side:
PlaceholderInput::make('snippet_picker')
->target('content')
->live()
->afterStateUpdated(function ($state, Set $set) {
$set('data.content', $state); // or append: $set('data.content', $current . $state)
}),
Build this yourself if the package's built-in binding proves brittle. The package's value is mostly the UI affordance (a picker, a token list) and the variable resolution; the state plumbing is five lines and you should own it if you depend on it.
Append vs. replace. Most "add content to another field" fields are appending. Confirm the semantics in the source. For an append workflow, keep the current target value in the closure ($get('data.content')) and concatenate, then clear the placeholder input so repeated clicks accumulate rather than duplicate.
Repeaters and Builder. Inside Repeater::make('items')->schema([...]), the target path must be relative to the repeater item (content), not absolute (data.items.0.content). Test with two repeater rows — if row 0's picker writes into row 1's editor, the package is hardcoding data. prefixes and you should avoid it inside repeaters.
Modals and actions. Works the same in Action::make()->form([...]); just remember the target field must be in the same schema. It cannot write into the underlying resource form from a modal action.
Persistence layer. If insertion happens client-side (Alpine), Livewire won't see it. Add a mutateFormDataBeforeSave or beforeSave hook that reads the target field's state and writes it through, and add an integration test that submits the form without touching the target field manually.
Testing. Use Filament's livewire(CreatePost::class)->fillForm([...])->call('create')->assertHasNoFormErrors(), then assert the persisted content contains the inserted text. This is the single highest-value test for this package because the failure mode (state shows in UI, never persists) is silent. Add a second case that exercises a variable-bearing placeholder so the getVariables() path is covered — that's the method that just shipped a bug fix.
getVariables(). Practical symptom in the earlier patch: composite expressions (concatenation combined with ?? / ?: / a comparison) resolve to the wrong token or the wrong default. If your placeholders look correct for single variables but break the moment you combine them, that's this bug. It's a one-line patch release — no migration work.composer update semantics carry over. Read the v2.0.0 changelog / diff for renamed fluent methods and any Filament 4 namespace move (Filament\Forms\Components\Field → Filament\Schemas\Components\Field). Expect one focused refactor, not a drop-in."wotz/filament-placeholder-input": "2.1.1" rather than ^2.1 is still the right call for a low-adoption package — but the presence of CI, Dependabot, and an external PR author means patch releases are likelier to be small and honest than to be rewrites.Filament\Schemas\Components. Check whether the v2 field class extends the schemas Field or still leans on Filament\Forms aliases; the latter works today but generates deprecation noise and will break at the next major.afterStateUpdated collisions. If the package registers its own afterStateUpdated internally and you chain another one, one may overwrite the other depending on whether it merges or replaces closures. Read the field's setUp() to see. If it replaces, extend the class and override rather than chaining.dehydrated(false) itself or that you do. Symptom: SQLSTATE[42S22]: Column not found on create/update.edit, the target field arrives pre-filled from the model. Make sure the placeholder input starts empty (->default(null) / no fill()) — otherwise the existing content gets re-appended on first interaction.content, not on the picker. If you validate the picker, users can bypass required-content rules by never touching it.getVariables() produces must match what your front end substitutes. If you render stored content elsewhere (Blade, mail, API), extract the variable map into a shared class rather than duplicating the keys — the package's resolution is only half of the contract.x-on:click) is invisible to $this->form->getState() until a Livewire request fires. If tests fail but the browser works, add ->live() or a wire:model binding, or force a $refresh.setUp() (adding your own target(), mode(), separator() helpers, or overriding getVariables() for a config-driven token list) is cheaper than forking. Publish nothing; just subclass.Livewire::dump() on the form state, or ->tap(fn ($field) => logger($field->getStatePath())), tells you the resolved state path — the fastest way to diagnose repeater/modal path scoping problems.app/Filament/Components/ and dropping the dependency is fully legitimate. Given the maintenance risk profile, that's often the right call once you've confirmed the feature is ~100 lines — though v2.1.1 is a small enough surface that subclassing and staying on the dependency is now the lower-effort default.How can I help you explore Laravel packages today?