Forms
Flashboard form payloads are generated from form() definitions on a resource. The preferred authoring path is schema-first: start with $form->schema([...]) for simple create and edit screens, then introduce grouped layout only when operators genuinely need it.
Simple forms first
<?php
use Pepperfm\Flashboard\Contracts\Forms\FormContract;
use Pepperfm\Flashboard\Core\Forms\Fields\Select;
use Pepperfm\Flashboard\Core\Forms\Fields\Textarea;
public static function form(FormContract $form): FormContract
{
return $form
->schema([
Select::make('status', 'Status')->required(),
Textarea::make('notes', 'Notes'),
])
->rules([
'status' => ['required', 'string'],
]);
}
This path renders as a single centered UPageCard shell in the package UI without an artificial wrapper section.
Field layout
Schema-first forms can place multiple fields on one row without introducing nested layout nodes.
<?php
use Pepperfm\Flashboard\Core\Forms\Fields\TextInput;
use Pepperfm\Flashboard\Contracts\Forms\FormContract;
public static function form(FormContract $form): FormContract
{
return $form
->columns(2)
->gap(4)
->schema([
TextInput::make('first_name', 'First name'),
TextInput::make('last_name', 'Last name'),
TextInput::make('email', 'Email')
->email()
->columnSpan(2),
]);
}
Available layout helpers:
columns(int|array)for grid containersgap(int|array)for grid or flex spacingcolumnSpan(int|array)for grid itemsfullWidth()as a shorthand for spanning the full grid width
Scalar columns(2) and columns(3) remain mobile-safe by default: Flashboard normalizes them to one column on small screens and activates multiple columns from md upward.
Grouped layouts
Use Section, Tabs, and Tab nodes inside schema() when the form has meaningful visual grouping.
<?php
use Pepperfm\Flashboard\Contracts\Forms\FormContract;
use Pepperfm\Flashboard\Core\Forms\Fields\Checkbox;
use Pepperfm\Flashboard\Core\Forms\Fields\NumberInput;
use Pepperfm\Flashboard\Core\Forms\Fields\Select;
use Pepperfm\Flashboard\Core\Forms\Fields\Textarea;
use Pepperfm\Flashboard\Core\Forms\Fields\TextInput;
use Pepperfm\Flashboard\Core\Forms\Fields\Toggle;
use Pepperfm\Flashboard\Core\Forms\Layout\Section;
use Pepperfm\Flashboard\Core\Forms\Layout\Tab;
use Pepperfm\Flashboard\Core\Forms\Layout\Tabs;
public static function form(FormContract $form): FormContract
{
return $form
->schema([
Section::make('content', 'Content')
->schema([
TextInput::make('name', 'Name')->required(),
TextInput::make('slug', 'Slug'),
Textarea::make('description', 'Description')->fullWidth(),
NumberInput::make('sort_order', 'Sort order'),
]),
Tabs::make('settings')
->tabs([
Tab::make('general', 'General')
->schema([
Select::make('status', 'Status'),
Checkbox::make('is_featured', 'Featured'),
Toggle::make('is_active', 'Is active'),
]),
]),
]);
}
Sections and tabs support the same layout API as the root form schema.
Every keyed typed schema node accepts a human label as the optional second make() argument, for example TextInput::make('name', 'Name'). Use ->label() when you want to override or compute the label later in the chain.
Advanced fields
Use purpose-built fields when the control has runtime behavior beyond plain text:
<?php
use Pepperfm\Flashboard\Core\Forms\Fields\DateInput;
use Pepperfm\Flashboard\Core\Forms\Fields\FileUpload;
use Pepperfm\Flashboard\Core\Forms\Fields\PasswordInput;
use Pepperfm\Flashboard\Core\Forms\Fields\RichText;
DateInput::make('published_on', 'Published on')
->minDate('2026-01-01')
->maxDate('2026-12-31');
FileUpload::make('cover_image', 'Cover image')
->accept('image/*')
->mimes(['jpg', 'png', 'webp'])
->maxSize(2048)
->disk('public')
->directory('covers');
RichText::make('body', 'Body')
->html()
->minLength(20)
->fullWidth();
PasswordInput::make('password', 'Password')
->minLength(12)
->confirmed();
PasswordInput::make('password_confirmation', 'Confirm password');
Behavior notes:
DateInputstores and validates date values asY-m-d; edit payloads normalize date-like values to that shape.FileUploadrenders through the package wrapper over Nuxt UIUFileUpload. WhenstoreFiles()is enabled, ordisk()/directory()is set, uploaded files are stored and the model receives the stored path or list of paths. Without package storage, uploaded file objects are available to mutation hooks and are then intentionally omitted from mass assignment. Edit forms can keep, replace, or clear existing file references; clear requests use a package-owned__removecompanion field.- edit payloads never include file contents; they expose only
existing_filesmetadata with safename,path, and optionalurlvalues. RichTextsupportshtml(),markdown(), andjson()content formats. JSON rich text is validated as an array; HTML and Markdown are validated as strings.PasswordInputis rendered as a password input, is never hydrated from the record on edit, and empty edit submissions are skipped so an unchanged password is not blanked. When usingconfirmed(), add a matching*_confirmationfield; Flashboard strips confirmation values before persistence.
Relation fields
Use BelongsTo when a form should store one local foreign key and let the operator pick one related record.
<?php
use App\Flashboard\CategoryResource;
use App\Flashboard\TagResource;
use Illuminate\Database\Eloquent\Builder;
use Pepperfm\Flashboard\Core\Forms\Fields\BelongsTo;
use Pepperfm\Flashboard\Core\Forms\Fields\BelongsToMany;
BelongsTo::make('category_id', 'Category')
->resource(CategoryResource::class)
->titleAttribute('name')
->searchable(['name', 'slug'])
->modifyQueryUsing(static fn (Builder $query): Builder => $query->with('parent'))
->required();
BelongsToMany::make('tags', 'Tags')
->resource(TagResource::class)
->titleAttribute('name')
->searchable(['name', 'slug'])
->modifyQueryUsing(static fn (Builder $query): Builder => $query->with('group'))
->maxItems(8);
BelongsTo::make(string $key, ?string $label = null, ?string $relationship = null) follows the same label convention as other typed fields. $key may be either the stored foreign key, such as category_id, or the relationship name, such as category. When the third argument is omitted, Flashboard infers the Eloquent relationship from FK-like keys: category_id, category_uuid, and category_ulid all resolve to category; relationship-name keys resolve as-is. Use relationship('...') when the relationship method differs from the field key.
Resolution rules:
- the field stores and submits the scalar key named by
$key, for examplecategory_idorcategory - Eloquent
BelongsTometadata resolves the related model, local foreign key, owner key, related table, and record key - when
$keyis a relationship name, save normalization maps the submitted value to the resolved foreign key before modelforceFill() resource(CategoryResource::class)overrides related resource resolution- when no explicit resource is set, Flashboard can infer one from the registered resource whose
model()matches the related model; ambiguous matches fail fast and should be fixed withresource() model(RelatedModel::class)enables an explicit model fallback for option loading when no related resource existsmodifyQueryUsing(fn (Builder $query): Builder => ...)applies a server-only field-level query modifier after the related resource query and query extensions; the callback must return an EloquentBuilder- empty submitted values are normalized to
null; v1 does not callassociate()implicitly
At runtime BelongsTo renders as a lazy relation_select field. The form payload includes relation metadata plus options_url, options_per_page, selected_option on edit, and related_routes only when the related resource is accessible and has a detail surface. The options endpoint is protected, searchable, paginated, and uses the related resource query plus query extensions when a related resource is available. Field-level query modifiers are not serialized into payloads; they run only on the server for option pages and selected-option hydration.
Validation still starts from the normalized field payload. Required relation fields infer required; optional ones infer nullable; both receive an exists:<related_table>,<owner_key> rule when relation metadata resolves safely. Explicit form builder rules merge on top.
Use BelongsToMany when the current record owns a pivot membership and the form should choose several related records. BelongsToMany::make(string $key, ?string $label = null, ?string $relationship = null) infers the relationship from $key, accepts the same explicit resource(), model(), titleAttribute(), searchable(), optionsPerPage(), and modifyQueryUsing() helpers, and adds maxItems() for a backend-enforced selection cap.
At runtime BelongsToMany renders as relation_multi_select. Form state is an array of related record keys, edit payloads include selected_options, and options use the same protected _relation-options/{field} route with repeated selected[] hydration. The persister removes the array from scalar mass assignment, saves the parent model inside a transaction, re-resolves submitted keys through the authorized related query, and then calls Eloquent sync($ids). Omitted fields are not synced; an explicit empty array calls sync([]). Pivot attributes, ordering, creation, update, and deletion of related records are out of scope for this field.
BelongsTo and BelongsToMany are form fields, not inverse relation managers. Use Resource::relations() with HasOne or HasMany when the parent resource should manage records on the inverse side. Those managers render outside the normal form schema, use protected nested routes, and overwrite any nested-create FK from server-resolved parent context before persistence. To show an inverse manager below an edit form, keep it in relations() and call showOnEdit(), for example HasMany::make('items', 'Items')->showOnEdit().
Relation option loading stays silent during normal use. HTTP-boundary failures may log sanitized WARN/ERROR context such as resource class, field key, failure category, and exception class; search terms, selected values, labels, model attributes, and full payloads should not be logged.
Renderer contract
Normalized form payloads expose an explicit renderer hint for every field.
- typed fields set a stable renderer automatically, such as
TextInput->input,DateInput->date,FileUpload->file_upload,RichText->rich_text,BelongsTo->relation_select,BelongsToMany->relation_multi_select, andToggle->switch - purpose-built field classes include
TextInput,Textarea,NumberInput,DateInput,FileUpload,RichText,PasswordInput,BelongsTo,BelongsToMany,Select,Checkbox, andToggle - override renderer intent explicitly only for custom or transitional controls where no purpose-built field exists
- legacy arrays can opt into the same contract with
['key' => 'notes', 'renderer' => 'textarea'] - the frontend maps these hints through package-owned wrappers such as
FBInput,FBTextarea,FBDateInput,FBFileUpload,FBRichText,FBRelationSelect,FBRelationMultiSelect,FBSelect,FBCheckbox, andFBSwitch
Toggle renders as a switch-style boolean control. PHP cannot expose a Switch field class because switch is a reserved keyword.
On create screens, visible Checkbox and Toggle fields default to false unless defaults() provides a value.
Layout contract
Normalized form payloads also expose package-owned layout metadata:
- container layout lives on
form.layout,section.layout, andtab.layout - field sizing lives on
field.layout.column_span - grid and flex settings are validated during normalization and fail fast on invalid combinations
- legacy arrays can opt into the same behavior with
layout,columns,gap,direction,justify,align,wrap, andcolumn_span
Invalid combinations such as columns() plus direction() on the same container raise an exception instead of being silently ignored.
Runtime flow
- Flashboard resolves the create or edit route.
ResourceFormDataSourcehydrates field state.ResourceFormPersistervalidates and saves data.afterSave()hooks and runtime hooks run; runtime hook payloads and record context redact password values and replace file values with minimal metadata.- The user is redirected to the detail screen.
Validation and hooks
- create rules:
creationRules() - update rules:
updateRules($record) - shared rules:
formRules() - inferred rules cover strings, numeric values,
date_format:Y-m-d, file constraints, JSON rich text arrays,BelongsToexistence checks,BelongsToManyarrays with per-item existence checks, and booleans - builder-level mutation:
mutateDataUsing() - resource-level mutation:
mutateFormDataBeforeSave() - post-persist hook:
afterSave()