Skip to main content

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

php artisan make:migration create_customers_table
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:
php artisan make:migration create_deals_table
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:
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:
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

<?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

<?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

<?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

<?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

<?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
This CRM example demonstrates how to build a sophisticated, real-world application with Larafast Multi-Tenancy!