+3

Hành Trình Build SaaS Quản Lý Cho Thuê: Laravel + Vue 3 + PostgreSQL Từ Ý Tưởng Đến Production

Đi làm đuợc một thời gian công việc chuyển từ công nghệ A sang công nghệ B, project Y sang project Z, khả năng nắm bắt vấn đề, học một công nghệ mới, debug ở môi trường production đều có thể thực hiện, bản thân cũng đặt ra câu hỏi liệu có thể tự build một Saas từ những gì đã trải qua không. Thêm một chất xúc tác từ người thân, bạn bè có phòng trọ hiện vẫn đang quản lý thủ công bằng tập giấy Vĩnh Tường và bút bi Thiên Long, hằng tháng ngồi cộng trừ nhân chia, người thuê phải xuống ký tên xác nhận đã thanh toán. Câu chuyện bắt đầu từ đây, đây là điểm khởi đầu của SaaS quản lý cho thuê multi-tenant mà tôi sẽ kể lại trong bài này, từng quyết định kỹ thuật cùng lý do thực sự đằng sau nó.


Bài Toán Lớn Hơn Tôi Nghĩ

Tôi bắt đầu research và tìm thấy một con số khá ấn tượng: Việt Nam có hơn 3 triệu phòng trọ, căn hộ dịch vụ đang hoạt động, đại đa số chủ cho thuê hoặc người quản lý căn hộ quản lý bằng sổ tay hoặc Excel. Mỗi tháng họ phải:

  • Đi từng phòng đọc công tơ điện và đồng hồ nước
  • Ngồi tính thủ công hoặc gõ máy tính bỏ túi
  • Gọi điện hoặc nhắn Zalo từng người báo số tiền
  • Theo dõi ai đã trả, ai còn nợ, kỳ nào

Trung bình mất 2–3 tiếng mỗi tháng chỉ riêng việc tính và thu tiền. Nhân 12 tháng, đó là hơn một tháng làm việc mỗi năm cho những việc lặp đi lặp lại hoàn toàn có thể tự động hóa.

Vậy là tôi không build app cho một người. Tôi build SaaS — một platform mà nhiều chủ cho thuê có thể dùng chung, mỗi người một "tenant" độc lập.


Quyết Định Đầu Tiên: Multi-Tenancy Theo Cách Nào?

Đây là câu hỏi kiến trúc đầu tiên và quan trọng nhất. Có ba hướng:

  • Database-per-tenant: cô lập hoàn toàn nhưng vận hành kinh hoàng — 100 tenant là 100 database cần backup, migrate, monitor. Chi phí vận hàng và là bài toán khá lớn ở giai đoạn này
  • Schema-per-tenant: PostgreSQL native schema, nghe hay nhưng phức tạp khi tenant tăng lên.
  • Single-database + tenant_id: đơn giản nhất, chi phí vận hành thấp nhất, phù hợp cho giai đoạn này và bài toán quản lý không quá lớn này.

Tôi chọn single-database. Mỗi bảng domain có cột tenant_id. Mỗi chủ trọ được cấp một 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 ứng dụng Laravel.

TenantMiddleware đọc Host header, tách subdomain, query bảng tenants theo slug, rồi inject vào singleton TenantContext. Từ đây, mọi repository trong request biết đang phục vụ tenant nào:

Browser → Nginx → TenantMiddleware (resolve tenant từ subdomain)
        → Controller → Service → Repository → DB

Một nguyên tắc sắt đá: không có một query domain nào được chạy mà thiếu WHERE tenant_id = :tenant_id. Cơ chế enforce nguyên tắc này là điều tôi sẽ kể tiếp.


SqlQueryManager: Khi Eloquent ORM Không Đủ Tin Cậy

Đây là quyết định đắn đo và mất nhiều thời gian nhất trong project: không dùng Eloquent query.

Không phải Room::where(...), không phải $room->save(), không phải Room::paginate(). Tất cả SQL chạy qua một abstract class tự viết: SqlQueryManager.

Tại sao? Vì Eloquent Global Scope — cơ chế thường dùng để tự động thêm WHERE tenant_id — có thể bị vô hiệu hóa bất cứ lúc nào bằng withoutGlobalScope(). Trong một codebase phức tạp, chỉ cần chủ quan vô tình dùng withoutGlobalScope() để debug rồi quên xóa là data leak xảy ra. Với SaaS, data leak giữa các tenant là lỗi không thể tha thứ.

SqlQueryManager buộc mọi repository phải gọi $this->resolveTenantId() — một method throw exception nếu không có tenant context — trước khi inject vào binding:

public function findAll(PageInfo $pageInfo): PaginationResult
{
    $sql = '
        SELECT *
        FROM   rooms
        WHERE  tenant_id = :tenant_id
        ORDER  BY room_number ASC
    ';

    $result = $this->paginate($sql, [
        ':tenant_id' => $this->resolveTenantId(),
    ], $pageInfo);

    $hydrated = Room::hydrate($result->getData()->all());

    return new PaginationResult(
        collect($hydrated),
        $result->getTotal(),
        $result->getCurrentPage(),
        $result->getPerPage()
    );
}

Thêm vào đó, method paginate() trong SqlQueryManager dùng PostgreSQL window function COUNT(*) OVER() để lấy cả data lẫn total count trong một query duy nhất — thay vì hai round trip riêng cho count và data. Khi bảng có hàng trăm nghìn dòng, đây là tối ưu có giá trị thực sự.

Model vẫn tồn tại nhưng chỉ là data container thuần túy: $casts, $hidden, $appends, và Accessor. Không relationship, không scope, không save(). Model::hydrate() làm nhiệm vụ chuyển stdClass từ query thành Model object đầy đủ $casts và Accessor.


Vue 3 + Inertia.js: SPA Không Cần REST API

Thay vì xây REST API riêng rồi connect Vue SPA từ frontend, project dùng Inertia.js — một bridge cho phép Laravel controller trả về Vue component trực tiếp.

Controller trả về page thay vì JSON:

return Inertia::render('Rooms/Index', [
    'rooms' => $result->toArray(),
    'filters' => $request->only(['status', 'search']),
]);

Vue component nhận rooms như prop TypeScript. Không cần fetch, không cần quản lý loading state thủ công, không cần CORS. Navigation vẫn là SPA — Inertia intercept link click, thực hiện XHR, swap component mà không reload trang.

Toàn bộ component dùng <script setup> với TypeScript. Tiền VND được format nhất quán trên toàn app qua useFormatters composable:

const formatter = new Intl.NumberFormat('vi-VN', {
  style: 'currency',
  currency: 'VND'
})
// "2.500.000 ₫"

Ở giai đoạn này, phần lớn dự án chủ yếu tự code; mở rộng thêm thì có thể có 1–2 bạn hỗ trợ phát triển. Đây là cấu trúc của một team nhỏ, nên Inertia.js giúp tiết kiệm hàng chục giờ cho mỗi feature nhờ không cần định nghĩa endpoint riêng, không phải viết type cho API response và cũng không phải xử lý auth token riêng cho SPA.


PostgreSQL: Ba Quyết Định Schema Quan Trọng

Tiền tệ dùng DECIMAL(12,0), không bao giờ dùng FLOAT. VND không có xu hào. FLOAT có lỗi floating-point (mình ko đi sâu chỗ này có thể hỏi thêm chị Google) — chẳng hạn gõ 1500 × 50 trên máy tính JavaScript và thỉnh thoảng bạn nhận được 74999.99999... thay vì 75000. Với tiền tệ, đây là lỗi không chấp nhận được.

Composite index luôn có tenant_id ở đầu. Vì mọi query đều filter theo tenant, PostgreSQL chỉ sử dụng index hiệu quả khi cột đầu tiên trong composite index là cột filter đầu tiên:

INDEX idx_rooms_status   ON rooms(tenant_id, status);
INDEX idx_invoices_due   ON invoices(tenant_id, status, due_date);

RETURNING id thay vì query thêm. PostgreSQL cho phép INSERT trả về ID ngay lập tức — không cần thêm một SELECT để lấy lastInsertId.


Deploy: $6/Tháng Cho Toàn Bộ Production Stack

Production chạy trên DigitalOcean Droplet $6/tháng (1 vCPU, 1GB RAM, Singapore), database là Neon.tech PostgreSQL free tier.

Dockerfile 4-stage đảm bảo image production không có Xdebug hay dev dependency:

  • node-builder: build Vite assets (Vue 3 + TypeScript)
  • php-base: PHP 8.3 extensions + Composer (shared giữa dev/prod)
  • development: php-base + Xdebug + source mount qua volume
  • production: php-base + source baked in + assets từ node-builder

Khi container khởi động, entrypoint script tự chạy migration, cache config/route/view, rồi exec PHP-FPM. Deploy mới chỉ cần:

docker-compose -f docker-compose.prod.yml up -d --build

Một tối ưu nhỏ: Vite đặt content hash vào tên file JS/CSS mỗi lần build. Nginx có thể cache asset này max-age=31536000, immutable — browser cache 1 năm, không bao giờ stale vì tên file luôn thay đổi khi code thay đổi.


Ba Bài Học Đắt Giá

1. Thiết kế multi-tenancy từ ngày đầu, không thêm vào sau. Tái cấu trúc thêm tenant_id vào một codebase sẵn có là công việc khá phức tạp và dễ bỏ sót. Quyết định này phải có trước dòng code đầu tiên.

2. Abstraction có giá trị khi giải quyết vấn đề cụ thể ngay bây giờ. SqlQueryManager không phải abstraction "phòng trường hợp sau này". Nó giải quyết: tenant isolation nhất quán, SQL performance logging tự động, error mapping từ PDOException sang domain exception. Đây là bài toán thật, giải pháp thật.

3. Inertia.js phù hợp cho Laravel developer muốn SPA nhanh. Nếu frontend và backend cùng một người hoặc một team nhỏ, không phải lúc nào cũng cần REST API đầy đủ. Inertia là con đường ngắn nhất từ Laravel controller đến Vue component với type safety.


Kết

Sau khoảng thời gian sáng đi làm Fulltime ở chỗ làm, tối về cặm cụi viết viết, code code, sửa sửa cuối cùng cũng có kết quả, có thể deploy và đưa vào thử nghiệm cho người thân, bạn bè trước, site https://quanlynhatro.net. Người thân giờ không còn ghi chép chủ công, xác nhận thanh toán bằng ký tên nữa. Chỉ cần vào web, nhập chỉ số, bấm một nút — hệ thống tính toán, tạo hóa đơn PDF, gửi email cho từng người thuê.

Stack Laravel 12 + Vue 3 + Inertia.js + PostgreSQL + Docker đủ mạnh để chạy production ổn định, đủ đơn giản để có thể một mình solo maintain hoặc một team nhỏ vận hành, và đủ rẻ để thử nghiệm ý tưởng mà không cần vốn lớn. Nếu bạn là đã từng code, làm việc với PHP, Laravel đang tìm project thực tế để hiểu cách một SaaS được cấu trúc — hãy thử theo dõi tiếp bài viết kế tiếp chia sẻ hành trình của mình.


Bài tiếp theo trong series: Từ Lý Thuyết Đến Thực Tế - Thiết Kế Ứng Dụng Multi-Tenant Với Laravel Và PostgreSQL


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí