Forms
Getting Started
Let's start with the absolute minimum. In your controller, create a method that returns a Form instance. Use the #[Forms\Expose] attribute to expose the form to the frontend, and then pass the name of the method to the components() method of the Props builder inside the Inertia render call.
When you pass a model instance to the Props builder's record() method, the form will automatically load that record for editing. If no record is provided, the form will operate in "create" mode.
use Hewcode\Hewcode\Props;
use Hewcode\Hewcode\Forms;
class PostController extends Controller
{
public function edit(Post $post): Response
{
return Inertia::render('posts/edit', Props\Props::for($this)
->record($post)
->components(['form'])
);
}
#[Forms\Expose]
public function form(): Forms\Form
{
return Forms\Form::make()
->model(Post::class)
->visible() // Required
->schema([
Forms\Schema\TextInput::make('title')
->required(),
Forms\Schema\Textarea::make('content')
->required(),
]);
}
}DANGER
Remember to always make sure the visibility of actions is properly set using authorization checks to prevent unauthorized access. Assume that controller middleware and controller method checks are not sufficient.
On the frontend, just spread the props:
import Form from '@hewcode/react/components/form/Form';
import { router, usePage } from '@inertiajs/react';
export default function Edit() {
const { form: formData } = usePage().props;
return (
<Form
{...formData}
onCancel={() => router.visit('/posts')}
/>
);
}By default, since you passed a record and/or specified a model class, the form will update or create records automatically when submitted. If you are not operating on a model or wish to customize the submission logic, you an use the submitUsing() method to provide your own callback.
You can receive the submitted form data using a $data parameter, and the current record (if any) using a $record parameter.
use Hewcode\Hewcode\Forms;
->submitUsing(function (array $data, ?Post $record) {
if ($record) {
// Update existing record
$record->update($data);
} else {
// Create new record
Post::create($data);
}
})Form Definition
You can define a Form definition class that allows you to reuse the same form in multiple places.
Create a form definition using the command:
php artisan hew:form UserForm --model=User --generateYou can pass:
--model=Userto specify the model explicitly.--generateto auto-generate form fields based on your model's table structure.
This will create a class that extends Hewcode\Hewcode\Forms\FormDefinition:
use App\Models\User;
use Hewcode\Hewcode\Forms;
class UserForm extends Forms\FormDefinition
{
protected string $model = User::class;
public function default(Forms\Form $form): Forms\Form
{
return $form
->visible()
->schema([
Forms\Schema\TextInput::make('name')
->required()
->maxLength(255),
Forms\Schema\TextInput::make('email')
->email()
->required()
->maxLength(255),
]);
}
}You can then use this Form definition in your controller:
#[Forms\Expose]
public function form(): Forms\Form
{
return UserForm::make();
}You can pass an additional context parameter which will use a different method than default():
#[Forms\Expose]
public function admins(): Forms\Form
{
return UserForm::make('employee', context: 'employee');
}
// In UserForm.php
class UserForm extends Forms\FormDefinition
{
// ...
public function employee(Forms\Form $form): Forms\Form
{
return $form
->visible()
->schema([
Forms\Schema\TextInput::make('name')
->required()
->maxLength(255),
Forms\Schema\TextInput::make('email')
->email()
->required()
->maxLength(255),
Forms\Schema\Select::make('role')
->options([
'admin' => 'Admin',
'editor' => 'Editor',
'viewer' => 'Viewer',
])
->required(),
]);
}
}Essentials
Visibility
Control whether the form is visible using the visible() method. You can pass a boolean or a closure that returns a boolean. This is a required step to ensure the form is only shown when appropriate.
->visible(true)
->visible(fn () => auth()->user()?->can('manage-posts') ?? false)Fill form state
You can fill the form state with custom data using the fillUsing() method. This is automatically done when a record is provided, but you can override it to customize the data population logic.
->fillUsing([
'name' => 'Default Name',
])You can also provide a closure that receives the current record (if any) and returns an array of field values:
->fillUsing(function (?Post $record) {
if (!$record) {
return [
'status' => PostStatus::DRAFT->value,
'author_id' => auth()->id(),
];
}
return [
'title' => $record->title,
'content' => $record->content,
'status' => $record->status->value,
];
})Schema
Define which fields to display using the schema() method.
Labels are automatically generated from your locale files if no explicit label is provided. See Automatic Locale Labels for more details.
use Hewcode\Hewcode\Forms;
->schema([
Forms\Schema\TextInput::make('title') // default label: __('app.posts.columns.title')
->required(),
Forms\Schema\Textarea::make('content')
->label('Content'),
Forms\Schema\Select::make('status')
->options(PostStatus::class),
])Visibility
Control field visibility using the visible() method on individual fields. You can pass a boolean or a closure that receives the current record (if any) and returns a boolean.
Forms\Schema\DateTimePicker::make('published_at')
->label('Published At')
->visible(fn (?Post $record) => $record?->status === PostStatus::PUBLISHED)Validation
Add validation rules using Laravel's validation syntax:
Forms\Schema\TextInput::make('email')
->label('Email Address')
->required()
->email()
->maxLength(255)Common validation methods:
required()- Field must have a valueemail()- Must be valid email formatmaxLength(int)- Maximum character lengthminLength(int)- Minimum character lengthnumeric()- Must be numericunique(string $table, string $column = null)- Must be unique in database
State formatting
You can format field values for display using the formatStateUsing() method. This accepts a closure that receives the current state and returns the formatted value.
Forms\Schema\TextInput::make('price')
->label('Price')
->formatStateUsing(fn ($state) => $state ? number_format($state / 100, 2) : null)Dehydration
By default, all fields are "dehydrated", meaning their values are included in the final form data sent to the server. If you have a computed or display-only field that shouldn't be part of the submission, use the dehydrated(false) method.
Forms\Schema\TextInput::make('full_name')
->formatStateUsing(fn ($record) => $record->first_name . ' ' . $record->last_name)
->dehydrated(false),
Forms\Schema\TextInput::make('age')
->label('Age')
->dehydrated(fn () => false),Mutate dehydrated state
You can transform field values before passing to the final state array using the dehydrateStateUsing() method. This accepts a closure that receives the current state and returns the transformed value.
Forms\Schema\TextInput::make('price')
->label('Price')
->dehydrateStateUsing(fn ($state) => $state ? (int) ($state * 100) : null)Mutate saved state
This is only relevant when a form is automatically operating (creating/editing) a model (either via the model() method or by passing a record). You can customize how a field's value is saved to the model using the saveUsing() method. This accepts a closure that receives the current state and the model instance, allowing you to implement custom save logic.
Forms\Schema\Select::make('tags')
->label('Tags')
->options(Tag::pluck('name', 'id')->toArray())
->multiple()
->dehydrated(false) // Don't try to save as a regular field
->saveUsing(function ($state, $record) {
// Custom sync logic
$record->tags()->sync($state);
})Reactive Fields
Fields can be made reactive to respond to state changes and update other form fields dynamically. When a reactive field changes, it can trigger callbacks that modify other field values.
Basic Reactive Behavior
Make a field reactive using the reactive() method, then define state update logic with onStateUpdate():
Forms\Schema\Select::make('status')
->label('Status')
->options(PostStatus::class)
->reactive()
->onStateUpdate(function (Forms\Set $set, array $data) {
// Clear published_at when status changes away from published
if (($data['status'] ?? null) !== 'published') {
$set('published_at', null);
}
})
->required()The onStateUpdate() callback receives two parameters:
$set- Helper to modify other field values using$set('field_name', $value)$data- Current form state array with all field values
Field Types
TextInput
Single-line text input for short strings:
Forms\Schema\TextInput::make('title')
->label('Post Title')
->placeholder('Enter a title...')
->required()
->maxLength(255)Textarea
Multi-line text input for longer content:
Forms\Schema\Textarea::make('content')
->label('Content')
->rows(8)
->placeholder('Write your post content here...')
->required()Select
Dropdown selection from predefined options. You can pass an array of options, a Closure that returns an array, or an Enum class.
Forms\Schema\Select::make('status')
->label('Status')
->options(PostStatus::class) // Enum
->required(),
Forms\Schema\Select::make('category')
->options([
'tech' => 'Technology',
'business' => 'Business',
'lifestyle' => 'Lifestyle',
]),
Forms\Schema\Select::make('tags')
->options(fn () => Tag::pluck('name', 'id')->toArray())If you want to allow multiple selections, use the multiple() method:
Forms\Schema\Select::make('tags')
->label('Tags')
->options(fn () => Tag::pluck('name', 'id')->toArray())
->multiple()You can set a default selected value using the default() method:
Forms\Schema\Select::make('status')
->label('Status')
->options(PostStatus::class)
->default(PostStatus::DRAFT->value)Relationship Select
The select field can also work with Eloquent relationships. Allowing you to select one or more related models easily. It also supports preloading options and searching.
Forms\Schema\Select::make('category_id')
->label('Category')
->relationship('category', titleColumn: 'name') // Relationship name and title column
->searchable()
->preload() // Load first 25 options immediately
->required()You can customize the query used to fetch options:
Forms\Schema\Select::make('author_id')
->label('Author')
->relationship(
relationshipName: 'author',
titleColumn: 'name',
modifyQueryUsing: fn ($query) => $query->where('active', true)
)
->searchable()
->preload()DateTimePicker
Date and time selection:
Forms\Schema\DateTimePicker::make('published_at')
->label('Published At')Configure which components to show:
// Date only
Forms\Schema\DateTimePicker::make('birth_date')
->time(false)
// Time only
Forms\Schema\DateTimePicker::make('meeting_time')
->date(false)
// Both date and time (default)
Forms\Schema\DateTimePicker::make('published_at')Use the custom calendar picker instead of native browser input:
Forms\Schema\DateTimePicker::make('published_at')
->native(false) // Custom calendar/time picker with popoverFileUpload
Upload single or multiple files with drag-and-drop support, file previews, and validation:
Forms\Schema\FileUpload::make('thumbnail')
->label('Thumbnail')
->image()
->maxSize(2048) // 2MB max
->required()Single file upload:
Forms\Schema\FileUpload::make('document')
->label('Document')
->acceptedFileTypes(['pdf', 'doc', 'docx'])
->maxSize(5120) // 5MB
->disk('public')
->directory('documents')Multiple file uploads:
Forms\Schema\FileUpload::make('attachments')
->label('Attachments')
->multiple()
->maxFiles(5)
->acceptedFileTypes(['pdf', 'jpg', 'png', 'zip'])
->maxSize(10240) // 10MB per fileImage uploads with preview:
Forms\Schema\FileUpload::make('gallery')
->label('Gallery Images')
->image() // Restricts to image types and enables preview
->multiple()
->maxFiles(10)
->maxSize(5120)
->disk('public')
->directory('gallery')Available methods:
image()- Restrict to image types (jpg, jpeg, png, gif, svg, webp) and enable previewacceptedFileTypes(array)- Restrict allowed file types (e.g.,['pdf', 'docx'])maxSize(int)- Maximum file size in kilobytesmultiple()- Allow multiple file uploadsmaxFiles(int)- Maximum number of files when usingmultiple()disk(string)- Laravel filesystem disk to store files on (default:config('filesystems.default'))directory(string)- Directory path within the disk (default:'uploads')storeFileNames()- Preserve original file names instead of generating unique namesenablePreview(bool)- Show/hide file previews (default:true)
Footer Actions
Add custom action buttons to the form footer alongside the default submit button:
use Hewcode\Hewcode\Actions;
#[Forms\Expose]
public function form(): Forms\Form
{
return Forms\Form::make()
->model(Post::class)
->visible()
->schema([
Forms\Schema\TextInput::make('title')->required(),
Forms\Schema\Textarea::make('content')->required(),
])
->footerActions([
Actions\Action::make('save_draft')
->label('Save as Draft')
->color('secondary')
->action(function (array $data, ?Post $record) {
$data['status'] = PostStatus::DRAFT;
if ($record) {
$record->update($data);
} else {
Post::create($data);
}
}),
Actions\Action::make('publish')
->label('Publish Now')
->color('primary')
->action(function (array $data, ?Post $record) {
$data['status'] = PostStatus::PUBLISHED;
$data['published_at'] = now();
if ($record) {
$record->update($data);
} else {
Post::create($data);
}
}),
]);
}Submit action
You can customize the main submit action using ->submitAction() method:
use Hewcode\Hewcode\Actions;
->submitAction(fn (Actions\Action $action) => $action
->label('Save Post')
->color('primary')
)Context
You can pass additional context to the form using the context() method. This allows you to customize the form behavior based on different scenarios.
->context('administration') // Pass a simple string context
->context([
'project_id' => request()->route('project'),
]) // Pass an array of context dataComplete Real-World Example
Here's a comprehensive example showing most features working together:
use Hewcode\Hewcode\Props;
use Hewcode\Hewcode\Forms;
class PostController extends Controller
{
public function create(): Response
{
return Inertia::render('posts/edit', Props\Props::for($this)
->components(['form'])
);
}
public function edit(Post $post): Response
{
return Inertia::render('posts/edit', Props\Props::for($this)
->record($post)
->components(['form'])
);
}
#[Forms\Expose]
public function form(): Forms\Form
{
return Forms\Form::make()
->model(Post::class)
->visible(auth()->user()?->can('manage-posts') ?? false)
->schema([
Forms\Schema\TextInput::make('title')
->label('Title')
->placeholder('Enter post title...')
->required()
->maxLength(255),
Forms\Schema\Textarea::make('content')
->label('Content')
->placeholder('Write your post content...')
->rows(8)
->required(),
Forms\Schema\Select::make('status')
->label('Status')
->options(PostStatus::class)
->default(PostStatus::DRAFT->value)
->reactive()
->onStateUpdate(function (Forms\Set $set, array $data) {
// Clear published_at when status changes away from published
if (($data['status'] ?? null) !== 'published') {
$set('published_at', null);
}
})
->required(),
Forms\Schema\Select::make('category_id')
->label('Category')
->relationship('category')
->searchable()
->preload()
->required(),
Forms\Schema\DateTimePicker::make('published_at')
->label('Published At')
->visible(fn (?Post $record) =>
$record?->status === PostStatus::PUBLISHED
),
])
->submitUsing(function (array $data, ?Post $record) {
if ($record) {
$record->update($data);
} else {
$post = Post::create($data);
$post->author_id = auth()->id();
$post->save();
}
});
}
}Frontend component:
import Form from '@hewcode/react/components/form/Form';
import { Head, router, usePage } from '@inertiajs/react';
import AppLayout from '../../layouts/app-layout';
export default function Edit() {
const { form: formData, record } = usePage().props;
const isEditing = !!record;
return (
<AppLayout
header={
<h2 className="text-xl leading-tight font-semibold text-gray-800">
{isEditing ? 'Edit Post' : 'Create Post'}
</h2>
}
>
<Head title={isEditing ? 'Edit Post' : 'Create Post'} />
<div className="py-12">
<div className="mx-auto max-w-7xl sm:px-6 lg:px-8">
<div className="bg-white dark:bg-gray-800 shadow-sm sm:rounded-lg">
<div className="p-6">
<Form
{...formData}
onCancel={() => router.visit('/posts')}
onSuccess={() => {
router.visit('/posts');
}}
/>
</div>
</div>
</div>
</div>
</AppLayout>
);
}ActionsContainer
The ActionsContainer allows you to embed one or more actions directly within a form. This is useful for providing quick actions or secondary operations that relate to the form data.
Forms\Schema\ActionsContainer::make('quick_actions')
->label('Quick Actions')
->actions([
Actions\Action::make('save_draft')
->label('Save as Draft')
->color(Color::SECONDARY)
->action(function (array $state) {
// Access form data and perform action
Toast::make()
->title('Draft saved')
->success()
->send();
}),
Actions\Action::make('preview')
->label('Preview')
->url('/posts/preview')
->openInNewTab()
->icon('lucide-eye'),
]),Actions in forms have access to the current form data through the $state parameter in their action closure, allowing you to perform operations based on the form's current state without needing to submit the form.
Wizard
A form can be turned into a multi-step wizard using the wizard() method. Users navigate through steps using Previous/Next buttons, with a step indicator showing progress. The submit button only appears on the last step.
Basic Usage
Use wizard() instead of schema() to define your form as a wizard:
Forms\Form::make()
->model(Post::class)
->visible()
->wizard([
Forms\Schema\Wizard\Step::make('basic')
->label('Basic Info')
->description('Enter the basic information')
->schema([
Forms\Schema\TextInput::make('title')
->required(),
Forms\Schema\Textarea::make('content')
->required(),
]),
Forms\Schema\Wizard\Step::make('publishing')
->label('Publishing')
->schema([
Forms\Schema\Select::make('status')
->options(PostStatus::class)
->required(),
Forms\Schema\DateTimePicker::make('published_at'),
]),
Forms\Schema\Wizard\Step::make('category')
->label('Category')
->schema([
Forms\Schema\Select::make('category_id')
->relationship('category')
->required(),
]),
])Step Configuration
Each step supports:
label()- Display name shown in the step indicatordescription()- Optional text shown above the step's fieldsicon()- Optional icon identifierschema()- Array of fields for this stepvisible()- Control step visibility with a boolean or closure
Forms\Schema\Wizard\Step::make('advanced')
->label('Advanced Settings')
->description('Configure advanced options')
->icon('lucide-settings')
->visible(fn () => auth()->user()->isAdmin())
->schema([...])Step Validation
By default, users can skip between steps freely. To enforce validation before proceeding to the next step, use skippable(false) on the form:
Forms\Form::make()
->visible()
->skippable(false)
->wizard([...])When skippable(false) is set:
- Clicking Next triggers server-side validation of the current step's fields
- Validation errors are displayed on the relevant fields
- Navigation is blocked until all required fields pass validation
- Clicking a future step in the indicator also triggers validation
Footer Actions Visibility
By default, footer actions (including the submit button) only appear on the last step. To show them on all steps:
Forms\Form::make()
->visible()
->showFooterActionsInLastStep(false)
->wizard([...])Reactive Fields in Wizards
Reactive fields work within wizard steps. When a field changes, it can update other fields in the same or different steps:
Forms\Schema\Wizard\Step::make('publishing')
->label('Publishing')
->schema([
Forms\Schema\Select::make('status')
->options(PostStatus::class)
->reactive()
->onStateUpdate(function (Forms\Set $set, array $state) {
if (($state['status'] ?? null) !== 'published') {
$set('published_at', null);
}
})
->required(),
Forms\Schema\DateTimePicker::make('published_at')
->visible(fn (array $state) => ($state['status'] ?? null) === 'published'),
])