Livewire Form Objects: The Clean Way to Handle Complex Forms

Bryan Heath Bryan Heath
· · 2 min read

Livewire components that handle forms tend to bloat fast. You start with a few properties, add validation rules, wire up some lifecycle hooks, handle submission, and before long your component is 200 lines of intertwined state management, validation, and business logic. I've had components hit 400 lines before I finally admitted the problem wasn't complexity — it was organization. The form itself becomes the hardest thing to test because everything is coupled to the component.

Livewire v3 introduced form objects to solve exactly this problem. A form object is a dedicated class that encapsulates all the properties, validation rules, and data-handling logic for a single form. Your component stays thin — it delegates to the form object for everything form-related and focuses on what components are actually good at: orchestrating the UI.

Let's build a real-world example from scratch and explore the patterns that make form objects genuinely useful.

The Problem: A Bloated Component

Consider a user registration form with name, email, password, and a few profile fields. Without form objects, everything ends up in the component:

<?php

namespace App\Livewire;

use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;

class RegisterUser extends Component
{
    public string $name = '';
    public string $email = '';
    public string $password = '';
    public string $password_confirmation = '';
    public string $company = '';
    public string $job_title = '';
    public string $timezone = 'UTC';

    protected function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'email', 'unique:users,email'],
            'password' => ['required', 'string', 'min:8', 'confirmed'],
            'company' => ['nullable', 'string', 'max:255'],
            'job_title' => ['nullable', 'string', 'max:255'],
            'timezone' => ['required', 'timezone'],
        ];
    }

    public function register(): void
    {
        $validated = $this->validate();

        User::create([
            'name' => $validated['name'],
            'email' => $validated['email'],
            'password' => Hash::make($validated['password']),
            'company' => $validated['company'],
            'job_title' => $validated['job_title'],
            'timezone' => $validated['timezone'],
        ]);

        $this->redirect(route('dashboard'));
    }

    public function render()
    {
        return view('livewire.register-user');
    }
}

This is a simple form and it already has seven public properties, a rules array, and submission logic all in one class. Now imagine adding real-time validation, edit mode with existing data, custom error messages, file uploads, and conditional fields. The component becomes a maintenance headache.

Creating a Form Object

Generate a form object with Artisan:

php artisan livewire:form RegisterForm

This creates a class at app/Livewire/Forms/RegisterForm.php. The generated class extends Livewire\Form, which is the base class that gives form objects their superpowers. Move all the form properties, rules, and data logic into it:

<?php

namespace App\Livewire\Forms;

use Livewire\Attributes\Validate;
use Livewire\Form;

class RegisterForm extends Form
{
    #[Validate('required|string|max:255')]
    public string $name = '';

    #[Validate('required|email|unique:users,email')]
    public string $email = '';

    #[Validate('required|string|min:8|confirmed')]
    public string $password = '';

    public string $password_confirmation = '';

    #[Validate('nullable|string|max:255')]
    public string $company = '';

    #[Validate('nullable|string|max:255')]
    public string $job_title = '';

    #[Validate('required|timezone')]
    public string $timezone = 'UTC';
}

The #[Validate] attribute attaches validation rules directly to each property. This is cleaner than a separate rules array because the rule lives right next to the property it governs — you never have to hunt for where a rule is defined.

Wiring the Form Object to a Component

Now the component becomes dramatically simpler:

<?php

namespace App\Livewire;

use App\Livewire\Forms\RegisterForm;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;

class RegisterUser extends Component
{
    public RegisterForm $form;

    public function register(): void
    {
        $validated = $this->form->validate();

        User::create([
            'name' => $validated['name'],
            'email' => $validated['email'],
            'password' => Hash::make($validated['password']),
            'company' => $validated['company'],
            'job_title' => $validated['job_title'],
            'timezone' => $validated['timezone'],
        ]);

        $this->redirect(route('dashboard'));
    }

    public function render()
    {
        return view('livewire.register-user');
    }
}

Livewire automatically instantiates the form object when the component is created. You declare it as a typed public property and Livewire handles the rest. In your Blade view, you reference form fields with the form. prefix:

<form wire:submit="register">
    <div>
        <label for="name">Name</label>
        <input type="text" id="name" wire:model="form.name" />
        @error('form.name') <span class="text-red-500">{{ $message }}</span> @enderror
    </div>

    <div>
        <label for="email">Email</label>
        <input type="email" id="email" wire:model="form.email" />
        @error('form.email') <span class="text-red-500">{{ $message }}</span> @enderror
    </div>

    <div>
        <label for="password">Password</label>
        <input type="password" id="password" wire:model="form.password" />
        @error('form.password') <span class="text-red-500">{{ $message }}</span> @enderror
    </div>

    <div>
        <label for="password_confirmation">Confirm Password</label>
        <input type="password" id="password_confirmation" wire:model="form.password_confirmation" />
    </div>

    <div>
        <label for="timezone">Timezone</label>
        <select id="timezone" wire:model="form.timezone">
            @foreach (timezone_identifiers_list() as $tz)
                <option value="{{ $tz }}">{{ $tz }}</option>
            @endforeach
        </select>
        @error('form.timezone') <span class="text-red-500">{{ $message }}</span> @enderror
    </div>

    <button type="submit">Register</button>
</form>

Every wire:model now uses the form.propertyName syntax, and error bags use the same prefix. This dot notation tells Livewire to proxy reads and writes through the form object.

Adding Store and Update Methods

The real power of form objects shows when you move the persistence logic into the form itself. The form object knows its own data and its own rules — it should also know how to save itself:

<?php

namespace App\Livewire\Forms;

use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Livewire\Attributes\Validate;
use Livewire\Form;

class RegisterForm extends Form
{
    #[Validate('required|string|max:255')]
    public string $name = '';

    #[Validate('required|email|unique:users,email')]
    public string $email = '';

    #[Validate('required|string|min:8|confirmed')]
    public string $password = '';

    public string $password_confirmation = '';

    #[Validate('nullable|string|max:255')]
    public string $company = '';

    #[Validate('nullable|string|max:255')]
    public string $job_title = '';

    #[Validate('required|timezone')]
    public string $timezone = 'UTC';

    public function store(): User
    {
        $validated = $this->validate();

        return User::create([
            'name' => $validated['name'],
            'email' => $validated['email'],
            'password' => Hash::make($validated['password']),
            'company' => $validated['company'],
            'job_title' => $validated['job_title'],
            'timezone' => $validated['timezone'],
        ]);
    }
}

Now the component is truly minimal:

public function register(): void
{
    $user = $this->form->store();

    auth()->login($user);

    $this->redirect(route('dashboard'));
}

The component calls $this->form->store() and handles the redirect. It doesn't know about validation rules, password hashing, or which columns exist on the users table. That separation matters when your form has 15 fields instead of 7.

Handling Edit Forms with Existing Data

Form objects really shine when the same form handles both creation and editing. Use the fill() method to hydrate the form with existing model data:

<?php

namespace App\Livewire\Forms;

use App\Models\Project;
use Livewire\Attributes\Validate;
use Livewire\Form;

class ProjectForm extends Form
{
    public ?Project $project = null;

    #[Validate('required|string|max:255')]
    public string $name = '';

    #[Validate('required|string|max:5000')]
    public string $description = '';

    #[Validate('required|in:active,paused,completed')]
    public string $status = 'active';

    #[Validate('nullable|date|after:today')]
    public ?string $deadline = null;

    #[Validate('required|integer|min:1|max:100')]
    public int $priority = 50;

    public function setProject(Project $project): void
    {
        $this->project = $project;

        $this->fill(
            $project->only(['name', 'description', 'status', 'deadline', 'priority'])
        );
    }

    public function store(): Project
    {
        $validated = $this->validate();

        return Project::create($validated);
    }

    public function update(): Project
    {
        $validated = $this->validate();

        $this->project->update($validated);

        return $this->project->fresh();
    }
}

The fill() method populates the form properties from an array, and only() on the model grabs just the fields the form cares about. The component then uses one form object for both create and edit workflows:

<?php

namespace App\Livewire;

use App\Livewire\Forms\ProjectForm;
use App\Models\Project;
use Livewire\Component;

class ManageProject extends Component
{
    public ProjectForm $form;

    public function mount(?Project $project = null): void
    {
        if ($project) {
            $this->form->setProject($project);
        }
    }

    public function save(): void
    {
        if ($this->form->project) {
            $this->form->update();
            session()->flash('message', 'Project updated.');
        } else {
            $this->form->store();
            session()->flash('message', 'Project created.');
        }

        $this->redirect(route('projects.index'));
    }

    public function render()
    {
        return view('livewire.manage-project');
    }
}

The component doesn't care whether it's creating or editing. It delegates to the form object, which knows what to do based on whether a project was loaded. This is a pattern you'll use constantly once you adopt form objects.

Dynamic Validation Rules

Sometimes validation rules need to change based on context. The classic example is making the email unique except for the current user during edits. Override the rules() method in the form object for conditional logic:

<?php

namespace App\Livewire\Forms;

use App\Models\User;
use Illuminate\Validation\Rule;
use Livewire\Form;

class UserProfileForm extends Form
{
    public ?User $user = null;

    public string $name = '';
    public string $email = '';
    public string $bio = '';

    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:255'],
            'email' => [
                'required',
                'email',
                Rule::unique('users', 'email')->ignore($this->user?->id),
            ],
            'bio' => ['nullable', 'string', 'max:1000'],
        ];
    }

    public function setUser(User $user): void
    {
        $this->user = $user;
        $this->fill($user->only(['name', 'email', 'bio']));
    }

    public function update(): void
    {
        $this->user->update($this->validate());
    }
}

When you define a rules() method, it takes precedence over #[Validate] attributes. Use attributes for static rules and the method for dynamic ones. Don't mix both approaches on the same form object — pick one and stay consistent.

Real-Time Validation

Form objects integrate naturally with real-time validation. To validate a field every time it changes, add an updated() hook in the component that delegates to the form:

public function updated(string $property): void
{
    $this->form->validateOnly($property);
}

The validateOnly() method validates a single property against its rule without touching the rest. This gives users immediate feedback on each field as they fill it out. The property name passed to updated() will include the form. prefix automatically, and Livewire handles the prefix resolution for you.

Resetting Form State

After a successful submission, you often want to clear the form. Form objects provide a reset() method that restores all properties to their initial values:

public function save(): void
{
    $this->form->store();

    $this->form->reset();

    session()->flash('message', 'Entry created successfully.');
}

This also clears validation errors. If you need to reset only specific fields, pass the property names as arguments: $this->form->reset('name', 'email'). This is particularly useful for forms that stay open after submission, like a quick-add modal where the user might create multiple items in a row.

Custom Error Messages and Attribute Names

Define custom validation messages and attribute names in the form object to keep everything co-located:

class ContactForm extends Form
{
    #[Validate('required|string|max:255')]
    public string $name = '';

    #[Validate('required|email')]
    public string $email = '';

    #[Validate('required|string|min:20|max:5000')]
    public string $message = '';

    #[Validate('required|in:support,sales,partnership')]
    public string $department = '';

    protected function validationAttributes(): array
    {
        return [
            'name' => 'full name',
            'message' => 'message body',
            'department' => 'department selection',
        ];
    }

    protected function messages(): array
    {
        return [
            'message.min' => 'Please provide at least 20 characters so we can understand your request.',
            'department.in' => 'Please select a valid department from the list.',
        ];
    }
}

The validationAttributes() method controls how field names appear in error messages (so you get "The full name field is required" instead of "The name field is required"). The messages() method provides custom error text for specific rule violations. Both work exactly like they do in Laravel form requests.

Testing Form Objects

One of the strongest arguments for form objects is testability. You can test validation rules, data transformations, and persistence logic without rendering any HTML or wiring up a full Livewire component. Test the form object directly:

<?php

namespace Tests\Feature\Livewire;

use App\Livewire\ManageProject;
use App\Livewire\Forms\ProjectForm;
use App\Models\Project;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;

class ManageProjectTest extends TestCase
{
    use RefreshDatabase;

    public function test_it_validates_required_fields(): void
    {
        Livewire::test(ManageProject::class)
            ->set('form.name', '')
            ->set('form.description', '')
            ->call('save')
            ->assertHasErrors(['form.name', 'form.description']);
    }

    public function test_it_creates_a_project(): void
    {
        Livewire::test(ManageProject::class)
            ->set('form.name', 'New Project')
            ->set('form.description', 'A detailed project description.')
            ->set('form.status', 'active')
            ->set('form.priority', 75)
            ->call('save')
            ->assertHasNoErrors()
            ->assertRedirect(route('projects.index'));

        $this->assertDatabaseHas('projects', [
            'name' => 'New Project',
            'priority' => 75,
        ]);
    }

    public function test_it_updates_an_existing_project(): void
    {
        $project = Project::factory()->create(['name' => 'Old Name']);

        Livewire::test(ManageProject::class, ['project' => $project])
            ->set('form.name', 'Updated Name')
            ->call('save')
            ->assertHasNoErrors();

        $this->assertDatabaseHas('projects', [
            'id' => $project->id,
            'name' => 'Updated Name',
        ]);
    }

    public function test_it_rejects_invalid_priority(): void
    {
        Livewire::test(ManageProject::class)
            ->set('form.priority', 200)
            ->call('save')
            ->assertHasErrors(['form.priority']);
    }
}

Notice how the tests read clearly. Every assertion targets form.propertyName, which maps directly to what the user sees in the UI. The test structure mirrors the user interaction: set fields, submit, check results.

Form Objects with File Uploads

Form objects work seamlessly with Livewire file uploads. Use the WithFileUploads trait on the component and declare the file property in the form object:

<?php

namespace App\Livewire\Forms;

use Livewire\Attributes\Validate;
use Livewire\Form;

class ArticleForm extends Form
{
    #[Validate('required|string|max:255')]
    public string $title = '';

    #[Validate('required|string|min:100')]
    public string $body = '';

    #[Validate('nullable|image|max:2048')]
    public $featured_image = null;

    #[Validate('required|in:draft,published')]
    public string $status = 'draft';

    public function store(): void
    {
        $validated = $this->validate();

        $path = null;

        if ($this->featured_image) {
            $path = $this->featured_image->store('articles', 'public');
        }

        \App\Models\Article::create([
            'title' => $validated['title'],
            'body' => $validated['body'],
            'featured_image' => $path,
            'status' => $validated['status'],
        ]);
    }
}

The WithFileUploads trait must be on the component, not the form object. But the file property itself and its validation rule live in the form, keeping the separation clean.

Multiple Form Objects in One Component

A single component can use multiple form objects. This is useful for pages that have several distinct forms, like a settings page with profile info, notification preferences, and password change sections:

<?php

namespace App\Livewire;

use App\Livewire\Forms\NotificationForm;
use App\Livewire\Forms\PasswordForm;
use App\Livewire\Forms\ProfileForm;
use Livewire\Component;

class UserSettings extends Component
{
    public ProfileForm $profileForm;
    public PasswordForm $passwordForm;
    public NotificationForm $notificationForm;

    public function mount(): void
    {
        $user = auth()->user();
        $this->profileForm->setUser($user);
        $this->notificationForm->setUser($user);
    }

    public function updateProfile(): void
    {
        $this->profileForm->update();
        session()->flash('profile-message', 'Profile updated.');
    }

    public function changePassword(): void
    {
        $this->passwordForm->change(auth()->user());
        $this->passwordForm->reset();
        session()->flash('password-message', 'Password changed.');
    }

    public function updateNotifications(): void
    {
        $this->notificationForm->update();
        session()->flash('notification-message', 'Preferences saved.');
    }

    public function render()
    {
        return view('livewire.user-settings');
    }
}

Each form object manages its own state, validation, and persistence independently. The Blade view references fields with their respective prefixes: wire:model="profileForm.name", wire:model="passwordForm.current_password", and so on. Each form submits to its own action method, so validation errors stay scoped to the correct form.

When to Use Form Objects

Not every form needs a form object. Here are guidelines for when they genuinely help:

Use a form object when the form has more than four or five fields. At that point, the properties and rules start to dominate the component, pushing the actual component logic — actions, events, computed properties — far down the file.

Use a form object when the same form is used for both create and edit. The fill() and reset() methods make dual-purpose forms clean and predictable.

Use a form object when you want to test validation rules in isolation. Testing a form object is faster than testing a full component because you skip the rendering pipeline.

Skip a form object when the form is trivially simple — a search input, a toggle, a quick action with one or two fields. Adding a form object for a single text input is over-engineering.

Conclusion

Livewire form objects solve a real problem that every Livewire developer hits eventually: components that try to do too much. By extracting form state, validation, and persistence into a dedicated class, you get components that read at a glance, forms that are trivial to test, and a clean separation between UI orchestration and data management.

The patterns worth internalizing: use #[Validate] attributes for static rules and the rules() method for dynamic ones. Use fill() to hydrate from existing models. Put store() and update() methods on the form object itself. Use multiple form objects when a page has multiple distinct forms. And call reset() after submission when the form needs to accept another entry.

Once you start using form objects, you'll wonder how you ever tolerated 300-line Livewire components. I certainly did. They're one of those features that feel like a minor convenience at first and quickly become indispensable.

Share:

Related Posts