Skip to main content
The backend is built on Laravel 12 with a multi-tenant architecture using the Stancl Tenancy package. Each tenant operates in an isolated database while sharing the same application codebase.

Architecture Overview

Multi-Tenancy

Database-per-tenant strategy with automatic tenant identification

Domain-Based Routing

Separate route files for system and tenant domains

Service Layer

Business logic encapsulated in service classes

Inertia Integration

Server-side rendering with Vue components

Multi-Tenancy

The application uses Stancl Tenancy to provide database isolation for each customer.

How It Works

1

Tenant Identification

The tenancy middleware identifies tenants by subdomain:
customer1.yoursaas.com → Tenant Database: tenant_customer1
customer2.yoursaas.com → Tenant Database: tenant_customer2
app.yoursaas.com       → Central Database (System)
2

Database Switching

Stancl automatically switches database connections based on the current tenant:
// Automatically uses tenant's database
Route::middleware(['web', 'tenant'])->group(function () {
    Route::get('/dashboard', [TenantDashboardController::class, 'index']);
});
3

Isolated Data

Each tenant’s data is completely isolated:
// In tenant context, queries only access tenant's database
$users = User::all(); // Only users from current tenant

Tenant Model

app/Models/System/Tenant.php
namespace App\Models\System;

use Stancl\Tenancy\Database\Models\Tenant as BaseTenant;
use Stancl\Tenancy\Contracts\TenantWithDatabase;
use Stancl\Tenancy\Database\Concerns\HasDatabase;
use Stancl\Tenancy\Database\Concerns\HasDomains;

class Tenant extends BaseTenant implements TenantWithDatabase
{
    use HasDatabase, HasDomains;

    protected $casts = [
        'subscription_ends_at' => 'datetime',
        'trial_ends_at' => 'datetime',
        'canceled_at' => 'datetime',
    ];

    /**
     * Define custom columns for the tenants table.
     */
    public static function getCustomColumns(): array
    {
        return [
            'id',
            'name',
            'tenancy_db_name',
            'plan_id',
            'owner_name',
            'owner_email',
            'owner_password',
            'status',
            'subscription_ends_at',
            'trial_ends_at',
            'canceled_at',
            'is_active',
        ];
    }

    public function plan()
    {
        return $this->belongsTo(Plan::class);
    }

    public function subscriptions()
    {
        return $this->hasMany(Subscription::class);
    }
}

Models

Models are organized into System and Tenant namespaces:

System Models (Central Database)

app/Models/System/Tenant.php
class Tenant extends BaseTenant implements TenantWithDatabase
{
    use HasDatabase, HasDomains;

    protected $fillable = [
        'name',
        'owner_name',
        'owner_email',
        'owner_password',
        'plan_id',
        'status',
    ];

    public function plan()
    {
        return $this->belongsTo(Plan::class);
    }

    public function domains()
    {
        return $this->hasMany(Domain::class);
    }
}

Tenant Models (Tenant Databases)

app/Models/Tenant/User.php
namespace App\Models\Tenant;

use Illuminate\Foundation\Auth\User as Authenticatable;

class User extends Authenticatable
{
    use HasFactory, Notifiable;

    protected $fillable = [
        'name',
        'email',
        'password',
    ];

    protected $hidden = [
        'password',
        'remember_token',
    ];

    protected $casts = [
        'email_verified_at' => 'datetime',
        'password' => 'hashed',
    ];
}
Important: Tenant models use the tenant database connection, automatically set by Stancl Tenancy middleware.

Controllers

Controllers are organized by domain (System vs Tenant) and responsibility:

System Controllers

app/Http/Controllers/System/DashboardController.php
namespace App\Http\Controllers\System;

use App\Http\Controllers\Controller;
use App\Models\System\Tenant;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;

class DashboardController extends Controller
{
    /**
     * Display the system dashboard with key metrics.
     */
    public function index()
    {
        // 1. Key Performance Indicators (KPIs)
        $stats = [
            'total_tenants' => Tenant::count(),
            'active_tenants' => Tenant::where('status', 'Active')->count(),
            'trial_tenants' => Tenant::where('status', 'Trial')->count(),
            'new_tenants_this_month' => Tenant::where(
                'created_at', '>=', now()->startOfMonth()
            )->count(),
        ];

        // 2. Recent Tenants (Last 5)
        $recentTenants = Tenant::with('plan')
            ->latest()
            ->take(5)
            ->get()
            ->map(function ($tenant) {
                return [
                    'id' => $tenant->id,
                    'name' => $tenant->name,
                    'email' => $tenant->owner_email,
                    'status' => $tenant->status,
                    'plan_name' => $tenant->plan?->name ?? 'N/A',
                    'created_at' => $tenant->created_at->diffForHumans(),
                ];
            });

        // 3. Plan Distribution
        $planDistribution = Tenant::select('plan_id', DB::raw('count(*) as count'))
            ->groupBy('plan_id')
            ->with('plan')
            ->get()
            ->map(function ($item) {
                return [
                    'name' => $item->plan?->name ?? 'No Plan',
                    'count' => $item->count,
                ];
            });

        return Inertia::render('system/Dashboard', [
            'stats' => $stats,
            'recentTenants' => $recentTenants,
            'planDistribution' => $planDistribution,
        ]);
    }
}

Resource Controllers

Use Laravel’s resource controller pattern:
app/Http/Controllers/System/TenantController.php
class TenantController extends Controller
{
    public function __construct(
        private TenantService $tenantService
    ) {}

    public function index(Request $request)
    {
        $tenants = $this->tenantService->listTenants([
            'search' => $request->input('search'),
            'status' => $request->input('status'),
            'plan_id' => $request->input('plan_id'),
        ]);

        return Inertia::render('system/tenants/Index', [
            'tenants' => $tenants,
            'filters' => $request->only(['search', 'status', 'plan_id']),
        ]);
    }

    public function store(Request $request)
    {
        $validated = $request->validate([
            'name' => 'required|string|max:255',
            'owner_name' => 'required|string|max:255',
            'owner_email' => 'required|email|unique:tenants,owner_email',
            'owner_password' => 'required|string|min:8',
            'plan_id' => 'required|exists:plans,id',
        ]);

        $tenant = $this->tenantService->createTenant($validated);

        return redirect()->route('tenants.index')
            ->with('message', 'Tenant created successfully');
    }

    public function update(Request $request, Tenant $tenant)
    {
        $validated = $request->validate([
            'name' => 'sometimes|string|max:255',
            'status' => 'sometimes|in:Active,Trial,Canceled',
            'plan_id' => 'sometimes|exists:plans,id',
        ]);

        $this->tenantService->updateTenant($tenant, $validated);

        return back()->with('message', 'Tenant updated successfully');
    }

    public function destroy(Tenant $tenant)
    {
        $this->tenantService->deleteTenant($tenant);

        return redirect()->route('tenants.index')
            ->with('message', 'Tenant deleted successfully');
    }
}

Services

Business logic is encapsulated in service classes:
app/Services/System/TenantService.php
namespace App\Services\System;

use App\Models\System\Tenant;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;

class TenantService
{
    /**
     * Create a new tenant with database and domain.
     */
    public function createTenant(array $data): Tenant
    {
        $tenantNameSlug = Str::slug($data['name'], '_');
        $dbName = 'tenant_' . $tenantNameSlug;

        // 1. Create Tenant (triggers database creation)
        $tenant = Tenant::create([
            'id' => Str::uuid(),
            'name' => $data['name'],
            'owner_name' => $data['owner_name'],
            'owner_email' => $data['owner_email'],
            'owner_password' => Hash::make($data['owner_password']),
            'plan_id' => $data['plan_id'] ?? null,
            'status' => $data['status'] ?? 'Trial',
            'is_active' => true,
            'tenancy_db_name' => $dbName,
        ]);

        try {
            // 2. Create Domain
            $baseDomain = config('app.url_base', 'localhost');
            $fullDomain = $data['domain'] . '.' . $baseDomain;

            $tenant->createDomain([
                'domain' => $fullDomain,
            ]);

            // 3. Create Admin User in Tenant DB
            $tenant->run(function () use ($data) {
                \App\Models\Tenant\User::create([
                    'name' => $data['owner_name'],
                    'email' => $data['owner_email'],
                    'password' => $data['owner_password'],
                ]);
            });

            return $tenant;

        } catch (\Exception $e) {
            // Rollback if setup fails
            $tenant->delete();
            throw $e;
        }
    }

    /**
     * List tenants with pagination and filters.
     */
    public function listTenants(array $filters = [])
    {
        $query = Tenant::query()->with(['plan', 'domains']);

        // Search filter
        if (!empty($filters['search'])) {
            $query->where(function ($q) use ($filters) {
                $q->where('name', 'like', '%' . $filters['search'] . '%')
                  ->orWhere('owner_name', 'like', '%' . $filters['search'] . '%')
                  ->orWhere('owner_email', 'like', '%' . $filters['search'] . '%');
            });
        }

        // Status filter
        if (!empty($filters['status'])) {
            $query->where('status', $filters['status']);
        }

        // Plan filter
        if (!empty($filters['plan_id'])) {
            $query->where('plan_id', $filters['plan_id']);
        }

        return $query->orderBy('created_at', 'desc')->paginate(10);
    }

    /**
     * Cancel a tenant subscription.
     */
    public function cancelTenant(Tenant $tenant): Tenant
    {
        $tenant->update([
            'status' => 'Canceled',
            'is_active' => false,
            'canceled_at' => now(),
        ]);

        return $tenant;
    }

    /**
     * Restore a canceled tenant within grace period.
     */
    public function restoreTenant(Tenant $tenant): Tenant
    {
        if ($tenant->status !== 'Canceled') {
            return $tenant;
        }

        // Check grace period (30 days)
        if ($tenant->canceled_at && $tenant->canceled_at->lt(now()->subDays(30))) {
            throw new \RuntimeException('Grace period expired');
        }

        $tenant->update([
            'status' => 'Active',
            'is_active' => true,
            'canceled_at' => null,
        ]);

        return $tenant;
    }
}

Service Benefits

Business logic is centralized, making controllers thin and focused on HTTP concerns.
Services can be used from controllers, commands, jobs, and tests.
Services are easy to test in isolation with dependency injection.
Complex operations with multiple database calls are wrapped in transactions.

Routing

Routes are organized by domain:

System Routes

routes/web.php
Route::domain(config('app.url_base'))->group(function () {
    // Root redirect
    Route::get('/', fn() => redirect()->route('login'));

    // Dashboard
    Route::get('dashboard', [DashboardController::class, 'index'])
        ->middleware(['auth', 'verified'])
        ->name('dashboard');

    // Tenants Management
    Route::resource('tenants', TenantController::class)
        ->middleware(['auth', 'verified'])
        ->except(['show', 'create', 'edit']);

    Route::post('tenants/{tenant}/cancel', [TenantController::class, 'cancel'])
        ->middleware(['auth', 'verified'])
        ->name('tenants.cancel');

    // Plans Management
    Route::resource('plans', PlanController::class)
        ->middleware(['auth', 'verified'])
        ->except(['show', 'create', 'edit']);

    // Authentication Routes
    Route::prefix('auth')->group(function () {
        Route::get('/login', [AuthenticatedSessionController::class, 'create'])
            ->middleware(['guest'])
            ->name('login');

        Route::post('/login', [AuthenticatedSessionController::class, 'store'])
            ->middleware(['guest']);

        Route::post('/logout', [AuthenticatedSessionController::class, 'destroy'])
            ->name('logout');
    });

    // Settings Routes
    require __DIR__.'/settings.php';
});

Tenant Routes

routes/tenant.php
Route::middleware(['web', 'tenant'])->group(function () {
    // Tenant Dashboard
    Route::get('/dashboard', [TenantDashboardController::class, 'index'])
        ->middleware(['auth'])
        ->name('tenant.dashboard');

    // Tenant-specific features
    // Add your tenant routes here
});

Middleware

Key middleware in the application:

InitializeTenancyByDomain

Identifies tenant by subdomain and switches database connection

HandleInertiaRequests

Shares data across all Inertia pages (auth user, flash messages)

auth

Ensures user is authenticated before accessing protected routes

verified

Ensures user has verified their email address

Database Migrations

Central Database Migrations

database/migrations/create_plans_table.php
Schema::create('plans', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->text('description')->nullable();
    $table->decimal('price', 10, 2);
    $table->enum('billing_period', ['monthly', 'yearly']);
    $table->integer('trial_days')->default(0);
    $table->timestamps();
});

Tenant Database Migrations

database/migrations/tenant/create_users_table.php
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->rememberToken();
    $table->timestamps();
});
Migrations in database/migrations/tenant/ run automatically when a new tenant is created.

Authentication

The application uses Laravel Fortify for authentication:
  • Login / Logout
  • Registration
  • Password Reset
  • Email Verification
  • Two-Factor Authentication
  • Password Confirmation

API Development

API routes are defined in routes/api.php:
routes/api.php
Route::middleware(['auth:sanctum'])->group(function () {
    Route::get('/user', function (Request $request) {
        return $request->user();
    });

    // Add API endpoints here
});
Laravel Sanctum provides API token authentication for SPAs and mobile apps.

Commands

Custom Artisan commands for administrative tasks:
# Create a new tenant
php artisan tenant:create

# List all tenants
php artisan tenants:list

# Run migrations for all tenants
php artisan tenants:migrate

# Seed tenant databases
php artisan tenants:seed

Best Practices

  • Keep controllers thin, delegate to services
  • Use resource controllers for CRUD operations
  • Validate requests with Form Request classes
  • Return Inertia responses for frontend views
  • Encapsulate complex business logic
  • Use dependency injection
  • Return models or collections, not views
  • Wrap multi-step operations in transactions
  • Never query tenant data from system context
  • Always use tenant() helper to get current tenant
  • Test tenant isolation thoroughly
  • Use $tenant->run() to execute code in tenant context
  • Use eager loading to prevent N+1 queries
  • Index foreign keys and frequently queried columns
  • Use database transactions for data consistency
  • Leverage Eloquent relationships

Next Steps

Testing

Learn how to test Laravel controllers and services

Frontend Development

Integrate backend with Vue components

Build docs developers (and LLMs) love