You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Filament-style fluent form builder for Laravel. One schema renders as Blade (javaabu/forms) or Inertia React (@javaabu/forms), with drivers for other frameworks, automatic model binding and closure-evaluated props. Filters, bulk-actions, settings, widgets, meta and form-builder are built on it.
Built to the next-gen general rules: CI with test and coverage badges, full docs for people and agents, Laravel Boost guideline and skill, Javaabu codestyle, PHP 8.4 / Laravel 12+ (Laravel packages) or TypeScript (JS packages), Dhivehi and RTL, Blade and Inertia React for views, overridable controllers and policies with javaabu/permissions.
Closure evaluation (EvaluatesClosures): every prop accepts a closure, injected by name or type: $get (Get), $set (Set), $state, $old, $record, $model, $operation, $component, $form, plus anything from the container.
State: hydrated per field, Filament-style, with forms' precedence (old input → record → default). Relationships (->relationship('category', 'name')), enums and casts handled by the field. Dehydration back onto the model (fillModel(), save(), saveRelationships()).
Validation: rules derived from the schema (required, type rules, in: options, maxLength, unique(ignoreRecord)…) for visible fields only, evaluated against the submitted state. $form->rules(), ->validationAttributes(), ->validate($request).
Conditional props, two kinds:
Declarative, client-side, no round trip: ->visibleWhen('type', 'company'), ->hiddenWhen(), ->visibleWhenChecked(), ->disabledWhen(). Rendered with forms' conditional-wrapper (both Blade and React have it), and evaluated server-side for validation.
Closures with $get, re-evaluated on the server when a ->live() field changes, through an evaluation endpoint registered by Route::stencil(). Needs a form class (class ArticleForm extends Form { protected function definition(): array }) so the server can rebuild it; the request carries an encrypted token (form class, record, operation, context, user). Supports afterStateUpdated(fn (Set $set) => …). Blade swaps only the changed fields (small JS shipped through manifest); React re-renders from the returned schema.
Drivers: Renderer contract + RendererManager (Stencil::extend('vue', fn () => new VueRenderer)). Built-in: blade (javaabu/forms v2 components; views publishable) and json (Inertia props; Form is JsonSerializable/Arrayable, so Inertia::render('Page', ['form' => $form]) just works). {{ $form }} renders with Blade. Driver test kit in Javaabu\Stencil\Testing.
React renderer in resources/js (alias @javaabu/stencil through javaabu/manifest): <StencilForm schema={form} /> on @javaabu/forms, registry for custom field components, TypeScript types for the JSON format.
Built-in field props are exactly the @javaabu/forms props (camelCase); the Blade driver passes the same props as kebab-case attributes, so parity comes from javaabu/forms.
No Repeater in v1 (javaabu/forms has no repeater component); can follow.
Server-side live re-evaluation needs a form class; inline Form::make() forms get everything else, including client-side conditions.
Grid/column layout uses a tiny framework-agnostic stylesheet (stencil::stencil, CSS grid with logical properties) so it works with both Onu and Bootstrap 6.
Public schema API (pushed to main, 2e6b66a; downstream can build on it)
Require: "javaabu/stencil": "^1.0@dev" + VCS repos for Javaabu/stencil, Javaabu/forms, Javaabu/manifest.
useJavaabu\Stencil\Form;
useJavaabu\Stencil\Get;
useJavaabu\Stencil\Set;
useJavaabu\Stencil\Components\{Section, Grid, Group, Fieldset, Tabs, Tab, Wizard, Step, Alert, TextEntry};
useJavaabu\Stencil\Fields\{TextInput, Textarea, Wysiwyg, Select, Checkbox, CheckboxList, Radio,
DatePicker, DateTimePicker, TimePicker, FileUpload, ImageUpload, Hidden};
// Inline$form = Form::make('article')
->model($article) // a model, a model class (create), or an array/object (settings, filters)
->action(route('articles.update', $article)) // method defaults to PATCH for an existing model, else POST
->schema([
Section::make(__('Details'))->columns(2)->schema([
TextInput::make('title')->required()->maxLength(255)->thaana(),
Select::make('status')->options(ArticleStatuses::class)->required(),
Select::make('category_id')->relationship('category', 'name')->searchable(),
Select::make('tags')->multiple()->relationship('tags', 'name'),
TextInput::make('company_name')->visibleWhen('type', 'company'), // browser, no round tripDateTimePicker::make('published_at')->visible(fn (Get$get) => $get('status') === 'published'), // server
]),
]);
// A class (needed for live fields: the server rebuilds it)class ArticleForm extends Form
{
protectedfunctiondefinition(): array { return [/* components */]; }
}
ArticleForm::make()->model($article)->with(['course' => $course]); // context, kept for live re-evaluation
Every prop takes a closure. Arguments are injected by name: $get, $set, $state (the field's own value), $record, $model (class), $operation, $component, $form, $old (in afterStateUpdated). They can also be injected by type: Get, Set, Form, Component/Field, the record's class, or anything from the container.
Conditions
Declarative, checked in the browser through forms' conditional-wrapper, and also applied on the server for validation and saving: visibleWhen, hiddenWhen, enabledWhen, disabledWhen, plus the …WhenChecked($checkbox) variants.
Server-side, which leaves the component out of the output: hidden(), visible(), hiddenOn('edit'), visibleOn('create').
Live fields.->live(debounce: 500, on_blur: false) and ->afterStateUpdated(fn (Set $set, ?string $state, $old) => …) re-evaluate the form through Route::stencil(), which the app adds to its routes with its own middleware. The request carries an encrypted token (form class, record, operation, context, user), and Form::authorize() can refuse it.
Common field API.label, hiddenLabel, placeholder, help, inline, required, disabled, default, rules([...]), validationMessages, validationAttribute, formatStateUsing, dehydrateStateUsing, dehydrated(false), extraAttributes([...]) (passes HTML or forms attributes through), columnSpan/columnSpanFull, view('my.view'), meta([...]).
Validation and saving
$data = $form->validate($request); // rules from the visible, enabled fields, checked against the submitted state$form->rules(); $form->validationAttributes(); $form->messages(); $form->validator($input);
$article = $form->save($data); // fill (fillable only) + save + saveRelationships (BelongsToMany sync, media)$form->fillModel($article, $data);
$form->getState(); $form->fill([...]); $form->getStateValue('address.city');
Rendering
Blade
{{ $form }}, $form->render('blade'), or Stencil::render($form), which uses config('stencil.views'): blade | inertia
Inertia
Inertia::render('Page', ['form' => $form]). Form is Arrayable/JsonSerializable.
Fields only, inside your own <form>
->embedded()
Custom drivers
Stencil::extend('vue', fn ($app) => new VueRenderer), implementing Javaabu\Stencil\Contracts\Renderer::render(Form $form)
JSON format for JS drivers, version 1 (pushed; TypeScript types follow with the React renderer)
$form->toArray() / json_encode($form) / an Inertia prop:
{
"stencil": 1, // format version; bumps only on a breaking change"id": "article-form", // HTML id"action": "/articles/1", // omitted when null"method": "PATCH", // GET | POST | PUT | PATCH | DELETE (spoof non-POST yourself, or let @javaabu/forms' Form do it)"operation": "edit", // create | edit | view | custom"files": true, // only when true (a file field, or ->files())"submit": "Save", // submit button label; omitted for ->submit(false) and embedded forms"summary": true, "errorBag": "profile", "embedded": true, // only when set"columns": { "default": 1, "md": 2 }, // only when set"state": { "title": "ސުރުހީ", "tags": [1, 2], "address": { "city": "މާލެ" } }, // nested by name; dates as strings (Y-m-d, Y-m-d H:i, H:i); enums as values"live": { "url": "/stencil/evaluate", "token": "…", "debounce": 500 }, // null without live fields"components": [ /* nodes */ ]
}
Node
{
"type": "text", // what to render: built-ins below, or a custom type from your registry"key": "title", // unique in the form; the field name for fields, generated for layouts (section-0, grid-0-1, tab-details)"name": "title", // fields only: input name in bracket notation (address[city], tags[], channels[])"component": "Text", // fields only: the @javaabu/forms export to render (Text, Email, Password, Number, Tel, Url, Hidden, Textarea, Wysiwyg, Select, Select2, Checkbox, Radio, Date, Datetime, Time, File, Image)"props": { … }, // camelCase. For fields: exactly @javaabu/forms props (label, required, disabled, placeholder+showPlaceholder, help, prepend, append, thaana, maxLength, options, multiple, …). Omitted when empty."children": [ … ], // containers only"hidden": true, // hidden on the server: render nothing (only type/key/name/span are sent)"conditions": [ { "effect": "visible|hidden|enabled|disabled", "field": "type", "selector": "#type", "values": ["company"], "checkbox": false } ],
"live": { "debounce": null, "onBlur": false }, // re-evaluate when this field changes"span": { "md": 2 } | { "md": "full" }, // grid columns taken, per breakpoint"meta": { … } // ->meta([...]), untouched
}
options are always a list, [{ "value": "2", "label": "…" }], so JS keeps the order. Values are strings.
Conditions map onto ConditionalWrapper: enableElem = selector, enableValue = checkbox ? 1 : values, enableCheckbox = checkbox, hideFields = effect in (visible, hidden), disable = effect in (hidden, disabled). Nest wrappers for several conditions (AND).
Built-in types and their props
Type
Props
section
heading, description, columns
grid, group
columns
fieldset
label, columns
tabs
active (tab key); children are tab (label, icon, columns)
wizard
startStep (1-based), nextLabel, previousLabel; children are step (label, description, columns)
alert
message, type, heading, icon
text-entry
name, label, value, multiline, inline
checkbox-list
component: "Checkbox", options, columns; render one checkbox per option named name
radio
component: "Radio", options, columns; one radio per option
other fields (text, textarea, wysiwyg, select, checkbox, date, datetime, time, file, image, hidden)
render component with name + props
Live evaluation. When a node with live changes, POST the form's values plus _stencil (the token), _stencil_changed (the field's name) and, optionally, _stencil_old to live.url, with the CSRF token. The response is { schema, state, changed }, where changed lists the names that $set changed. With _stencil_driver=blade, it's { html, state, changed } instead.
Local only, not committed: the JS tooling (package.json, which installs @javaabu/forms#next-gen) and the React renderer under resources/js: types, registry/context, node rendering, the built-in components and the wizard. It isn't typechecked or tested yet, so I haven't pushed it.
javaabu/stencil v1.0 is on main. CI is green on the full matrix:
PHP 8.4/8.5 × Laravel 12/13 × lowest/stable
Pint
a leg without the optional javaabu/translatable
the JS job: lint, format, typecheck, Vitest with coverage, and the dist freshness check
There are 97 PHPUnit tests and 31 Vitest tests. Every next-gen issue is closed except #13 (needs a human).
What was built
Schema. Forms are built inline or as classes. Layouts are Section, Grid, Group, Fieldset, Tabs and Wizard. Display components are Alert and TextEntry, and there's a field for each javaabu/forms input component. Every prop takes a closure, injected by name or type.
Conditions.visibleWhen() and friends are checked in the browser (forms' conditional-wrapper) and on the server.
Live fields.live() and afterStateUpdated()/$set go through Route::stencil(), with an encrypted token tied to the user.
Binding, validation and saving.
State comes from old input, then the record (casts, enums, relationships), then defaults.
Rules and attribute names come from the schema, for visible fields only.
validate(), fillModel(), save() and saveRelationships() cover BelongsToMany sync and media.
Drivers.
Blade renders with javaabu/forms v2; views are publishable.
JSON (alias inertia) produces format v1, typed in resources/js/types.ts. Since the earlier contract comment, nodes gained an optional translatable key and the wizard gained stepLabel.
Stencil::extend() registers new drivers, and RendererTestKit tests them.
React renderer.StencilForm, StencilFields and a component registry, on @javaabu/forms, delivered as @javaabu/stencil through javaabu/manifest. A Blade script for wizards and live swaps ships prebuilt in dist/.
Extending and testing. Custom field types with make:stencil-field. Testing helpers: AssertableForm, InteractsWithStencil.
Dhivehi and RTL. Dhivehi strings, Thaana fields, logical-property CSS.
Docs. Docusaurus docs, llms.txt, AGENTS.md/CLAUDE.md, and the Laravel Boost guideline and javaabu-stencil skill.
Intentionally out of scope for v1
Repeater. javaabu/forms has no repeater component.
MapInput. Not a built-in field; it can be added as a custom field type.
Live fields on inline forms. They need a form class so the server can rebuild them, and inline forms throw a clear error. Declarative conditions work everywhere.
Live state after a failed validation. After a validation failure, React fields that have old input keep it over values $set sends. This matches @javaabu/forms' value precedence.
For the user
Codecov. Make sure CODECOV_TOKEN is available to this repo (uploads are configured with fail_ci_if_error: false).
Dhivehi review. The lines listed in docs/advanced-usage/dhivehi-and-rtl.md#for-review need a Dhivehi speaker.
Release. Tag and submit to Packagist. Once javaabu/forms v2, manifest v1 and translatable v3 are released, drop the VCS repositories in composer.json. Once @javaabu/forms is on npm, change its npm dependency in StencilServiceProvider and package.json.
Activity
n4ff4h commentedon Nov 4, 2026
Implementation plan
Architecture
One PHP schema → a resolved tree (closures evaluated against the current state) → a renderer driver. The schema knows nothing about Blade or React.
EvaluatesClosures): every prop accepts a closure, injected by name or type:$get(Get),$set(Set),$state,$old,$record,$model,$operation,$component,$form, plus anything from the container.default). Relationships (->relationship('category', 'name')), enums and casts handled by the field. Dehydration back onto the model (fillModel(),save(),saveRelationships()).required, type rules,in:options,maxLength,unique(ignoreRecord)…) for visible fields only, evaluated against the submitted state.$form->rules(),->validationAttributes(),->validate($request).->visibleWhen('type', 'company'),->hiddenWhen(),->visibleWhenChecked(),->disabledWhen(). Rendered with forms'conditional-wrapper(both Blade and React have it), and evaluated server-side for validation.$get, re-evaluated on the server when a->live()field changes, through an evaluation endpoint registered byRoute::stencil(). Needs a form class (class ArticleForm extends Form { protected function definition(): array }) so the server can rebuild it; the request carries an encrypted token (form class, record, operation, context, user). SupportsafterStateUpdated(fn (Set $set) => …). Blade swaps only the changed fields (small JS shipped through manifest); React re-renders from the returned schema.Renderercontract +RendererManager(Stencil::extend('vue', fn () => new VueRenderer)). Built-in:blade(javaabu/forms v2 components; views publishable) andjson(Inertia props;FormisJsonSerializable/Arrayable, soInertia::render('Page', ['form' => $form])just works).{{ $form }}renders with Blade. Driver test kit inJavaabu\Stencil\Testing.resources/js(alias@javaabu/stencilthrough javaabu/manifest):<StencilForm schema={form} />on@javaabu/forms, registry for custom field components, TypeScript types for the JSON format.JSON format (sketch, the contract for JS drivers)
{ "stencil": 1, "id": "article-form", "operation": "edit", "action": "/articles/1", "method": "PATCH", "files": false, "state": { "title": "ސުރުހީ", "status": "draft" }, "live": { "url": "/stencil/evaluate", "token": "…" }, "components": [ { "type": "section", "key": "details", "props": { "heading": "…", "columns": 2 }, "children": [ { "type": "text", "key": "title", "name": "title", "component": "Text", "props": { "label": "…", "required": true, "thaana": true }, "span": 1, "conditions": [], "live": null } ] } ] }Built-in field
propsare exactly the@javaabu/formsprops (camelCase); the Blade driver passes the same props as kebab-case attributes, so parity comes from javaabu/forms.Order of work
resources/js)Open questions / decisions I'm taking
Form::make()forms get everything else, including client-side conditions.stencil::stencil, CSS grid with logical properties) so it works with both Onu and Bootstrap 6.n4ff4h commentedon Nov 4, 2026
Public schema API (pushed to
main, 2e6b66a; downstream can build on it)Require:
"javaabu/stencil": "^1.0@dev"+ VCS repos forJavaabu/stencil,Javaabu/forms,Javaabu/manifest.Every prop takes a closure. Arguments are injected by name:
$get,$set,$state(the field's own value),$record,$model(class),$operation,$component,$form,$old(inafterStateUpdated). They can also be injected by type:Get,Set,Form,Component/Field, the record's class, or anything from the container.Conditions
conditional-wrapper, and also applied on the server for validation and saving:visibleWhen,hiddenWhen,enabledWhen,disabledWhen, plus the…WhenChecked($checkbox)variants.hidden(),visible(),hiddenOn('edit'),visibleOn('create').Live fields.
->live(debounce: 500, on_blur: false)and->afterStateUpdated(fn (Set $set, ?string $state, $old) => …)re-evaluate the form throughRoute::stencil(), which the app adds to its routes with its own middleware. The request carries an encrypted token (form class, record, operation, context, user), andForm::authorize()can refuse it.Common field API.
label,hiddenLabel,placeholder,help,inline,required,disabled,default,rules([...]),validationMessages,validationAttribute,formatStateUsing,dehydrateStateUsing,dehydrated(false),extraAttributes([...])(passes HTML or forms attributes through),columnSpan/columnSpanFull,view('my.view'),meta([...]).Validation and saving
Rendering
{{ $form }},$form->render('blade'), orStencil::render($form), which usesconfig('stencil.views'):blade|inertiaInertia::render('Page', ['form' => $form]).FormisArrayable/JsonSerializable.<form>->embedded()Stencil::extend('vue', fn ($app) => new VueRenderer), implementingJavaabu\Stencil\Contracts\Renderer::render(Form $form)Stencil::view('rating', 'stencil.rating')n4ff4h commentedon Nov 4, 2026
JSON format for JS drivers, version 1 (pushed; TypeScript types follow with the React renderer)
$form->toArray()/json_encode($form)/ an Inertia prop:{ "stencil": 1, // format version; bumps only on a breaking change "id": "article-form", // HTML id "action": "/articles/1", // omitted when null "method": "PATCH", // GET | POST | PUT | PATCH | DELETE (spoof non-POST yourself, or let @javaabu/forms' Form do it) "operation": "edit", // create | edit | view | custom "files": true, // only when true (a file field, or ->files()) "submit": "Save", // submit button label; omitted for ->submit(false) and embedded forms "summary": true, "errorBag": "profile", "embedded": true, // only when set "columns": { "default": 1, "md": 2 }, // only when set "state": { "title": "ސުރުހީ", "tags": [1, 2], "address": { "city": "މާލެ" } }, // nested by name; dates as strings (Y-m-d, Y-m-d H:i, H:i); enums as values "live": { "url": "/stencil/evaluate", "token": "…", "debounce": 500 }, // null without live fields "components": [ /* nodes */ ] }Node
{ "type": "text", // what to render: built-ins below, or a custom type from your registry "key": "title", // unique in the form; the field name for fields, generated for layouts (section-0, grid-0-1, tab-details) "name": "title", // fields only: input name in bracket notation (address[city], tags[], channels[]) "component": "Text", // fields only: the @javaabu/forms export to render (Text, Email, Password, Number, Tel, Url, Hidden, Textarea, Wysiwyg, Select, Select2, Checkbox, Radio, Date, Datetime, Time, File, Image) "props": { … }, // camelCase. For fields: exactly @javaabu/forms props (label, required, disabled, placeholder+showPlaceholder, help, prepend, append, thaana, maxLength, options, multiple, …). Omitted when empty. "children": [ … ], // containers only "hidden": true, // hidden on the server: render nothing (only type/key/name/span are sent) "conditions": [ { "effect": "visible|hidden|enabled|disabled", "field": "type", "selector": "#type", "values": ["company"], "checkbox": false } ], "live": { "debounce": null, "onBlur": false }, // re-evaluate when this field changes "span": { "md": 2 } | { "md": "full" }, // grid columns taken, per breakpoint "meta": { … } // ->meta([...]), untouched }optionsare always a list,[{ "value": "2", "label": "…" }], so JS keeps the order. Values are strings.ConditionalWrapper:enableElem = selector,enableValue = checkbox ? 1 : values,enableCheckbox = checkbox,hideFields = effect in (visible, hidden),disable = effect in (hidden, disabled). Nest wrappers for several conditions (AND).Built-in types and their props
sectionheading,description,columnsgrid,groupcolumnsfieldsetlabel,columnstabsactive(tab key); children aretab(label,icon,columns)wizardstartStep(1-based),nextLabel,previousLabel; children arestep(label,description,columns)alertmessage,type,heading,icontext-entryname,label,value,multiline,inlinecheckbox-listcomponent: "Checkbox",options,columns; render one checkbox per option namednameradiocomponent: "Radio",options,columns; one radio per optiontext,textarea,wysiwyg,select,checkbox,date,datetime,time,file,image,hidden)componentwithname+propsLive evaluation. When a node with
livechanges, POST the form's values plus_stencil(the token),_stencil_changed(the field's name) and, optionally,_stencil_oldtolive.url, with the CSRF token. The response is{ schema, state, changed }, wherechangedlists the names that$setchanged. With_stencil_driver=blade, it's{ html, state, changed }instead.n4ff4h commentedon Nov 4, 2026
Status (session paused at its usage limit):
main, CI green: Foundations: PHP 8.4 / Laravel 12+, CI matrix, coverage and codestyle #1 is closed. The PHP core is in place: schema API (Fluent schema API #2), server-side closures, conditions and the live endpoint (Closure-evaluated, conditional props #3), drivers plusStencil::extend(Renderer driver architecture #4), the Blade driver (Blade driver (javaabu/forms v2) #5), and binding, validation and saving (Automatic model binding and validation #7). It has 61 tests. The API and JSON format are summarised in the two comments above.package.json, which installs@javaabu/forms#next-gen) and the React renderer underresources/js: types, registry/context, node rendering, the built-in components and the wizard. It isn't typechecked or tested yet, so I haven't pushed it.StencilForm/StencilFieldsand live evaluation in React; the Blade script (wizard + live swap) inresources/js/blade/stencil.ts; the JS CI job and a prebuiltdist; then Custom field types #8, Testing helpers #9, Translatable fields: ->translatable() hook for javaabu/translatable v3 #15 (translatable), Dhivehi localization and RTL #12, Documentation for people and agents #10 and Laravel Boost guideline and skill #11. I'll also add the wizard'sstepLabelprop on the PHP side.n4ff4h commentedon Nov 4, 2026
Final summary
javaabu/stencil v1.0 is on
main. CI is green on the full matrix:distfreshness checkThere are 97 PHPUnit tests and 31 Vitest tests. Every
next-genissue is closed except #13 (needs a human).What was built
visibleWhen()and friends are checked in the browser (forms' conditional-wrapper) and on the server.live()andafterStateUpdated()/$setgo throughRoute::stencil(), with an encrypted token tied to the user.validate(),fillModel(),save()andsaveRelationships()cover BelongsToMany sync and media.inertia) produces format v1, typed inresources/js/types.ts. Since the earlier contract comment, nodes gained an optionaltranslatablekey and the wizard gainedstepLabel.Stencil::extend()registers new drivers, andRendererTestKittests them.StencilForm,StencilFieldsand a component registry, on @javaabu/forms, delivered as@javaabu/stencilthrough javaabu/manifest. A Blade script for wizards and live swaps ships prebuilt indist/.make:stencil-field. Testing helpers:AssertableForm,InteractsWithStencil.llms.txt,AGENTS.md/CLAUDE.md, and the Laravel Boost guideline andjavaabu-stencilskill.Intentionally out of scope for v1
$setsends. This matches @javaabu/forms' value precedence.For the user
CODECOV_TOKENis available to this repo (uploads are configured withfail_ci_if_error: false).composer.json. Once @javaabu/forms is on npm, change its npm dependency inStencilServiceProviderandpackage.json.