Từ Lý Thuyết Đến Thực Tế: 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
Tôi đã dùng Eloquent ORM được một thời gian. Tôi biết nó tốt ở đâu và giới hạn ở đâu. Quyết định không dùng Eloquent query trong project SaaS này không phải vì tôi ghét nó — mà vì tôi hiểu một điểm yếu cụ thể của nó đủ để không tin tưởng nó với dữ liệu của nhiều khách hàng trong cùng một database.
Bài này kể lại quá trình đó: tại sao Global Scope không đủ tin cậy cho multi-tenancy, và SqlQueryManager được thiết kế như thế nào để lấp đầy khoảng trống đó.
Vấn Đề Với Eloquent Global Scope
Cách phổ biến nhất để tự động filter theo tenant trong Laravel là Global Scope:
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->where('tenant_id', app(TenantContext::class)->getTenantId());
}
}
Gắn vào Model:
class Room extends Model
{
protected static function booted(): void
{
static::addGlobalScope(new TenantScope());
}
}
Từ giờ Room::find(1) tự động thêm WHERE tenant_id = ?. Nghe ổn. Nhưng có một dòng code làm tôi suy nghĩ nhiều (có khi đi xe đi làm còn suy nghĩ ^_^ )
Room::withoutGlobalScope(TenantScope::class)->find($id);
Nếu có 1 bug gấp ở môi trường real lúc 11 giờ đêm. Người phát triển cần xem record trong db mà không bị giới hạn để tái hiện vấn đề. Thêm withoutGlobalScope() để kiểm tra. Fix xong, push, tạo pull request. Code review không bắt được vì logic trông đúng. Merge, deploy — và từ lúc đó tenant A có thể thấy phòng của tenant B nếu biết ID.
Không có exception. Không có log cảnh báo. Không có test nào bắt được nếu test không check đúng ngữ cảnh đó. Dữ liệu chảy sang nhau âm thầm.
Với ứng dụng CRUD thông thường, đây là bug cân nhắc xem xét Nghiêm Trọng. Với SaaS multi-tenant, đây là lỗi LỚN vì dữ liệu người dùng A bị lộ qua người dùng B, rất khó để Chấp Nhận
Thiết Kế Phòng Tuyến Không Thể Bypass
Câu hỏi tôi đặt ra là: làm thế nào để làm cho việc quên điều kiện tenant trở nên khó hơn việc nhớ?
Câu trả lời là SqlQueryManager — một abstract class đóng vai trò là điểm truy cập duy nhất vào database, và nó không cho phép query nào chạy mà thiếu tenant context.
TenantContext: Singleton Của Request
Trước tiên cần một nơi lưu tenant hiện tại xuyên suốt vòng đời request:
class TenantContext
{
private ?int $tenantId = null;
public function setTenant(int $tenantId): void
{
$this->tenantId = $tenantId;
}
public function getTenantId(): ?int
{
return $this->tenantId;
}
public function isResolved(): bool
{
return $this->tenantId !== null;
}
}
Đăng ký singleton trong AppServiceProvider:
$this->app->singleton(TenantContext::class);
Cùng một instance tồn tại xuyên suốt request — Repository, Service, Job đều đọc từ đây mà không cần truyền $tenantId qua parameter.
TenantMiddleware set nó ngay khi request vào:
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);
}
Vì sao sử dụng DB::selectOne ở class này bài trước mình có viết xem thêm Từ Lý Thuyết Đến Thực Tế: Thiết Kế Ứng Dụng Multi-Tenant Với Laravel Và PostgreSQL
resolveTenantId(): Tường Lửa Không Thể Tắt
Đây là method quan trọng nhất trong toàn bộ thiết kế:
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;
}
}
Mọi Repository đều extend SqlQueryManager. Mọi query domain đều phải gọi $this->resolveTenantId(). Nếu không có tenant context — vì middleware không chạy, vì route không được bảo vệ, vì Artisan command quên set context — method này throw exception ngay lập tức. Không âm thầm trả về dữ liệu sai. Không có withoutTenant(). Không có cách bypass.
So sánh với Global Scope:
| Eloquent Global Scope | SqlQueryManager | |
|---|---|---|
| Có thể bypass không? | Có — withoutGlobalScope() |
Không — exception nếu thiếu context |
| Phát hiện lỗi khi nào? | Khi đọc dữ liệu sai (runtime/production) | Ngay khi query chạy (exception) |
| Test coverage cần thiết | Phải test mọi query path | Exception tự enforce |
Cấu Trúc SqlQueryManager: Sáu Method Cốt Lõi
SqlQueryManager là abstract class với sáu protected method mà mọi Repository con đều dùng:
abstract class SqlQueryManager
{
public function __construct(
protected readonly TenantContext $tenantContext,
protected readonly QueryLogger $logger,
) {}
protected function select(string $sql, array $bindings = []): array { ... }
protected function selectOne(string $sql, array $bindings = []): ?object { ... }
protected function insert(string $sql, array $bindings = []): int { ... }
protected function update(string $sql, array $bindings = []): int { ... }
protected function delete(string $sql, array $bindings = []): int { ... }
protected function paginate(string $sql, array $bindings, PageInfo $pageInfo): PaginationResult { ... }
}
Tất cả đều route qua một private method duy nhất — dbAccessCore() — nơi duy nhất SQL thực sự chạy, nơi log thời gian thực thi, nơi catch PDOException và convert sang domain exception.
Named Binding: Bắt Buộc, Không Thương Lượng
// ✅ Đúng — named binding, an toàn, dễ đọc
$this->selectOne('
SELECT * FROM rooms
WHERE id = :id
AND tenant_id = :tenant_id
LIMIT 1
', [
':id' => $id,
':tenant_id' => $this->resolveTenantId(),
]);
// ❌ Sai — string concat: SQL injection
$this->select("SELECT * FROM rooms WHERE id = {$id}");
// ❌ Sai — positional binding: khó đọc, dễ nhầm thứ tự
$this->select('SELECT * FROM rooms WHERE id = ? AND tenant_id = ?', [$id, $tenantId]);
Paginate Với Window Function: Một Query Thay Vì Hai
Hầu hết các xử lý pagination đều chạy hai query: một để đếm total, một để lấy data. SqlQueryManager dùng PostgreSQL window function để gộp lại thành một:
protected function paginate(string $sql, array $bindings, PageInfo $pageInfo): PaginationResult
{
$wrappedSql = "
SELECT COUNT(*) OVER() AS _total_count, sub.*
FROM ({$sql}) AS sub
LIMIT :_limit OFFSET :_offset
";
$bindings[':_limit'] = $pageInfo->getPerPage();
$bindings[':_offset'] = $pageInfo->getOffset();
$rows = $this->select($wrappedSql, $bindings);
$total = empty($rows) ? 0 : (int) $rows[0]->_total_count;
foreach ($rows as $row) {
unset($row->_total_count);
}
return new PaginationResult(
collect($rows),
$total,
$pageInfo->getPage(),
$pageInfo->getPerPage()
);
}
COUNT(*) OVER() là window function — nó tính tổng số row của toàn bộ result set và gắn vào mỗi row. Row đầu tiên có _total_count, lấy xong thì unset. Kết quả: bảng một triệu row, tenant query 50 row — một lần truy xuất đến database thay vì hai.
Repository gọi xong thì hydrate :
hydrate trong Laravel hiểu nôm na là biến row DB thành object PHP
public function findAll(PageInfo $pageInfo): PaginationResult
{
$result = $this->paginate('
SELECT * FROM rooms
WHERE tenant_id = :tenant_id
ORDER BY room_number ASC
', [':tenant_id' => $this->resolveTenantId()], $pageInfo);
$hydrated = Room::hydrate($result->getData()->all());
return new PaginationResult(
collect($hydrated),
$result->getTotal(),
$result->getCurrentPage(),
$result->getPerPage()
);
}
Model::hydrate(): Giữ $casts Và Accessor
Raw SQL trả về stdClass. Nếu chỉ dùng (array) $row hay trả thẳng stdClass, bạn mất hoàn toàn $casts và Accessor của Model.
Room::hydrate([$row]) nhận array of stdClass, tạo Model instance cho từng row, áp dụng đầy đủ $casts và $appends. Accessor base_rent_formatted, status_label hoạt động bình thường. Column tenant_id bị $hidden không lộ ra JSON. Type casting 'base_rent' => 'integer' đảm bảo không bao giờ trả về string số từ PostgreSQL.
// stdClass từ DB: $row->base_rent = "2500000" (string)
// Sau hydrate: $room->base_rent = 2500000 (int)
// $room->base_rent_formatted = "2.500.000 ₫" (Accessor)
// $room->tenant_id → không tồn tại trong JSON ($hidden)
PdoErrorMapper: Tất Cả Lỗi DB Đều Có Tên
Khi PostgreSQL throw PDOException, SQLSTATE code là chuỗi 5 ký tự không có ý nghĩa với developer. PdoErrorMapper chuyển nó thành domain exception có ý nghĩa:
final class PdoErrorMapper
{
public static function map(string $sqlState, \PDOException $e): never
{
match (true) {
in_array($sqlState, ['08001', '08006', '28000', '28P01'])
=> throw new ConnectionException($e),
$sqlState === '23505'
=> throw new DuplicateKeyException($e),
$sqlState === '23503'
=> throw new DataSourceException('Foreign key violation', 0, $e),
in_array($sqlState, ['40001', '40P01'])
=> throw new OptimisticLockException($e),
default
=> throw new DataSourceException($e->getMessage(), 0, $e),
};
}
}
Service layer có thể catch DuplicateKeyException để trả về message thân thiện:
try {
return $this->roomRepository->create($data);
} catch (DuplicateKeyException) {
throw new \DomainException("Số phòng '{$data['room_number']}' đã tồn tại");
}
Không cần parse message string của PostgreSQL. Không cần nhớ SQLSTATE 23505 là gì. Domain exception nói lên mọi thứ.
Kết
Sau một thời gian test tích hợp và test demo, có một điều tôi chú ý: không có lần nào phải debug "tại sao tenant A thấy data của tenant B". Không phải vì may mắn — mà vì kiến trúc không cho phép điều đó xảy ra theo cách âm thầm.
Mỗi lần viết thêm query mới trong Repository, người viết buộc phải gọi $this->resolveTenantId() — không phải vì quy định trong coding rule mà vì code sẽ không chạy nếu thiếu nó. Đây chính xác là điều tôi muốn từ ngày đầu: làm cho việc làm đúng trở nên dễ hơn làm sai.
Raw SQL dài dòng hơn Eloquent, cái này là ĐÚNG. Tuy nhiên, đổi lại SQL dài dòng hơn nhưng an toàn và dễ triển khai các logic phức tạp, ngăn chặn việc bị bypass. Với SaaS multi-tenant, sự tin cậy và an toàn dữ liệu không phải lựa chọn có nên hay ko mà theo cá nhân đó là yêu cầu bắt buộc.
Bài tiếp theo trong series: Thiết kế module Invoice nâng cao — 3 dạng tính điện/nước, cấu hình dịch vụ động, và InvoiceCalculator thuần túy không phụ thuộc database.
All rights reserved