ramondev / formcraft
A declarative form library for Laravel: field definitions, validation, theming, multi-step wizards, and Artisan scaffolding.
Requires
- php: ^8.1
- illuminate/console: ^10.0|^11.0|^12.0|^13.0
- illuminate/http: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
- illuminate/validation: ^10.0|^11.0|^12.0|^13.0
- intervention/image: ^4.2
README
Formcraft is a declarative form library for Laravel. You declare a form's fields once as a PHP class, and Formcraft handles HTML rendering, server-side validation, theming (Bootstrap/Tailwind/your own), file uploads, model binding, repeated field groups, and multi-step wizards.
This guide covers everything needed to use the package day to day: install,
build forms, render them, validate and save data, upload files, theme them,
and wire up wizards — based on the actual package source (ramondev/formcraft).
Table of contents
- Installation
- Quick start
- Defining fields
- Field option reference
- Rendering a form
- Validating a submission
- Reading submitted values
- Saving to an Eloquent model
- File & image uploads
- Group fields (nested/repeated data)
- Form Sets ("add another" repeated forms)
- Multi-step wizards (
FormWizard) - Theming
- Customizing a form at runtime
- Artisan commands
- Full worked example
- Behavior notes & gotchas
1. Installation
Install Formcraft straight from Composer:
composer require ramondev/formcraft
FormServiceProvider registers automatically via Laravel package
auto-discovery — no manual config/app.php edit needed. After installing,
php artisan list should show make:form and make:theme.
To update to a newer version later:
composer update ramondev/formcraft
Requirements: PHP ^8.1, Laravel (illuminate/*) ^10–^13, and
intervention/image ^4.2 (used for resizing image uploads).
2. Quick start
Every form is a class extending Formcraft\FormProcessor that implements
setFields():
use Formcraft\FormProcessor; use Formcraft\FormFields; use Formcraft\LaravelFormTemplate; class ContactForm extends FormProcessor { public function setFields(): LaravelFormTemplate { return new LaravelFormTemplate(fn (FormFields $f) => [ 'name' => $f->CharField(label: 'Full name'), 'email' => $f->EmailField(), ]); } }
In a controller:
public function store(Request $request, ContactForm $form) { $form->validate($request->all()); if (! $form->is_validated()) { return back()->withErrors($form->errors()->all())->withInput(); } // do something with $form->values() }
In a Blade view, just echo the form — every field renders itself:
<form method="POST"> @csrf {!! $form !!} <button type="submit">Send</button> </form>
You can also scaffold a new form with Artisan (see §15):
php artisan make:form RegistrationForm --fields="name:char,email:email"
3. Defining fields
setFields() must return a LaravelFormTemplate, built from a closure that
receives a FormFields builder and returns an associative array of
field_name => $f->SomeField(...). The array key is the field's name
(used for the HTML name/id, the validation key, and the model attribute
on save).
public function setFields(): LaravelFormTemplate { return new LaravelFormTemplate(fn (FormFields $f) => [ 'name' => $f->CharField(label: 'Full name', required: true), 'email' => $f->EmailField(), 'password' => $f->PasswordField(minLength: 8), 'bio' => $f->TextareaField(required: false), 'age' => $f->IntegerField(min: 18, max: 120), 'birthday' => $f->DateField(), 'role' => $f->SelectField(options: [ ['value' => 'admin', 'text' => 'Administrator'], ['value' => 'user', 'text' => 'Regular user'], ]), 'terms' => $f->CheckBoxField(label: 'I agree to the terms', value: '1'), 'avatar' => $f->ImageField(required: false, uploadTo: 'uploads/avatars'), ]); }
Available field builders
| Method | Renders as | Notes |
|---|---|---|
InputField(type: ...) |
<input type="..."> |
Generic — set any HTML input type yourself |
CharField() |
<input type="text"> |
Plain text |
EmailField() |
<input type="email"> |
Adds an email validation rule automatically |
PasswordField() |
<input type="password"> |
Not cached in old() by default; can auto-hash (see below) |
IntegerField() |
<input type="number"> |
Adds a numeric validation rule automatically |
DateField() |
<input type="date"> |
Adds a date validation rule automatically |
CheckBoxField() |
<input type="checkbox"> |
Use checked/value to control state |
RadioField() |
<input type="radio"> |
Render several with the same field name and different value |
TextareaField() |
<textarea> |
|
SelectField() |
<select> |
Pass options (see below) |
FileField() |
<input type="file"> |
Handles uploads through FileManager |
ImageField() |
<input type="file"> |
FileField specialised for images — restricts accept, supports resizing/thumbnails |
GroupField() |
wraps child fields in a <fieldset> |
Nesting / repeating structures — see §10 |
4. Field option reference
Every builder above shares this common set of keyword arguments (pass only the ones you need — all have sensible defaults):
| Option | Type | Default | Purpose |
|---|---|---|---|
required |
bool | true |
Adds HTML required + a required validation rule |
requiredIf |
callable(array $submittedData): bool |
null |
Dynamically overrides required per-submission (both the rendered attribute and the validation rule) |
label |
string | field name, prettified | Visible label/legend text |
showLabel |
bool | true |
Set false to skip rendering a label entirely |
labelAttr |
array | [] |
Extra HTML attributes on the <label>/<legend> (e.g. ['class' => 'font-bold']) |
attr |
array | [] |
Extra HTML attributes on the input itself |
value |
string | "" |
A fixed value to render (overridden by cached/old input and initial values when applicable) |
default |
mixed or callable | null |
Fallback value used only when there's no submitted/initial value |
id |
string | field name | Override the HTML id |
placeholder |
string | null |
Placeholder text |
minLength / maxLength |
int | null |
Adds minlength/maxlength HTML attrs + min:/max: validation rules |
pattern |
string | null |
HTML pattern attribute |
autocomplete |
string | null |
HTML autocomplete attribute |
datalist |
array | [] |
List of suggestion strings; renders a <datalist> and wires it via list= |
readonly / disabled |
bool | null |
HTML flags |
cache |
bool | true (false for PasswordField) |
Whether to repopulate the field from Laravel's old() input after a failed submission |
translate |
bool | false |
Runs the label/placeholder through Laravel's __() helper |
widget |
callable(ObjectClass $fields): string |
null |
Fully custom renderer for this one field — bypasses the active theme entirely |
ignore |
bool | false |
Excludes the field from rendering, validation, values(), and save() |
saveAs |
string | null |
Model attribute (or dotted relation path, e.g. "profile.bio") to write this field's value to on save() |
validate |
callable(ObjectClass $submittedData): true|string |
null |
Custom validation callback; return true to pass or an error string to fail |
validateRule |
array | [] |
Extra Laravel validation rule strings/objects appended to the auto-generated rules |
validateMessageRule |
array | [] |
Custom Laravel validation messages, passed straight to Validator::make() |
validateMessageAttribute |
string | null |
Custom :attribute name used inside validation messages |
Type-specific options
DateField
format— appended as a second Laraveldaterule parameter.
PasswordField
hashValue(bool, defaulttrue) — when saving viasave(), the value is run throughHash::make()before being written to the model.
IntegerField
min,max— numeric bounds (validation + HTML attrs).step— HTMLstepattribute.
SelectField
options— array of either plain scalars (['a', 'b']) or associative arrays:['value' => ..., 'text' => ..., 'attr' => [...], 'selected' => bool].valuecan itself be a two-element array[storedValue, displayLabelWhenResolved]if you want the raw HTML value to differ from whatvalues()returns.multiple(bool) — renders a multi-select and expects/returns an array.
FileField
multiple(bool) — allow multiple files.accepts(array) — HTMLacceptlist (e.g.['.pdf', '.docx']).fileMimes(array) — adds a Laravelmimes:validation rule.min/max— min/max validation for the value.uploadTo(string, default'upload') — destination directory.imageSize(string, e.g."800x800") — resize dimensions (images only).thumbnail(string, e.g."150x150") — also generate a thumbnail (images only).oldFile(string) — a previously-stored filename to delete once the new upload succeeds.fileType(string, default'file') — adds a matching validation rule (ImageFieldsets this to'image'automatically).filename(string) — save the upload under this name instead of an auto-generated one (the original extension is appended automatically if you don't include it).thumbnailTo(string) — destination directory for the thumbnail; defaults touploadTowhen omitted, so the thumbnail can live in its own folder.thumbnailName(string) — save the thumbnail under this name instead of the default"thumb_" + filename.oldThumbnail(string) — a previously-stored thumbnail filename to delete (fromthumbnailTo) once the new upload succeeds — the thumbnail equivalent ofoldFile.thumbnailSaveAs(string) — model attribute the thumbnail's filename is written to onsave(), alongside the main file'ssaveAs(see below).
ImageField
- Same as
FileField, buttype/fileTypeare pre-set to image handling, andaccepts/fileMimesdefault to common image extensions (jpg, jpeg, png, gif, webp, bmp) unless you pass your ownaccepts.
GroupField
fields(array) — child fields, built the same way as top-levelsetFields().valueType(string, default"all") —"all"— a plain fieldset; children behave like independent top-level fields."array"— children are named as an array (name[0][city]) and their combined value comes back as an array under the group's own key — use this for repeating nested data."single"— all children share one input name/value (rare; used for composite single-value widgets).
applyRuleToFields(array) — extra validation rules applied to every child whenvalueTypeis"array"(as"key.*"rules).groundName— internal; lets nested groups compute dotted array names correctly.
5. Rendering a form
Whole form at once — a FormProcessor instance has a __toString(),
so you can just echo/print it (e.g. {!! $form !!} in Blade). Each field is
wrapped and rendered according to the active theme.
One field at a time — access any field as a property to get a compiled
object with ->label, ->input, and ->errors:
<div> {!! $form->email->label !!} {!! $form->email->input !!} @foreach ($form->email->errors->all() as $error) <p class="text-red-600">{{ $error }}</p> @endforeach </div>
Each of ->label, ->input also has its own __toString(), so you can
echo them directly ({!! $form->email->input !!}), or read finer detail off
them: $form->email->input->name, ->id, ->value, ->is_required, etc.
6. Validating a submission
$form->validate($request->all(), $request->allFiles()); if ($form->is_validated()) { // ... }
validate(array $data, array $files = [])— stages the submitted data (and optionally files) for validation; doesn't run anything yet.is_validated(): bool— runs every field's Laravel validation rules (built automatically from the field options in §4, plus anyvalidateRuleyou added) and returns whether all fields passed.errors(): ObjectClass— afteris_validated()returnsfalse, use:$form->errors()->all()— flat array of"field" => "combined message".$form->errors()->messages()—"field" => [message, message, ...].$form->errors()->get(['field'])— messages for one specific field.$form->errors()->first()/->last()— first/last error message overall.
If you render the form again after a failed submission (without redirecting),
fields marked cache: true (the default, except PasswordField) automatically
repopulate from Laravel's old() input.
7. Reading submitted values
$values = $form->values(); // ObjectClass $values->email; // the submitted email $values->toArray(); // as a plain array
values() resolves each field's submitted value — including matching a
SelectField's stored option back to its declared value, and substituting
uploaded files with their generated filenames once uploadFiles() has run.
Detecting changes against an initial value (e.g. an edit form pre-filled
via initialValue()):
$form->initialValue($existingModel); // array or object $form->validate($request->all()); $form->dirty(); // ['email', 'bio'] - keys whose submitted value changed $form->isDirty('email'); // true/false
8. Saving to an Eloquent model
$form->setModel(new User()); $form->validate($request->all()); if ($form->is_validated()) { $user = $form->save(); // returns the saved model }
save() copies every fillable field's resolved value onto the bound model
(calling ->save() on it at the end). Control exactly what gets copied:
setFillableAttribute(array $keys)— only copy these field keys (default: every key present in the submitted data).setExceptAttribute(array $keys)/addToExceptAttribute(array $keys)— exclude specific keys from being copied.changeSaveKey(['formField' => 'model_attribute'])— write a field's value to a different model attribute name (overrides a field's ownsaveAsoption for this instance).- A field's own
saveAs: 'profile.bio'option (see §4) writes into an already-loaded relation instead of a literal attribute — if any segment of the path isn't already set on the model, that assignment is silently skipped rather than erroring. setDefaultAttributeValue(['created_by' => $userId])— attributes applied to the model before the field values are copied over, useful for values that don't come from the form itself.
setModelAttribute() performs the copy without calling ->save(), if you
need to do more to the model first.
Passwords: a PasswordField with hashValue: true (the default) is
automatically run through Hash::make() when copied onto the model.
9. File & image uploads
Basic upload
Declare the field:
'avatar' => $f->ImageField( required: false, uploadTo: 'uploads/avatars', imageSize: '800x800', oldFile: $user->avatar ?? null, ),
Then, in your controller, explicitly trigger the upload (this is a separate step from validation/save, so you control exactly when files hit disk):
$form->validate($request->all(), $request->allFiles()); if ($form->is_validated()) { $form->uploadFiles(); // moves/resizes files, generates filenames $user = $form->save(); // values()/save() now use the uploaded filenames }
uploadFiles(bool $returnWithPath = false)— uploads every file field. By default only the generated filename is stored; passtrueto store the full"path/filename"instead.getUploadedFiles(): ObjectClass— inspect what was uploaded, keyed by field name (nested by group, for group fields).- Non-image files are moved as-is; image files are opened and optionally
resized to
imageSize. - Passing
oldFile(the previous filename) deletes that old file once the new upload succeeds.
Custom filenames
By default an uploaded file gets an auto-generated unique name. Set
filename to force your own instead — the original extension is appended
automatically if you don't include one:
'avatar' => $f->ImageField( uploadTo: 'uploads/avatars', filename: 'user-' . auth()->id() . '-avatar', // -> user-42-avatar.jpg ),
Thumbnails: separate folder, own filename, deletable, saved to the DB
Add thumbnail (the resize dimensions) to have a thumbnail generated
alongside the main upload. Four more options give you full control over it:
'avatar' => $f->ImageField( uploadTo: 'uploads/avatars', imageSize: '800x800', filename: 'user-' . auth()->id() . '-avatar', thumbnail: '150x150', // enables the thumbnail thumbnailTo: 'uploads/avatars/thumbs', // saved into its own folder thumbnailName: 'user-' . auth()->id() . '-avatar-thumb', oldFile: $user->avatar ?? null, // deletes the old main file oldThumbnail: $user->avatar_thumb ?? null, // deletes the old thumbnail, same way saveAs: 'avatar', // main filename -> users.avatar thumbnailSaveAs: 'avatar_thumb', // thumbnail filename -> users.avatar_thumb ),
With thumbnail set, the field's resolved value is no longer a plain
filename string — it becomes ['filename' => ..., 'thumbnail' => ...], so:
$form->uploadFiles(); $form->values()->avatar['filename']; // e.g. "user-42-avatar.jpg" $form->values()->avatar['thumbnail']; // e.g. "user-42-avatar-thumb.jpg" // or, before/without calling values(): $form->getUploadedFiles()->avatar['thumbnail'];
thumbnailSaveAs makes save() write both filenames onto the model in one
call — the main filename to the field's usual saveAs, and the thumbnail's
filename to whatever attribute thumbnailSaveAs names:
$form->setModel($user); $form->uploadFiles(); $user = $form->save(); // sets $user->avatar AND $user->avatar_thumb, then saves
If a field has thumbnail set but no thumbnailSaveAs, only the main
filename is written on save — the thumbnail filename is still available via
values()/getUploadedFiles() if you want to handle it yourself.
Using FileManager directly
Under the hood, uploads go through Formcraft\FileManager, which you can
also use standalone (e.g. outside of a form, for an ad-hoc upload):
use Formcraft\FileManager; $manager = new FileManager($request->file('avatar')); $manager->path = 'uploads/avatars'; $manager->size = '800x800'; $manager->filename = 'user-42-avatar'; // optional - custom main filename $manager->thumb = '150x150'; $manager->thumbPath = 'uploads/avatars/thumbs'; // optional - separate thumbnail folder $manager->thumbFilename = 'user-42-avatar-thumb'; // optional - custom thumbnail filename $manager->old = $user->avatar; $manager->oldThumb = $user->avatar_thumb; $manager->upload(); $newFilename = $manager->filename; $newThumbFilename = $manager->thumbFilename; // set whenever $thumb was configured
Every FileManager method can also be called statically, e.g.
FileManager::removeFile('uploads/avatars/old.jpg').
10. Group fields (nested/repeated data)
Use GroupField to nest several fields under one key.
Plain grouping (valueType: "all", the default) — just visual/logical
grouping; children behave like normal top-level fields:
'address' => $f->GroupField(label: 'Address', fields: [ 'street' => $f->CharField(), 'city' => $f->CharField(), ]),
Repeated/array data (valueType: "array") — for something like "add up
to N line items", where the submitted HTML uses array-style names
(items[0][name], items[1][name], ...) and the group's resolved value is
an array of child arrays:
'items' => $f->GroupField(valueType: 'array', fields: [ 'name' => $f->CharField(), 'qty' => $f->IntegerField(min: 1), ], applyRuleToFields: ['required']),
Validation rules for array-typed groups are generated per index
automatically (items.*.name, items.*.qty), and values()/save()
flatten group children's values into the top-level result, so
$form->values()->items gives you the full array of rows.
Groups can be nested inside groups; access a nested field's props at
definition time via a "parent/child" path with changeProps() (see §14).
11. Form Sets ("add another" repeated forms)
FormSet builds several independent copies of a whole FormProcessor
class — useful when each "row" needs its own full validation/theme rather
than being one group of fields.
use Formcraft\FormProcessor; $addressSet = FormProcessor::formSet(AddressForm::class, num: 3, label: 'address'); // Render each copy (names are auto-namespaced: address_0, address_1, address_2) foreach ($addressSet->set() as $addressForm) { echo $addressForm; }
Key FormSet methods:
| Method | Purpose |
|---|---|
set(): FormProcessor[] |
Every instance, ready to render (->setId/->setName populated) |
getField(int $index): ?FormProcessor |
One specific instance |
initialValue(array $values) |
Pre-fill each instance from $values[$index] |
setTheme($theme) |
Apply one theme to every instance |
validate(array $data, array $files = []) |
Splits a flat submission back out per-instance by its {label}_{index}_ name prefix |
is_validated(): bool |
True only if every instance validates |
errors(): ObjectClass |
->all()/->messages()/->first()/->last(), each keyed by index; ->toListKey() flattens to "field.index" => message" |
uploadFiles() |
Uploads files for every instance |
save(): array |
Saves every instance, keyed by index |
removeField(int $id) |
Drop an instance (e.g. "remove this row") |
changeProps(int $index, string $key, $value) |
Tweak one instance's field |
each(callable $callback) |
Run $callback($form) against every instance (e.g. to setModel() on each) — a beforeProcess() alias also exists for backward compatibility |
Redisplaying errors after a failed submission
A FormSet you build to render the page (e.g. in your create() action)
is a fresh set of FormProcessor instances — it has no memory of a
previous failed validate() call, even one from the very same request
cycle a moment ago. If you return back()->withErrors(...)->withInput()
after validation fails and then redisplay the page, its errors won't show
up unless you explicitly re-validate the new instances from the flashed
old() input:
public function create(Request $request) { $rows = max((int) $request->query('rows', 3), 1); if ($request->session()->hasOldInput()) { $rows = max((int) old('item_count', $rows), 1); // keep the same row count } $items = FormProcessor::formSet(OrderItemForm::class, $rows, 'item'); if ($request->session()->hasOldInput()) { $items->validate($request->session()->getOldInput()); } return view('orders.create', compact('items')); }
This mirrors what FormWizard::form() already does internally for a
single step. If your view tracks a dynamic row count (an "add row" button),
also flash/read that count (e.g. a hidden item_count input) so the
redisplay rebuilds the same number of rows the person actually submitted.
12. Multi-step wizards (FormWizard)
FormWizard drives a sequence of FormProcessor "steps" — one submitted
and validated at a time, stored in the session, with everything available
once all steps are complete.
use Formcraft\FormWizard; $wizard = new FormWizard('signup', [ 'account' => AccountStepForm::class, 'profile' => ProfileStepForm::class, 'billing' => BillingStepForm::class, ]); // Show the current step $step = $wizard->currentStep(); // e.g. 'account' - null once all steps are done $form = $wizard->form(); // that step's FormProcessor, pre-filled from any prior submission // Handle a submission if ($wizard->submitStep($request->all(), $request->allFiles())) { return redirect()->route('signup.step', $wizard->currentStep()); } // Failed - use $wizard->lastForm()->errors(). If the failed step has a file // input, render $wizard->lastForm() directly instead of redirecting, since a // redirect can't carry an uploaded file back with it. // Once done if ($wizard->isComplete()) { $allData = $wizard->allData(); // ['account' => [...], 'profile' => [...], 'billing' => [...]] }
Other methods: stepKeys(), completedSteps(), goToStep($step) (jump
back, un-completing that step and everything after it — stored data is kept
so it still pre-fills), and reset() (clear all progress).
13. Theming
A theme controls the CSS classes injected into labels/inputs and how a field's label/input/errors are wrapped together — without you touching your field definitions.
Built-in themes
| Theme | Class | Style |
|---|---|---|
| Plain (default) | Formcraft\Support\PlainTheme |
No CSS framework — the library's original bare markup |
| Bootstrap 5 | Formcraft\Support\BootstrapTheme |
.form-label/.form-control/.form-select, .form-check-* for checkboxes/radios, .invalid-feedback errors |
| Tailwind | Formcraft\Support\TailwindTheme |
Utility classes directly on elements — no build step or @tailwindcss/forms plugin required |
| Card (example) | Formcraft\Support\CardTheme |
A framework-free "floating card" style meant to be copied and adapted |
Applying a theme
Per instance:
$form->setTheme(new \Formcraft\Support\BootstrapTheme());
As a form's standing default, override defaultTheme() in your
FormProcessor subclass:
protected function defaultTheme(): FormTheme { return new \Formcraft\Support\TailwindTheme(); }
A field's own widget option (§4) always wins over the theme for that one
field — it bypasses theming entirely.
Writing your own theme
Implement Formcraft\Support\FormTheme (or run php artisan make:theme YourName — see §15):
use Formcraft\Support\FormTheme; use Formcraft\ObjectClass; class MyTheme implements FormTheme { public function inputClasses(string $renderType, array $props): string { // $renderType: "input" | "textarea" | "select" | "groupField" // for "input", $props['type'] is the HTML input type return 'my-input'; } public function labelClasses(string $renderType, array $props): string { return 'my-label'; } public function render(string $name, ObjectClass $fields, array $props, string $renderType): string { // $fields->label / ->input are already-rendered HTML strings // $fields->errors is a Collection of this field's error messages $wrapperTag = ($props['group'] ?? false) ? 'fieldset' : 'div'; return "<{$wrapperTag}>{$fields->label}{$fields->input}</{$wrapperTag}>"; } }
14. Customizing a form at runtime
These let you tweak an already-defined form on a per-instance basis, without
touching setFields():
| Method | Purpose |
|---|---|
appendFields(callable $callback) |
Add extra fields: $form->appendFields(fn ($f) => ['extra' => $f->CharField()]); |
changeProps(string $key, array $keyValue) |
Override one or more props on an existing field: $form->changeProps('email', ['required' => false]); — for a nested group field, use a "parent/child" path |
changeFieldName(array $mapping) |
Rename how a field's input/data key is looked up (useful when the submitted payload uses different keys than your field names) |
changeSaveKey(array $mapping) |
See §8 — redirect where a field's value is written on save() |
setFillableAttribute / setExceptAttribute / addToExceptAttribute |
See §8 |
setDefaultAttributeValue(array $default) |
See §8 |
initialValue(object|array $value) |
Pre-fill fields (and enable dirty()/isDirty()) from an existing record |
15. Artisan commands
make:form — scaffold a new form class
php artisan make:form RegistrationForm php artisan make:form RegistrationForm --fields="name:char,email:email,password:password" php artisan make:form RegistrationForm --fields="name:char,email:email" --theme=BootstrapTheme php artisan make:form Signup/AccountStep --path=Forms/Signup
| Option | Purpose |
|---|---|
--fields="key:type,..." |
Pre-populate fields; recognized types: char/text, email, password, integer/int/number, date, checkbox, radio, textarea, select, file, image, group |
--theme=BootstrapTheme |
Adds a defaultTheme() override to the generated class (accepts a short name resolved under Formcraft\Support\, or a fully-qualified class name) |
--path=Forms |
Where under app/ to place the class (default app/Forms) |
--namespace= |
Override the namespace instead of deriving it from --path |
--force |
Overwrite an existing file |
make:theme — scaffold a new theme class
php artisan make:theme Card # creates CardTheme
php artisan make:theme MyCompanyTheme --path=Themes
Generates a class implementing FormTheme with all three required methods
stubbed out and commented, under app/{--path} (default app/Themes).
16. Full worked example
Form definition (app/Forms/RegistrationForm.php):
<?php namespace App\Forms; use Formcraft\FormFields; use Formcraft\FormProcessor; use Formcraft\LaravelFormTemplate; use Formcraft\Support\FormTheme; use Formcraft\Support\BootstrapTheme; class RegistrationForm extends FormProcessor { protected function defaultTheme(): FormTheme { return new BootstrapTheme(); } public function setFields(): LaravelFormTemplate { return new LaravelFormTemplate(fn (FormFields $f) => [ 'name' => $f->CharField( label: 'Full name', minLength: 2, maxLength: 100, ), 'email' => $f->EmailField( validateRule: ['unique:users,email'], ), 'password' => $f->PasswordField( minLength: 8, hashValue: true, ), 'avatar' => $f->ImageField( required: false, uploadTo: 'uploads/avatars', imageSize: '600x600', thumbnail: '120x120', ), 'terms' => $f->CheckBoxField( label: 'I agree to the terms of service', value: '1', validate: fn ($data) => $data->terms == '1' ? true : 'You must accept the terms.', ), ]); } }
Controller:
<?php namespace App\Http\Controllers; use App\Forms\RegistrationForm; use App\Models\User; use Illuminate\Http\Request; class RegistrationController extends Controller { public function create(RegistrationForm $form) { return view('register', compact('form')); } public function store(Request $request, RegistrationForm $form) { $form->validate($request->all(), $request->allFiles()); if (! $form->is_validated()) { return back()->withErrors($form->errors()->all())->withInput(); } $form->uploadFiles(); $form->setModel(new User()); $user = $form->save(); return redirect()->route('dashboard')->with('status', 'Welcome, ' . $user->name . '!'); } }
Blade view (resources/views/register.blade.php):
<form method="POST" action="{{ route('register.store') }}" enctype="multipart/form-data"> @csrf {!! $form !!} <button type="submit">Create account</button> </form>
That's it — Formcraft renders every field with Bootstrap classes, validates
on submit, uploads and resizes the avatar, hashes the password, and saves
the new User.
17. Behavior notes & gotchas
A few implementation details worth knowing as you use the package:
values()vs raw$requestinput: always read submitted data through$form->values(), not the raw request — it resolvesSelectFieldoption matching and substitutes uploaded files' final filenames onceuploadFiles()has run.- Upload order matters: call
uploadFiles()afteris_validated()passes and beforesave()(or before readingvalues()for a file field), so the resolved value is the new filename rather than the raw uploaded file object. PasswordFieldisn't cached by default (cache: false) — on a failed submission the password field is intentionally left blank rather than repopulated fromold().removeFile()/thumbnail cleanup: deleting the old main file and the old thumbnail are two explicit, independent operations —oldFile(fromuploadTo) andoldThumbnail(fromthumbnailTo). Set both when you want both cleaned up;FileManager::removeFile()never guesses a thumbnail's path from the main file's.- File field values change shape once a thumbnail is configured: a
FileField/ImageFieldwithoutthumbnailresolves to a plain filename string, same as always. Addthumbnailand it resolves to['filename' => ..., 'thumbnail' => ...]instead (fromvalues()andgetUploadedFiles()) — code that reads that field's value as a plain string needs updating if you add a thumbnail to an existing field. DateField's extravalidateRule: avalidateRulearray you pass toDateFieldis appended as one nested array element alongside the automaticdate(and optionalformat) rules, rather than merged flat into the rule list — pass any extra Laravel date-related rule as a single array item if you rely on this option.- Group field naming: for
valueType: "array"groups, HTML input names use bracket syntax (items[0][name]), while Laravel validation andvalues()/save()use dot syntax (items.0.name) — you don't need to convert between them yourself, but keep it in mind if you're inspecting raw request data. ignore: truefields are skipped everywhere: they don't render, validate, appear invalues(), or get written onsave().