Skip to content

Next-gen: javaabu/stencil v1.0 #14

Open

Description

@n4ff4h

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.

Package: javaabu/stencil · Target: v1.0 · Wave: 2
Depends on: javaabu/forms, @javaabu/forms, javaabu/manifest

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.

Checklist

Developed by an agent orchestrated from the Javaabu next-gen plan. Cross-package requests go in the dependency's repo with the cross-package label.

Activity

  1. added
    next-genNext-gen Javaabu packages
    epicTracks a whole package
    on Nov 4, 2026
  2. n4ff4h commented on Nov 4, 2026

    @n4ff4h
    CollaboratorAuthor

    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.

    Form (root: model/record, operation, action, method, state)
     └─ Components (layout: Section, Grid, Fieldset, Tabs/Tab, Wizard/Step, Group)
         └─ Fields (TextInput, Textarea, Wysiwyg, Select, Checkbox, CheckboxList, Radio,
                    DatePicker, DateTimePicker, TimePicker, FileUpload, ImageUpload, MapInput, Hidden,
                    display: TextEntry, Alert, View)
    
    • 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:
      1. 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.
      2. 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.

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

    Order of work

    1. Foundations: PHP 8.4 / Laravel 12+, CI matrix, coverage and codestyle #1 Foundations (composer, CI matrix, Pint, JS tooling for resources/js)
    2. Fluent schema API #2 schema API, Closure-evaluated, conditional props #3 closures (server side), Renderer driver architecture #4 driver architecture + JSON format → summary comments here so downstream packages can start
    3. Automatic model binding and validation #7 binding, validation and dehydration
    4. Blade driver (javaabu/forms v2) #5 Blade driver, Inertia React driver (@javaabu/forms) #6 React renderer
    5. Closure-evaluated, conditional props #3 live re-evaluation endpoint (Blade JS + React)
    6. Custom field types #8 custom fields, Testing helpers #9 testing helpers, Dhivehi localization and RTL #12 Dhivehi/RTL, Documentation for people and agents #10 docs, Laravel Boost guideline and skill #11 Boost

    Open questions / decisions I'm taking

    • 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.
  3. n4ff4h commented on Nov 4, 2026

    @n4ff4h
    CollaboratorAuthor

    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.

    use Javaabu\Stencil\Form;
    use Javaabu\Stencil\Get;
    use Javaabu\Stencil\Set;
    use Javaabu\Stencil\Components\{Section, Grid, Group, Fieldset, Tabs, Tab, Wizard, Step, Alert, TextEntry};
    use Javaabu\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 trip
                DateTimePicker::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
    {
        protected function definition(): 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)
    Custom Blade view for a type Stencil::view('rating', 'stencil.rating')
  4. n4ff4h commented on Nov 4, 2026

    @n4ff4h
    CollaboratorAuthor

    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.

  5. n4ff4h commented on Nov 4, 2026

    @n4ff4h
    CollaboratorAuthor

    Status (session paused at its usage limit):

  6. n4ff4h commented on Nov 4, 2026

    @n4ff4h
    CollaboratorAuthor

    Final summary

    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.
    • Translatable fields (Translatable fields: ->translatable() hook for javaabu/translatable v3 #15). Optional javaabu/translatable support with per-locale inputs, in both drivers.
    • 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).
    • Docs registration. Register with docs.javaabu.com (Register with Javaabu Docs (docs.javaabu.com) #13).
    • 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.
    • pnpm. pnpm is pinned to 10.28 because of the Git-hosted @javaabu/forms build; CI also moves the pnpm store (Git install fails to build when the pnpm store is inside node_modules (TS2883) react-forms#20). Both can go once @javaabu/forms is on npm.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    epicTracks a whole packagenext-genNext-gen Javaabu packages

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions