Từ Lý Thuyết Đến Thực Tế: Thiết Kế Ứng Dụng Multi-Tenant Với Laravel Và PostgreSQL
Tôi từng đọc rất nhiều bài về multi-tenancy. Tất cả đều giống nhau: giải thích khái niệm hay, vẽ diagram đẹp, rồi kết thúc bằng một đoạn code minh họa ngắn và câu "implement tùy dự án." Không bài nào nói cho tôi biết điều tôi thực sự cần: khi ngồi vào code thật, cái gì sẽ bắn vào mặt mình đầu tiên?
Bài này được viết sau khi tôi đã build xong một SaaS multi-tenant từ đầu đến production. Không phải lý thuyết. Là những quyết định cụ thể, lý do đằng sau, và cả những chỗ tôi đã làm sai rồi phải sửa.
Tại Sao Multi-Tenancy Lại Khó Hơn Bạn Nghĩ
Nghe thì đơn giản: mỗi khách hàng là một "tenant", dữ liệu của họ phải cách ly nhau. Chủ cho thuê A không được thấy phòng của chủ cho thuê B. Nghe như chỉ cần thêm một cột user_id vào bảng là xong, đúng không?
Không phải vậy.
Vấn đề không phải ở chỗ bạn biết cần filter theo tenant. Vấn đề là chức năng + nghiệp vụ logic sẽ lớn dần, dự án sẽ lớn dần, và codebase cũng sẽ lớn dần, với nhiều cùng làm, bạn cần đảm bảo không một query nào bị bỏ sót điều kiện tenant — dù là lúc code ban đầu hay khi chức năng được thêm vào sáu tháng sau. Một điều kiện WHERE bị quên là data leak giữa tenant. Với SaaS, đây là một vấn đề LỚN.
Vậy câu hỏi thực sự không phải là "multi-tenancy là gì" mà là "làm thế nào để bắt nó hiện diện ở mọi query, mọi lúc, không cần dựa vào kỷ luật của từng phát triển?"
Ba Chiến Lược — Và Lý Do Tôi Chọn Cái Đơn Giản Nhất
Về 3 chiến lược Multi-Tenant mình có viết ở bài trước xem thêm Hành Trình Build SaaS Quản Lý Cho Thuê: Laravel + Vue 3 + PostgreSQL Từ Ý Tưởng Đến Production .
Tôi chọn cái thứ ba Single-database + tenant_id. Không phải vì nó tốt nhất về mặt lý thuyết, mà vì nó là cách đơn giản nhất: một database, mọi bảng domain đều có cột tenant_id, mọi query đều có điều kiện WHERE tenant_id = ?. Chi phí vận hành thấp nhất. Đánh đổi là isolation phụ thuộc hoàn toàn vào application layer. Thêm nữa, nó phù hợp nhất với giai đoạn dự án: một database duy nhất để backup và migrate, và toàn bộ effort kỹ thuật tập trung vào làm cho cái đơn giản này trở nên đáng tin cậy.
Thiết Kế Schema: Hai Quy Tắc Không Được Phá Vỡ
Khi thêm tenant_id vào schema, có hai quy tắc mà tôi học được — một cái bằng lý luận, một cái bằng cách mắc lỗi.
Quy tắc 1: tenant_id trên mọi bảng domain, là cột NOT NULL, có foreign key về bảng tenants.
CREATE TABLE rooms (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
room_number VARCHAR(20) NOT NULL,
base_rent DECIMAL(12,0) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'available',
UNIQUE (tenant_id, room_number)
);
Quy tắc 2: Mọi unique constraint liên quan đến dữ liệu business phải bao gồm tenant_id.
Đây là cái tôi học bằng cách làm sai. Ban đầu tôi tạo UNIQUE(room_number). Kết quả: chủ cho thuê A đặt tên phòng "101", sau đó chủ cho thuê B cũng muốn đặt "101" — lỗi unique violation. Dữ liệu hai tenant hoàn toàn không liên quan nhau nhưng lại xung đột vì constraint quá rộng. UNIQUE(tenant_id, room_number) mới là đúng.
Index: Luôn Đặt tenant_id Ở Đầu
PostgreSQL sử dụng composite index hiệu quả khi cột được filter xuất hiện đầu tiên trong index. Vì mọi query đều có WHERE tenant_id = ?, đây là quy tắc không thể thương lượng:
-- Đúng
CREATE INDEX idx_rooms_status ON rooms(tenant_id, status);
CREATE INDEX idx_invoices_due ON invoices(tenant_id, status, due_date);
-- Sai — PostgreSQL không dùng index này hiệu quả cho tenant queries
CREATE INDEX idx_rooms_status_wrong ON rooms(status, tenant_id);
Xem thêm về Composite Index ở Cluster Index, Non-Cluster Index và Composite Index
Implement Trong Laravel: Middleware → Singleton → Repository
Subdomain Là Cổng Vào
Mỗi tenant có subdomain riêng: 87dth.quanlynhatro.net, 756dvb.quanlynhatro.net. Nginx cấu hình wildcard *.quanlynhatro.net forward tất cả về cùng một PHP-FPM instance. Laravel nhận request và tự biết đang phục vụ ai — từ Host header.
TenantContext: Lưu Tenant Hiện Tại Cho Cả Request
Trước khi viết Middleware, cần một singleton để lưu thông tin tenant trong suốt vòng đời request. Bất kỳ class nào trong application cũng có thể đọc từ đây — Repository, Service, Job:
class TenantContext
{
private ?int $tenantId = null;
public function setTenant(int $tenantId): void
{
$this->tenantId = $tenantId;
}
public function getTenantId(): ?int
{
return $this->tenantId;
}
}
Đăng ký singleton trong AppServiceProvider để cùng một instance tồn tại xuyên suốt request:
$this->app->singleton(TenantContext::class);
TenantMiddleware: Resolve Và Inject
class TenantMiddleware
{
public function __construct(private TenantContext $tenantContext) {}
public function handle(Request $request, Closure $next): Response
{
$subdomain = explode('.', $request->getHost())[0];
$tenant = DB::selectOne(
'SELECT id FROM tenants WHERE slug = ? AND is_active = true LIMIT 1',
[$subdomain]
);
if ($tenant === null) {
abort(404);
}
$this->tenantContext->setTenant($tenant->id);
return $next($request);
}
}
Lưu ý: middleware này dùng DB::selectOne trực tiếp, không qua Repository. Lý do: đây là bước bootstrap, chưa có TenantContext nào được set, nên mọi class phụ thuộc vào context đó đều chưa dùng được.
Cái Làm Tôi Suy Nghĩ và Cân Nhắc Nhiều: Eloquent Global Scope Có Thể Bị Vô Hiệu Hóa
Cách phổ biến để enforce tenant isolation trong Laravel là Eloquent Global Scope:
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->where('tenant_id', app(TenantContext::class)->getTenantId());
}
}
Trông ổn. Nhưng Global Scope có thể bị disable bất cứ lúc nào:
Room::withoutGlobalScope(TenantScope::class)->find($id);
Trong quá trình phát triển có thể dùng withoutGlobalScope() để debug một bug kỳ lạ, fix xong nhưng quên xóa dòng đó, commit, merge, deploy — và tenant A bắt đầu thấy dữ liệu của tenant B. Không có test nào bắt được nếu test không kiểm tra đúng scenario.
Thay vào đó, tôi xây dựng tầng Repository riêng không dùng Eloquent query. Mọi câu SQL được viết thủ công, và điều kiện tenant được enforce bằng resolveTenantId() — một method throw exception nếu không có context:
abstract class SqlQueryManager
{
protected function resolveTenantId(): int
{
$id = $this->tenantContext->getTenantId();
if ($id === null || $id <= 0) {
throw new TenantIsolationException('No tenant context found');
}
return $id;
}
}
Repository bắt buộc dùng method này trong mọi query:
public function findById(int $id): ?Room
{
$row = $this->selectOne('
SELECT * FROM rooms
WHERE id = :id
AND tenant_id = :tenant_id
LIMIT 1
', [
':id' => $id,
':tenant_id' => $this->resolveTenantId(),
]);
return $row ? Room::hydrate([$row])->first() : null;
}
Không có withoutTenant(). Không có cách bypass. Muốn query là phải có tenant context — đây là thiết kế cố ý.
Những Bẫy Tôi Đã Từng Phải
Bỏ tenant_id trong JOIN. Query chính có WHERE i.tenant_id = ? nhưng LEFT JOIN sang bảng rooms lại không có alias prefix. Kết quả trả về có thể bao gồm dữ liệu từ tenant khác nếu FK trỏ sang nhầm.
Cache key không scope theo tenant. cache('rooms_count') phải là cache("rooms_count_{$tenantId}"). Tenant A thấy số phòng của tenant B từ cache là bug rất khó debug vì không có error log.
File storage không phân tách. PDF hóa đơn lưu tại storage/app/pdfs/{tenant_slug}/{year}/invoice_{id}.pdf. Bỏ {tenant_slug} trong path là hai tenant có thể ghi đè file của nhau nếu invoice ID trùng.
Artisan command quên set context. Command invoices:generate-monthly phải loop qua từng tenant, set TenantContext, rồi xử lý. Quên bước set context là toàn bộ command throw TenantIsolationException và không tạo được hóa đơn nào.
Kết
Theo quan điểm cá nhân với Laravel + PostgreSQL, chiến lược single-database là điểm xuất phát thực tế cho SaaS giai đoạn đầu. Chi phí vận hành thấp, migrate đơn giản, và nếu thiết kế đúng từ đầu — isolation đủ chắc để tin cậy.
Phần còn lại là kỷ luật: mọi query có tenant_id, mọi unique constraint bao gồm tenant_id, mọi composite index bắt đầu bằng tenant_id. Ba quy tắc đơn giản, nhưng phải áp dụng nhất quán trong toàn bộ codebase — và kiến trúc tốt là kiến trúc khiến việc làm sai trở nên khó hơn việc làm đúng.
Bài tiếp theo trong series: SqlQueryManager — tại sao bỏ Eloquent ORM và build tầng data access bằng raw SQL là quyết định đúng cho SaaS multi-tenant.
All rights reserved