Xây dựng SaaS với Laravel (Phần 2): Multi-Tenancy & Data Isolation

· 13 min read

Phần 1, chúng ta đã setup project và database schema. Bây giờ là lúc đi sâu vào phần quan trọng nhất của SaaS: multi-tenancy — cách đảm bảo dữ liệu giữa các tổ chức hoàn toàn tách biệt.

Chiến lược Multi-Tenancy

Có 3 cách tiếp cận phổ biến:

Chiến lược Isolation Phức tạp Scale
Shared Database, Shared Schema Thấp Thấp Dễ
Shared Database, Separate Schema Trung bình Trung bình Trung bình
Separate Database Cao Cao Khó

Chúng ta chọn Shared Database, Shared Schema vì:

  • Đơn giản nhất để triển khai
  • Tiết kiệm tài nguyên cho giai đoạn đầu
  • Laravel Eloquent hỗ trợ tốt qua Global Scopes
  • Có thể migrate sang separate database sau nếu cần

1. Hoàn thiện Tenant Scope

Ở phần 1, chúng ta đã tạo TenantScope cơ bản. Bây giờ hãy hoàn thiện nó:

// app/Domain/Tenant/Scopes/TenantScope.php
<?php

declare(strict_types=1);

namespace App\Domain\Tenant\Scopes;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;

class TenantScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        $tenant = app('current_tenant');

        if ($tenant) {
            $builder->where(
                $model->qualifyColumn('tenant_id'),
                $tenant->id
            );
        }
    }

    public function extend(Builder $builder): void
    {
        // Cho phép query across tenants khi cần (admin)
        $builder->macro('withoutTenantScope', function (Builder $builder) {
            return $builder->withoutGlobalScope(self::class);
        });

        // Query cho một tenant cụ thể
        $builder->macro('forTenant', function (Builder $builder, int $tenantId) {
            return $builder->withoutGlobalScope(self::class)
                ->where('tenant_id', $tenantId);
        });
    }
}

2. Cải tiến BelongsToTenant Trait

// app/Domain/Tenant/Traits/BelongsToTenant.php
<?php

declare(strict_types=1);

namespace App\Domain\Tenant\Traits;

use App\Domain\Tenant\Models\Tenant;
use App\Domain\Tenant\Scopes\TenantScope;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

trait BelongsToTenant
{
    protected static function bootBelongsToTenant(): void
    {
        static::addGlobalScope(new TenantScope());

        static::creating(function ($model) {
            if (! app()->runningInConsole() || app()->runningUnitTests()) {
                $tenant = app('current_tenant');
                if ($tenant && ! $model->tenant_id) {
                    $model->tenant_id = $tenant->id;
                }
            }
        });
    }

    public function tenant(): BelongsTo
    {
        return $this->belongsTo(Tenant::class);
    }

    /**
     * Đảm bảo model thuộc về tenant hiện tại.
     */
    public function belongsToCurrentTenant(): bool
    {
        $tenant = app('current_tenant');

        return $tenant && $this->tenant_id === $tenant->id;
    }
}

3. Áp dụng cho các Models

Project Model

// app/Domain/Project/Models/Project.php
<?php

declare(strict_types=1);

namespace App\Domain\Project\Models;

use App\Domain\Team\Models\Team;
use App\Domain\Tenant\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;

class Project extends Model
{
    use BelongsToTenant, SoftDeletes;

    protected $fillable = [
        'tenant_id',
        'team_id',
        'name',
        'description',
        'status',
        'due_date',
    ];

    protected $casts = [
        'due_date' => 'date',
    ];

    public function team(): BelongsTo
    {
        return $this->belongsTo(Team::class);
    }

    public function tasks(): HasMany
    {
        return $this->hasMany(Task::class);
    }

    public function isActive(): bool
    {
        return $this->status === 'active';
    }

    public function completionPercentage(): float
    {
        $total = $this->tasks()->count();

        if ($total === 0) {
            return 0;
        }

        $done = $this->tasks()->where('status', 'done')->count();

        return round(($done / $total) * 100, 1);
    }
}

Task Model

// app/Domain/Project/Models/Task.php
<?php

declare(strict_types=1);

namespace App\Domain\Project\Models;

use App\Domain\Tenant\Traits\BelongsToTenant;
use App\Domain\User\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Task extends Model
{
    use BelongsToTenant;

    protected $fillable = [
        'project_id',
        'tenant_id',
        'assigned_to',
        'created_by',
        'title',
        'description',
        'status',
        'priority',
        'due_date',
        'position',
    ];

    protected $casts = [
        'due_date' => 'date',
        'position' => 'integer',
    ];

    public function project(): BelongsTo
    {
        return $this->belongsTo(Project::class);
    }

    public function assignee(): BelongsTo
    {
        return $this->belongsTo(User::class, 'assigned_to');
    }

    public function creator(): BelongsTo
    {
        return $this->belongsTo(User::class, 'created_by');
    }

    public function isDone(): bool
    {
        return $this->status === 'done';
    }

    public function isOverdue(): bool
    {
        return $this->due_date !== null
            && $this->due_date->isPast()
            && ! $this->isDone();
    }
}

Team Model

// app/Domain/Team/Models/Team.php
<?php

declare(strict_types=1);

namespace App\Domain\Team\Models;

use App\Domain\Project\Models\Project;
use App\Domain\Tenant\Traits\BelongsToTenant;
use App\Domain\User\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Team extends Model
{
    use BelongsToTenant;

    protected $fillable = [
        'tenant_id',
        'name',
        'description',
    ];

    public function members(): BelongsToMany
    {
        return $this->belongsToMany(User::class, 'team_user')
            ->withPivot('role')
            ->withTimestamps();
    }

    public function projects(): HasMany
    {
        return $this->hasMany(Project::class);
    }
}

4. Subdomain Routing

Để mỗi tenant có URL riêng như acme.saas-app.com:

Cấu hình Domain

// config/app.php - thêm vào mảng config
'tenant_domain' => env('TENANT_DOMAIN', 'saas-app.com'),

Middleware Subdomain Resolution

// app/Http/Middleware/ResolveSubdomainTenant.php
<?php

declare(strict_types=1);

namespace App\Http\Middleware;

use App\Domain\Tenant\Models\Tenant;
use App\Providers\TenantServiceProvider;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class ResolveSubdomainTenant
{
    public function handle(Request $request, Closure $next): Response
    {
        $host = $request->getHost();
        $baseDomain = config('app.tenant_domain');

        // Bỏ qua nếu đang ở main domain
        if ($host === $baseDomain || $host === 'www.' . $baseDomain) {
            return $next($request);
        }

        // Lấy subdomain
        $subdomain = str_replace('.' . $baseDomain, '', $host);

        $tenant = Tenant::where('slug', $subdomain)
            ->where('status', 'active')
            ->first();

        if (! $tenant) {
            abort(404, 'Organization not found.');
        }

        TenantServiceProvider::setTenant($tenant);

        // Đặt vào request để dùng ở middleware khác
        $request->attributes->set('tenant', $tenant);

        return $next($request);
    }
}

Routes với Subdomain

// routes/web.php
<?php

use Illuminate\Support\Facades\Route;

// Main domain routes: đăng ký, landing page
Route::domain(config('app.tenant_domain'))->group(function () {
    Route::get('/', fn () => view('welcome'));
    Route::get('/register', [RegisterController::class, 'show'])->name('register');
    Route::post('/register', [RegisterController::class, 'store']);
    Route::get('/login', [LoginController::class, 'show'])->name('login');
    Route::post('/login', [LoginController::class, 'store']);
});

// Tenant subdomain routes
Route::domain('{tenant}.' . config('app.tenant_domain'))
    ->middleware(['web', 'auth', 'resolve.subdomain'])
    ->group(function () {
        Route::get('/dashboard', [DashboardController::class, 'index'])
            ->name('tenant.dashboard');

        Route::resource('projects', ProjectController::class);
        Route::resource('projects.tasks', TaskController::class)->shallow();
    });

5. Tenant Context Helper

Tạo helper để access tenant hiện tại dễ dàng:

// app/Domain/Tenant/helpers.php
<?php

use App\Domain\Tenant\Models\Tenant;

if (! function_exists('current_tenant')) {
    function current_tenant(): ?Tenant
    {
        return app('current_tenant');
    }
}

if (! function_exists('tenant_id')) {
    function tenant_id(): ?int
    {
        return current_tenant()?->id;
    }
}

Đăng ký trong composer.json:

{
    "autoload": {
        "files": [
            "app/Domain/Tenant/helpers.php"
        ]
    }
}

Chạy composer dump-autoload sau khi thêm.

6. Tenant Switching

Cho phép users thuộc nhiều tenant switch giữa chúng:

// app/Http/Controllers/TenantSwitchController.php
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Domain\Tenant\Models\Tenant;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

class TenantSwitchController extends Controller
{
    public function __invoke(Request $request, Tenant $tenant): RedirectResponse
    {
        $user = $request->user();

        // Kiểm tra user có quyền truy cập tenant này không
        if (! $user->tenants()->where('tenant_id', $tenant->id)->exists()) {
            abort(403);
        }

        $user->switchTenant($tenant);

        // Redirect về dashboard của tenant mới
        $domain = $tenant->slug . '.' . config('app.tenant_domain');

        return redirect()->to(
            $request->getScheme() . '://' . $domain . '/dashboard'
        );
    }
}

7. Bảo vệ Cross-Tenant Access

Một trong những lỗi bảo mật nguy hiểm nhất trong SaaS là cross-tenant data leakage. Có nhiều lớp bảo vệ:

Layer 1: Global Scope (đã có)

Global scope tự động filter theo tenant trong mọi query.

Layer 2: Route Model Binding Check

// app/Providers/AppServiceProvider.php
public function boot(): void
{
    // Tự động verify tenant ownership khi route model binding
    Route::bind('project', function ($value) {
        $project = Project::findOrFail($value);

        if (! $project->belongsToCurrentTenant()) {
            abort(404);
        }

        return $project;
    });

    Route::bind('task', function ($value) {
        $task = Task::findOrFail($value);

        if (! $task->belongsToCurrentTenant()) {
            abort(404);
        }

        return $task;
    });
}

Layer 3: Policy Authorization

// app/Domain/Project/Policies/ProjectPolicy.php
<?php

declare(strict_types=1);

namespace App\Domain\Project\Policies;

use App\Domain\Project\Models\Project;
use App\Domain\User\Models\User;

class ProjectPolicy
{
    public function viewAny(User $user): bool
    {
        // Tenant scope đã handle filtering
        return true;
    }

    public function view(User $user, Project $project): bool
    {
        return $project->tenant_id === $user->current_tenant_id;
    }

    public function create(User $user): bool
    {
        return $user->current_tenant_id !== null;
    }

    public function update(User $user, Project $project): bool
    {
        return $project->tenant_id === $user->current_tenant_id;
    }

    public function delete(User $user, Project $project): bool
    {
        return $project->tenant_id === $user->current_tenant_id;
    }
}

Layer 4: Middleware Double Check

// app/Http/Middleware/VerifyTenantAccess.php
<?php

declare(strict_types=1);

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class VerifyTenantAccess
{
    public function handle(Request $request, Closure $next): Response
    {
        $user = $request->user();
        $tenant = current_tenant();

        if (! $tenant || ! $user) {
            abort(403);
        }

        // Verify user thực sự thuộc về tenant hiện tại
        $isMember = $user->tenants()
            ->where('tenant_id', $tenant->id)
            ->exists();

        if (! $isMember) {
            abort(403, 'You do not have access to this organization.');
        }

        return $next($request);
    }
}

8. Caching theo Tenant

Cache phải được tách biệt giữa các tenant:

// app/Domain/Tenant/Services/TenantCacheService.php
<?php

declare(strict_types=1);

namespace App\Domain\Tenant\Services;

use Illuminate\Support\Facades\Cache;

class TenantCacheService
{
    public static function key(string $key): string
    {
        $tenantId = tenant_id();

        return $tenantId ? "tenant_{$tenantId}:{$key}" : $key;
    }

    public static function get(string $key, mixed $default = null): mixed
    {
        return Cache::get(self::key($key), $default);
    }

    public static function put(string $key, mixed $value, int $ttl = 3600): bool
    {
        return Cache::put(self::key($key), $value, $ttl);
    }

    public static function forget(string $key): bool
    {
        return Cache::forget(self::key($key));
    }

    /**
     * Xóa tất cả cache của một tenant.
     */
    public static function flush(int $tenantId): void
    {
        Cache::tags(["tenant_{$tenantId}"])->flush();
    }
}

9. Testing Multi-Tenancy

Test là phần quan trọng nhất — đảm bảo data isolation hoạt động đúng:

// tests/Feature/MultiTenancyTest.php
<?php

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->action = new CreateTenantAction();

    // Tạo 2 users và 2 tenants
    $this->user1 = User::factory()->create();
    $this->user2 = User::factory()->create();

    $this->tenant1 = $this->action->execute($this->user1, 'Company A');
    $this->tenant2 = $this->action->execute($this->user2, 'Company B');
});

test('tenant scope isolates data', function () {
    // Tạo project cho tenant 1
    TenantServiceProvider::setTenant($this->tenant1);
    $project1 = Project::create([
        'name' => 'Project A',
        'tenant_id' => $this->tenant1->id,
    ]);

    // Tạo project cho tenant 2
    TenantServiceProvider::setTenant($this->tenant2);
    $project2 = Project::create([
        'name' => 'Project B',
        'tenant_id' => $this->tenant2->id,
    ]);

    // Tenant 1 chỉ thấy project của mình
    TenantServiceProvider::setTenant($this->tenant1);
    $projects = Project::all();
    expect($projects)->toHaveCount(1)
        ->and($projects->first()->name)->toBe('Project A');

    // Tenant 2 chỉ thấy project của mình
    TenantServiceProvider::setTenant($this->tenant2);
    $projects = Project::all();
    expect($projects)->toHaveCount(1)
        ->and($projects->first()->name)->toBe('Project B');
});

test('cannot access other tenant project via API', function () {
    // Tạo project của tenant 1
    TenantServiceProvider::setTenant($this->tenant1);
    $project = Project::create([
        'name' => 'Secret Project',
        'tenant_id' => $this->tenant1->id,
    ]);

    // User 2 cố truy cập project của tenant 1
    TenantServiceProvider::setTenant($this->tenant2);

    $this->actingAs($this->user2)
        ->get("/projects/{$project->id}")
        ->assertNotFound();
});

test('auto-assigns tenant_id on create', function () {
    TenantServiceProvider::setTenant($this->tenant1);

    $project = Project::create(['name' => 'Auto Tenant Project']);

    expect($project->tenant_id)->toBe($this->tenant1->id);
});

test('withoutTenantScope returns all data', function () {
    TenantServiceProvider::setTenant($this->tenant1);
    Project::create([
        'name' => 'Project A',
        'tenant_id' => $this->tenant1->id,
    ]);

    TenantServiceProvider::setTenant($this->tenant2);
    Project::create([
        'name' => 'Project B',
        'tenant_id' => $this->tenant2->id,
    ]);

    // Admin query across all tenants
    $all = Project::withoutTenantScope()->get();
    expect($all)->toHaveCount(2);
});

test('user can switch between tenants', function () {
    // Thêm user1 vào tenant2
    $this->tenant2->users()->attach($this->user1->id, ['role' => 'member']);

    $this->user1->switchTenant($this->tenant2);

    expect($this->user1->fresh()->current_tenant_id)
        ->toBe($this->tenant2->id);
});

test('user cannot switch to tenant they do not belong to', function () {
    // User2 cố switch sang tenant1 mà không được invite
    expect(fn () => $this->user2->switchTenant($this->tenant1))
        ->toThrow(\DomainException::class);
});

10. Debugging Tips

Log Tenant Context

Thêm tenant info vào mọi log entry:

// app/Providers/AppServiceProvider.php
use Illuminate\Log\LogManager;
use Illuminate\Support\Facades\Log;

public function boot(): void
{
    Log::shareContext([
        'tenant_id' => fn () => tenant_id(),
    ]);
}

Query Log cho Development

// Thêm vào AppServiceProvider::boot() trong environment local
if (app()->isLocal()) {
    \DB::listen(function ($query) {
        if (str_contains($query->sql, 'tenant_id')) {
            logger()->debug('Tenant Query', [
                'sql' => $query->sql,
                'bindings' => $query->bindings,
                'time' => $query->time . 'ms',
            ]);
        }
    });
}

Tổng kết

Ở phần 2, chúng ta đã xây dựng:

  • Global Scope tự động filter data theo tenant
  • Subdomain routing cho mỗi tenant
  • 4 layers bảo vệ chống cross-tenant access
  • Tenant switching cho users thuộc nhiều tổ chức
  • Cache isolation theo tenant
  • Test suite toàn diện cho multi-tenancy

Điểm quan trọng nhất: multi-tenancy phải được bảo vệ ở nhiều lớp. Không bao giờ chỉ dựa vào một lớp duy nhất.

Phần tiếp theo: Billing với Stripe Cashier — tích hợp thanh toán subscription, quản lý plans, và xử lý webhook.

Bình luận