Skip to content

Building a Form Page ​

This guide builds the post editor of the demo app's Showcase, the pages at /posts/create and /posts/{id}/edit. It covers rendering a form from a controller, saving and validating what comes back, serving the create and edit pages from one set of field definitions, and extending a form. Every snippet is the demo's own code, so you can run the demo app and watch it work.

Describe the fields ​

A form starts with a YAML file of field definitions. The post form puts the title and slug above the tabs, the details and publishing fields in primary tabs, and the content and the editor notes in secondary tabs:

yaml
# ===================================
#  Post Form Field Definitions
# ===================================

fields:

    title:
        label: Title
        span: left
        required: true

    slug@create:
        label: Slug
        span: right
        required: true
        comment: Filled in from the title while you type.
        preset:
            field: title
            type: slug

    slug@update:
        label: Slug
        span: right
        readOnly: true
        comment: Fixed once the post exists, so links to it keep working.

tabs:
    fields:

        category:
            label: Category
            tab: Details
            type: relation
            span: left
            required: true
            emptyOption: Choose a category

        author:
            label: Author
            tab: Details
            type: relation
            span: right
            emptyOption: No author
            defaultSort:
                column: name
                direction: asc

        _permalink:
            label: Permalink
            tab: Details
            readOnly: true
            dependsOn: [category, slug]
            comment: Built from the category path and the slug, and refreshed when either changes.

        excerpt:
            label: Excerpt
            tab: Details
            type: textarea
            size: small

        tags:
            label: Tags
            tab: Details
            type: taglist
            mode: relation
            nameFrom: name
            customTags: true
            placeholder: Add tags
            comment: Pick existing tags, or type a new one and press Enter to create it.

        featured_image:
            label: Featured Image
            tab: Details
            type: fileupload
            mode: image
            imageWidth: 260
            imageHeight: 160
            fileTypes: jpg,jpeg,png,gif,webp

        status:
            label: Status
            tab: Publishing
            type: dropdown
            span: left

        published:
            label: Published
            tab: Publishing
            type: switch
            comment: Turn on to choose when the post goes live.

        published_at:
            label: Publish Date
            tab: Publishing
            type: datepicker
            mode: datetime
            span: left
            comment: Shown only while the post is published.
            trigger:
                action: show
                field: published
                condition: checked

secondaryTabs:
    fields:

        content:
            tab: Content
            type: richeditor
            size: huge

        notes:
            tab: Editor Notes
            type: markdown
            size: large
            commentAbove: Private notes for editors, written in Markdown.

Each key is a model attribute or relation name, and its definition picks the field type and how it looks. fields holds the fields outside any tabs, tabs the primary tabs and secondaryTabs a second row of tabs below them. The field types, layout and behaviour pages cover every option, and each form widget used here, such as the relation, tag list and file upload, has a page of its own.

Rendering a form ​

The controller keeps to its page actions. create and edit build the form for their record and pass it to the view:

php
<?php

namespace App\Controllers;

use App\Classes\ControllerBase;
use App\Controllers\PostsController\PostForm;
use App\Controllers\PostsController\PostList;
use App\Controllers\PostsController\QuickPostForm;
use App\Models\Post;
use Illuminate\Contracts\View\View;

/**
 * PostsController holds the post pages, with the list, filter, toolbar and form widgets and their AJAX handlers in the PostList, PostForm and QuickPostForm traits.
 */
class PostsController extends ControllerBase
{
    use PostList;
    use PostForm;
    use QuickPostForm;

    /**
     * index displays a filtered, searchable, configurable list of posts with bulk actions and a quick post popup.
     */
    public function index(): View
    {
        return view('posts.index', $this->prepareListVars());
    }

    /**
     * create shows the form for a new post.
     */
    public function create(): View
    {
        $this->formWidget = $this->makeFormWidget(new Post, 'create');

        return view('posts.create', [
            'formWidget' => $this->formWidget,
        ]);
    }

    /**
     * edit shows the form for an existing post.
     */
    public function edit(Post $post): View
    {
        $this->formWidget = $this->makeFormWidget($post, 'update');

        return view('posts.edit', [
            'post' => $post,
            'formWidget' => $this->formWidget,
        ]);
    }
}

The form and the handlers that save it live in the PostForm trait, in a folder named after the controller (see Organising a controller):

php
<?php

namespace App\Controllers\PostsController;

use App\Models\Post;
use Larajax\Classes\AjaxResponse;
use Larajax\Ui\Widgets\Form;

/**
 * PostForm builds the post form and handles saving and deleting a post.
 */
trait PostForm
{
    /**
     * @var Form|null formWidget is kept on the instance so the automatic handler routing reaches its AJAX handlers.
     */
    public ?Form $formWidget = null;

    /**
     * onStore persists a new post and opens it for editing, as the AJAX handler for the create form.
     */
    public function onStore(): AjaxResponse
    {
        $this->formWidget = $this->makeFormWidget(new Post, 'create');

        $post = new Post;

        $this->saveForm($post);

        return ajax()
            ->flash('success', 'Post created.')
            ->redirect(route('posts.edit', $post));
    }

    /**
     * onUpdate persists changes to a post, as the AJAX handler for the edit form.
     */
    public function onUpdate(Post $post): AjaxResponse
    {
        $this->formWidget = $this->makeFormWidget($post, 'update');

        $this->saveForm($post);

        return ajax()->flash('success', 'Post updated.');
    }

    /**
     * onDestroy deletes a post with its comments, tags and image, as the AJAX handler for the edit form.
     */
    public function onDestroy(Post $post): AjaxResponse
    {
        $post->delete();

        return ajax()
            ->flash('success', 'Post deleted.')
            ->redirect(route('posts.index'));
    }

    /**
     * makeFormWidget builds the post form for a given context.
     */
    protected function makeFormWidget(Post $post, string $context): Form
    {
        return Form::make(
            context: $context,
            model: $post,
            fields: '~/resources/ui/post/fields.yaml',
        );
    }

    /**
     * saveForm fills the post from the form data and saves it, where the model rules validate it and the session key attaches the uploaded image.
     */
    protected function saveForm(Post $post): void
    {
        $data = $this->formWidget->getSaveData();

        // The relation names fill through the model, so the category and author associate and the tags sync on save
        $post->fill($data)->save(null, $this->formWidget->getSessionKey());
    }
}

makeFormWidget() calls Form::make() with three named arguments: model is the record the fields read their values from, fields is the YAML file (a path starting with ~/ resolves from the application root, and an array of definitions works too), and context picks the fields for the page, see Contexts. The full list of options is under Widget configuration.

The trait keeps the form in the public $formWidget property. A widget built inside a controller action is bound to that controller, which is what routes the widget's own AJAX handlers, such as the file upload and the refresh of a field that dependsOn another, back to it, see Widgets and Controllers.

The view wraps the widget in a <form> element:

blade
@extends('layouts.app')
@section('title', 'New Post')
@section('content')

<form data-request="onStore" data-request-flash>
    {{-- The save posts the same key the featured image upload was held against, which attaches it --}}
    <input type="hidden" name="_session_key" value="{{ $formWidget->getSessionKey() }}" />

    <div class="d-flex align-items-center justify-content-between mb-7">
        <h1 class="mb-0">
            New Post
        </h1>
        <a href="{{ route('posts.index') }}" class="btn-link text-decoration-none">
            &larr; Back to posts
        </a>
    </div>

    {{ $formWidget }}

    <div class="mt-7">
        {{ Ui::ajaxButton(
            dataRequestFlash: true,
            handler: 'onStore',
            label: 'Create Post',
            primary: true,
            hotkey: ['ctrl+s', 'cmd+s']
        ) }}
    </div>
</form>

@endsection
  • data-request="onStore" names the controller handler that runs when the form is submitted, and data-request-flash shows the message the handler returns.
  • renders the fields. Widgets are Htmlable, so Blade prints their markup as it is.
  • The hidden _session_key input posts the form's session key back with the save, which attaches the uploaded image, see Deferred uploads.
  • Ui::ajaxButton() renders the submit button, with Ctrl+S (or Cmd+S) as its hotkey.

The edit view is the same form with onUpdate as its handler, plus a Delete button that calls onDestroy after a confirmation:

blade
@extends('layouts.app')
@section('title', 'Edit Post')
@section('content')

<form data-request="onUpdate" data-request-flash>
    {{-- The save posts the same key the featured image upload was held against, which attaches it --}}
    <input type="hidden" name="_session_key" value="{{ $formWidget->getSessionKey() }}" />

    <div class="d-flex align-items-center justify-content-between mb-7">
        <h1 class="mb-0">
            Edit Post
        </h1>
        <a href="{{ route('posts.index') }}" class="btn-link text-decoration-none">
            &larr; Back to posts
        </a>
    </div>

    {{ $formWidget }}

    <div class="mt-7 d-flex gap-3">
        {{ Ui::ajaxButton(
            dataRequestFlash: true,
            handler: 'onUpdate',
            label: 'Save',
            primary: true,
            hotkey: ['ctrl+s', 'cmd+s']
        ) }}
        {{ Ui::ajaxButton(
            dataRequestFlash: true,
            handler: 'onDestroy',
            label: 'Delete',
            danger: true,
            dataRequestConfirm: 'Delete this post with its comments?'
        ) }}
    </div>
</form>

@endsection

The page and its handlers share one address, so each route accepts both GET and POST, as shown in Routes.

Saving submitted data ​

When the form is submitted, Larajax runs the page action again and then the handler named in data-request: onStore on the create page, onUpdate on the edit page. Each handler builds the form the way the page did and calls saveForm().

getSaveData() returns the posted values, prepared by the field definitions:

  • only fields of the current form and context are included, and a field missing from the post is left out, so a partial post never empties the others;
  • disabled and hidden fields are removed, including fields the model hides in filterFields;
  • form widgets turn their input into the value to save, such as a tag list into an array, and a number field returns a number or null.

fill($data) then sets the values on the post. The post is a Rain model whose fillable attributes include its relation names, so category and author associate their records and tags syncs its list as part of the save. A plain Eloquent model maps those values itself, as the Authors form below and the models guide show.

Deferred uploads and the session key ​

The featured image is a file upload field. On a new post there is no record to attach the image to yet, so the upload is held as a deferred binding against the form's session key. The view posts that key back in _session_key, getSessionKey() reads it, and the second argument of a Rain model's save(null, $sessionKey) commits the bindings held against it, which attaches the image. Without the key, the image stays pending and the post saves without it.

To share one session key between several forms on a page, pass it to each with the sessionKey option.

Saving related records ​

fill() and save() are enough while the form's values are attributes and relation names. When a form edits a related record in place, such as a nested form bound to a singular relation, or pivot data, save it with performSaveOnModel($model, $data, $sessionKey) from the Larajax\Ui\Traits\FormModelSaver trait. It fills the model, and every singular relation that the data holds an array for, then saves them all in one database transaction. A validation error from a related record is prefixed with the relation's name, so it belongs to the right field.

Fields whose names start with an underscore, like the post form's read-only _permalink, hold values for the form only. performSaveOnModel() sets them on the model for the save and drops them before the record is written, so they never reach the database. getSaveData() and a plain fill() keep them: the post saves with fill(), and _permalink stays out of the database only because it is not one of the post's fillable attributes. When you save with fill(), leave such fields out of the fillable list or remove them from the data first.

The method is protected: add use \Larajax\Ui\Traits\FormModelSaver; to your controller, or to its base class, and call $this->performSaveOnModel(...). The demo app's Reference pages save their examples this way, in onSaveExamples() of ReferenceControllerBase.

Validation ​

Fields do not validate anything themselves: the required property only marks the label. Check the values on the model or in the controller, and throw a ValidationException when they fail.

On a Rain model ​

The post is an October Rain model with the Validation trait, so its rules sit on the model and run on every save:

php
/**
 * @var array<string, mixed> rules for validation, where the category rule reads the relation value.
 */
public $rules = [
    'title' => 'required',
    'slug' => ['required', 'regex:/^[a-z0-9-]+$/', 'unique:posts'],
    'category' => 'required',
    'status' => 'in:draft,review,approved,archived',
    'views' => 'nullable|integer|min:0',
];

A failed rule throws a ValidationException from save(), which stops the save and comes back to the browser as the error message. A field whose rule includes required also gets the required marker without setting required in the YAML. The validation example shows it live.

In the controller ​

The authors are a stock Eloquent User model, which has no rules of its own, so the Authors form validates in its saveForm() with Laravel's validator before it saves:

php
/**
 * saveForm validates the form data here, since the Eloquent model has no rules of its own, and saves it onto the given author.
 */
protected function saveForm(User $user): void
{
    $data = $this->formWidget->getSaveData();

    $validator = Validator::make($data, [
        'name' => 'required',
        'email' => ['required', 'email', Rule::unique('users')->ignore($user->id)],
        'password' => [$user->exists ? 'nullable' : 'required', 'confirmed'],
    ]);

    if ($validator->fails()) {
        throw new ValidationException($validator);
    }

    // A blank password keeps the current one, and the model casts hash a new one
    if (empty($data['password'])) {
        unset($data['password']);
    }

    // The belongs-to field posts under its relation name, so it maps onto the foreign key
    if (array_key_exists('primary_group', $data)) {
        $data['primary_group_id'] = $data['primary_group'] ?: null;
    }

    // The many-to-many ids sync once the record exists, and an empty selection clears them
    $hasOtherGroups = array_key_exists('groups', $data);
    $otherGroups = ($data['groups'] ?? null) ?: [];
    unset($data['groups'], $data['primary_group'], $data['password_confirmation']);

    $user->fill($data)->save();

    if ($hasOtherGroups) {
        $user->groups()->sync($otherGroups);
    }
}

The rules can depend on the record, here a unique email that ignores the author being edited and a password only required for a new author. Passing the failed validator to ValidationException returns its messages by field name. For a single check, throw it with an array of messages instead, as the group form does with new ValidationException(['name' => 'The name field is required.']).

Contexts ​

A form has a context, usually create or update, and a field can belong to one context only. Pass the context to Form::make(), as makeFormWidget() does above, then limit a field in either of two ways:

  • add context: create (or a list of contexts) to its definition;
  • end its name with the context, as the post form does with slug@create and slug@update: the slug is filled in from the title on a new post and read-only once the post exists.

Both forms of the name save to the same slug attribute. The Authors form uses the same pattern for its password field, which reads "Password" when creating an author and "Reset Password" when editing one. See contexts for the live example.

Widget configuration ​

Form::make() takes these options as named arguments:

OptionDescription
modelthe model the form binds to. Field values are read from it, and relation fields and options methods use it.
fieldsthe fields outside any tabs, as a YAML file path or an array of definitions. A YAML file may also hold the tabs and secondaryTabs keys.
tabsthe primary tabs and their fields, when the fields file does not hold them.
secondaryTabsthe secondary tabs and their fields.
contextthe form context, such as create or update. Fields that belong to another context are left out.
dataan object or array to read the field values from instead of the model.
sessionKeythe session key for deferred bindings, such as uploads on a record that is not saved yet. Default: the posted _session_key, or a new key.
arrayNamewraps the input names in an array, such as Post[title], and reads the posted values from it.
isNestedmarks a form rendered inside a field of another form. Default: false.
previewModerenders the values as read-only text, with no inputs. Default: false.
horizontalModeputs each label beside its field instead of above it. Default: false.
surveyModerenders the form as one column of questions, without tabs. Default: false.
useFilterFieldscalls the model's filterFields method when the form renders, refreshes and saves. Default: true.
aliasthe name the form's AJAX handlers are addressed by, as alias::onHandler. Default: form. A second form on the same page needs its own, like the quick post form.

Options without a named argument pass through make() by name as well. To build a form from a configuration array put together at runtime, call Form::makeWithConfig($config).

Extending the form ​

The model can adjust its own form. A filterFields($fields, $context) method on the model runs every time the form renders, refreshes or saves, with each field reachable by name. The post fills its read-only permalink field from the category and the slug:

php
/**
 * filterFields fills the read-only permalink field, which the form refreshes when the category or slug changes.
 */
public function filterFields($fields, $context = null): void
{
    if (isset($fields->_permalink)) {
        $fields->_permalink->value = $this->getPermalink();
    }
}

The permalink field lists category and slug in dependsOn, so changing either refreshes it and filterFields runs again. Setting hidden, disabled, label, options or value on a field works the same way, see dependent fields.

For changes that belong to the form rather than the model, subclass Larajax\Ui\Widgets\Form, override its extension points, and build your class with make() as before:

MethodCalled
eventExtendFieldsBefore()before the field definitions are read, to add fields with addField(), addTabField() or addSecondaryTabField().
eventExtendFields($fields)once the fields are defined, to change or remove them. $fields holds every field by name.
eventBeforeRefresh($dataHolder)before a refresh. Changing $dataHolder->data changes the values the fields are filled from.
eventRefreshFields($fields)during a refresh, before the fields render again.
eventRefresh($result)after a refresh, with the partial updates for the browser. Return it with your own updates added.

From the controller you can also change a form after building it, with addFields(), removeField(), removeTab() and getField($name), before it renders.

A form built without fields, tabs or secondaryTabs asks the model for its fields instead. The model's defineFormFields($form) method adds the fields outside the tabs, and definePrimaryFormFields($form) and defineSecondaryFormFields($form) add those of each set of tabs. Each adds a field with $form->addFormField('name', 'Label'), which returns a definition to chain the field's options on, such as ->displayAs('textarea')->span('left').

Next steps ​