DEV Community

Software Solutions
Software Solutions

Posted on

Building a Custom CRM with Laravel 12: From Authentication to Lead Management

Off-the-shelf CRMs like HubSpot or Salesforce are feature-packed, but they often come with steep pricing tiers, bloated interfaces, and rigid workflows that don't fit unique business models.

When your business needs tailored lead pipelines, custom reporting, and granular role permissions, building a custom CRM with Laravel is one of the most cost-effective and scalable routes you can take.

With the release of Laravel 12, building enterprise-grade tools is faster and cleaner than ever. In this comprehensive guide, we'll build a custom CRM foundation covering authentication, multi-tenant lead pipelines, dynamic status management, and automated follow-up scheduling.


1. CRM Database Architecture

A custom CRM revolves around three core pillars: Users (Sales Reps/Managers), Companies (Accounts), and Leads (Opportunities).

+---------------+        +---------------+        +---------------+
|     Users     | 1 <-> M|     Leads     |M <-> 1 |   Companies   |
+---------------+        +---------------+        +---------------+
| id            |        | id            |        | id            |
| name          |        | title         |        | name          |
| email         |        | value         |        | industry      |
| role          |        | status        |        | phone         |
+---------------+        | user_id       |        +---------------+
| company_id    |
+---------------+
Enter fullscreen mode Exit fullscreen mode

Let's generate the models and migrations:

php artisan make:model Company -m
php artisan make:model Lead -mf

Enter fullscreen mode Exit fullscreen mode

Companies Migration (create_companies_table.php)

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('companies', function (Blueprint $table) {
            $table->id();$table->string('name');
            $table->string('email')->nullable();$table->string('phone')->nullable();
            $table->string('industry')->nullable();$table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('companies');
    }
};
Enter fullscreen mode Exit fullscreen mode

Leads Migration (create_leads_table.php)

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('leads', function (Blueprint $table) {
            $table->id();$table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->foreignId('company_id')->nullable()->constrained()->nullOnDelete();$table->string('title');
            $table->decimal('value', 12, 2)->default(0.00);$table->string('status')->default('new'); // new, contacted, qualified, won, lost
            $table->timestamp('contacted_at')->nullable();$table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('leads');
    }
};

Enter fullscreen mode Exit fullscreen mode

2. Defining Eloquent Relationships & Enums

Using PHP 8 Enums for lead status ensures strict typing and prevents invalid data entry across your API endpoints and Blade/Vue views.

Define Lead Status Enum (app/Enums/LeadStatus.php)

namespace App\Enums;

enum LeadStatus: string
{
    case NEW = 'new';
    case CONTACTED = 'contacted';
    case QUALIFIED = 'qualified';
    case WON = 'won';
    case LOST = 'lost';

    public function label(): string
    {
        return match($this) {
            self::NEW => 'New Lead',
            self::CONTACTED => 'Contacted',
            self::QUALIFIED => 'Qualified',
            self::WON => 'Deal Won',
            self::LOST => 'Deal Lost',
        };
    }
}

Enter fullscreen mode Exit fullscreen mode

Update Lead Model (app/Models/Lead.php)


namespace App\Models;

use App\Enums\LeadStatus;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Lead extends Model
{
    use HasFactory;

    protected $fillable = [
        'user_id',
        'company_id',
        'title',
        'value',
        'status',
        'contacted_at',
    ];

    protected function casts(): array
    {
        return [
            'status' => LeadStatus::class,
            'value' => 'decimal:2',
            'contacted_at' => 'datetime',
        ];
    }

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    public function company(): BelongsTo
    {
        return $this->belongsTo(Company::class);
    }
}

Enter fullscreen mode Exit fullscreen mode

3. Secure Lead Access via Policy Authorization

Sales teams need clear access boundaries: Sales Reps should only view/manage their assigned leads, while Managers can view and reassign all deals.

Generate a Lead Policy:

app/Policies/LeadPolicy.php


namespace App\Policies;

use App\Models\Lead;
use App\Models\User;

class LeadPolicy
{
    public function viewAny(User $user): bool
    {
        return true;
    }

    public function view(User $user, Lead$lead): bool
    {
        return $user->role === 'manager' || $lead->user_id ===$user->id;
    }

    public function update(User $user, Lead$lead): bool
    {
        return $user->role === 'manager' || $lead->user_id ===$user->id;
    }

    public function delete(User $user, Lead$lead): bool
    {
        return $user->role === 'manager';
    }
}

Enter fullscreen mode Exit fullscreen mode

4. Building the Lead Management Controller

Let's build a clean, production-ready controller using Form Requests for validation.


php artisan make:request StoreLeadRequest
php artisan make:controller LeadController

Enter fullscreen mode Exit fullscreen mode

Validation Request (app/Http/Requests/StoreLeadRequest.php)


namespace App\Http\Requests;

use App\Enums\LeadStatus;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Enum;

class StoreLeadRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'company_id' => ['nullable', 'exists:companies,id'],
            'title' => ['required', 'string', 'max:255'],
            'value' => ['required', 'numeric', 'min:0'],
            'status' => ['required', new Enum(LeadStatus::class)],
        ];
    }
}

Enter fullscreen mode Exit fullscreen mode

app/Http/Controllers/LeadController.php


namespace App\Http\Controllers;

use App\Http\Requests\StoreLeadRequest;
use App\Models\Lead;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;

class LeadController extends Controller
{
    public function index(Request $request)
    {
        $query = Lead::with(['company', 'user']);

        // Filter leads based on user role
        if ($request->user()->role !== 'manager') {
            $query->where('user_id',$request->user()->id);
        }

        $leads =$query->latest()->paginate(15);

        return view('leads.index', compact('leads'));
    }

    public function store(StoreLeadRequest $request): RedirectResponse
    {
        $request->user()->leads()->create($request->validated());

        return redirect()->route('leads.index')
            ->with('success', 'Lead created successfully.');
    }

    public function updateStatus(Request $request, Lead$lead): RedirectResponse
    {
        Gate::authorize('update', $lead);

        $request->validate([
            'status' => ['required', new Enum(LeadStatus::class)],
        ]);

        $lead->update([
            'status' => $request->status,
            'contacted_at' => $request->status === LeadStatus::CONTACTED->value ? now() : $lead->contacted_at,
        ]);

        return back()->with('success', 'Lead status updated.');
    }
}

Enter fullscreen mode Exit fullscreen mode

5. Automated Lead Follow-Up via Scheduled Tasks

In a CRM, leads quickly go cold if reps don't follow up promptly. Let's create an automated command to alert reps about uncontacted leads that have been sitting for over 48 hours.

php artisan make:command SendLeadFollowUpAlerts

Enter fullscreen mode Exit fullscreen mode

app/Console/Commands/SendLeadFollowUpAlerts.php


namespace App\Console\Commands;

use App\Enums\LeadStatus;
use App\Models\Lead;
use App\Notifications\LeadFollowUpNotification;
use Illuminate\Console\Command;

class SendLeadFollowUpAlerts extends Command
{
    protected $signature = 'crm:send-followup-alerts';
    protected $description = 'Alert sales reps about uncontacted leads older than 48 hours';

    public function handle(): void
    {
        $staleLeads = Lead::where('status', LeadStatus::NEW)
            ->where('created_at', '<=', now()->subHours(48))
            ->with('user')
            ->get();

        foreach ($staleLeads as$lead) {
            $lead->user->notify(new LeadFollowUpNotification($lead));
        }

        $this->info("Follow-up notifications sent for {$staleLeads->count()} stale leads.");
    }
}

Enter fullscreen mode Exit fullscreen mode

Schedule this task in Laravel 12 inside routes/console.php:


use Illuminate\Support\Facades\Schedule;

Schedule::command('crm:send-followup-alerts')->dailyAt('09:00');

Enter fullscreen mode Exit fullscreen mode

Key Takeaways for Building Custom CRMs

  1. Leverage Enums: Keep lead statuses, deal stages, and user roles type-safe across your codebase.
  2. Strict Authorization Policies: Enforce granular visibility checks so reps only access deals assigned to them.
  3. Automate Tasks: Use Laravel's Scheduler and Queue system to automate email follow-ups, stale lead alerts, and weekly pipeline summaries.

Need a Scalable Custom CRM for Your Business?

Building enterprise web platforms requires robust database design, secure role-based permissions, and scalable server architecture.

Partner with Software Solutions for custom PHP & Laravel development, enterprise software engineering, CRM builds, and cloud solutions tailored to your growth goals.

What features are essential in your team's CRM workflow? Share your thoughts in the comments below!

Top comments (0)