ramondev/formcraft

A declarative form library for Laravel: field definitions, validation, theming, multi-step wizards, and Artisan scaffolding.

Maintainers

Package info

github.com/ramondev180/formcraft

pkg:composer/ramondev/formcraft

Transparency log

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-08-13 11:56 UTC

This package is auto-updated.

Last update: 2026-08-13 12:17:50 UTC


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

  1. Installation
  2. Quick start
  3. Defining fields
  4. Field option reference
  5. Rendering a form
  6. Validating a submission
  7. Reading submitted values
  8. Saving to an Eloquent model
  9. File & image uploads
  10. Group fields (nested/repeated data)
  11. Form Sets ("add another" repeated forms)
  12. Multi-step wizards (FormWizard)
  13. Theming
  14. Customizing a form at runtime
  15. Artisan commands
  16. Full worked example
  17. 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 Laravel date rule parameter.

PasswordField

  • hashValue (bool, default true) — when saving via save(), the value is run through Hash::make() before being written to the model.

IntegerField

  • min, max — numeric bounds (validation + HTML attrs).
  • step — HTML step attribute.

SelectField

  • options — array of either plain scalars (['a', 'b']) or associative arrays: ['value' => ..., 'text' => ..., 'attr' => [...], 'selected' => bool]. value can itself be a two-element array [storedValue, displayLabelWhenResolved] if you want the raw HTML value to differ from what values() returns.
  • multiple (bool) — renders a multi-select and expects/returns an array.

FileField

  • multiple (bool) — allow multiple files.
  • accepts (array) — HTML accept list (e.g. ['.pdf', '.docx']).
  • fileMimes (array) — adds a Laravel mimes: 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 (ImageField sets 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 to uploadTo when 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 (from thumbnailTo) once the new upload succeeds — the thumbnail equivalent of oldFile.
  • thumbnailSaveAs (string) — model attribute the thumbnail's filename is written to on save(), alongside the main file's saveAs (see below).

ImageField

  • Same as FileField, but type/fileType are pre-set to image handling, and accepts/fileMimes default to common image extensions (jpg, jpeg, png, gif, webp, bmp) unless you pass your own accepts.

GroupField

  • fields (array) — child fields, built the same way as top-level setFields().
  • 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 when valueType is "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 any validateRule you added) and returns whether all fields passed.
  • errors(): ObjectClass — after is_validated() returns false, 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 own saveAs option 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; pass true to 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 $request input: always read submitted data through $form->values(), not the raw request — it resolves SelectField option matching and substitutes uploaded files' final filenames once uploadFiles() has run.
  • Upload order matters: call uploadFiles() after is_validated() passes and before save() (or before reading values() for a file field), so the resolved value is the new filename rather than the raw uploaded file object.
  • PasswordField isn't cached by default (cache: false) — on a failed submission the password field is intentionally left blank rather than repopulated from old().
  • removeFile()/thumbnail cleanup: deleting the old main file and the old thumbnail are two explicit, independent operations — oldFile (from uploadTo) and oldThumbnail (from thumbnailTo). 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/ImageField without thumbnail resolves to a plain filename string, same as always. Add thumbnail and it resolves to ['filename' => ..., 'thumbnail' => ...] instead (from values() and getUploadedFiles()) — code that reads that field's value as a plain string needs updating if you add a thumbnail to an existing field.
  • DateField's extra validateRule: a validateRule array you pass to DateField is appended as one nested array element alongside the automatic date (and optional format) 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 and values()/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: true fields are skipped everywhere: they don't render, validate, appear in values(), or get written on save().