Multi-Tenancy Patterns trong Laravel: Xây dựng Ứng dụng SaaS
Multi-tenancy cho phép một ứng dụng duy nhất phục vụ nhiều khách hàng (tenants) trong khi giữ dữ liệu của họ bị cô lập. Điều này rất cần thiết cho các ứng dụng SaaS cần phải mở rộng một cách hiệu quả.
Xác định Tenants
Bước đầu tiên là xác định cách xác định tenant nào mà một request thuộc về.
Option 1: Domain-Based
Mỗi tenant nhận được domain riêng của họ:
acme.yourapp.comwidgets.yourapp.com
// app/Services/TenantResolver.php
class TenantResolver
{
public static function getTenant(): ?Tenant
{
$domain = request()->getHost();
return Tenant::where('domain', $domain)->first();
}
}
Option 2: Subdomain-Based
Trích xuất subdomain từ một domain duy nhất:
public static function getTenant(): ?Tenant
{
$subdomain = explode('.', request()->getHost())[0];
return Tenant::where('subdomain', $subdomain)->first();
}
Option 3: Path-Based
Sử dụng route parameter:
// routes/web.php
Route::prefix('{tenant}')->group(function () {
Route::get('/dashboard', [DashboardController::class, 'show']);
});
// Controller
public function show(Tenant $tenant)
{
return view('dashboard', ['tenant' => $tenant]);
}
Option 4: Header-Based (API)
Sử dụng API token hoặc header:
public static function getTenant(): ?Tenant
{
$token = request()->bearerToken();
return Tenant::whereHas('apiTokens', function ($query) use ($token) {
$query->where('token', $token);
})->first();
}
Tenant Middleware
Tạo middleware để resolve và validate tenants:
namespace App\Http\Middleware;
use App\Services\TenantResolver;
use Closure;
class ResolveTenant
{
public function handle($request, Closure $next)
{
$tenant = TenantResolver::getTenant();
if (!$tenant) {
abort(404, 'Tenant not found');
}
// Make tenant available globally
app()->instance('tenant', $tenant);
// Bind in container
app()->bind(Tenant::class, fn() => $tenant);
return $next($request);
}
}
Đăng ký trong App\Http\Kernel.php:
protected $middlewareGroups = [
'web' => [
// ...
\App\Http\Middleware\ResolveTenant::class,
],
];
Database Isolation
Single Database with Tenant Column
Phương pháp phổ biến nhất:
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained();
$table->string('title');
$table->longText('content');
$table->timestamps();
});
Query scoping:
class Post extends Model
{
protected static function booted()
{
static::addGlobalScope('tenant', function (Builder $query) {
if (app()->bound('tenant')) {
$query->where('tenant_id', app('tenant')->id);
}
});
}
}
Separate Tenant Databases
Để cô lập hoàn toàn:
// config/database.php
'connections' => [
'tenant' => [
'driver' => 'mysql',
'host' => env('DB_HOST'),
'database' => 'tenant_' . app('tenant')->id,
'username' => env('DB_USERNAME'),
'password' => env('DB_PASSWORD'),
],
],
Sử dụng trong models:
class Post extends Model
{
protected $connection = 'tenant';
}
Migrate cho mỗi tenant:
public function migrate(Tenant $tenant)
{
Artisan::call('migrate', [
'--database' => 'tenant_' . $tenant->id,
]);
}
Service Class Pattern
Tạo các services có tenant-aware:
namespace App\Services;
use App\Models\Tenant;
class PostService
{
public function __construct(
private Tenant $tenant,
) {}
public function getPosts()
{
return Post::where('tenant_id', $this->tenant->id)->get();
}
public function createPost(array $data)
{
return Post::create([
'tenant_id' => $this->tenant->id,
...$data,
]);
}
}
// Trong controller
public function store(Request $request)
{
$service = new PostService(app('tenant'));
$post = $service->createPost($request->validated());
return redirect()->route('posts.show', $post);
}
Hoặc sử dụng service container binding:
// AppServiceProvider
public function register()
{
$this->app->bind(PostService::class, function () {
return new PostService(app('tenant'));
});
}
Feature Flags theo Tenant
Các tenants khác nhau có thể có các tính năng khác nhau:
class Tenant extends Model
{
public function hasFeature(string $feature): bool
{
return $this->features->contains('name', $feature);
}
public function enableFeature(string $feature)
{
$this->features()->attach(Feature::where('name', $feature)->first());
}
}
// Trong controller
if (auth()->user()->tenant->hasFeature('comments')) {
// Show comments feature
}
Tenant Seeding
Seed dữ liệu của mỗi tenant:
// database/seeders/TenantSeeder.php
class TenantSeeder extends Seeder
{
public function run(Tenant $tenant)
{
User::factory(5)->for($tenant)->create();
Post::factory(20)->for($tenant)->create();
}
}
// Create command
public function handle()
{
$tenant = Tenant::create([
'name' => 'ACME Corp',
'domain' => 'acme.localhost',
]);
$this->call(TenantSeeder::class, parameters: ['tenant' => $tenant]);
}
Testing Multi-Tenancy
use Illuminate\Foundation\Testing\RefreshDatabase;
class PostTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->tenant = Tenant::factory()->create();
$this->actingAsTenant($this->tenant);
}
protected function actingAsTenant(Tenant $tenant)
{
app()->instance('tenant', $tenant);
$this->be(User::factory()->for($tenant)->create());
return $this;
}
public function test_can_create_post()
{
$response = $this->post('/posts', [
'title' => 'Test Post',
'content' => 'Content',
]);
$this->assertDatabaseHas('posts', [
'tenant_id' => $this->tenant->id,
'title' => 'Test Post',
]);
}
public function test_cannot_access_other_tenant_posts()
{
$otherTenant = Tenant::factory()->create();
$post = Post::factory()->for($otherTenant)->create();
$response = $this->get("/posts/{$post->id}");
$response->assertNotFound();
}
}
Tenant Context Helper
Tạo một helper để quản lý tenant context:
namespace App\Support;
class TenantContext
{
private static ?Tenant $current = null;
public static function set(Tenant $tenant): void
{
self::$current = $tenant;
app()->instance('tenant', $tenant);
}
public static function get(): ?Tenant
{
return self::$current ?? app('tenant');
}
public static function id(): ?int
{
return self::get()?->id;
}
public static function isSet(): bool
{
return self::$current !== null;
}
}
// Usage
TenantContext::set($tenant);
$posts = Post::where('tenant_id', TenantContext::id())->get();
Billing & Subscriptions per Tenant
Theo dõi usage per tenant:
// database/migrations/xxxx_create_tenant_subscriptions_table.php
Schema::create('tenant_subscriptions', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained();
$table->string('plan');
$table->integer('max_users');
$table->integer('max_storage'); // MB
$table->timestamp('renews_at');
$table->timestamps();
});
Usage tracking:
class Tenant extends Model
{
public function getUserCount(): int
{
return User::where('tenant_id', $this->id)->count();
}
public function getStorageUsed(): int
{
// Calculate storage usage
}
public function isWithinLimits(): bool
{
return $this->getUserCount() <= $this->subscription->max_users
&& $this->getStorageUsed() <= $this->subscription->max_storage;
}
}
Best Practices
- Luôn scope queries - Sử dụng global scopes
- Validate tenant access - Kiểm tra user thuộc về tenant
- Isolate data - Sử dụng tenant_id columns hoặc separate databases
- Test thoroughly - Test multi-tenant scenarios
- Handle migrations - Chạy cho mỗi tenant riêng biệt
- Monitor per-tenant - Theo dõi usage và performance
- Plan for growth - Thiết kế cho scalability
Common Architectures
Lightweight SaaS
- Single shared database
- Tenant column trên tất cả tables
- Global scopes cho automatic filtering
Enterprise SaaS
- Separate database per tenant
- Complete data isolation
- Tenant-specific features và customizations
Hybrid
- Shared database cho core data
- Separate database cho sensitive data
- Configurable per tenant
Tóm tắt
Multi-tenancy patterns cho phép các ứng dụng SaaS hiệu quả:
- Identify tenants - Domain, subdomain, path, hoặc header
- Middleware - Resolve và validate tenants per request
- Data isolation - Tenant columns hoặc separate databases
- Service classes - Encapsulate tenant logic
- Feature flags - Các tính năng khác nhau per tenant
- Testing - Verify tenant isolation
Chọn phương pháp đúng cho SaaS model của bạn.