unnathianalytics / laraform
Keyboard-first, accessible form fields for Laravel — fluent column-style field classes, Busy-style fuzzy date entry, Enter-to-advance flow, vanilla JS, zero consumer build tooling.
Requires
- php: ^8.1
- illuminate/contracts: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
- illuminate/validation: ^10.0|^11.0|^12.0|^13.0
- illuminate/view: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^2.9|^3.0
- laravel/pint: ^1.13
- orchestra/testbench: ^8.21|^9.0|^10.0|^11.0
- pestphp/pest: ^2.34|^3.0|^4.0
- pestphp/pest-plugin-laravel: ^2.4|^3.0|^4.0
README
Keyboard-first, accessible form fields for Laravel — the form-side sibling of
laragrid. Fluent field classes in the
laragrid Column::make() style, Busy-style fuzzy date entry, Enter-to-advance flow,
vanilla JS, and zero consumer build tooling: composer require and render.
use LaraForm\Form; use LaraForm\Fields\{TextField, DateField, MoneyField, SearchSelectField, ToggleField}; $form = Form::make() ->action(route('invoices.store')) ->fields([ TextField::make('party')->label('Party name')->upper()->required()->autofocus(), DateField::make('invoice_date')->label('Invoice date')->financialYear(4)->required(), MoneyField::make('amount')->label('Amount')->indian()->prefix('₹'), SearchSelectField::make('customer_id')->label('Customer')->optionsUrl('/api/customers'), ToggleField::make('active')->label('Active')->default(true), ]) ->submit('Save');
{!! $form !!}
That is the whole integration. The stylesheet and script auto-inject into any page that
rendered a field (disable via laraform.inject_assets and place @laraformStyles /
@laraformScripts yourself).
Requirements
PHP ^8.1 · Laravel 10 / 11 / 12. No Livewire, no Alpine, no npm.
Install
composer require unnathianalytics/laraform
Optional publishes: --tag=laraform-config, --tag=laraform-views, --tag=laraform-assets.
Fields
| Field | Posts | Notes |
|---|---|---|
TextField |
string | ->type('email'), ->maxlength(), ->upper() / ->lower() live case transform |
TextareaField |
string | ->rows(); Enter stays newline, Ctrl+Enter advances |
SelectField |
string | native <select>, embedded ->options() |
SearchSelectField |
string (hidden) | combobox; ->options() embedded, ->optionsUrl() remote (?q= → [{value,label}]), or ->searchVia('name') through an app-registered JS adapter |
MultiSelectField |
name[] array |
chips + filter listbox over a native <select multiple>; ->max() |
DateField |
canonical Y-m-d (hidden) |
Busy-style freeform entry — see below |
MoneyField |
fixed-scale string (hidden) | ->decimals(), ->indian() / ->grouping(), ->prefix(), ->negative(false) |
IntegerField |
integer string (hidden) | MoneyField at scale 0 |
YesNoField |
'1' / '0' (hidden) |
one-char Y/N box — type y/n, Space or ↑/↓ toggles (laragrid YesNoColumn) |
CheckboxField |
'1' / '0' |
always posts (paired hidden 0) |
ToggleField |
'1' / '0' |
switch-styled checkbox, role="switch" |
CheckboxGroupField |
name[] array |
fieldset of native checkboxes; ->inline() |
RadioField |
string | fieldset of native radios (browser roving-arrow model); ->inline() |
Every field chains the shared surface: label · placeholder · help · default · required · disabled · readonly · autofocus · id · attributes([...]) · class() · controlClass().
Fields render standalone too — no Form needed:
<form method="POST" action="..." data-lf-form data-lf-enter="1" data-lf-enter-last="submit" novalidate> @csrf {!! \LaraForm\Fields\DateField::make('from_date')->label('From') !!} </form>
The date field
The same fuzzy parser laragrid's DateEditor uses (a verbatim port of its shared date module — the two packages resolve typed dates identically). The operator types anything day-first; on Enter/blur it resolves, the box snaps to the display pattern, and a hidden input posts the canonical value:
| Typed | Resolves to (display d-m-Y) |
|---|---|
31/12/2026, 31.12.26, 31-12-26, 31 12 26 |
31-12-2026 |
311226, 31122026 |
31-12-2026 |
3112 (no year) |
year inferred |
2.1 |
02-01-<inferred> |
The display pattern's TOKEN ORDER drives parsing, so the operator always types what they
see: with 'display' => 'm/d/Y' in the config (or ->displayFormat('m/d/Y')), 1231
and 12/31 read month-first as Dec 31; 'Y-m-d' reads 20261231. Day-first is the
default. Missing years use plain calendar inference by default; opt into financial-year
windows globally (laraform.date.fy_start_month) or per field (->financialYear(4)),
where 31/12 lands inside the FY that started in April. Unparseable text refuses the
Enter-advance and marks the field invalid — forced-entry discipline. The server guard is
strict: validate with 'invoice_date' => ['required', 'date_format:Y-m-d'] (or
DateField::make(...)->rule()), never a second fuzzy parser.
Display pattern tokens: d m Y M (->displayFormat('d/m/Y'), d-M-Y → 31-Dec-2026).
Numbers
MoneyField/IntegerField are masked text inputs (inputmode=decimal), never
type=number — grouping commas, partial input and paste behave predictably, and typed
text casts exactly like laragrid's number editor: grouping stripped, rounded half-up at
scale, posted as a fixed-scale string via the hidden input. Display grouping is plain
(1,234,567.00) or indian (12,34,567.00).
Keyboard map
| Key | Where | Does |
|---|---|---|
| Enter | any field | commit + advance to next field (serpentine, the laragrid 'entry' keymap) |
| Shift+Enter | any field | commit + back |
| Ctrl+Enter | textarea | advance (plain Enter = newline) |
| Enter | last field | submit the form (config/->enterOnLast('stay')) |
| focus | select / search / multi select | popup opens immediately (keyboard focus; native selects via showPicker) |
| ↓ / ↑ | search/multi select | open popup, move active option |
| y / n / Space / ↑↓ | Y/N field | set or toggle the answer |
| Enter | last panel field | close the panel, resume the flow |
| Enter | open combobox | pick the active option and advance |
| Enter | open multi-select | toggle the active option, popup stays open |
| Backspace | empty multi-select box | remove the last chip |
| Esc | open popup | close, revert |
| Space / arrows | checkbox, radio group | native toggle / roving selection |
Enter-advance is per form (->enterAdvance(false)) or global (laraform.enter_advance).
A field whose text cannot resolve (bad date, no matching option) cancels the advance —
focus stays until fixed or cleared.
Theming
The default stylesheet is driven entirely by --lf-* tokens — restyle by overriding
tokens, never by fighting selectors:
:root { --lf-accent: #16a34a; --lf-radius: 2px; --lf-control-height: 2rem; }
Tailwind apps can go further: set 'theme' => 'headless' in the config (structural CSS
only — popup positioning, sr-only, chip layout) and inject utilities per part:
'classes' => [ 'control' => 'rounded border-gray-300 focus:ring-2 focus:ring-emerald-500', 'label' => 'text-sm font-medium text-gray-700', 'popup' => 'rounded-md border bg-white shadow-lg', ],
Per-field hooks: ->class('col-span-2') (wrapper) and ->controlClass('text-right').
Validation
Rules live ON the field — one source of truth for the server AND the Enter-speed client checks:
TextField::make('shortcode')->minLength(2)->maxLength(8), MoneyField::make('room_rate')->required()->min(10)->max(99), TextField::make('email')->email(), DateField::make('opened_on'), // implicit date_format:Y-m-d SelectField::make('type')->options($types), // implicit in:options TextField::make('slug')->rules('alpha_dash|unique:resorts'), // any Laravel rule
Every field contributes implicit type rules (string, numeric/integer + min/max,
date_format, in:options, array + name.* members, in:0,1; a required checkbox/toggle
becomes accepted) plus presence (required/nullable) and whatever ->rules() adds.
Form::rules() aggregates the lot — panel fields included, since they post with the
form — and Form::validate() runs it:
$data = $form->validate($request, [ 'city' => [Rule::in($cities)], // app-specific extras, appended per key ]);
Errors render under each field automatically ($errors + old() round-trip, with
aria-invalid/aria-describedby wiring). Forms render novalidate — the server is the
authority; browser popups fight data-entry flow.
Client checks at Enter speed. A forward Enter refuses to leave a field that is
empty-but-required, outside its ->min()/->max() bounds, shorter than
->minLength(), or a malformed email — message under the field (templates in
laraform.messages, :min/:max substituted), cleared as soon as the value is fixed.
An Enter-driven submit or panel-close sweeps its scope first and lands on the first
offender. Groups need one checked member, multi selects one pick, canonical fields a
committed value. Shift+Enter back, Tab, Esc and pointer clicks are never blocked — and
the server re-validates everything anyway.
Panels (auto-opening modals)
The laragrid Column::opensPanel() contract at form scope, in two modes.
Fluent — package-rendered
Hand ->opensPanel() a Panel and LaraForm renders and drives the whole dialog — no
host JS:
use LaraForm\Panel; TextField::make('item')->label('Item')->opensPanel( Panel::make('item-desc') ->title('Item description') ->fields([ TextField::make('desc1')->label('Description 1'), TextField::make('desc2')->label('Description 2'), ]) ->doneLabel('Done') // default 'Done' ->hint('Enter to move down · Esc to close') // default; '' hides it ->width('28rem'), ),
Forward Enter on the field opens the dialog and focuses its first field; Enter advances within the panel only; Enter on the last panel field, the Done button, the × or Esc all close it — and the deferred advance resumes onto the field after the opener. Shift+Enter never triggers a panel; nested panels are not supported.
Form renders each attached panel INSIDE the <form> (deduped by name), so panel
fields post with the request and old()/$errors work unchanged; a panel holding a
server-side validation error opens itself on page load. Outside Form, render the panel
yourself — {!! $panel !!} anywhere inside your <form> (it must sit inside it for its
fields to post).
Content slots
Panels take more than fields — slots render in declaration order:
Panel::make('items') ->title('Items') ->width('72rem') ->fields([YesNoField::make('taxable')->label('Taxable?')]) // Field | Htmlable | string ->slot(new HtmlString('<hr>')) // raw content (plain strings are escaped) ->view('vouchers.item-grid', ['voucher' => $id]) // any blade partial ->livewire('item-grid', ['voucher' => $id]), // sugar — requires Livewire in the app
laragrid inside (and around) panels
Grids coexist with the flow by contract: anything inside [data-lgrid] is invisible to
LaraForm's focus stops and Enter handling — the grid owns its keyboard world. When a
grid fires lgrid:complete (its Busy End-of-List exit), the flow catches it and focuses
the first field after the grid. The bridge works both ways: a panel host also opens on
laragrid's lgrid:panel {panel, rowKey} and dispatches lgrid:panel-done on close, so
one fluent Panel can serve a grid column's per-row popup.
Host-owned — string mode
->opensPanel('name') without a Panel keeps the modal yours:
document.addEventListener('lf:panel', (e) => { if (e.detail.panel !== 'item-desc') return; myDialog.showModal(); // your modal, your fields myDialog.addEventListener('close', () => e.detail.resume(), { once: true }); });
resume() (or dispatching lf:panel-done) runs the deferred advance exactly once.
Extending
Panel extends LaraForm\Components\Component — the small base for every non-field
building block: free attributes(), a class() hook, and a viewName() resolved in
the publishable laraform:: view namespace. Planned components (Toolbar, StatusBar,
Tabs) will extend the same base, and app-specific ones can too: subclass Component,
point viewName() at a view under resources/views/vendor/laraform/, render it
anywhere with {!! $component !!}.
JS API
window.LaraForm:
init(root)— wire late-arriving markup (a MutationObserver already covers Livewire morphs and fetched modals).setValue(target, value, label?)— programmatically set a field's canonical value and repaint its display (target: the[data-lf]root, any node inside it, or a selector). Noinput/changeecho — server-originated changes must not loop back.registerSearch(name, handler)— register a combobox search adapter:(query) => [{value, label}]or a Promise of it; pair with->searchVia(name).parseDate(text, fyMonth, fyYear, order?)/formatNumber(value, scale, style)— the shared parsers.pending— boot-order-safe registration queue: scripts that run before the bundle push callbacks ((window.LaraForm ??= {pending: []}).pending.push((LF) => ...)); each runs with the live API at boot, late pushes run immediately.
Events: lf:commit (cancelable, before an advance), lf:panel / lf:panel-done
(the modal handoff). Every committed value — date resolve, number cast, Y/N toggle,
combobox pick/clear, multi-select toggle — dispatches bubbling input and change
on the posted (hidden or native) input, and only when the value actually changed.
Livewire
The package stays framework-free; these three seams make a thin app-side bridge all Livewire needs:
- Client → server:
wire:modelon the field name works because committed values dispatchinputon the posted input. - Server → client: watch your property and repaint via
LaraForm.setValue('#lf-uom', value, label)— edit screens and dependent fields (item pick pre-filling UoM) stay in sync after morphs. - Search transport: back a combobox with your component instead of a JSON
endpoint —
LaraForm.registerSearch('accounts', (q) => $wire.searchAccounts(q))plus->searchVia('accounts')keeps tenant scoping and permission gating inside Livewire.
Demo
demo/index.html runs the full widget set in a browser straight from the repo — no
Laravel, no server. Open it and put your mouse away.
Roadmap
Calendar popup (opt-in; entry stays keyboard-first), time field, date range, file field, chained/dependent selects, async validation hooks.
License
MIT