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:
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.
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:
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.
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:
updateTotals
functionThis 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:
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: