A Livewire component that owns a checkout form quickly grows twelve public properties, a $rules array, a messages() method, an attributes() method, a save() method, and a resetForm() helper. Add a second form on the same page — say an edit modal — and the surface area doubles. By the time you've named everything carefully, the component is 300 lines and 90% of it is just describing what the form looks like.
Form Objects fix that. They were introduced in Livewire 3 and stayed in Livewire 4 alongside the v3→v4 migration story, but most blog posts still show the old $rules array approach. Combined with the #[Validate] attribute, you get a dedicated class that owns the data, the rules, the messages, and the persistence — and a component class that's back to coordinating the page.
Here's the full refactor, end to end.
Generate the form object with artisan#
Livewire 4 ships an artisan command that scaffolds the file for you. Run it with the form's PascalCase name — the Form suffix is conventional and worth keeping.
php artisan livewire:form PostForm
That writes app/Livewire/Forms/PostForm.php with a stub class extending Livewire\Form. The directory is created on first use, so there's nothing to register and no service provider to update.
<?php
namespace App\Livewire\Forms;
use Livewire\Form;
class PostForm extends Form
{
// properties, rules, and save() logic go here
}
If you want to keep an existing Forms\ namespace alongside Filament's or your own — for example app/Livewire/Forms/Admin/PostForm.php — pass the subdirectory in the command: php artisan livewire:form Admin/PostForm. The class will use the matching namespace automatically.
Move the public properties into the form object#
The point of a Form Object is to take everything form-shaped off the component and put it on the form class. Properties, defaults, the lot. Start with the before — a bloated CreatePost component you've seen a hundred times.
// Before: app/Livewire/CreatePost.php
namespace App\Livewire;
use App\Models\Post;
use Livewire\Component;
class CreatePost extends Component
{
public string $title = '';
public string $slug = '';
public string $excerpt = '';
public string $content = '';
public string $status = 'draft';
public ?int $category_id = null;
protected $rules = [
'title' => 'required|string|min:5|max:200',
'slug' => 'required|string|alpha_dash|unique:posts,slug',
'excerpt' => 'nullable|string|max:300',
'content' => 'required|string|min:50',
'status' => 'required|in:draft,published',
'category_id' => 'required|exists:categories,id',
];
public function save(): void
{
$this->validate();
Post::create($this->only(array_keys($this->rules)));
$this->reset();
session()->flash('status', 'Post created.');
}
public function render() { /* ... */ }
}
Now move every public property onto PostForm verbatim. Keep the types, keep the defaults.
// app/Livewire/Forms/PostForm.php
namespace App\Livewire\Forms;
use Livewire\Form;
class PostForm extends Form
{
public string $title = '';
public string $slug = '';
public string $excerpt = '';
public string $content = '';
public string $status = 'draft';
public ?int $category_id = null;
}
The component class loses every property in one move. Don't add #[Validate] yet — we'll do that in the next step so the diff stays small.
Replace the rules array with #[Validate] attributes#
#[Validate] is a PHP attribute from Livewire\Attributes\Validate that declares the rule at the property level. One rule string per property, sitting directly above the declaration. This replaces both the protected $rules = [...] array and any matching entries in messages() and attributes().
// app/Livewire/Forms/PostForm.php
namespace App\Livewire\Forms;
use Livewire\Attributes\Validate;
use Livewire\Form;
class PostForm extends Form
{
#[Validate('required|string|min:5|max:200')]
public string $title = '';
#[Validate('required|string|alpha_dash|unique:posts,slug')]
public string $slug = '';
#[Validate('nullable|string|max:300')]
public string $excerpt = '';
#[Validate('required|string|min:50')]
public string $content = '';
#[Validate('required|in:draft,published')]
public string $status = 'draft';
#[Validate('required|exists:categories,id', as: 'category')]
public ?int $category_id = null;
}
A few options worth knowing about. as: 'category' rewrites the field name in the error message — "The category field is required" reads better than "The category_id field is required". message: 'Pick a category' overrides the whole message; stack multiple #[Validate] attributes on the same property if you want a different message per rule. onUpdate: false switches off the per-update validation that Livewire runs on #[Validate] properties, which is useful when validation is expensive or you only want it to fire on submit.
One thing the attribute can't do is take a Laravel Rule object — Rule::unique('posts')->ignore($this->post) won't survive PHP's attribute restrictions. For those cases, define a protected function rules() method on the Form class instead. The two coexist: #[Validate] for the simple string rules, rules() for anything that needs runtime arguments.
Wire the form object into the component as a public property#
The Form Object connects to the component the same way any other property does — declare it as a public typed property, give it the class name, and Livewire instantiates it on mount. No __construct, no mount() setup.
// app/Livewire/CreatePost.php
namespace App\Livewire;
use App\Livewire\Forms\PostForm;
use App\Models\Post;
use Livewire\Component;
class CreatePost extends Component
{
public PostForm $form;
public function save(): void
{
$this->form->validate();
Post::create($this->form->all());
$this->form->reset();
session()->flash('status', 'Post created.');
}
public function render() { /* ... */ }
}
$this->form->validate() runs the rules declared on the Form Object and throws a ValidationException if anything fails — exactly like calling $this->validate() on the component would have. $this->form->all() returns every public property as an associative array, ready to hand straight to Post::create(). If you want a subset, use $this->form->only(['title', 'slug', 'content']).
For a tidier component you can push the save() call onto the Form Object itself.
// app/Livewire/Forms/PostForm.php
public function store(): Post
{
$this->validate();
return Post::create($this->all());
}
Then the component reduces to one line: $this->form->store(). The Form Object becomes a small, testable unit that owns both the shape of the form and what happens when it's submitted.
Bind Blade inputs through the form object#
In the Blade template, every wire:model now goes through the form namespace — wire:model="form.title" instead of wire:model="title". Same for @error: the error bag keys are prefixed with form. too.
<form wire:submit="save">
<input type="text" wire:model.live.blur="form.title">
@error('form.title') <span class="error">{{ $message }}</span> @enderror
<input type="text" wire:model.live.blur="form.slug">
@error('form.slug') <span class="error">{{ $message }}</span> @enderror
<textarea wire:model.live.blur="form.content"></textarea>
@error('form.content') <span class="error">{{ $message }}</span> @enderror
<button type="submit">Save</button>
</form>
wire:model.live.blur is the sweet spot for forms — it sends a network request when the user tabs out of the input, runs the #[Validate] rule for that single field, and shows the inline error if it fails. The user sees the slug error the moment they leave the input, not after they've finished filling in five other fields. Pair it with keeping ephemeral UI state in Alpine for modals or dropdowns so you don't pay a round trip for opening a panel — only for the data you genuinely care about.
The same Form Object class works for an edit screen too. Add a setPost(Post $post) method that takes an existing model and hydrates the properties — Livewire's morphdom keeps the diff small.
// app/Livewire/Forms/PostForm.php
use App\Models\Post;
public ?Post $post = null;
public function setPost(Post $post): void
{
$this->post = $post;
$this->title = $post->title;
$this->slug = $post->slug;
$this->excerpt = $post->excerpt ?? '';
$this->content = $post->content;
$this->status = $post->status;
$this->category_id = $post->category_id;
}
public function update(): void
{
$this->validate();
$this->post->update($this->only(['title', 'slug', 'excerpt', 'content', 'status', 'category_id']));
}
One Form Object, two components — CreatePost calls $this->form->store(), EditPost calls $this->form->setPost($post) in mount() then $this->form->update() on submit. Rules and field names live in one file.
Reset the form cleanly after save#
Form Objects ship with a reset() method that walks every public property and sets it back to its declared default. No more loops, no more manual $this->title = '' blocks.
public function store(): Post
{
$this->validate();
$post = Post::create($this->all());
$this->reset();
return $post;
}
Pass property names to reset a subset: $this->reset('title', 'slug') clears just those two. There's also pull() if you want both operations in one call — Post::create($this->pull()) returns the property array and resets the form in the same expression, which is handy when the form's only job is to create a model and clear itself.
The trap people hit is validation errors hanging around after a reset. reset() clears the values; it doesn't clear the error bag. In Livewire 4 the Form Object can't call resetValidation() on itself directly — that lives on the component. Call it from the parent: $this->resetValidation() (on the component) wipes the error bag, then $this->form->reset() zeroes the values.
// app/Livewire/CreatePost.php
public function save(): void
{
$this->form->store();
$this->resetValidation(); // clear error bag (component-level)
session()->flash('status', 'Post created.');
}
Gotchas and Edge Cases#
resetValidation() belongs to the component, not the Form. Calling $this->resetValidation() from inside PostForm::store() throws "undefined method". The error bag lives on the parent component, so either call it from the component ($this->resetValidation() followed by $this->form->reset()) or, if you really want the Form to do it, reach back via $this->component->resetValidation().
Multiple Form Objects per component is fine. A page that combines a search filter and a create modal can hold both — public FilterForm $filters and public PostForm $form. Each binds independently in Blade (wire:model="filters.search", wire:model="form.title"), and each validates on its own when you call $this->filters->validate() or $this->form->validate(). Errors don't cross-contaminate because the error bag prefixes them.
The #[Validate] array syntax matters for nested data. If your form has public array $tags = [] and you want each tag validated, use the array form of the attribute: #[Validate(['tags' => 'array|max:5', 'tags.*' => 'string|max:32'])]. A single string rule won't reach into the child elements.
Sharing rules with Form Requests is possible but not free. A common ask is "can the API and the Livewire form use the same rules?" Yes — extract them into a static method on the Form Object (public static function rulesFor(): array) and call it from both the rules() method here and the Form Request's rules() method elsewhere. The Form Request stays the API gateway; the Form Object stays the Livewire one. Just don't try to extend a Form Request from a Form Object or vice versa — they don't share a base class.
Test the Form Object directly, not through the Livewire harness. A Form Object is a plain PHP class with public properties; you can instantiate it, set properties, and assert on validate() throwing or not throwing — no Livewire component test required for rule coverage. Save the Pest architecture testing approach for the wider component, and unit-test each Form Object in isolation. It runs in milliseconds and catches rule regressions before the Blade template ever boots.
Filament forms aren't the same thing. If you've come from Filament v4, the custom form field component pattern and Livewire Form Objects look superficially similar — both wrap form state in a class. They're solving different problems though: Filament's form builder is a schema-first abstraction, Livewire Form Objects are a code organisation pattern. Don't mix them in the same component unless you've thought hard about which one owns validation.
Wrapping Up#
Pull every form-shaped property off the component and onto a Form Object the moment a component grows past one form. The component shrinks to a coordinator that calls validate() and store(); the Form Object owns the data, the rules, the messages, and the persistence. #[Validate] makes rules visible at the property they apply to, and reset()/pull() keep edit flows from accumulating leftover state.
For the next refactor in the same direction, Livewire 4 Islands let you lazy-load the expensive parts of a page that contains big forms — pair them with Form Objects and an edit screen that used to fetch everything on mount only renders what the user actually opens.
FAQ#
What is a form object in Livewire 4?
A Form Object is a dedicated class extending Livewire\Form that holds the public properties, validation rules, and submission logic for a single form. You generate it with php artisan livewire:form PostForm, declare properties on it with #[Validate] attributes, and bind to it from the component by declaring public PostForm $form. The component class stays thin and the form's concerns live in one file you can test in isolation.
How does the #[Validate] attribute work in Livewire?
#[Validate] is a PHP attribute from Livewire\Attributes\Validate that you place directly above a property. The string argument is a standard Laravel validation rule — 'required|email|unique:users,email' — and Livewire runs that rule whenever the property updates, when $this->validate() is called, or both. You can add as: for a friendly field name, message: for a custom error message, and onUpdate: false to suppress per-update validation when you only want validation on submit.
When should I use a Livewire form object vs a Laravel form request?
Use a Form Object when the form lives inside a Livewire component and you want real-time validation as the user types. Use a Form Request when the form posts to a controller action over HTTP — typically API endpoints or non-Livewire pages. They cover different transports, not different validation strategies. If you genuinely need the same rules in both places, extract them into a static method one side can borrow from the other; don't try to make a single class do both jobs.
How do I reset a Livewire form object after save?
Call $this->form->reset() inside the component, or $this->reset() from inside a method on the Form Object itself. Both walk every public property and restore its declared default value. To clear validation errors at the same time, call $this->resetValidation() on the component — that method lives on the component, not the Form Object, so you can't call it directly on $this->form. The typical "after save" cleanup is $this->form->reset() followed by $this->resetValidation().
Can I have multiple form objects in one Livewire component?
Yes — declare each one as its own public property: public FilterForm $filters and public PostForm $form on the same component, for example. Each binds independently in Blade (wire:model="filters.search" vs wire:model="form.title"), each validates on its own when you call $this->filters->validate() or $this->form->validate(), and Livewire prefixes the error bag keys so messages don't collide. It's the cleanest way to run a search filter and a create modal on the same page.
How do I share validation rules between a Livewire form and an API endpoint?
Put the rules in a static method on the Form Object — public static function rulesFor(): array { return [...]; } — and call that method from both sides. Your Livewire Form Object's rules() method returns self::rulesFor(); your Laravel Form Request's rules() method returns the same. The shared array is the source of truth; the two classes stay independent and you don't introduce a fragile inheritance chain between them. If a rule needs runtime data (a current user, a model being edited), pass it into rulesFor() as an argument.