Xây dựng SaaS với Laravel (Phần 5): Plans, Limits & Usage Tracking
·
15 min read
Ở Phần 4, chúng ta đã xây dựng team management. Giờ đến phần mà nhiều SaaS bỏ sót nhưng cực kỳ quan trọng: usage tracking — theo dõi và giới hạn việc sử dụng features theo plan.
1. Kiến trúc Usage Tracking
┌─────────────────────────────────────┐
│ Feature Usage Check │
│ │
│ Request → Middleware → Action │
│ ↓ │
│ UsageLimiter::check('projects') │
│ ↓ │
│ ┌─── Within limit? ───┐ │
│ │ │ │
│ Yes No │
│ │ │ │
│ Continue Reject/Prompt │
│ │ Upgrade │
│ Track Usage │
└─────────────────────────────────────┘
2. Database: Usage Tracking
// database/migrations/2026_03_19_000001_create_usage_records_table.php
<?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('usage_records', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->string('feature'); // projects, members, storage, api_calls
$table->integer('used')->default(0);
$table->integer('limit')->default(0); // -1 = unlimited
$table->date('period_start')->nullable(); // null = lifetime
$table->date('period_end')->nullable();
$table->timestamps();
$table->unique(['tenant_id', 'feature', 'period_start']);
$table->index(['tenant_id', 'feature']);
});
}
public function down(): void
{
Schema::dropIfExists('usage_records');
}
};
3. Usage Model
// app/Domain/Billing/Models/UsageRecord.php
<?php
declare(strict_types=1);
namespace App\Domain\Billing\Models;
use App\Domain\Tenant\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
class UsageRecord extends Model
{
use BelongsToTenant;
protected $fillable = [
'tenant_id',
'feature',
'used',
'limit',
'period_start',
'period_end',
];
protected $casts = [
'used' => 'integer',
'limit' => 'integer',
'period_start' => 'date',
'period_end' => 'date',
];
public function isUnlimited(): bool
{
return $this->limit === -1;
}
public function percentage(): float
{
if ($this->isUnlimited() || $this->limit === 0) {
return 0;
}
return min(round(($this->used / $this->limit) * 100, 1), 100);
}
public function remaining(): int
{
if ($this->isUnlimited()) {
return PHP_INT_MAX;
}
return max($this->limit - $this->used, 0);
}
public function isExhausted(): bool
{
if ($this->isUnlimited()) {
return false;
}
return $this->used >= $this->limit;
}
public function isNearLimit(int $thresholdPercent = 80): bool
{
return ! $this->isUnlimited() && $this->percentage() >= $thresholdPercent;
}
}
4. Usage Limiter Service
Đây là service trung tâm của toàn bộ hệ thống:
// app/Domain/Billing/Services/UsageLimiterService.php
<?php
declare(strict_types=1);
namespace App\Domain\Billing\Services;
use App\Domain\Billing\Models\UsageRecord;
use App\Domain\Tenant\Models\Tenant;
use Illuminate\Support\Facades\Cache;
class UsageLimiterService
{
/**
* Features cần tracking theo count (lifetime).
*/
private const COUNT_FEATURES = [
'projects',
'members',
];
/**
* Features cần tracking theo period (monthly).
*/
private const PERIODIC_FEATURES = [
'api_calls',
];
/**
* Features cần tracking theo dung lượng.
*/
private const STORAGE_FEATURES = [
'storage_mb',
];
/**
* Kiểm tra xem tenant có thể sử dụng feature không.
*/
public function canUse(Tenant $tenant, string $feature): bool
{
$limit = $tenant->planLimit($feature);
// Unlimited
if ($limit === -1) {
return true;
}
// Boolean feature (api_access)
if (is_bool($limit)) {
return $limit;
}
$currentUsage = $this->getCurrentUsage($tenant, $feature);
return $currentUsage < $limit;
}
/**
* Lấy usage hiện tại cho một feature.
*/
public function getCurrentUsage(Tenant $tenant, string $feature): int
{
$cacheKey = "tenant_{$tenant->id}:usage:{$feature}";
return (int) Cache::remember($cacheKey, 300, function () use ($tenant, $feature) {
return match (true) {
in_array($feature, self::COUNT_FEATURES) => $this->countFeatureUsage($tenant, $feature),
in_array($feature, self::PERIODIC_FEATURES) => $this->periodicFeatureUsage($tenant, $feature),
in_array($feature, self::STORAGE_FEATURES) => $this->storageFeatureUsage($tenant, $feature),
default => 0,
};
});
}
/**
* Đếm usage cho features dạng count (projects, members).
*/
private function countFeatureUsage(Tenant $tenant, string $feature): int
{
return match ($feature) {
'projects' => $tenant->projects()->count(),
'members' => $tenant->users()->count(),
default => 0,
};
}
/**
* Usage cho features dạng periodic (API calls per month).
*/
private function periodicFeatureUsage(Tenant $tenant, string $feature): int
{
$record = UsageRecord::withoutTenantScope()
->where('tenant_id', $tenant->id)
->where('feature', $feature)
->where('period_start', now()->startOfMonth()->toDateString())
->first();
return $record?->used ?? 0;
}
/**
* Usage cho storage.
*/
private function storageFeatureUsage(Tenant $tenant, string $feature): int
{
$record = UsageRecord::withoutTenantScope()
->where('tenant_id', $tenant->id)
->where('feature', $feature)
->whereNull('period_start')
->first();
return $record?->used ?? 0;
}
/**
* Tăng usage counter.
*/
public function increment(Tenant $tenant, string $feature, int $amount = 1): void
{
if (in_array($feature, self::PERIODIC_FEATURES)) {
$this->incrementPeriodic($tenant, $feature, $amount);
} elseif (in_array($feature, self::STORAGE_FEATURES)) {
$this->incrementStorage($tenant, $feature, $amount);
}
// Xóa cache
$this->clearUsageCache($tenant, $feature);
}
/**
* Giảm usage counter.
*/
public function decrement(Tenant $tenant, string $feature, int $amount = 1): void
{
if (in_array($feature, self::STORAGE_FEATURES)) {
UsageRecord::withoutTenantScope()
->where('tenant_id', $tenant->id)
->where('feature', $feature)
->whereNull('period_start')
->decrement('used', $amount);
}
$this->clearUsageCache($tenant, $feature);
}
private function incrementPeriodic(Tenant $tenant, string $feature, int $amount): void
{
$periodStart = now()->startOfMonth()->toDateString();
$periodEnd = now()->endOfMonth()->toDateString();
UsageRecord::withoutTenantScope()->updateOrCreate(
[
'tenant_id' => $tenant->id,
'feature' => $feature,
'period_start' => $periodStart,
],
[
'period_end' => $periodEnd,
'limit' => $tenant->planLimit($feature),
]
)->increment('used', $amount);
}
private function incrementStorage(Tenant $tenant, string $feature, int $amount): void
{
UsageRecord::withoutTenantScope()->updateOrCreate(
[
'tenant_id' => $tenant->id,
'feature' => $feature,
'period_start' => null,
],
[
'limit' => $tenant->planLimit($feature),
]
)->increment('used', $amount);
}
/**
* Lấy tổng quan usage cho dashboard.
*/
public function getUsageSummary(Tenant $tenant): array
{
$plan = $tenant->plan;
if (! $plan) {
return [];
}
$summary = [];
foreach ($plan->limits as $feature => $limit) {
// Bỏ qua boolean features
if (is_bool($limit)) {
continue;
}
$used = $this->getCurrentUsage($tenant, $feature);
$summary[$feature] = [
'feature' => $feature,
'label' => $this->featureLabel($feature),
'used' => $used,
'limit' => $limit,
'unlimited' => $limit === -1,
'percentage' => $limit === -1 ? 0 : min(round(($used / max($limit, 1)) * 100, 1), 100),
'near_limit' => $limit !== -1 && $limit > 0 && ($used / $limit) >= 0.8,
'exhausted' => $limit !== -1 && $used >= $limit,
];
}
return $summary;
}
private function featureLabel(string $feature): string
{
return match ($feature) {
'projects' => 'Projects',
'members' => 'Team Members',
'storage_mb' => 'Storage (MB)',
'api_calls' => 'API Calls / tháng',
default => ucfirst(str_replace('_', ' ', $feature)),
};
}
private function clearUsageCache(Tenant $tenant, string $feature): void
{
Cache::forget("tenant_{$tenant->id}:usage:{$feature}");
}
}
5. Usage Check Middleware
// app/Http/Middleware/CheckFeatureLimit.php
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Domain\Billing\Services\UsageLimiterService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class CheckFeatureLimit
{
public function __construct(
private readonly UsageLimiterService $limiter,
) {}
/**
* Usage: Route::middleware('feature:projects')
*/
public function handle(Request $request, Closure $next, string $feature): Response
{
$tenant = current_tenant();
if (! $tenant) {
abort(403);
}
if (! $this->limiter->canUse($tenant, $feature)) {
if ($request->expectsJson()) {
return response()->json([
'error' => 'feature_limit_exceeded',
'message' => "Bạn đã đạt giới hạn {$feature} cho plan hiện tại.",
'upgrade_url' => route('billing.index'),
], 403);
}
return redirect()->route('billing.index')
->with('warning', "Bạn đã đạt giới hạn {$feature}. Vui lòng nâng cấp plan.");
}
return $next($request);
}
}
Đăng ký middleware:
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'tenant' => \App\Http\Middleware\ResolveTenant::class,
'feature' => \App\Http\Middleware\CheckFeatureLimit::class,
]);
})
6. Áp dụng vào Routes
// routes/web.php
Route::middleware(['auth', 'tenant'])->group(function () {
// Tạo project - check limit trước
Route::post('/projects', [ProjectController::class, 'store'])
->middleware('feature:projects');
// Invite members - check limit trước
Route::post('/invitations', [InvitationController::class, 'store'])
->middleware('feature:members');
});
7. Usage Tracking khi Actions Xảy ra
Tích hợp vào Action classes bằng Events:
// app/Domain/Project/Events/ProjectCreated.php
<?php
declare(strict_types=1);
namespace App\Domain\Project\Events;
use App\Domain\Project\Models\Project;
use Illuminate\Foundation\Events\Dispatchable;
class ProjectCreated
{
use Dispatchable;
public function __construct(
public readonly Project $project,
) {}
}
// app/Domain/Project/Actions/CreateProjectAction.php
<?php
declare(strict_types=1);
namespace App\Domain\Project\Actions;
use App\Domain\Billing\Services\UsageLimiterService;
use App\Domain\Project\Events\ProjectCreated;
use App\Domain\Project\Models\Project;
class CreateProjectAction
{
public function __construct(
private readonly UsageLimiterService $limiter,
) {}
public function execute(array $data): Project
{
$tenant = current_tenant();
// Double check limit (middleware đã check, nhưng defense in depth)
if (! $this->limiter->canUse($tenant, 'projects')) {
throw new \DomainException('Project limit exceeded for current plan.');
}
$project = Project::create([
'tenant_id' => $tenant->id,
'team_id' => $data['team_id'] ?? null,
'name' => $data['name'],
'description' => $data['description'] ?? null,
'status' => 'active',
'due_date' => $data['due_date'] ?? null,
]);
ProjectCreated::dispatch($project);
return $project;
}
}
8. Usage Dashboard Widget
// app/Http/Controllers/DashboardController.php
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Domain\Billing\Services\UsageLimiterService;
class DashboardController extends Controller
{
public function index(UsageLimiterService $limiter)
{
$tenant = current_tenant();
$usageSummary = $limiter->getUsageSummary($tenant);
return view('dashboard', [
'usage' => $usageSummary,
'tenant' => $tenant,
]);
}
}
Blade Component: Usage Bar
{{-- resources/views/components/usage-bar.blade.php --}}
@props(['feature'])
@php
$percentage = $feature['percentage'];
$colorClass = match(true) {
$feature['exhausted'] => 'bg-red-500',
$feature['near_limit'] => 'bg-yellow-500',
default => 'bg-blue-500',
};
@endphp
<div class="mb-4">
<div class="flex justify-between items-center mb-1">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ $feature['label'] }}
</span>
<span class="text-sm text-gray-500">
@if($feature['unlimited'])
{{ $feature['used'] }} / Unlimited
@else
{{ $feature['used'] }} / {{ $feature['limit'] }}
@endif
</span>
</div>
@unless($feature['unlimited'])
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2.5">
<div
class="{{ $colorClass }} h-2.5 rounded-full transition-all duration-300"
style="width: {{ $percentage }}%"
></div>
</div>
@if($feature['exhausted'])
<p class="text-xs text-red-500 mt-1">
Đã đạt giới hạn.
<a href="{{ route('billing.index') }}" class="underline">Nâng cấp plan</a>
</p>
@elseif($feature['near_limit'])
<p class="text-xs text-yellow-600 mt-1">
Sắp đạt giới hạn ({{ $feature['remaining'] ?? '' }} còn lại).
</p>
@endif
@endunless
</div>
Dashboard View
{{-- resources/views/dashboard.blade.php --}}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
{{-- Main content --}}
<div class="lg:col-span-2">
{{-- Projects, tasks, etc. --}}
</div>
{{-- Sidebar: Usage --}}
<div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 class="font-semibold text-lg mb-4">
Sử dụng — {{ $tenant->plan?->name ?? 'Free' }}
</h3>
@foreach($usage as $feature)
<x-usage-bar :feature="$feature" />
@endforeach
<div class="mt-4 pt-4 border-t dark:border-gray-700">
<a
href="{{ route('billing.index') }}"
class="text-sm text-blue-600 hover:text-blue-800"
>
Xem plans & nâng cấp →
</a>
</div>
</div>
</div>
</div>
9. Upgrade Prompts thông minh
Hiển thị upgrade prompt đúng lúc, đúng chỗ:
// app/View/Composers/UsageComposer.php
<?php
declare(strict_types=1);
namespace App\View\Composers;
use App\Domain\Billing\Services\UsageLimiterService;
use Illuminate\View\View;
class UsageComposer
{
public function __construct(
private readonly UsageLimiterService $limiter,
) {}
public function compose(View $view): void
{
$tenant = current_tenant();
if (! $tenant) {
return;
}
$warnings = [];
$summary = $this->limiter->getUsageSummary($tenant);
foreach ($summary as $feature => $data) {
if ($data['exhausted']) {
$warnings[] = [
'type' => 'error',
'feature' => $feature,
'message' => "Bạn đã hết quota {$data['label']}.",
];
} elseif ($data['near_limit']) {
$warnings[] = [
'type' => 'warning',
'feature' => $feature,
'message' => "{$data['label']}: còn " . ($data['limit'] - $data['used']) . " quota.",
];
}
}
$view->with('usageWarnings', $warnings);
}
}
Đăng ký trong AppServiceProvider:
// app/Providers/AppServiceProvider.php
use App\View\Composers\UsageComposer;
use Illuminate\Support\Facades\View;
public function boot(): void
{
View::composer('layouts.app', UsageComposer::class);
}
Alert Banner Component
{{-- resources/views/components/usage-warnings.blade.php --}}
@if(isset($usageWarnings) && count($usageWarnings) > 0)
<div class="space-y-2 mb-4">
@foreach($usageWarnings as $warning)
<div class="px-4 py-3 rounded-lg flex items-center justify-between
{{ $warning['type'] === 'error'
? 'bg-red-50 text-red-800 dark:bg-red-900/20 dark:text-red-300'
: 'bg-yellow-50 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300' }}">
<p class="text-sm">{{ $warning['message'] }}</p>
<a href="{{ route('billing.index') }}" class="text-sm font-medium underline ml-4 whitespace-nowrap">
Nâng cấp
</a>
</div>
@endforeach
</div>
@endif
10. Resetting Usage khi Đổi Plan
// app/Domain/Billing/Listeners/SyncUsageLimitsOnPlanChange.php
<?php
declare(strict_types=1);
namespace App\Domain\Billing\Listeners;
use App\Domain\Billing\Models\UsageRecord;
use App\Domain\Billing\Services\UsageLimiterService;
class SyncUsageLimitsOnPlanChange
{
public function handle(object $event): void
{
$tenant = $event->tenant;
$plan = $tenant->plan;
if (! $plan) {
return;
}
// Cập nhật limits trong usage records
foreach ($plan->limits as $feature => $limit) {
if (is_bool($limit)) {
continue;
}
UsageRecord::withoutTenantScope()->updateOrCreate(
[
'tenant_id' => $tenant->id,
'feature' => $feature,
'period_start' => in_array($feature, ['api_calls'])
? now()->startOfMonth()->toDateString()
: null,
],
['limit' => $limit]
);
}
// Clear all usage caches
foreach ($plan->limits as $feature => $limit) {
\Cache::forget("tenant_{$tenant->id}:usage:{$feature}");
}
}
}
11. Artisan Command: Reset Monthly Usage
// app/Console/Commands/ResetMonthlyUsage.php
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Domain\Billing\Models\UsageRecord;
use Illuminate\Console\Command;
class ResetMonthlyUsage extends Command
{
protected $signature = 'usage:reset-monthly';
protected $description = 'Tạo records usage mới cho tháng mới';
public function handle(): int
{
$this->info('Resetting monthly usage records...');
// Archive old records (optional - giữ cho reporting)
$lastMonth = now()->subMonth();
$count = UsageRecord::where('period_start', $lastMonth->startOfMonth()->toDateString())
->count();
$this->info("Archived {$count} records from last month.");
return self::SUCCESS;
}
}
Schedule trong routes/console.php:
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('usage:reset-monthly')->monthlyOn(1, '00:00');
12. Testing Usage Limits
// tests/Feature/UsageLimitsTest.php
<?php
use App\Domain\Billing\Models\Plan;
use App\Domain\Billing\Services\UsageLimiterService;
use App\Domain\Project\Models\Project;
use App\Domain\Tenant\Actions\CreateTenantAction;
use App\Domain\User\Models\User;
use App\Providers\TenantServiceProvider;
beforeEach(function () {
$this->seed(\Database\Seeders\PlanSeeder::class);
$this->user = User::factory()->create();
$this->tenant = (new CreateTenantAction())->execute($this->user, 'Test Co');
$this->tenant->update(['plan_id' => Plan::where('slug', 'starter')->first()->id]);
TenantServiceProvider::setTenant($this->tenant);
$this->limiter = app(UsageLimiterService::class);
});
test('starter plan limits projects to 3', function () {
expect($this->limiter->canUse($this->tenant, 'projects'))->toBeTrue();
// Tạo 3 projects
for ($i = 1; $i <= 3; $i++) {
Project::create(['name' => "Project {$i}", 'tenant_id' => $this->tenant->id]);
}
expect($this->limiter->canUse($this->tenant, 'projects'))->toBeFalse();
});
test('professional plan has unlimited projects', function () {
$proPlan = Plan::where('slug', 'professional')->first();
$this->tenant->update(['plan_id' => $proPlan->id]);
for ($i = 1; $i <= 100; $i++) {
Project::create(['name' => "Project {$i}", 'tenant_id' => $this->tenant->id]);
}
expect($this->limiter->canUse($this->tenant, 'projects'))->toBeTrue();
});
test('creating project over limit returns 403', function () {
for ($i = 1; $i <= 3; $i++) {
Project::create(['name' => "Project {$i}", 'tenant_id' => $this->tenant->id]);
}
$this->actingAs($this->user)
->post('/projects', ['name' => 'Over Limit Project'])
->assertRedirect(route('billing.index'));
});
test('usage summary shows correct data', function () {
Project::create(['name' => 'Test', 'tenant_id' => $this->tenant->id]);
$summary = $this->limiter->getUsageSummary($this->tenant);
expect($summary['projects'])
->used->toBe(1)
->limit->toBe(3)
->percentage->toBe(33.3)
->near_limit->toBeFalse()
->exhausted->toBeFalse();
});
test('near limit detection works at 80%', function () {
// Starter: 3 projects limit, tạo 3 => 100%
for ($i = 1; $i <= 3; $i++) {
Project::create(['name' => "P{$i}", 'tenant_id' => $this->tenant->id]);
}
$summary = $this->limiter->getUsageSummary($this->tenant);
expect($summary['projects'])
->near_limit->toBeTrue()
->exhausted->toBeTrue();
});
Tổng kết
Ở phần 5, chúng ta đã xây dựng:
- UsageLimiterService trung tâm để quản lý mọi loại limit
- 3 loại tracking: count-based, periodic, storage
- Middleware check limit trước khi action xảy ra
- Double check trong Action classes (defense in depth)
- Usage dashboard với progress bars trực quan
- Smart upgrade prompts xuất hiện đúng lúc
- Caching usage data để performance tốt
- Monthly reset cho periodic features
- Test suite đầy đủ
Key takeaways:
- Check limit ở nhiều tầng: middleware + action + database constraint
- Cache usage nhưng clear ngay khi data thay đổi
- Upgrade prompt phải contextual — hiện đúng lúc user cần
- Giới hạn
-1nghĩa là unlimited, dễ handle trong code
Phần tiếp theo: Admin Dashboard & Metrics — xây dựng trang admin để monitor tenants, revenue, và health metrics.