Next, we will dive deeper into a specific CRM use case: Products and Quotes. In this lesson, we will start creating our simple Products and allow users to create Quotes that later we will turn into a PDF. Here's what our Products and Quotes will look like:
In this lesson, we will do the following:
- Create a Product Model, Database Table, and CRUD
- Create a Quote Model, Database Table, and CRUD
- Create a complex Quote create/edit form with real-time calculations
- Create an action button on the Customer list to create a Quote
- Modify how our Customer actions look like
Creating the Product Model
Our first task is to create a Product table in the database so that we would have something to sell:
Migration
Schema::create('products', function (Blueprint $table) { $table->id(); $table->string('name'); $table->integer('price'); $table->timestamps();});
Next, let's work on the Model:
app/Models/Product.php
use Illuminate\Database\Eloquent\Casts\Attribute;use Illuminate\Database\Eloquent\Factories\HasFactory;use Illuminate\Database\Eloquent\Model;use Illuminate\Database\Eloquent\Relations\BelongsToMany; class Product extends Model{ use HasFactory; protected $fillable = ['name', 'price']; protected function price(): Attribute { return Attribute::make( get: static fn($value) => $value / 100, set: static fn($value) => $value * 100, ); }}
Lastly, for our Database setup, we need a seeder:
database/seeders/DatabaseSeeder.php
use App\Models\Product; // ... public function run(): void{ // ... $products = [ ['name' => 'Product 1', 'price' => 12.99], ['name' => 'Product 2', 'price' => 2.99], ['name' => 'Product 3', 'price' => 55.99], ['name' => 'Product 4', 'price' => 99.99], ['name' => 'Product 5', 'price' => 1.99], ['name' => 'Product 6', 'price' => 12.99], ['name' => 'Product 7', 'price' => 15.99], ['name' => 'Product 8', 'price' => 29.99], ['name' => 'Product 9', 'price' => 33.99], ['name' => 'Product 10', 'price' => 62.99], ['name' => 'Product 11', 'price' => 42.99], ['name' => 'Product 12', 'price' => 112.99], ['name' => 'Product 13', 'price' => 602.99], ['name' => 'Product 14', 'price' => 129.99], ['name' => 'Product 15', 'price' => 1200.99], ]; foreach ($products as $product) { Product::create($product); }}
Then running php artisan migrate:fresh --seed
will give us a simple set of products to test the system.
Creating Product Resource
Next, we want to manage the products using Filament, so let's create a new resource:
php artisan make:filament-resource Product --generate
This will generate all of our Resource files. This time, we don't have to customize anything on them:
Creating the Quote Model
Next, we will work on our Quote database table and model. First, let's create the migration:
Migration
use App\Models\Customer; // ... Schema::create('quotes', function (Blueprint $table) { $table->id(); $table->foreignIdFor(Customer::class)->constrained(); $table->integer('taxes'); $table->timestamps();});
Next, let's work on the Model:
app/Models/Quote.php
use Illuminate\Database\Eloquent\Casts\Attribute;use Illuminate\Database\Eloquent\Model;use Illuminate\Database\Eloquent\Relations\BelongsTo;use Illuminate\Database\Eloquent\Relations\BelongsToMany;use Illuminate\Database\Eloquent\Relations\HasMany; class Quote extends Model{ protected $fillable = ['customer_id', 'taxes']; public function customer(): BelongsTo { return $this->belongsTo(Customer::class); }}
As you can see, we are still missing the connection to our Products, so let's add that:
Migration
use App\Models\Product;use App\Models\Quote; // ... Schema::create('product_quote', function (Blueprint $table) { $table->id(); $table->foreignIdFor(Quote::class)->constrained(); $table->foreignIdFor(Product::class)->constrained(); $table->unsignedInteger('quantity'); $table->integer('price'); $table->timestamps();});
This way, we created a pivot table with the quantity
and price
columns. Next, let's add the relationship to our Quote model:
app/Models/Quote.php
use Illuminate\Database\Eloquent\Relations\BelongsToMany; // ... public function products(): BelongsToMany{ return $this->belongsToMany(Product::class)->withPivot(['quantity', 'price']);}
But this is not enough for Filament, as it needs a pivot model to work correctly, so let's create that too:
app/Models/ProductQuote.php
use Illuminate\Database\Eloquent\Casts\Attribute;use Illuminate\Database\Eloquent\Relations\BelongsTo;use Illuminate\Database\Eloquent\Relations\Pivot; class ProductQuote extends Pivot{ public $incrementing = true; public $timestamps = false; protected function price(): Attribute { return Attribute::make( get: fn($value) => $value / 100, set: fn($value) => $value * 100, ); } public function quote(): BelongsTo { return $this->belongsTo(Quote::class); } public function product(): BelongsTo { return $this->belongsTo(Product::class); }}
Then we can finish our Quote Model:
app/Models/Quote.php
use Illuminate\Database\Eloquent\Relations\HasMany; // ... public function quoteProducts(): HasMany{ return $this->hasMany(ProductQuote::class);} protected function total(): Attribute{ return Attribute::make( get: function () { $total = 0; foreach ($this->quoteProducts as $product) { $total += $product->price * $product->quantity; } return $total * (1 + (is_numeric($this->taxes) ? $this->taxes : 0) / 100); } );} protected function subtotal(): Attribute{ return Attribute::make( get: function () { $subtotal = 0; foreach ($this->quoteProducts as $product) { $subtotal += $product->price * $product->quantity; } return $subtotal; } );}
As you can see, we have added another relationship - quoteProducts
. It will be used inside the Filament to create many-to-many records. As for our total()
and subtotal()
functions - we will use them to calculate the total and subtotal of the Quote in real time using Laravel's Attribute Casting.
Creating Quote Resource
Next, we want to manage the Quotes using Filament, so let's create a new resource:
php artisan make:filament-resource Quote --generate
This generated our resource, and visiting it - we can see that once again, we will have to make significant modifications:
So let's do that and create a modified form that will allow us to create a Quote with Products:
app/Filament/Resources/QuoteResource.php
use App\Models\Customeruse Filament\Forms\Components\Sectionuse Filament\Forms\Getuse Filament\Forms\Setuse App\Models\Productuse Filament\Forms\Components\Actions\Action // ... public static function form(Form $form): Form{ return $form ->schema([ Forms\Components\Select::make('customer_id') ->searchable() ->relationship('customer') ->getOptionLabelFromRecordUsing(fn(Customer $record) => $record->first_name . ' ' . $record->last_name) ->searchable(['first_name', 'last_name']) ->default(request()->has('customer_id') ? request()->get('customer_id') : null) ->required(), Section::make() ->columns(1) ->schema([ Forms\Components\Repeater::make('quoteProducts') ->relationship() ->schema([ Forms\Components\Select::make('product_id') ->relationship('product', 'name') ->disableOptionWhen(function ($value, $state, Get $get) { return collect($get('../*.product_id')) ->reject(fn($id) => $id == $state) ->filter() ->contains($value); }) ->live() ->afterStateUpdated(function (Get $get, Set $set, $livewire) { $set('price', Product::find($get('product_id'))->price); self::updateTotals($get, $livewire); }) ->required(), Forms\Components\TextInput::make('price') ->required() ->numeric() ->live() ->afterStateUpdated(function (Get $get, $livewire) { self::updateTotals($get, $livewire); }) ->prefix('$'), Forms\Components\TextInput::make('quantity') ->integer() ->default(1) ->required() ->live() ]) ->live() ->afterStateUpdated(function (Get $get, $livewire) { self::updateTotals($get, $livewire); }) ->afterStateHydrated(function (Get $get, $livewire) { self::updateTotals($get, $livewire); }) ->deleteAction( fn(Action $action) => $action->after(fn(Get $get, $livewire) => self::updateTotals($get, $livewire)), ) ->reorderable(false) ->columns(3) ]), Section::make() ->columns(1) ->maxWidth('1/2') ->schema([ Forms\Components\TextInput::make('subtotal') ->numeric() ->readOnly() ->prefix('$') ->afterStateUpdated(function (Get $get, $livewire) { self::updateTotals($get, $livewire); }), Forms\Components\TextInput::make('taxes') ->suffix('%') ->required() ->numeric() ->default(20) ->live(true) ->afterStateUpdated(function (Get $get, $livewire) { self::updateTotals($get, $livewire); }), Forms\Components\TextInput::make('total') ->numeric() ->readOnly() ->prefix('$') ]) ]);} public static function updateTotals(Get $get, $livewire): void{ // Retrieve the state path of the form. Most likely, it's `data` but could be something else. $statePath = $livewire->getFormStatePath(); $products = data_get($livewire, $statePath . '.quoteProducts'); if (collect($products)->isEmpty()) { return; } $selectedProducts = collect($products)->filter(fn($item) => !empty($item['product_id']) && !empty($item['quantity'])); $prices = collect($products)->pluck('price', 'product_id'); $subtotal = $selectedProducts->reduce(function ($subtotal, $product) use ($prices) { return $subtotal + ($prices[$product['product_id']] * $product['quantity']); }, 0); data_set($livewire, $statePath . '.subtotal', number_format($subtotal, 2, '.', '')); data_set($livewire, $statePath . '.total', number_format($subtotal + ($subtotal * (data_get($livewire, $statePath . '.taxes') / 100)), 2, '.', ''));} // ...
While this code seems really complex, it's actually just doing the following:
- Adds a Customer select field with a search
- Adds a Repeater field for our Quote Products
- This field allows us to add multiple products to the Quote
- Each of the Products has price (changeable) and quantity (changeable)
- You can add/remove products as needed
- Adds a Subtotal, Taxes, and Total fields
- Subtotal is calculated by adding all the products together (price * quantity)
- Taxes is a percentage that is added to the subtotal
- Total is the subtotal + Taxes
- All of these fields are reactive and calculated in real-time with the
updateTotals
function
This is what our form looks like:
Once we create a Quote - we can see that there's an ugly List page being loaded:
Let's fix that to display the correct information:
app/Filament/Resources/QuoteResource.php
// ... public static function table(Table $table): Table{ return $table ->columns([ Tables\Columns\TextColumn::make('customer.first_name') ->formatStateUsing(function ($record) { return $record->customer->first_name . ' ' . $record->customer->last_name; }) ->searchable(['first_name', 'last_name']) ->sortable(), Tables\Columns\TextColumn::make('taxes') ->numeric() ->suffix('%') ->sortable(), Tables\Columns\TextColumn::make('subtotal') ->numeric() ->money() ->sortable(), Tables\Columns\TextColumn::make('total') ->numeric() ->money() ->sortable(), Tables\Columns\TextColumn::make('created_at') ->dateTime() ->sortable() ->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('updated_at') ->dateTime() ->sortable() ->toggleable(isToggledHiddenByDefault: true), ]) ->filters([ // ]) ->actions([ Tables\Actions\EditAction::make(), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ Tables\Actions\DeleteBulkAction::make(), ]), ]);} // ...
Now, loading the same page - we can see that it looks much better:
Create Quotes From Customer Table
To make life easier for our users, we want to add a button to the Customer table to allow us to create a Quote for that Customer. Let's do this:
app/Filament/Resources/CustomerResource.php
use App\Filament\Resources\QuoteResource\Pages\CreateQuote; // ... public static function table(Table $table): Table{ return $table // ... ->actions([ // ... Tables\Actions\Action::make('Create Quote') ->icon('heroicon-m-book-open') ->url(function ($record) { return CreateQuote::getUrl(['customer_id' => $record->id]); }) ]) // ...}
This indeed added our Creation Quote action that links to our Quote creation page, but it made our Customer table look a bit ugly:
Let's fix that by adding a dropdown menu for all the actions:
app/Filament/Resources/CustomerResource.php
use App\Filament\Resources\QuoteResource\Pages\CreateQuote; // ... public static function table(Table $table): Table{ return $table // ... ->actions([ Tables\Actions\ActionGroup::make([ // ... ]) ]) // ...}
Once we surrounded our Actions with an ActionGroup
we can see that it looks much better:
I've updated to PHP 8.3 and disabled lazy loading and now I have lazy loading issues with the product name in the product-quote-list.blade.php, anybody else is having this issue or did I messed something else?
The error : Attempted to lazy load [product] on model [App\Models\ProductQuote] but lazy loading is disabled.
It might have been there at the start, will have to take a look to see what causes it. Thanks for reporting!
Hi, sorry for overdue reply - I have checked the project with LazyLoading prevented and it seems to function just fine. I was unable to find any issues with this.
Could you provide more details on your issue? Especially from where did the file
product-quote-list.blade.php
came from, as we are also unable to find this in our projectHi. In principle, could you use this lesson and the last one to create a printable list of users loaded onto an event? I'm having issues finding a solution in filament for just that and this could be my saviour! Great course by the way
In theory - yes, this can be done. But I'm not sure exactly as I haven't played around with printable lists at all
Thanks. I will give it a go and if I get anything workable, I will post back here.