Livewire v3 introduced Form Objects to offload the field logic from the Component. In this tutorial, we'll build the create/edit modal forms powered by the Wire Elements package and reuse the same Livewire component and Form Object.
For the Laravel project visual design, we will use our own starter kit Laravel Breeze Pages Skeleton.
In the Model Product
we will have two DB fields: name
and description
.
database/migrations/xxx_create_products_table.php:
public function up(): void{ Schema::create('products', function (Blueprint $table) { $table->id(); $table->string('name'); $table->text('description'); $table->timestamps(); });}
app/Models/Product.php:
class Product extends Model{ protected $fillable = [ 'name', 'description', ];}
First, of course, we need to install install Livewire.
composer require livewire/livewire
Next, because Alpine.js is baked into the core of Livewire, we need to remove it from our Breeze-powered skeleton. If you don't use Laravel Breeze, you can skip this step.
resources/js/app.js:
import './bootstrap'; import Alpine from 'alpinejs'; window.Alpine = Alpine; Alpine.start();
And recompile assets.
npm run prod
The last thing is changing the main layout path.
Livewire looks for a layout in resources/views/components/layouts/app.blade.php
, but our Breeze-based Starter kit has it in a different place, so we need to set it in the Livewire config.
php artisan livewire:publish --config
config/livewire.php:
return [ // ... 'layout' => 'components.layouts.app', 'layout' => 'layouts.app', // ...];
And that's it, we can use Livewire in our project. Now, let's create a Livewire component to show the products list.
php artisan make:livewire ProductList
app/Livewire/ProductList.php:
use App\Models\Product;use Illuminate\Contracts\View\View; class ProductList extends Component{ public function render(): View { return view('livewire.product-list', [ 'products' => Product::all(), ]); }}
Next, let's add a Route leading to that Livewire component. We will use it as a full-page Livewire component, without Laravel Controller:
routes/web.php
use App\Livewire\ProductList; // ... Route::get('products', ProductList::class)->name('products');
Let's add a link to the navigation and show the products table.
resources/views/layouts/navigation.blade.php:
// ... <div class="hidden space-x-8 sm:-my-px sm:ml-10 sm:flex"> <x-nav-link :href="route('users.index')" :active="request()->routeIs('users.index')"> {{ __('Users') }} </x-nav-link> <x-nav-link :href="route('products')" :active="request()->routeIs('products')"> {{ __('Products') }} </x-nav-link> </div> // ...
resources/views/livewire/product-list.blade.php:
<div> <x-slot name="header"> <h2 class="text-xl font-semibold leading-tight text-gray-800"> {{ __('Products') }} </h2> </x-slot> <div class="py-12"> <div class="mx-auto max-w-7xl sm:px-6 lg:px-8"> <div class="overflow-hidden bg-white shadow-sm sm:rounded-lg"> <div class="p-6 overflow-hidden overflow-x-auto bg-white border-b border-gray-200"> <div class="min-w-full align-middle"> <table class="min-w-full border divide-y divide-gray-200"> <thead> <tr> <th class="px-6 py-3 text-left bg-gray-50"> <span class="text-xs font-medium leading-4 tracking-wider text-gray-500 uppercase">Name</span> </th> <th class="px-6 py-3 text-left bg-gray-50"> <span class="text-xs font-medium leading-4 tracking-wider text-gray-500 uppercase">Description</span> </th> <th class="px-6 py-3 text-left bg-gray-50"> </th> </tr> </thead> <tbody class="bg-white divide-y divide-gray-200 divide-solid"> @forelse($products as $product) <tr class="bg-white"> <td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap"> {{ $product->name }} </td> <td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap"> {{ $product->description }} </td> <td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap"> {{-- Edit Button --}} </td> </tr> @empty <tr class="bg-white"> <td colspan="3" class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap"> No products found. </td> </tr> @endforelse </tbody> </table> </div> </div> </div> </div> </div></div>
Now, let's create a component that will be a modal and use Form Object for properties, validation, etc.
php artisan make:livewire ProductModalphp artisan livewire:form ProductForm
We will use a package wire-elements/modal for modal.
composer require wire-elements/modal:^2.0
To make a Livewire component as a modal, it needs to extend LivewireUI\Modal\ModalComponent
instead of Livewire\Component
.
app/Livewire/ProductModal.php:
use Livewire\Component;use Illuminate\Contracts\View\View;use LivewireUI\Modal\ModalComponent; class ProductModal extends Component class ProductModal extends ModalComponent { public function render(): View { return view('livewire.product-form'); }}
And let's add a form to the Blade file.
resources/views/livewire/product-form.blade.php:
<div class="p-6"> <form wire:submit="save"> <div> <x-input-label for="name" :value="__('Name')" /> <x-text-input id="name" class="mt-1 block w-full" type="text" /> </div> <div class="mt-4"> <x-input-label for="description" :value="__('Description')" /> <textarea id="description" class="mt-1 w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:focus:border-indigo-600"></textarea> </div> <div class="mt-4"> <x-primary-button> Save </x-primary-button> </div> </form></div>
Notice: we're reusing Blade components like x-text-input
and x-primary-button
from the default Laravel Breeze.
Next, we need buttons to open a modal.
resources/views/livewire/product-list.blade.php:
// ... <x-primary-button wire:click="$dispatch('openModal', { component: 'product-modal' })" class="mb-4"> New Product</x-primary-button> // ...@forelse($products as $product) <tr class="bg-white"> <td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap"> {{ $product->name }} </td> <td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap"> {{ $product->description }} </td> <td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap"> <x-secondary-button wire:click="$dispatch('openModal', { component: 'product-modal', arguments: { product: {{ $product }} }})"> Edit </x-secondary-button> </td> </tr>@empty// ...
After pressing the New Product
button, we see an opened modal with the form inside.
Now that we can open a modal, let's create and update a product.
First, we must add properties in the Form Object and bind them to inputs.
app/Livewire/Forms/ProductForm.php:
class ProductForm extends Form{ public string $name = ''; public string $description = '';}
app/Livewire/ProductModal.php:
class ProductModal extends ModalComponent{ public Forms\ProductForm $form; // ...}
resources/views/livewire/product-modal.blade.php:
<div class="p-6"> <form wire:submit="save"> <div> <x-input-label for="name" :value="__('Name')" /> <x-text-input id="name" class="mt-1 block w-full" type="text" /> <x-text-input wire:model="form.name" id="name" class="mt-1 block w-full" type="text" /> <x-input-error :messages="$errors->get('form.name')" class="mt-2" /> </div> <div class="mt-4"> <x-input-label for="description" :value="__('Description')" /> <textarea id="description" class="mt-1 w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:focus:border-indigo-600"></textarea> <textarea wire:model="form.description" id="description" class="mt-1 w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:focus:border-indigo-600"></textarea> <x-input-error :messages="$errors->get('form.description')" class="mt-2" /> </div> <div class="mt-4"> <x-primary-button> Save </x-primary-button> </div> </form></div>
For saving, we defined the save
method in the wire:submit
directive. The whole creating and updating logic will be in the Form Object. In the modal component we just need to call it.
app/Livewire/ProductForm.php:
class ProductForm extends Form{ public string $name = ''; public string $description = ''; public function save(): void { $this->validate(); Product::create($this->only(['name', 'description'])); $this->reset(); } public function rules(): array { return [ 'name' => [ 'required', ], 'description' => [ 'required' ], ]; }}
We are validating inputs and creating a DB record.
app/Livewire/ProductModal.php:
class ProductModal extends ModalComponent{ public Forms\ProductForm $form; public function save(): void { $this->form->save(); $this->closeModal(); } // ...}
After saving a product, we close a modal. And, of course, we need to refresh the table automatically.
app/Livewire/ProductModal.php:
class ProductModal extends ModalComponent{ public Forms\ProductForm $form; public function save(): void { $this->form->save(); $this->closeModal(); $this->dispatch('refresh-list'); } // ...}
app/Livewire/ProductList.php:
use Livewire\Attributes\On; class ProductList extends Component{ // ... #[On('refresh-list')] public function refresh() {} }
Now, let's add logic to edit the product. First, we need to set properties only if a product is passed.
app/Livewire/ProductModal.php:
use App\Models\Product; class ProductModal extends ModalComponent{ public ?Product $product = null; public Forms\ProductForm $form; public function mount(Product $product = null): void { if ($product->exists) { $this->form->setProduct($product); } } // ...}
app/Livewire/Forms/ProductForm.php:
use App\Models\Product; class ProductForm extends Form{ public ?Product $product = null; public string $name = ''; public string $description = ''; public function setProduct(?Product $product = null): void { $this->product = $product; $this->name = $product->name; $this->description = $product->description; } // ...}
For updating a record in the DB, we need to do a simple check and, based on it, create or update a record.
app/Livewire/Forms/ProductForm.php:
class ProductForm extends Form{ // ... public function save(): void { $this->validate(); if (empty($this->product)) { Product::create($this->only(['name', 'description'])); } else { $this->product->update($this->only(['name', 'description'])); } $this->reset(); } // ...}
Now, we can create and update products using the same Livewire modal component and the same Form Object.
Now, let's add a unique rule for the product name.
app/Livewire/Forms/ProductForm.php:
use Illuminate\Validation\Rule; class ProductForm extends Form{ // ... public function rules(): array { return [ 'name' => [ 'required', Rule::unique('products', 'name')->ignore($this->component->product), ], 'description' => [ 'required' ], ]; }}
The crucial part here is how to ignore record when editing a product. We can access properties from the component in the Livewire component using $this->component
.
When validation rules are triggered now because we use Form Object, the attributes are called with a prefix of form.
We can fix it by just adding a validationAttributes
method.
app/Livewire/Forms/ProductForm.php:
class ProductForm extends Form{ // ... public function validationAttributes(): array { return [ 'name' => 'name', 'description' => 'description', ]; }}
Now, they are shown as expected.
The full code can be found in the GitHub repository.
You may also be interested in our PREMIUM course Livewire 3 From Scratch