Building SaaS with Laravel (Part 5): Plans, Limits & Usage Tracking

· 14 min read

In Part 4, we built team management. Now it's time for a part that many SaaS applications overlook but is critically important: usage tracking — monitoring and limiting feature usage based on the subscription plan.

1. Usage Tracking Architecture

┌─────────────────────────────────────┐
│        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

This is the central service for the entire system:

// 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 tracked by count (lifetime).
     */
    private const COUNT_FEATURES = [
        'projects',
        'members',
    ];

    /**
     * Features tracked by period (monthly).
     */
    private const PERIODIC_FEATURES = [
        'api_calls',
    ];

    /**
     * Features tracked by storage.
     */
    private const STORAGE_FEATURES = [
        'storage_mb',
    ];

    /**
     * Check if a tenant can use a feature.
     */
    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;
    }

    /**
     * Get current usage for a 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,
            };
        });
    }

    /**
     * Count usage for count-based features (projects, members).
     */
    private function countFeatureUsage(Tenant $tenant, string $feature): int
    {
        return match ($feature) {
            'projects' => $tenant->projects()->count(),
            'members' => $tenant->users()->count(),
            default => 0,
        };
    }

    /**
     * Usage for periodic features (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 for 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;
    }

    /**
     * Increment 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);
        }

        // Clear cache
        $this->clearUsageCache($tenant, $feature);
    }

    /**
     * Decrement 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);
    }

    /**
     * Get usage overview for the dashboard.
     */
    public function getUsageSummary(Tenant $tenant): array
    {
        $plan = $tenant->plan;

        if (! $plan) {
            return [];
        }

        $summary = [];

        foreach ($plan->limits as $feature => $limit) {
            // Skip 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 / month',
            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' => "You have reached the {$feature} limit for your current plan.",
                    'upgrade_url' => route('billing.index'),
                ], 403);
            }

            return redirect()->route('billing.index')
                ->with('warning', "You have reached the {$feature} limit. Please upgrade your plan.");
        }

        return $next($request);
    }
}

Register the middleware:

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->alias([
        'tenant' => \App\Http\Middleware\ResolveTenant::class,
        'feature' => \App\Http\Middleware\CheckFeatureLimit::class,
    ]);
})

6. Applying to Routes

// routes/web.php
Route::middleware(['auth', 'tenant'])->group(function () {
    // Create project - check limit first
    Route::post('/projects', [ProjectController::class, 'store'])
        ->middleware('feature:projects');

    // Invite members - check limit first
    Route::post('/invitations', [InvitationController::class, 'store'])
        ->middleware('feature:members');
});

7. Usage Tracking via Events

Integrate into Action classes using 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 already checked, but 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">
        Limit reached.
        <a href="{{ route('billing.index') }}" class="underline">Upgrade your plan</a>
    </p>
    @elseif($feature['near_limit'])
    <p class="text-xs text-yellow-600 mt-1">
        Approaching limit ({{ $feature['remaining'] ?? '' }} remaining).
    </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">
                Usage — {{ $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"
                >
                    View plans & upgrade →
                </a>
            </div>
        </div>
    </div>
</div>

9. Smart Upgrade Prompts

Display upgrade prompts at the right time and place:

// 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' => "You've exhausted your {$data['label']} quota.",
                ];
            } elseif ($data['near_limit']) {
                $warnings[] = [
                    'type' => 'warning',
                    'feature' => $feature,
                    'message' => "{$data['label']}: " . ($data['limit'] - $data['used']) . " remaining.",
                ];
            }
        }

        $view->with('usageWarnings', $warnings);
    }
}

Register in 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">
            Upgrade
        </a>
    </div>
    @endforeach
</div>
@endif

10. Resetting Usage on Plan Change

// 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;
        }

        // Update limits in 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 = 'Create new usage records for the new month';

    public function handle(): int
    {
        $this->info('Resetting monthly usage records...');

        // Archive old records (optional - keep for 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 in 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();

    // Create 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, create 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();
});

Summary

In Part 5, we built:

  • UsageLimiterService as the central service for managing all types of limits
  • 3 types of tracking: count-based, periodic, storage
  • Middleware to check limits before actions occur
  • Double checks in Action classes (defense in depth)
  • Usage dashboard with visual progress bars
  • Smart upgrade prompts that appear at the right moment
  • Caching of usage data for good performance
  • Monthly reset for periodic features
  • Comprehensive test suite

Key takeaways:

  • Check limits at multiple levels: middleware + action + database constraints
  • Cache usage but clear immediately when data changes
  • Upgrade prompts should be contextual — appear when the user actually needs them
  • A limit of -1 means unlimited, making it easy to handle in code

Next up: Admin Dashboard & Metrics — building an admin panel to monitor tenants, revenue, and health metrics.

Comments