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.

Maintainers

Package info

github.com/unnathianalytics/LaraForm

pkg:composer/unnathianalytics/laraform

Transparency log

Statistics

Installs: 33

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.4.0 2026-07-17 12:38 UTC

This package is auto-updated.

Last update: 2026-07-17 12:38:26 UTC


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-Y31-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). No input/change echo — 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:

  1. Client → server: wire:model on the field name works because committed values dispatch input on the posted input.
  2. 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.
  3. 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