Real-World Scenario
You’re building a B2B CRM (Customer Relationship Management) system for sales teams. Each company (team) needs to:- Track leads and opportunities through a sales pipeline
- Manage customer relationships
- Log interactions (calls, emails, meetings)
- Forecast revenue
- Collaborate on deals with team members
- Generate sales reports
The Business Model
SaaS CRM Platform:- Each company subscribes and gets their own workspace
- Sales teams can invite colleagues
- All customer data is isolated per team
- Subscription pricing based on # of users or features
- Integration with email and calendar
Database Structure
1. Customers Table
Copy
php artisan make:migration create_customers_table
Copy
Schema::create('customers', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
$table->string('company_name');
$table->string('contact_name');
$table->string('email')->nullable();
$table->string('phone')->nullable();
$table->string('website')->nullable();
$table->enum('industry', ['technology', 'finance', 'healthcare', 'retail', 'manufacturing', 'other'])->nullable();
$table->enum('company_size', ['1-10', '11-50', '51-200', '201-500', '500+'])->nullable();
$table->text('address')->nullable();
$table->enum('status', ['lead', 'prospect', 'customer', 'churned'])->default('lead');
$table->decimal('lifetime_value', 10, 2)->default(0);
$table->timestamps();
$table->softDeletes();
$table->index(['team_id', 'status']);
$table->index(['team_id', 'company_name']);
});
2. Deals Table
Track sales opportunities and pipeline:Copy
php artisan make:migration create_deals_table
Copy
Schema::create('deals', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
$table->foreignId('customer_id')->constrained()->cascadeOnDelete();
$table->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete();
$table->string('title');
$table->text('description')->nullable();
$table->decimal('value', 10, 2);
$table->enum('stage', [
'lead',
'qualified',
'proposal',
'negotiation',
'won',
'lost'
])->default('lead');
$table->integer('probability')->default(10); // 0-100%
$table->date('expected_close_date')->nullable();
$table->date('closed_at')->nullable();
$table->text('lost_reason')->nullable();
$table->timestamps();
$table->index(['team_id', 'stage']);
$table->index(['team_id', 'assigned_to']);
$table->index('expected_close_date');
});
3. Interactions Table
Log all customer touchpoints:Copy
Schema::create('interactions', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
$table->foreignId('customer_id')->constrained()->cascadeOnDelete();
$table->foreignId('deal_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('user_id')->constrained(); // Who logged it
$table->enum('type', ['call', 'email', 'meeting', 'note', 'task']);
$table->string('subject');
$table->text('notes')->nullable();
$table->timestamp('scheduled_at')->nullable();
$table->timestamp('completed_at')->nullable();
$table->enum('outcome', ['positive', 'neutral', 'negative'])->nullable();
$table->timestamps();
$table->index(['team_id', 'customer_id']);
$table->index(['team_id', 'type']);
$table->index('scheduled_at');
});
4. Revenue Table
Track actual revenue from customers:Copy
Schema::create('revenue', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
$table->foreignId('customer_id')->constrained()->cascadeOnDelete();
$table->foreignId('deal_id')->nullable()->constrained()->nullOnDelete();
$table->decimal('amount', 10, 2);
$table->date('revenue_date');
$table->enum('type', ['monthly_recurring', 'one_time', 'annual']);
$table->text('description')->nullable();
$table->timestamps();
$table->index(['team_id', 'revenue_date']);
$table->index(['team_id', 'customer_id']);
});
Complete Model Implementations
Customer Model
Copy
<?php
namespace App\Models;
use Filament\Facades\Filament;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Customer extends Model
{
use SoftDeletes;
protected $fillable = [
'team_id',
'company_name',
'contact_name',
'email',
'phone',
'website',
'industry',
'company_size',
'address',
'status',
'lifetime_value',
];
protected $casts = [
'lifetime_value' => 'decimal:2',
];
protected static function booted(): void
{
static::addGlobalScope('team', function (Builder $builder) {
if (Filament::getTenant()) {
$builder->where('team_id', Filament::getTenant()->id);
}
});
static::creating(function (Customer $customer) {
if (!$customer->team_id && Filament::getTenant()) {
$customer->team_id = Filament::getTenant()->id;
}
});
}
// Relationships
public function deals(): HasMany
{
return $this->hasMany(Deal::class);
}
public function interactions(): HasMany
{
return $this->hasMany(Interaction::class);
}
public function revenue(): HasMany
{
return $this->hasMany(Revenue::class);
}
// Business Logic
public function activeDeals(): HasMany
{
return $this->deals()->whereNotIn('stage', ['won', 'lost']);
}
public function wonDeals(): HasMany
{
return $this->deals()->where('stage', 'won');
}
public function getTotalDealsValueAttribute(): float
{
return $this->deals()->sum('value');
}
public function getPipelineValueAttribute(): float
{
return $this->activeDeals()->sum('value');
}
public function getLastInteractionAttribute(): ?Interaction
{
return $this->interactions()->latest()->first();
}
public function getDaysSinceLastContactAttribute(): ?int
{
$lastInteraction = $this->lastInteraction;
return $lastInteraction ? now()->diffInDays($lastInteraction->created_at) : null;
}
public function needsFollowUp(): bool
{
$daysSince = $this->days_since_last_contact;
return $daysSince === null || $daysSince > 14; // 2 weeks
}
}
Deal Model with Pipeline Logic
Copy
<?php
namespace App\Models;
use Filament\Facades\Filament;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Deal extends Model
{
protected $fillable = [
'team_id',
'customer_id',
'assigned_to',
'title',
'description',
'value',
'stage',
'probability',
'expected_close_date',
'closed_at',
'lost_reason',
];
protected $casts = [
'value' => 'decimal:2',
'probability' => 'integer',
'expected_close_date' => 'date',
'closed_at' => 'date',
];
protected static function booted(): void
{
static::addGlobalScope('team', function (Builder $builder) {
if (Filament::getTenant()) {
$builder->where('team_id', Filament::getTenant()->id);
}
});
static::creating(function (Deal $deal) {
if (!$deal->team_id && Filament::getTenant()) {
$deal->team_id = Filament::getTenant()->id;
}
});
// Auto-update probability based on stage
static::creating(function (Deal $deal) {
$deal->probability = self::getDefaultProbability($deal->stage);
});
// Mark as closed when won/lost
static::updating(function (Deal $deal) {
if ($deal->isDirty('stage') && in_array($deal->stage, ['won', 'lost'])) {
$deal->closed_at = now();
}
});
}
// Relationships
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
public function assignedTo(): BelongsTo
{
return $this->belongsTo(User::class, 'assigned_to');
}
public function interactions(): HasMany
{
return $this->hasMany(Interaction::class);
}
// Business Logic
public static function getDefaultProbability(string $stage): int
{
return match($stage) {
'lead' => 10,
'qualified' => 25,
'proposal' => 50,
'negotiation' => 75,
'won' => 100,
'lost' => 0,
};
}
public function getExpectedValueAttribute(): float
{
return $this->value * ($this->probability / 100);
}
public function getStageColorAttribute(): string
{
return match($this->stage) {
'won' => 'success',
'lost' => 'danger',
'negotiation' => 'warning',
'proposal' => 'info',
'qualified' => 'primary',
default => 'secondary',
};
}
public function markAsWon(): void
{
$this->update([
'stage' => 'won',
'probability' => 100,
'closed_at' => now(),
]);
// Update customer lifetime value
$this->customer->increment('lifetime_value', $this->value);
}
public function markAsLost(string $reason): void
{
$this->update([
'stage' => 'lost',
'probability' => 0,
'closed_at' => now(),
'lost_reason' => $reason,
]);
}
public function isOverdue(): bool
{
return $this->expected_close_date &&
$this->expected_close_date->isPast() &&
!in_array($this->stage, ['won', 'lost']);
}
}
Filament Resources
Deal Resource with Kanban View
Copy
<?php
namespace App\Filament\App\Resources;
use App\Models\Deal;
use Filament\Forms;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class DealResource extends Resource
{
protected static ?string $model = Deal::class;
protected static ?string $navigationIcon = 'heroicon-o-currency-dollar';
protected static ?string $navigationLabel = 'Deals';
public static function form(Forms\Form $form): Forms\Form
{
return $form
->schema([
Forms\Components\Section::make('Deal Information')
->schema([
Forms\Components\Select::make('customer_id')
->relationship('customer', 'company_name')
->required()
->searchable()
->preload()
->createOptionForm([
Forms\Components\TextInput::make('company_name')->required(),
Forms\Components\TextInput::make('contact_name')->required(),
Forms\Components\TextInput::make('email')->email(),
]),
Forms\Components\TextInput::make('title')
->required()
->maxLength(255)
->placeholder('Q4 Enterprise License'),
Forms\Components\Textarea::make('description')
->rows(3),
Forms\Components\TextInput::make('value')
->numeric()
->prefix('$')
->required(),
]),
Forms\Components\Section::make('Sales Process')
->schema([
Forms\Components\Select::make('stage')
->options([
'lead' => 'Lead',
'qualified' => 'Qualified',
'proposal' => 'Proposal Sent',
'negotiation' => 'Negotiation',
'won' => 'Won',
'lost' => 'Lost',
])
->required()
->live()
->afterStateUpdated(fn ($state, Forms\Set $set) =>
$set('probability', Deal::getDefaultProbability($state))
),
Forms\Components\TextInput::make('probability')
->numeric()
->suffix('%')
->minValue(0)
->maxValue(100)
->required(),
Forms\Components\Select::make('assigned_to')
->relationship('assignedTo', 'name')
->searchable()
->preload(),
Forms\Components\DatePicker::make('expected_close_date')
->native(false),
])
->columns(2),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('title')
->searchable()
->sortable()
->weight('bold'),
Tables\Columns\TextColumn::make('customer.company_name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('value')
->money('USD')
->sortable(),
Tables\Columns\TextColumn::make('expected_value')
->label('Expected')
->money('USD')
->sortable(query: function ($query, $direction) {
return $query->orderByRaw('value * (probability / 100) ' . $direction);
})
->description(fn (Deal $record) => $record->probability . '%'),
Tables\Columns\BadgeColumn::make('stage')
->colors([
'secondary' => 'lead',
'primary' => 'qualified',
'info' => 'proposal',
'warning' => 'negotiation',
'success' => 'won',
'danger' => 'lost',
]),
Tables\Columns\TextColumn::make('expected_close_date')
->date('M d, Y')
->sortable()
->color(fn (Deal $record) => $record->isOverdue() ? 'danger' : null),
Tables\Columns\TextColumn::make('assignedTo.name')
->label('Owner'),
])
->filters([
Tables\Filters\SelectFilter::make('stage')
->multiple()
->options([
'lead' => 'Lead',
'qualified' => 'Qualified',
'proposal' => 'Proposal',
'negotiation' => 'Negotiation',
'won' => 'Won',
'lost' => 'Lost',
]),
Tables\Filters\SelectFilter::make('assigned_to')
->relationship('assignedTo', 'name')
->label('Deal Owner'),
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\Action::make('won')
->icon('heroicon-o-check-circle')
->color('success')
->requiresConfirmation()
->visible(fn (Deal $record) => !in_array($record->stage, ['won', 'lost']))
->action(fn (Deal $record) => $record->markAsWon()),
Tables\Actions\Action::make('lost')
->icon('heroicon-o-x-circle')
->color('danger')
->requiresConfirmation()
->form([
Forms\Components\Textarea::make('reason')
->label('Loss Reason')
->required(),
])
->visible(fn (Deal $record) => !in_array($record->stage, ['won', 'lost']))
->action(fn (Deal $record, array $data) =>
$record->markAsLost($data['reason'])
),
])
->defaultSort('expected_close_date', 'asc');
}
}
Customer Resource
Copy
<?php
namespace App\Filament\App\Resources;
use App\Models\Customer;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class CustomerResource extends Resource
{
protected static ?string $model = Customer::class;
protected static ?string $navigationIcon = 'heroicon-o-building-office';
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('company_name')
->searchable()
->sortable()
->weight('bold'),
Tables\Columns\TextColumn::make('contact_name')
->searchable(),
Tables\Columns\BadgeColumn::make('status')
->colors([
'secondary' => 'lead',
'primary' => 'prospect',
'success' => 'customer',
'danger' => 'churned',
]),
Tables\Columns\TextColumn::make('pipeline_value')
->label('Pipeline')
->money('USD')
->description(fn (Customer $record) =>
$record->activeDeals()->count() . ' active deals'
),
Tables\Columns\TextColumn::make('lifetime_value')
->label('LTV')
->money('USD')
->sortable(),
Tables\Columns\IconColumn::make('needs_follow_up')
->label('Follow-up')
->boolean()
->trueIcon('heroicon-o-exclamation-triangle')
->falseIcon('heroicon-o-check-circle')
->trueColor('warning')
->falseColor('success')
->description(fn (Customer $record) =>
$record->days_since_last_contact ?
$record->days_since_last_contact . ' days ago' :
'No contact'
),
])
->filters([
Tables\Filters\SelectFilter::make('status')
->multiple()
->options([
'lead' => 'Lead',
'prospect' => 'Prospect',
'customer' => 'Customer',
'churned' => 'Churned',
]),
Tables\Filters\TernaryFilter::make('needs_follow_up')
->label('Needs Follow-up')
->queries(
true: fn ($query) => $query->whereDoesntHave('interactions')
->orWhereHas('interactions', function ($q) {
$q->latest()->take(1)->where('created_at', '<', now()->subDays(14));
}),
),
])
->actions([
Tables\Actions\ViewAction::make(),
Tables\Actions\EditAction::make(),
]);
}
}
Dashboard Widgets
Sales Pipeline Widget
Copy
<?php
namespace App\Filament\App\Widgets;
use App\Models\Deal;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class SalesPipelineWidget extends BaseWidget
{
protected function getStats(): array
{
$totalPipeline = Deal::whereNotIn('stage', ['won', 'lost'])->sum('value');
$expectedRevenue = Deal::whereNotIn('stage', ['won', 'lost'])
->get()
->sum('expected_value');
$wonThisMonth = Deal::where('stage', 'won')
->whereMonth('closed_at', now()->month)
->sum('value');
$dealsClosing = Deal::whereNotIn('stage', ['won', 'lost'])
->where('expected_close_date', '<=', now()->addDays(30))
->count();
return [
Stat::make('Total Pipeline', '$' . number_format($totalPipeline, 2))
->description("Expected: $" . number_format($expectedRevenue, 2))
->descriptionIcon('heroicon-o-currency-dollar')
->color('primary'),
Stat::make('Won This Month', '$' . number_format($wonThisMonth, 2))
->description('Closed deals')
->descriptionIcon('heroicon-o-trophy')
->color('success')
->chart([2000, 3000, 2500, 4000, 5500, 6000, $wonThisMonth]),
Stat::make('Closing Soon', $dealsClosing)
->description('Next 30 days')
->descriptionIcon('heroicon-o-calendar')
->color('warning'),
];
}
}
Why This Example is Production-Ready
Real Sales Methodology
- ✅ Proper sales pipeline stages
- ✅ Probability-based forecasting
- ✅ Expected vs actual value tracking
- ✅ Deal ownership and assignment
- ✅ Loss reason tracking
Complete CRM Features
- ✅ Customer lifecycle management
- ✅ Interaction logging
- ✅ Follow-up reminders
- ✅ Revenue tracking
- ✅ Lifetime value calculation
Business Intelligence
- ✅ Pipeline value calculations
- ✅ Expected revenue forecasting
- ✅ Won/Lost tracking
- ✅ Performance metrics
- ✅ Activity monitoring
User Experience
- ✅ Kanban board for deals (optional view)
- ✅ Quick win/loss actions
- ✅ Visual status indicators
- ✅ Smart filtering
- ✅ Mobile-friendly
Data Integrity
- ✅ Soft deletes for customers
- ✅ Proper foreign key relationships
- ✅ Automatic calculations
- ✅ State management (won/lost)
- ✅ Historical data preservation

