Multi-tenancy, Stripe billing, role-based permissions, feature flags, onboarding flows, audit logging, and impersonation — the 11 features every SaaS needs on day one that nobody packages together cleanly. Here’s the architecture, the packages, and the decisions that saved me from rebuilding it twice.
The first SaaS I built in Laravel took three weeks before a single line of actual product code was written. Multi-tenancy model, billing integration, permission system, onboarding flow, audit trail, feature flags — each one a week’s worth of decisions even when the implementation is straightforward. The second time I started a SaaS, I had notes. The third time, I had a documented architecture. This post is that architecture: the 11 features that every SaaS needs before it’s a product, the packages I chose and why, the schema decisions that prevent the most common refactors, and the architectural choice that saved me from rebuilding the tenancy layer twice.
The Architecture Decision That Changes Everything Else
Before any code: the tenancy model. Every other decision branches from this one.
There are two approaches to multi-tenancy in Laravel:
Single database, tenant scoping via a tenant_id column:
→ One database, all tenants' data in shared tables
→ Tenant isolation via Eloquent global scope or explicit where clauses
→ Simple infrastructure, single migration, easy cross-tenant reporting
→ Risk: a missing scope leaks data across tenants
Database per tenant:
→ Each tenant gets their own database
→ Complete data isolation at the infrastructure level
→ Complex infrastructure, per-tenant migrations, no cross-tenant queries
→ Risk: database connection management, migration deployment complexity
I’ve built both. For SaaS products under 500 tenants with standard data isolation requirements, single-database with scoping is the right default. For products with enterprise clients who have contractual data residency requirements, database-per-tenant becomes necessary. The single-database approach can be retrofitted to database-per-tenant later with significant effort. Choose based on your actual requirements, not hypothetical scale.
This starter kit uses single-database tenancy with a global scope. Here’s the decision explained in code:
// The Tenant model — the root of every scoped query
class Tenant extends Model
{
protected $fillable = ['name', 'slug', 'plan', 'status'];
// Everything hangs off the tenant
public function users(): HasMany { return $this->hasMany(User::class); }
public function subscriptions(): HasMany { return $this->hasMany(Subscription::class); }
public function features(): HasMany { return $this->hasMany(TenantFeature::class); }
public function auditLogs(): HasMany { return $this->hasMany(AuditLog::class); }
}
// The trait that every tenant-scoped model uses
trait BelongsToTenant
{
protected static function bootBelongsToTenant(): void
{
static::creating(function (Model $model) {
if (!$model->tenant_id && auth()->check()) {
$model->tenant_id = auth()->user()->tenant_id;
}
});
static::addGlobalScope('tenant', function (Builder $builder) {
if (auth()->check() && !app()->runningInConsole()) {
$builder->where(
$builder->getModel()->getTable() . '.tenant_id',
auth()->user()->tenant_id
);
}
});
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
}
Every model that belongs to a tenant uses the trait. The global scope fires automatically. A query for projects only returns the current tenant’s projects — without any where tenant_id = in the controller.
The two escape hatches you’ll need:
// Admin queries that bypass tenant scoping
Project::withoutGlobalScope('tenant')->where('status', 'active')->get();
// Impersonation — see the full impersonation section
Feature 1: The Database Schema That Prevents Refactors
The schema decisions you’ll regret: adding tenant_id to tables later, using auto-increment IDs when UUIDs are needed for security, not separating users from tenant_users which prevents a user from belonging to multiple tenants.
// Migration: tenants
Schema::create('tenants', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->string('plan')->default('free'); // free, starter, pro, enterprise
$table->string('status')->default('active'); // active, suspended, cancelled
$table->string('stripe_customer_id')->nullable()->unique();
$table->timestamp('trial_ends_at')->nullable();
$table->timestamps();
});
// Migration: users — users exist independently of tenants
// A user can belong to multiple tenants (pivot table)
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->string('avatar_url')->nullable();
$table->rememberToken();
$table->timestamps();
});
// The pivot that connects users to tenants
Schema::create('tenant_users', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('role')->default('member'); // owner, admin, member, viewer
$table->timestamp('invited_at')->nullable();
$table->timestamp('joined_at')->nullable();
$table->timestamps();
$table->unique(['tenant_id', 'user_id']);
$table->index(['user_id', 'tenant_id']); // for looking up a user's tenants
});
The separation of users from tenant_users means a user can accept an invitation to a second organization without creating a new account. This sounds like a nice-to-have feature until your first customer asks for it six months in and you realize it requires a significant migration.
The tenant_id on the user is still useful for the current session context:
// The User model stores the currently active tenant
// for single-database tenant scoping
class User extends Authenticatable
{
// The active tenant for the current session
// Can be different from their primary tenant if they've switched
protected $fillable = ['name', 'email', 'password', 'current_tenant_id'];
public function currentTenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'current_tenant_id');
}
public function tenants(): BelongsToMany
{
return $this->belongsToMany(Tenant::class, 'tenant_users')
->withPivot('role', 'invited_at', 'joined_at')
->withTimestamps();
}
public function switchTenant(Tenant $tenant): void
{
abort_unless($this->tenants->contains($tenant), 403);
$this->update(['current_tenant_id' => $tenant->id]);
}
}
Feature 2: Stripe Billing With Cashier
Laravel Cashier is the correct abstraction for Stripe billing. It’s first-party, maintained by the Laravel team, and handles subscriptions, invoices, payment methods, and webhooks. Don’t write a Stripe integration from scratch.
composer require laravel/cashier
php artisan cashier:install
php artisan migrate
The Billable trait goes on Tenant, not User — the tenant pays, not individual users:
class Tenant extends Model
{
use \Laravel\Cashier\Billable;
use HasFactory;
protected $dates = ['trial_ends_at'];
public function onTrial(): bool
{
return $this->trial_ends_at && $this->trial_ends_at->isFuture();
}
public function onPlan(string $plan): bool
{
return $this->plan === $plan;
}
}
The billing service that wraps Cashier for the application’s needs:
class BillingService
{
public function startTrial(Tenant $tenant): void
{
$tenant->update([
'trial_ends_at' => now()->addDays(14),
'plan' => 'trial',
]);
}
public function subscribe(Tenant $tenant, string $priceId, string $paymentMethodId): Subscription
{
$tenant->createOrGetStripeCustomer(['name' => $tenant->name]);
$tenant->addPaymentMethod($paymentMethodId);
$tenant->updateDefaultPaymentMethod($paymentMethodId);
$subscription = $tenant->newSubscription('default', $priceId)
->create($paymentMethodId);
$tenant->update([
'plan' => $this->planFromPriceId($priceId),
'status' => 'active',
]);
return $subscription;
}
public function cancel(Tenant $tenant): void
{
$tenant->subscription('default')?->cancel();
$tenant->update(['status' => 'cancelling']);
}
private function planFromPriceId(string $priceId): string
{
return config("services.stripe.plans.{$priceId}", 'pro');
}
}
The webhook handler — the piece most integrations get wrong:
// app/Http/Controllers/WebhookController.php
class WebhookController extends \Laravel\Cashier\Http\Controllers\WebhookController
{
public function handleInvoicePaymentSucceeded(array $payload): Response
{
$subscription = $this->findSubscription($payload['data']['object']['subscription']);
if ($subscription) {
$tenant = $subscription->billable;
$tenant->update(['status' => 'active']);
TenantBillingSucceeded::dispatch($tenant, $payload['data']['object']);
}
return $this->successMethod();
}
public function handleInvoicePaymentFailed(array $payload): Response
{
$subscription = $this->findSubscription($payload['data']['object']['subscription']);
if ($subscription) {
$tenant = $subscription->billable;
// Give a grace period before suspending
if ($tenant->subscription('default')?->pastDue()) {
$tenant->update(['status' => 'past_due']);
TenantPaymentFailed::dispatch($tenant, $payload['data']['object']);
}
}
return $this->successMethod();
}
public function handleCustomerSubscriptionDeleted(array $payload): Response
{
parent::handleCustomerSubscriptionDeleted($payload);
$customer = $this->getUserByStripeId($payload['data']['object']['customer']);
if ($customer) {
$customer->update(['plan' => 'free', 'status' => 'cancelled']);
}
return $this->successMethod();
}
}
// routes/web.php — Stripe webhooks bypass CSRF
Route::post('/stripe/webhook', [WebhookController::class, 'handleWebhook'])
->name('cashier.webhook');
Feature 3: Role-Based Permissions With Spatie
composer require spatie/laravel-permission
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
php artisan migrate
The permission seeder — the set of roles and permissions that every SaaS ships with:
class PermissionSeeder extends Seeder
{
public function run(): void
{
// Reset cached roles and permissions
app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();
$permissions = [
// Member permissions — everyone gets these
'view projects', 'create tasks', 'edit own tasks', 'view billing',
// Admin permissions
'manage projects', 'manage tasks', 'invite members', 'remove members',
'manage roles', 'view audit log',
// Owner permissions
'manage billing', 'manage subscription', 'delete workspace',
'transfer ownership',
];
foreach ($permissions as $permission) {
Permission::firstOrCreate(['name' => $permission]);
}
// Member — can view and create basic content
$member = Role::firstOrCreate(['name' => 'member']);
$member->syncPermissions(['view projects', 'create tasks', 'edit own tasks']);
// Admin — can manage content and people
$admin = Role::firstOrCreate(['name' => 'admin']);
$admin->syncPermissions([
'view projects', 'manage projects', 'create tasks', 'manage tasks',
'edit own tasks', 'invite members', 'remove members', 'manage roles',
'view billing', 'view audit log',
]);
// Owner — full control
$owner = Role::firstOrCreate(['name' => 'owner']);
$owner->syncPermissions(Permission::all());
}
}
Assign the owner role when a tenant is created:
class TenantService
{
public function create(User $user, array $data): Tenant
{
return DB::transaction(function () use ($user, $data) {
$tenant = Tenant::create([
'name' => $data['name'],
'slug' => Str::slug($data['name']),
'plan' => 'trial',
]);
// Attach the user as owner
$tenant->users()->attach($user, [
'role' => 'owner',
'joined_at' => now(),
]);
$user->update(['current_tenant_id' => $tenant->id]);
$user->assignRole('owner');
return $tenant;
});
}
}
Feature 4: Feature Flags
Feature flags control which tenants have access to which features — either by plan tier or by individual enablement (beta access, enterprise features, early access programs).
Schema::create('feature_flags', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->string('key'); // 'ai_assistant', 'advanced_reporting', 'api_access'
$table->boolean('enabled')->default(false);
$table->json('metadata')->nullable(); // limits, config per feature
$table->timestamps();
$table->unique(['tenant_id', 'key']);
$table->index(['tenant_id', 'enabled']);
});
class FeatureService
{
// Plan-based features — derived from the subscription plan
private array $planFeatures = [
'free' => ['basic_projects', 'csv_export'],
'starter' => ['basic_projects', 'csv_export', 'api_access', 'custom_domains'],
'pro' => ['basic_projects', 'csv_export', 'api_access', 'custom_domains',
'advanced_reporting', 'priority_support'],
'enterprise' => ['*'], // all features
];
public function enabled(string $key, ?Tenant $tenant = null): bool
{
$tenant ??= auth()->user()?->currentTenant;
if (!$tenant) return false;
// Check plan-based features first
$planFeatures = $this->planFeatures[$tenant->plan] ?? [];
if (in_array('*', $planFeatures) || in_array($key, $planFeatures)) {
return true;
}
// Check individually enabled features (beta, enterprise add-ons)
return Cache::remember(
"tenant:{$tenant->id}:feature:{$key}",
now()->addMinutes(5),
fn () => FeatureFlag::where('tenant_id', $tenant->id)
->where('key', $key)
->where('enabled', true)
->exists()
);
}
public function enable(Tenant $tenant, string $key, array $metadata = []): void
{
FeatureFlag::updateOrCreate(
['tenant_id' => $tenant->id, 'key' => $key],
['enabled' => true, 'metadata' => $metadata]
);
Cache::forget("tenant:{$tenant->id}:feature:{$key}");
}
public function disable(Tenant $tenant, string $key): void
{
FeatureFlag::where('tenant_id', $tenant->id)
->where('key', $key)
->update(['enabled' => false]);
Cache::forget("tenant:{$tenant->id}:feature:{$key}");
}
}
Blade directive for feature-gated UI:
// AppServiceProvider::boot()
Blade::if('feature', function (string $key) {
return app(FeatureService::class)->enabled($key);
});
@feature('advanced_reporting')
<a href="{{ route('reports.advanced') }}">Advanced Reports</a>
@endfeature
Middleware for feature-gated routes:
class RequireFeature
{
public function handle(Request $request, Closure $next, string $feature): Response
{
if (!app(FeatureService::class)->enabled($feature)) {
if ($request->expectsJson()) {
return response()->json(['message' => 'This feature is not available on your plan.'], 403);
}
return redirect()->route('billing.upgrade')
->with('message', 'Upgrade your plan to access this feature.');
}
return $next($request);
}
}
Feature 5: Onboarding Flows
Every SaaS has an onboarding flow. Without a structured model, onboarding state is tracked in a column that grows to 20 boolean flags or a JSON blob that becomes impossible to query.
Schema::create('onboarding_steps', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->string('step'); // 'profile', 'invite_team', 'create_project', 'connect_integration'
$table->boolean('completed')->default(false);
$table->timestamp('completed_at')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
$table->unique(['tenant_id', 'step']);
$table->index(['tenant_id', 'completed']);
});
class OnboardingService
{
private array $steps = [
'complete_profile',
'invite_team_member',
'create_first_project',
'connect_integration',
];
public function initialize(Tenant $tenant): void
{
foreach ($this->steps as $step) {
OnboardingStep::firstOrCreate([
'tenant_id' => $tenant->id,
'step' => $step,
]);
}
}
public function complete(Tenant $tenant, string $step, array $metadata = []): void
{
OnboardingStep::where('tenant_id', $tenant->id)
->where('step', $step)
->update([
'completed' => true,
'completed_at' => now(),
'metadata' => $metadata,
]);
if ($this->allCompleted($tenant)) {
$tenant->update(['onboarding_completed_at' => now()]);
OnboardingCompleted::dispatch($tenant);
}
}
public function progress(Tenant $tenant): array
{
$steps = OnboardingStep::where('tenant_id', $tenant->id)->get();
return [
'total' => count($this->steps),
'completed' => $steps->where('completed', true)->count(),
'steps' => $steps->keyBy('step'),
'percent' => $steps->isEmpty() ? 0 :
round(($steps->where('completed', true)->count() / count($this->steps)) * 100),
];
}
public function allCompleted(Tenant $tenant): bool
{
return OnboardingStep::where('tenant_id', $tenant->id)
->where('completed', false)
->doesntExist();
}
}
Complete steps automatically via model observers:
class ProjectObserver
{
public function created(Project $project): void
{
app(OnboardingService::class)->complete(
auth()->user()->currentTenant,
'create_first_project',
['project_id' => $project->id]
);
}
}
Feature 6: Audit Logging
composer require owen-it/laravel-auditing
php artisan vendor:publish --provider="OwenIt\Auditing\AuditingServiceProvider" --tag="config"
php artisan auditing:install
php artisan migrate
Beyond the automatic Eloquent audit trail, SaaS products need explicit audit events for non-model actions:
Schema::create('activity_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('event'); // 'user.invited', 'billing.subscription_cancelled'
$table->string('subject_type')->nullable(); // App\Models\Project
$table->unsignedBigInteger('subject_id')->nullable();
$table->json('metadata')->nullable();
$table->string('ip_address')->nullable();
$table->string('user_agent')->nullable();
$table->timestamps();
$table->index(['tenant_id', 'created_at']);
$table->index(['tenant_id', 'user_id']);
$table->index(['tenant_id', 'event']);
$table->index(['subject_type', 'subject_id']);
});
class ActivityLogger
{
public static function log(
string $event,
?Model $subject = null,
array $metadata = [],
?Request $request = null
): ActivityLog {
$request ??= request();
return ActivityLog::create([
'tenant_id' => auth()->user()?->current_tenant_id,
'user_id' => auth()->id(),
'event' => $event,
'subject_type' => $subject ? get_class($subject) : null,
'subject_id' => $subject?->getKey(),
'metadata' => $metadata,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
]);
}
}
// Usage throughout the application
ActivityLogger::log('project.created', $project, ['name' => $project->name]);
ActivityLogger::log('member.invited', $user, ['email' => $email, 'role' => $role]);
ActivityLogger::log('billing.subscription_cancelled', null, ['plan' => $tenant->plan]);
ActivityLogger::log('settings.updated', null, ['fields' => array_keys($changes)]);
Feature 7: User Impersonation
Support teams need to see what a user sees. Impersonation lets an admin log in as any user without their password — and leave a clear audit trail of doing so.
class ImpersonationService
{
private const SESSION_KEY = 'impersonator_id';
public function impersonate(User $admin, User $target): void
{
abort_unless($admin->hasRole('super_admin'), 403);
abort_if($target->hasRole('super_admin'), 403, 'Cannot impersonate another admin.');
// Store the original admin's ID in the session
session([self::SESSION_KEY => $admin->id]);
// Log the impersonation action
ActivityLogger::log('impersonation.started', $target, [
'impersonator_id' => $admin->id,
]);
Auth::login($target);
}
public function stopImpersonating(): void
{
$impersonatorId = session(self::SESSION_KEY);
abort_unless($impersonatorId, 403, 'Not currently impersonating.');
$impersonator = User::findOrFail($impersonatorId);
ActivityLogger::log('impersonation.ended', auth()->user(), [
'impersonator_id' => $impersonatorId,
]);
session()->forget(self::SESSION_KEY);
Auth::login($impersonator);
}
public function isImpersonating(): bool
{
return session()->has(self::SESSION_KEY);
}
public function impersonator(): ?User
{
$id = session(self::SESSION_KEY);
return $id ? User::find($id) : null;
}
}
The middleware that adds the impersonation banner:
class TrackImpersonation
{
public function handle(Request $request, Closure $next): Response
{
if (app(ImpersonationService::class)->isImpersonating()) {
View::share('isImpersonating', true);
View::share('impersonator', app(ImpersonationService::class)->impersonator());
}
return $next($request);
}
}
{{-- In your layout --}}
@if($isImpersonating ?? false)
<div class="bg-yellow-500 text-black text-sm py-2 px-4 flex justify-between">
<span>You are viewing as {{ auth()->user()->name }} (impersonating)</span>
<form action="{{ route('impersonation.stop') }}" method="POST">
@csrf
<button type="submit">Stop Impersonating</button>
</form>
</div>
@endif
Feature 8: Team Invitations
Schema::create('invitations', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('invited_by')->constrained('users')->cascadeOnDelete();
$table->string('email');
$table->string('role')->default('member');
$table->string('token')->unique();
$table->timestamp('expires_at');
$table->timestamp('accepted_at')->nullable();
$table->timestamps();
$table->unique(['tenant_id', 'email']); // prevent duplicate invitations
$table->index('token');
});
class InvitationService
{
public function invite(Tenant $tenant, User $inviter, string $email, string $role): Invitation
{
// Check if user is already a member
abort_if(
$tenant->users()->where('email', $email)->exists(),
422,
'This person is already a member of your workspace.'
);
$invitation = Invitation::updateOrCreate(
['tenant_id' => $tenant->id, 'email' => $email],
[
'invited_by' => $inviter->id,
'role' => $role,
'token' => Str::random(32),
'expires_at' => now()->addDays(7),
]
);
Mail::to($email)->send(new TeamInvitationMailable($invitation, $tenant));
ActivityLogger::log('member.invited', null, [
'email' => $email,
'role' => $role,
]);
return $invitation;
}
public function accept(string $token, User $user): void
{
$invitation = Invitation::where('token', $token)
->where('email', $user->email)
->where('expires_at', '>', now())
->whereNull('accepted_at')
->firstOrFail();
DB::transaction(function () use ($invitation, $user) {
$tenant = $invitation->tenant;
$tenant->users()->attach($user, [
'role' => $invitation->role,
'joined_at' => now(),
]);
$user->update(['current_tenant_id' => $tenant->id]);
$user->assignRole($invitation->role);
$invitation->update(['accepted_at' => now()]);
});
}
}
Feature 9: Subscription Gating Middleware
The middleware stack that protects the application based on billing and trial status:
class RequireActiveSubscription
{
public function handle(Request $request, Closure $next): Response
{
$tenant = auth()->user()?->currentTenant;
if (!$tenant) {
return redirect()->route('tenants.select');
}
// Allow through if on trial
if ($tenant->onTrial()) {
return $next($request);
}
// Allow through if subscription is active
if ($tenant->subscribed('default')) {
return $next($request);
}
// Redirect to billing if expired
if ($request->expectsJson()) {
return response()->json([
'message' => 'Your subscription has expired.',
'action' => route('billing.index'),
], 402);
}
return redirect()->route('billing.index')
->with('warning', 'Your subscription has expired. Please renew to continue.');
}
}
// routes/web.php
Route::middleware([
'auth',
'verified',
RequireActiveSubscription::class,
])->group(function () {
// All protected app routes
Route::resource('projects', ProjectController::class);
Route::resource('tasks', TaskController::class);
});
Feature 10: The Admin Panel (Filament)
composer require filament/filament:"^3.0" -W
php artisan filament:install --panels
Filament provides the super-admin interface: tenant management, user management, subscription oversight, feature flag management, audit log browsing, and impersonation triggers. Building this from scratch on every SaaS project is two weeks of work that Filament reduces to configuration.
// FilamentAdminPanel — limited to users with 'super_admin' role
class AdminPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->default()
->id('admin')
->path('admin')
->authMiddleware([Authenticate::class])
->authGuard('web')
->login()
->resources([
TenantResource::class,
UserResource::class,
SubscriptionResource::class,
ActivityLogResource::class,
]);
}
}
// Gate in AppServiceProvider — only super admins can access admin panel
Filament::auth()->policy(function (User $user) {
return $user->hasRole('super_admin');
});
Feature 11: Graceful Degradation for Suspended Tenants
When a tenant is suspended (payment failure, abuse, manual action), their users should see a clear explanation rather than broken pages or generic errors.
class CheckTenantStatus
{
public function handle(Request $request, Closure $next): Response
{
$tenant = auth()->user()?->currentTenant;
if (!$tenant) {
return $next($request);
}
return match($tenant->status) {
'suspended' => $this->handleSuspended($request, $tenant),
'cancelled' => $this->handleCancelled($request, $tenant),
default => $next($request),
};
}
private function handleSuspended(Request $request, Tenant $tenant): Response
{
if ($request->expectsJson()) {
return response()->json(['message' => 'Account suspended.', 'status' => 'suspended'], 402);
}
return response()->view('errors.suspended', compact('tenant'), 402);
}
private function handleCancelled(Request $request, Tenant $tenant): Response
{
// Allow access to billing page so they can reactivate
if ($request->routeIs('billing.*')) {
return $next($request);
}
if ($request->expectsJson()) {
return response()->json(['message' => 'Account cancelled.', 'status' => 'cancelled'], 402);
}
return response()->view('errors.cancelled', compact('tenant'), 402);
}
}
The Middleware Stack in Order
// bootstrap/app.php (Laravel 11+)
->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [
TrackImpersonation::class,
CheckTenantStatus::class,
]);
$middleware->alias([
'tenant' => CheckTenantStatus::class,
'require.feature' => RequireFeature::class,
'require.subscription' => RequireActiveSubscription::class,
]);
})
// Route groups with layered middleware
Route::middleware(['auth', 'verified', 'tenant', 'require.subscription'])->group(function () {
Route::resource('projects', ProjectController::class);
Route::middleware('require.feature:advanced_reporting')->group(function () {
Route::get('/reports/advanced', [ReportController::class, 'advanced']);
});
});
The Packages That Power All 11 Features
Package Purpose Why this one
─────────────────────────────────────────────────────────────────────────
laravel/cashier Stripe billing First-party, maintained
spatie/laravel-permission Roles and permissions Most used, battle-tested
owen-it/laravel-auditing Eloquent change tracking Model-level audit trail
filament/filament Admin panel Complete, first-party-adjacent
spatie/laravel-data Typed DTOs Validation + transformation
wire-elements/modal Livewire modals Eliminates a week of code
barryvdh/laravel-ide-helper IDE autocompletion Dev productivity
sentry/sentry-laravel Error tracking User-context error visibility
The Three Weeks Saved
The three weeks I spent on boilerplate the first time came from making these decisions sequentially, in isolation, without a plan. Multi-tenancy first, then realizing the schema needed UUIDs, then the permission system didn’t account for tenant-scoped roles, then billing was on the user instead of the tenant, then the audit log had no tenant column.
Every feature here is designed to compose with the others from the start. The tenant is the root of the schema. Billing attaches to the tenant. Permissions are scoped by tenant context. Audit logs carry the tenant ID. Feature flags are keyed by tenant. Impersonation bypasses tenant scoping safely through the escape hatch built into the global scope.
You’ll still spend two to three days on initial setup. But they’ll be two to three days of intentional configuration rather than three weeks of discovering that your initial assumptions were wrong.
