Advanced Form Object Patterns
Nested Form Objects
// app/Livewire/Forms/PersonalInfoForm.php
class PersonalInfoForm extends Form
{
public $name = '';
public $email = '';
public $phone = '';
public function rules(): array
{
return [
'name' => 'required|min:3',
'email' => 'required|email',
'phone' => 'required',
];
}
}
// app/Livewire/Forms/ExperienceForm.php
class ExperienceForm extends Form
{
public $company = '';
public $position = '';
public $years = '';
public function rules(): array
{
return [
'company' => 'required',
'position' => 'required',
'years' => 'required|numeric',
];
}
}
// app/Livewire/Forms/JobApplicationForm.php
class JobApplicationForm extends Form
{
public PersonalInfoForm $personal;
public ExperienceForm $experience;
public function mount(): void
{
$this->personal = new PersonalInfoForm();
$this->experience = new ExperienceForm();
}
public function submit(): void
{
$this->personal->validate();
$this->experience->validate();
JobApplication::create([
'name' => $this->personal->name,
'email' => $this->personal->email,
'phone' => $this->personal->phone,
'company' => $this->experience->company,
'position' => $this->experience->position,
'years' => $this->experience->years,
]);
}
}
// View binding:
// <input wire:model="form.personal.name">
// <input wire:model="form.experience.company">
Dynamic Validation Rules
class OrderForm extends Form
{
public $shipping_method = 'standard';
public $express_date = '';
public $billing_same_as_shipping = true;
public $billing_address = '';
public function rules(): array
{
return [
'shipping_method' => 'required|in:standard,express',
'express_date' => $this->shipping_method === 'express'
? 'required|date|after:today'
: 'nullable',
'billing_address' => $this->billing_same_as_shipping
? 'nullable'
: 'required|min:10',
];
}
}
File Uploads in Form Objects
// app/Livewire/Forms/ProfileForm.php
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
class ProfileForm extends Form
{
public $name = '';
public $bio = '';
public ?TemporaryUploadedFile $avatar = null;
public function rules(): array
{
return [
'name' => 'required|min:3',
'bio' => 'required|max:500',
'avatar' => 'nullable|image|max:2048',
];
}
public function save($userId): void
{
$this->validate();
$user = User::findOrFail($userId);
$data = [
'name' => $this->name,
'bio' => $this->bio,
];
// Validate file
if ($this->avatar) {
// Store file
$data['avatar'] = $this->avatar->store('avatars', 'public');
}
$user->update($data);
$this->reset('avatar');
}
}
// app/Livewire/EditProfile.php
use Livewire\WithFileUploads;
class EditProfile extends Component
{
use WithFileUploads;
public ProfileForm $form;
public function save(): void
{
$this->form->save(auth()->id());
session()->flash('success', 'Profile updated!');
}
}
// View:
<form wire:submit="save">
<input type="text" wire:model="form.name">
<textarea wire:model="form.bio"></textarea>
<input type="file" wire:model="form.avatar">
@if ($form->avatar)
<img src="{{ $form->avatar->temporaryUrl() }}" width="100">
@endif
<button type="submit">Save Profile</button>
</form>
Form State Management
// app/Livewire/Forms/ArticleForm.php
class ArticleForm extends Form
{
public $title = '';
public $content = '';
public $status = 'draft';
public $lastSaved = null;
public function rules(): array
{
return [
'title' => $this->status === 'published' ? 'required|min:5' : 'nullable',
'content' => $this->status === 'published' ? 'required|min:100' : 'nullable',
];
}
public function saveDraft($articleId): void
{
Article::findOrFail($articleId)->update([
'title' => $this->title,
'content' => $this->content,
'status' => 'draft',
]);
$this->lastSaved = now()->format('g:i A');
}
public function publish($articleId): void
{
$this->status = 'published';
$this->validate();
Article::findOrFail($articleId)->update([
'title' => $this->title,
'content' => $this->content,
'status' => 'published',
'published_at' => now(),
]);
}
}
// app/Livewire/ArticleEditor.php
class ArticleEditor extends Component
{
public ArticleForm $form;
public Article $article;
public function mount(Article $article): void
{
$this->article = $article;
$this->form->fill($article);
}
public function updated($property): void
{
if (str_starts_with($property, 'form.')) {
$this->form->saveDraft($this->article->id);
}
}
public function publish(): void
{
$this->form->publish($this->article->id);
return redirect()->route('articles.show', $this->article);
}
}
Performance Optimization Tips
Use Debouncing for Live Validation
<!-- Validates immediately - lots of requests -->
<input wire:model.live="form.email">
<!-- Validates after 500ms of no typing - much better -->
<input wire:model.live.debounce.500ms="form.email">
Validate Only Changed Fields
public function updatedFormEmail(): void
{
$this->form->validateOnly('email');
}
Use Computed Properties Wisely
class CheckoutForm extends Form
{
public $items = [];
public $tax_rate = 0.08;
#[Computed]
public function subtotal(): float
{
return collect($this->items)->sum('price');
}
#[Computed]
public function tax(): float
{
return $this->subtotal() * $this->tax_rate;
}
#[Computed]
public function total(): float
{
return $this->subtotal() + $this->tax();
}
}
// Access in views as properties: {{ $form->total }}
Explore a live demo or discuss how custom Project can scale your bussiness!
Common Pitfalls and Solutions
Forgetting the form. Prefix
<!-- Wrong -->
<input wire:model="name">
<!-- Correct -->
<input wire:model="form.name">
Not Resetting After Submission
public function submit(): void
{
$this->form->validate();
// Save data...
$this->form->reset(); // Don't forget this!
session()->flash('success', 'Saved!');
}
Over-Validating
<!-- Bad: Database query on every keystroke -->
<input wire:model.live="form.username">
<!-- Good: Validate on blur -->
<input wire:model.blur="form.username">
Testing Form Objects
use App\Livewire\Forms\ContactForm;
use Illuminate\Validation\ValidationException;
test('contact form validates email', function () {
$form = new ContactForm();
$form->name = 'John Doe';
$form->email = 'invalid-email';
$form->message = 'Hello there';
expect(fn() => $form->validate())
->toThrow(ValidationException::class);
});
test('contact form accepts valid data', function () {
$form = new ContactForm();
$form->name = 'John Doe';
$form->email = '[email protected]';
$form->message = 'Hello there';
expect($form->validate())->toBeArray();
});
test('contact form stores data correctly', function () {
$form = new ContactForm();
$form->name = 'John Doe';
$form->email = '[email protected]';
$form->message = 'Hello there';
$form->store();
$this->assertDatabaseHas('contacts', [
'email' => '[email protected]',
]);
});
When NOT to Use Form Objects
Don't use for:
- Single-field forms (like search boxes)
- Simple toggles or checkboxes
- Forms with only 2-3 fields
- One-off forms used in a single place
Do use for:
- Forms with 5+ fields
- Complex validation logic
- Reusable forms across components
- Forms with custom methods or business logic
Production Checklist
- Add debouncing to all live-validated fields
- Implement proper error messages for all validation rules
- Test file upload size limits
- Add loading states for submission buttons
- Reset forms after successful submission
- Add server-side rate limiting for form submissions
- Test with real user data (long names, special characters)
- Ensure CSRF protection is enabled
Quick Reference
Create form object
php artisan livewire:form ContactFormUse in component
public ContactForm $form;Bind in view
< i nput wire:model="form.property">Custom methods
public function store() { /* logic */ }Nested forms
public PersonalInfoForm $personal;Conditional validation
'field' => $this->condition ? 'required' : 'nullable'Form Objects transform your code by
- Isolating form logic into dedicated, testable classes
- Making components cleaner and more focused
- Enabling reusability across your application
- Simplifying complex validation scenarios
Best practices
- Use debouncing for performance
- Reset forms after submission
- Validate conditionally when appropriate
- Test form objects independently
Remember
Form Objects aren't just a feature—they're a mindset shift toward cleaner, more maintainable Laravel applications.
The best code is boring code. Form Objects make your forms predictable, testable, and boring—in the best way possible.
Thank You for Spending Your Valuable Time
I truly appreciate you taking the time to read blog. Your valuable time means a lot to me, and I hope you found the content insightful and engaging!
Frequently Asked Questions
Use nested Form Objects when you have distinct sections with their own validation logic (like personal info, experience, and references in a job application). This approach keeps each section's logic isolated and reusable. However, if your form fields are closely related and share validation rules, a single Form Object is simpler and more appropriate. The rule of thumb: if you can describe different "parts" of your form as separate concepts, nest them.
Always use the TemporaryUploadedFile type hint and reset the file property after storing it with $this->reset('avatar'). Use Livewire's WithFileUploads trait in your component, not in the Form Object itself. For large files, add wire:loading states and consider using chunk uploads. Set appropriate validation rules (max file size) and always store files in the form's save method, not during validation, to avoid processing files multiple times.
wire:model.live validates on every keystroke, which is useful for instant feedback on simple fields but can cause performance issues with complex validation (like database queries). wire:model.blur only validates when the user leaves the field, reducing server requests significantly. Use .live.debounce.500ms as a middle ground for fields that benefit from live validation but don't need instant feedback on every character typed.
Form Objects can absolutely contain business logic beyond validation! Methods like store(), update(), or publish() belong in Form Objects when they're directly related to form submission. This keeps your Livewire components thin and focused on UI concerns. However, complex domain logic or operations involving multiple models should live in dedicated service classes or actions that your Form Object calls.
Form Objects are plain PHP classes, so you can instantiate and test them directly without rendering components. Set properties, call validate(), and assert exceptions are thrown for invalid data. Test custom methods like store() by checking database state afterward. This isolation makes unit tests fast and focused. You can test the form logic separately from the component's UI behavior, leading to better test coverage and easier debugging.
Comments are closed