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:
# ===================================
# 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
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
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:
@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">
← 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>
@endsectiondata-request="onStore"names the controller handler that runs when the form is submitted, anddata-request-flashshows the message the handler returns.renders the fields. Widgets areHtmlable, so Blade prints their markup as it is.- The hidden
_session_keyinput 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:
@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">
← 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>
@endsectionThe 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;
disabledandhiddenfields are removed, including fields the model hides infilterFields;- 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:
/**
* @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:
/**
* 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@createandslug@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:
| Option | Description |
|---|---|
| model | the model the form binds to. Field values are read from it, and relation fields and options methods use it. |
| fields | the 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. |
| tabs | the primary tabs and their fields, when the fields file does not hold them. |
| secondaryTabs | the secondary tabs and their fields. |
| context | the form context, such as create or update. Fields that belong to another context are left out. |
| data | an object or array to read the field values from instead of the model. |
| sessionKey | the 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. |
| arrayName | wraps the input names in an array, such as Post[title], and reads the posted values from it. |
| isNested | marks a form rendered inside a field of another form. Default: false. |
| previewMode | renders the values as read-only text, with no inputs. Default: false. |
| horizontalMode | puts each label beside its field instead of above it. Default: false. |
| surveyMode | renders the form as one column of questions, without tabs. Default: false. |
| useFilterFields | calls the model's filterFields method when the form renders, refreshes and saves. Default: true. |
| alias | the 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:
/**
* 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:
| Method | Called |
|---|---|
| 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
- Building a List Page: the posts list that links to this form, and the quick post popup
- Models: Rain and Eloquent models side by side
- Field Types: every field a form can hold