PHPackages                             unnathianalytics/laraform - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. unnathianalytics/laraform

ActiveLibrary

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.

v0.5.0(2w ago)192↓57.8%MITPHPPHP ^8.1

Since Jul 17Pushed 2w agoCompare

[ Source](https://github.com/unnathianalytics/LaraForm)[ Packagist](https://packagist.org/packages/unnathianalytics/laraform)[ RSS](/packages/unnathianalytics-laraform/feed)WikiDiscussions main Synced 1w ago

READMEChangelogDependencies (18)Versions (6)Used By (0)

LaraForm
========

[](#laraform)

Keyboard-first, accessible form fields for Laravel — the form-side sibling of [laragrid](https://github.com/unnathianalytics/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
------------

[](#requirements)

PHP ^8.1 · Laravel 10 / 11 / 12. No Livewire, no Alpine, no npm.

Install
-------

[](#install)

```
composer require unnathianalytics/laraform
```

Optional publishes: `--tag=laraform-config`, `--tag=laraform-views`, `--tag=laraform-assets`.

Fields
------

[](#fields)

FieldPostsNotes`TextField`string`->type('email')`, `->maxlength()`, `->upper()` / `->lower()` live case transform`TextareaField`string`->rows()`; Enter stays newline, Ctrl+Enter advances`SelectField`stringnative ``, embedded `->options()``SearchSelectField`string (hidden)combobox; `->options()` embedded, `->optionsUrl()` remote (`?q=` → `[{value,label}]`), or `->searchVia('name')` through an app-registered JS adapter`MultiSelectField``name[]` arraychips + filter listbox over a native ``; `->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[]` arrayfieldset of native checkboxes; `->inline()``RadioField`stringfieldset of native radios (browser roving-arrow model); `->inline()``HiddenField`stringbare `` — ids, mode flags, tokens; no wrapper, never a focus stopEvery field chains the shared surface: `label · placeholder · help · default · required · disabled · readonly · autofocus · id · attributes([...]) · class() · controlClass()`.

Fields render standalone too — no `Form` needed:

```

    @csrf
    {!! \LaraForm\Fields\DateField::make('from_date')->label('From') !!}

```

The date field
--------------

[](#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:

TypedResolves 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-`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
-------

[](#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
------------

[](#keyboard-map)

KeyWhereDoesEnterany fieldcommit + advance to next field (serpentine, the laragrid 'entry' keymap)Shift+Enterany fieldcommit + backCtrl+Entertextareaadvance (plain Enter = newline)Enterlast fieldsubmit the form (config/`->enterOnLast('stay')`)focusany inputselect its existing editable textfocusselect / search / multi selectpopup opens immediately (keyboard focus; native selects via `showPicker`)↓ / ↑search/multi selectopen popup, move active optiony / n / Space / ↑↓Y/N fieldset or toggle the answerEnterlast panel fieldclose the panel, resume the flowEnteropen comboboxpick the active option and advanceEnteropen multi-selecttoggle the active option, popup stays openBackspaceempty multi-select boxremove the last chipEscopen popupclose, revertSpace / arrowscheckbox, radio groupnative toggle / roving selectionEnter-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
-------

[](#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
----------

[](#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)
----------------------------

[](#panels-auto-opening-modals)

The laragrid `Column::opensPanel()` contract at form scope, in two modes.

### Fluent — package-rendered

[](#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 `` (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 `` (it must sit inside it for its fields to post).

### Content slots

[](#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(''))                // 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

[](#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

[](#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
---------

[](#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
------

[](#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
--------

[](#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)

`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
-------

[](#roadmap)

Calendar popup (opt-in; entry stays keyboard-first), time field, date range, file field, chained/dependent selects, async validation hooks.

License
-------

[](#license)

MIT

###  Health Score

40

—

FairBetter than 86% of packages

Maintenance96

Actively maintained with recent releases

Popularity15

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity36

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~7 days

Total

5

Last Release

19d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/462bc50346da90eced8dacb4bdd9137560c2ed79ac06c20487b09f95d053a064?d=identicon)[unnathianalytics](/maintainers/unnathianalytics)

---

Top Contributors

[![unnathianalytics](https://avatars.githubusercontent.com/u/79654976?v=4)](https://github.com/unnathianalytics "unnathianalytics (6 commits)")

---

Tags

laravelaccessibilityFormsform-builderkeyboarddata-entry

###  Code Quality

TestsPest

Static AnalysisPHPStan

Code StyleLaravel Pint

### Embed Badge

![Health badge](/badges/unnathianalytics-laraform/health.svg)

```
[![Health](https://phpackages.com/badges/unnathianalytics-laraform/health.svg)](https://phpackages.com/packages/unnathianalytics-laraform)
```

###  Alternatives

[psalm/plugin-laravel

Psalm plugin for Laravel

3365.5M359](/packages/psalm-plugin-laravel)[laravel/mcp

Rapidly build MCP servers for your Laravel applications.

80732.6M276](/packages/laravel-mcp)[propaganistas/laravel-disposable-email

Disposable email validator

6093.4M10](/packages/propaganistas-laravel-disposable-email)[moonshine/moonshine

Laravel administration panel

1.3k280.5k91](/packages/moonshine-moonshine)[illuminate/console

The Illuminate Console package.

13747.3M7.7k](/packages/illuminate-console)[tallstackui/tallstackui

TallStackUI is a powerful suite of Blade components that elevate your workflow of Livewire applications.

731189.9k19](/packages/tallstackui-tallstackui)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
