Customer Relationship Management (Mini CRM) system built on Laravel 12 — including Contacts, Deals, Notes, Stages, Filtering, and Authentication. Everything is structured so you can copy & paste directly into a fresh Laravel 12 project.
I reference the official Laravel docs for the installation steps.
✅ Mini CRM App — Laravel 12
Your CRM will include:
Entities
✔ Customers
✔ Deals associated with each customer
✔ Notes (timeline activities)
✔ Deal stages (New → Contacted → Proposal → Won/Lost)
✔ User authentication (Laravel Breeze)
✔ Search + filter
✔ Dashboard (Total customers, active deals, pipeline value)
1) Install Laravel 12
bash
composer create-project laravel/laravel mini-crm
cd mini-crm
bash
composer require laravel/breeze --dev
php artisan breeze:install blade
npm install
npm run dev
php artisan migrate
3) Models You Need
We will generate CRM models:
Customer
Deal
Note
Run:
bash
php artisan make:model Customer -m -c -r -f
php artisan make:model Deal -m -c -r -f
php artisan make:model Note -m -c -r -f
4) Migrations (Full CRM Schema)
customers table
bash
// database/migrations/xxxx_create_customers_table.php
public function up()
{
Schema::create('customers', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->nullable();
$table->string('phone')->nullable();
$table->string('company')->nullable();
$table->text('address')->nullable();
$table->timestamps();
});
}
bash
// database/migrations/xxxx_create_deals_table.php
public function up()
{
Schema::create('deals', function (Blueprint $table) {
$table->id();
$table->foreignId('customer_id')->constrained()->cascadeOnDelete();
$table->string('title');
$table->decimal('amount', 10, 2)->default(0);
$table->enum('stage', ['new', 'contacted', 'proposal', 'won', 'lost'])->default('new');
$table->timestamps();
});
}
bash
// database/migrations/xxxx_create_notes_table.php
public function up()
{
Schema::create('notes', function (Blueprint $table) {
$table->id();
$table->foreignId('customer_id')->constrained()->cascadeOnDelete();
$table->text('note');
$table->timestamps();
});
}
bash
php artisan migrate
5) Model Relationships
Customer
bash
// app/Models/Customer.php
class Customer extends Model
{
use HasFactory;
protected $fillable = ['name','email','phone','company','address'];
public function deals()
{
return $this->hasMany(Deal::class);
}
public function notes()
{
return $this->hasMany(Note::class);
}
}
bash
// app/Models/Deal.php
class Deal extends Model
{
use HasFactory;
protected $fillable = ['customer_id','title','amount','stage'];
public function customer()
{
return $this->belongsTo(Customer::class);
}
}
bash
// app/Models/Note.php
class Note extends Model
{
use HasFactory;
protected $fillable = ['customer_id','note'];
public function customer()
{
return $this->belongsTo(Customer::class);
}
}
bash
// app/Http/Controllers/CustomerController.php
class CustomerController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function index(Request $request)
{
$q = $request->query('q');
$customers = Customer::when($q, function ($query) use($q) {
$query->where('name', 'like', "%$q%")
->orWhere('email', 'like', "%$q%")
->orWhere('company', 'like', "%$q%");
})
->orderBy('id','desc')
->paginate(10);
return view('customers.index', compact('customers','q'));
}
public function create()
{
return view('customers.create');
}
public function store(Request $request)
{
$data = $request->validate([
'name'=>'required',
'email'=>'nullable|email',
'phone'=>'nullable|string',
'company'=>'nullable|string',
'address'=>'nullable|string'
]);
Customer::create($data);
return redirect()->route('customers.index')->with('success','Customer added.');
}
public function show(Customer $customer)
{
return view('customers.show', compact('customer'));
}
public function edit(Customer $customer)
{
return view('customers.edit', compact('customer'));
}
public function update(Request $request, Customer $customer)
{
$data = $request->validate([
'name'=>'required',
'email'=>'nullable|email',
'phone'=>'nullable|string',
'company'=>'nullable|string',
'address'=>'nullable|string'
]);
$customer->update($data);
return redirect()->route('customers.index')->with('success','Updated.');
}
public function destroy(Customer $customer)
{
$customer->delete();
return redirect()->route('customers.index')->with('success','Deleted.');
}
}
bash
class DealController extends Controller
{
public function store(Request $request, Customer $customer)
{
$data = $request->validate([
'title'=>'required|string',
'amount'=>'required|numeric',
'stage'=>'required|in:new,contacted,proposal,won,lost',
]);
$customer->deals()->create($data);
return back()->with('success','Deal added.');
}
public function update(Request $request, Deal $deal)
{
$data = $request->validate([
'title'=>'required|string',
'amount'=>'required|numeric',
'stage'=>'required|in:new,contacted,proposal,won,lost',
]);
$deal->update($data);
return back()->with('success','Deal updated.');
}
public function destroy(Deal $deal)
{
$deal->delete();
return back()->with('success','Deal deleted.');
}
}
bash
class NoteController extends Controller
{
public function store(Request $request, Customer $customer)
{
$data = $request->validate(['note'=>'required']);
$customer->notes()->create($data);
return back()->with('success','Note added.');
}
public function destroy(Note $note)
{
$note->delete();
return back()->with('success','Note removed.');
}
}
bash
use App\Http\Controllers\CustomerController;
use App\Http\Controllers\DealController;
use App\Http\Controllers\NoteController;
Route::middleware(['auth'])->group(function () {
Route::resource('customers', CustomerController::class);
Route::post('/customers/{customer}/deals', [DealController::class,'store'])->name('deals.store');
Route::patch('/deals/{deal}', [DealController::class,'update'])->name('deals.update');
Route::delete('/deals/{deal}', [DealController::class,'destroy'])->name('deals.delete');
Route::post('/customers/{customer}/notes', [NoteController::class,'store'])->name('notes.store');
Route::delete('/notes/{note}', [NoteController::class,'destroy'])->name('notes.delete');
});
require __DIR__.'/auth.php';
bash
resources/views/customers/
index.blade.php (list + search)
create.blade.php
edit.blade.php
show.blade.php (customer details + deals + notes)
bash
Route::get('/dashboard', function () {
return [
'customers' => \App\Models\Customer::count(),
'active_deals' => \App\Models\Deal::where('stage','!=','won')->count(),
'pipeline_value' => \App\Models\Deal::where('stage','!=','lost')->sum('amount')
];
})->middleware('auth');
bash
php artisan serve
resources/views/customers/index.blade.php
bash
<?php
@extends('layouts.app')
@section('content')
<div class="max-w-6xl mx-auto p-4">
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-semibold">Customers</h2>
<div class="flex items-center space-x-3">
<form action="{{ route('customers.index') }}" method="GET" class="flex items-center">
<input
name="q"
value="{{ request('q') }}"
placeholder="Search name, email, company..."
class="border rounded px-3 py-2 w-64"
/>
<button class="ml-2 px-3 py-2 bg-blue-600 text-white rounded">Search</button>
</form>
<a href="{{ route('customers.create') }}"
class="px-4 py-2 bg-green-600 text-white rounded shadow-sm hover:bg-green-700">
Add Customer
</a>
</div>
</div>
@if(session('success'))
<div class="mb-4 p-3 bg-green-50 border border-green-200 text-green-800 rounded">
{{ session('success') }}
</div>
@endif
<div class="bg-white shadow-sm rounded overflow-hidden">
<table class="w-full table-auto">
<thead class="bg-gray-100">
<tr>
<th class="text-left px-4 py-3">#</th>
<th class="text-left px-4 py-3">Name</th>
<th class="text-left px-4 py-3">Email</th>
<th class="text-left px-4 py-3">Phone</th>
<th class="text-left px-4 py-3">Company</th>
<th class="text-left px-4 py-3">Actions</th>
</tr>
</thead>
<tbody>
@forelse($customers as $customer)
<tr class="border-t hover:bg-gray-50">
<td class="px-4 py-3">{{ $customer->id }}</td>
<td class="px-4 py-3">{{ $customer->name }}</td>
<td class="px-4 py-3">{{ $customer->email ?? '—' }}</td>
<td class="px-4 py-3">{{ $customer->phone ?? '—' }}</td>
<td class="px-4 py-3">{{ $customer->company ?? '—' }}</td>
<td class="px-4 py-3">
<a href="{{ route('customers.show', $customer) }}" class="text-blue-600 mr-3">View</a>
<a href="{{ route('customers.edit', $customer) }}" class="text-yellow-600 mr-3">Edit</a>
<form action="{{ route('customers.destroy', $customer) }}" method="POST" class="inline"
onsubmit="return confirm('Delete this customer?')">
@csrf
@method('DELETE')
<button class="text-red-600">Delete</button>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-4 py-6 text-center text-gray-500">No customers found.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-4">
{{ $customers->links() }}
</div>
</div>
@endsection
bash
@extends('layouts.app')
@section('content')
<div class="max-w-2xl mx-auto p-4">
<div class="bg-white shadow rounded p-6">
<h2 class="text-xl font-semibold mb-4">Add Customer</h2>
<form action="{{ route('customers.store') }}" method="POST">
@csrf
<div class="grid grid-cols-1 gap-4">
<div>
<label class="block text-sm font-medium mb-1">Name <span class="text-red-500">*</span></label>
<input type="text" name="name" value="{{ old('name') }}"
class="w-full border rounded px-3 py-2" required>
@error('name') <p class="text-sm text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium mb-1">Email</label>
<input type="email" name="email" value="{{ old('email') }}"
class="w-full border rounded px-3 py-2">
@error('email') <p class="text-sm text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium mb-1">Phone</label>
<input type="text" name="phone" value="{{ old('phone') }}"
class="w-full border rounded px-3 py-2">
@error('phone') <p class="text-sm text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium mb-1">Company</label>
<input type="text" name="company" value="{{ old('company') }}"
class="w-full border rounded px-3 py-2">
</div>
<div>
<label class="block text-sm font-medium mb-1">Address</label>
<textarea name="address" rows="3" class="w-full border rounded px-3 py-2">{{ old('address') }}</textarea>
</div>
<div class="flex items-center space-x-3 mt-2">
<button class="px-4 py-2 bg-green-600 text-white rounded">Save Customer</button>
<a href="{{ route('customers.index') }}" class="px-4 py-2 border rounded">Cancel</a>
</div>
</div>
</form>
</div>
</div>
@endsection
bash
@extends('layouts.app')
@section('content')
<div class="max-w-2xl mx-auto p-4">
<div class="bg-white shadow rounded p-6">
<h2 class="text-xl font-semibold mb-4">Edit Customer</h2>
<form action="{{ route('customers.update', $customer) }}" method="POST">
@csrf
@method('PUT')
<div class="grid grid-cols-1 gap-4">
<div>
<label class="block text-sm font-medium mb-1">Name <span class="text-red-500">*</span></label>
<input type="text" name="name" value="{{ old('name', $customer->name) }}"
class="w-full border rounded px-3 py-2" required>
@error('name') <p class="text-sm text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium mb-1">Email</label>
<input type="email" name="email" value="{{ old('email', $customer->email) }}"
class="w-full border rounded px-3 py-2">
@error('email') <p class="text-sm text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium mb-1">Phone</label>
<input type="text" name="phone" value="{{ old('phone', $customer->phone) }}"
class="w-full border rounded px-3 py-2">
</div>
<div>
<label class="block text-sm font-medium mb-1">Company</label>
<input type="text" name="company" value="{{ old('company', $customer->company) }}"
class="w-full border rounded px-3 py-2">
</div>
<div>
<label class="block text-sm font-medium mb-1">Address</label>
<textarea name="address" rows="3" class="w-full border rounded px-3 py-2">{{ old('address', $customer->address) }}</textarea>
</div>
<div class="flex items-center space-x-3 mt-2">
<button class="px-4 py-2 bg-blue-600 text-white rounded">Update Customer</button>
<a href="{{ route('customers.index') }}" class="px-4 py-2 border rounded">Cancel</a>
</div>
</div>
</form>
</div>
</div>
@endsection
bash
@extends('layouts.app')
@section('content')
<div class="max-w-5xl mx-auto p-4 space-y-6">
{{-- Header --}}
<div class="flex items-start justify-between">
<div>
<h2 class="text-2xl font-semibold">{{ $customer->name }}</h2>
<p class="text-sm text-gray-600">{{ $customer->company ?? '' }}</p>
<p class="text-sm text-gray-500 mt-1">
{{ $customer->email ?? 'No email' }} • {{ $customer->phone ?? 'No phone' }}
</p>
</div>
<div class="flex items-center space-x-3">
<a href="{{ route('customers.edit', $customer) }}" class="px-4 py-2 border rounded">Edit</a>
<form action="{{ route('customers.destroy', $customer) }}" method="POST" onsubmit="return confirm('Delete this customer?')">
@csrf @method('DELETE')
<button class="px-4 py-2 bg-red-600 text-white rounded">Delete</button>
</form>
</div>
</div>
{{-- Main grid: Details | Deals --}}
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
{{-- Left: Customer details --}}
<div class="md:col-span-1 bg-white p-4 rounded shadow">
<h3 class="font-medium mb-2">Details</h3>
<div class="text-sm text-gray-700 space-y-2">
<p><strong>Company:</strong> {{ $customer->company ?? '—' }}</p>
<p><strong>Email:</strong> {{ $customer->email ?? '—' }}</p>
<p><strong>Phone:</strong> {{ $customer->phone ?? '—' }}</p>
<p><strong>Address:</strong> <br><span class="whitespace-pre-line">{{ $customer->address ?? '—' }}</span></p>
<p class="text-xs text-gray-400 mt-3">Created: {{ $customer->created_at->format('M d, Y') }}</p>
</div>
</div>
{{-- Right: Deals & Notes --}}
<div class="md:col-span-2 space-y-6">
{{-- Deals card --}}
<div class="bg-white rounded shadow p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="font-medium">Deals</h3>
<button onclick="document.getElementById('deal-form').classList.toggle('hidden')" class="text-sm px-3 py-1 border rounded">New Deal</button>
</div>
<div id="deal-form" class="hidden mb-3">
<form action="{{ route('deals.store', $customer) }}" method="POST" class="grid grid-cols-1 md:grid-cols-3 gap-2 items-end">
@csrf
<div>
<label class="text-sm">Title</label>
<input name="title" required class="w-full border rounded px-2 py-1" />
</div>
<div>
<label class="text-sm">Amount</label>
<input name="amount" type="number" step="0.01" required class="w-full border rounded px-2 py-1" />
</div>
<div>
<label class="text-sm">Stage</label>
<select name="stage" class="w-full border rounded px-2 py-1">
<option value="new">New</option>
<option value="contacted">Contacted</option>
<option value="proposal">Proposal</option>
<option value="won">Won</option>
<option value="lost">Lost</option>
</select>
</div>
<div class="md:col-span-3 mt-2">
<button class="px-4 py-2 bg-green-600 text-white rounded">Save Deal</button>
</div>
</form>
</div>
{{-- Deals list --}}
<div class="space-y-2">
@forelse($customer->deals()->latest()->get() as $deal)
<div class="flex items-center justify-between border rounded p-3">
<div>
<div class="font-medium">{{ $deal->title }}</div>
<div class="text-sm text-gray-500">Amount: ${{ number_format($deal->amount,2) }} • Stage: <span class="capitalize">{{ $deal->stage }}</span></div>
</div>
<div class="flex items-center space-x-2">
<form action="{{ route('deals.update', $deal) }}" method="POST" class="flex items-center space-x-2">
@csrf
@method('PATCH')
<input type="hidden" name="title" value="{{ $deal->title }}">
<input type="hidden" name="amount" value="{{ $deal->amount }}">
<select name="stage" class="border rounded px-2 py-1 text-sm">
<option value="new" {{ $deal->stage == 'new' ? 'selected' : '' }}>New</option>
<option value="contacted" {{ $deal->stage == 'contacted' ? 'selected' : '' }}>Contacted</option>
<option value="proposal" {{ $deal->stage == 'proposal' ? 'selected' : '' }}>Proposal</option>
<option value="won" {{ $deal->stage == 'won' ? 'selected' : '' }}>Won</option>
<option value="lost" {{ $deal->stage == 'lost' ? 'selected' : '' }}>Lost</option>
</select>
<button class="px-3 py-1 bg-blue-600 text-white rounded text-sm">Update</button>
</form>
<form action="{{ route('deals.delete', $deal) }}" method="POST" onsubmit="return confirm('Delete deal?')">
@csrf @method('DELETE')
<button class="px-3 py-1 bg-red-600 text-white rounded text-sm">Delete</button>
</form>
</div>
</div>
@empty
<div class="text-gray-500">No deals yet.</div>
@endforelse
</div>
</div>
{{-- Notes / Activity log --}}
<div class="bg-white rounded shadow p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="font-medium">Notes / Activity</h3>
<button onclick="document.getElementById('note-form').classList.toggle('hidden')" class="text-sm px-3 py-1 border rounded">Add Note</button>
</div>
<div id="note-form" class="hidden mb-3">
<form action="{{ route('notes.store', $customer) }}" method="POST">
@csrf
<textarea name="note" rows="3" class="w-full border rounded px-2 py-1" placeholder="Write a note..." required></textarea>
<div class="mt-2">
<button class="px-4 py-2 bg-green-600 text-white rounded">Save Note</button>
</div>
</form>
</div>
<div class="space-y-3">
@forelse($customer->notes()->latest()->get() as $note)
<div class="border rounded p-3">
<div class="text-sm text-gray-700 whitespace-pre-line">{{ $note->note }}</div>
<div class="flex items-center justify-between mt-2">
<div class="text-xs text-gray-400">— {{ $note->created_at->diffForHumans() }}</div>
<form action="{{ route('notes.delete', $note) }}" method="POST" onsubmit="return confirm('Remove note?')">
@csrf @method('DELETE')
<button class="text-red-600 text-sm">Delete</button>
</form>
</div>
</div>
@empty
<div class="text-gray-500">No activity yet.</div>
@endforelse
</div>
</div>
</div>
</div>
</div>
@endsection