Xây dựng SaaS với Laravel (Phần 2): Multi-Tenancy & Data Isolation
Ở 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.