Xây dựng SaaS với Laravel (Phần 1): Khởi tạo dự án & kiến trúc nền tảng
Đây là phần đầu tiên trong series "Xây dựng SaaS với Laravel" — hướng dẫn từng bước xây dựng một ứng dụng SaaS hoàn chỉnh từ con số 0. Chúng ta sẽ xây dựng một ứng dụng quản lý dự án (project management) đơn giản nhưng đầy đủ tính năng SaaS.
Series bao gồm
- Khởi tạo dự án & kiến trúc nền tảng ← Bạn đang ở đây
- Multi-tenancy & data isolation
- Billing với Stripe Cashier
- Team management & phân quyền
- Plans, limits & usage tracking
- Admin dashboard & metrics
- API & Webhooks cho third-party integrations
- Deploy production & scaling
Sản phẩm cuối cùng
Ứng dụng SaaS hoàn chỉnh với:
- Multi-tenant architecture
- Subscription billing (monthly/yearly)
- Team collaboration
- Usage limits theo plan
- Admin dashboard
- REST API
- Production-ready deployment
Yêu cầu
- PHP 8.3+
- Composer
- Node.js 20+
- MySQL 8.0 hoặc PostgreSQL 16
- Redis
- Stripe account (test mode)
1. Khởi tạo Laravel Project
composer create-project laravel/laravel saas-app
cd saas-app
Cài đặt các package cần thiết ngay từ đầu:
composer require laravel/cashier
composer require laravel/sanctum
composer require spatie/laravel-permission
composer require spatie/laravel-data
composer require --dev larastan/larastan
composer require --dev pestphp/pest
composer require --dev pestphp/pest-plugin-laravel
2. Cấu trúc thư mục
Thay vì dùng cấu trúc mặc định, chúng ta tổ chức theo domain-oriented:
app/
├── Domain/
│ ├── Tenant/
│ │ ├── Models/
│ │ │ └── Tenant.php
│ │ ├── Actions/
│ │ │ ├── CreateTenantAction.php
│ │ │ └── DeleteTenantAction.php
│ │ └── Data/
│ │ └── TenantData.php
│ ├── User/
│ │ ├── Models/
│ │ │ └── User.php
│ │ ├── Actions/
│ │ └── Data/
│ ├── Team/
│ │ ├── Models/
│ │ │ ├── Team.php
│ │ │ └── TeamInvitation.php
│ │ ├── Actions/
│ │ └── Data/
│ ├── Project/
│ │ ├── Models/
│ │ │ ├── Project.php
│ │ │ └── Task.php
│ │ ├── Actions/
│ │ └── Data/
│ └── Billing/
│ ├── Models/
│ ├── Actions/
│ └── Data/
├── Http/
│ ├── Controllers/
│ ├── Middleware/
│ └── Requests/
├── Services/
└── Providers/
Tạo thư mục:
mkdir -p app/Domain/{Tenant,User,Team,Project,Billing}/{Models,Actions,Data}
3. Cấu hình môi trường
File .env cho development:
APP_NAME="SaaS App"
APP_ENV=local
APP_DEBUG=true
APP_URL=http://saas-app.test
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=saas_app
DB_USERNAME=root
DB_PASSWORD=
CACHE_STORE=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
STRIPE_KEY=pk_test_xxxxxxx
STRIPE_SECRET=sk_test_xxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxx
MAIL_MAILER=smtp
MAIL_HOST=127.0.0.1
MAIL_PORT=1025
4. Database Schema nền tảng
Migration: Tenants
// database/migrations/2026_03_15_000001_create_tenants_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('tenants', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->string('domain')->nullable()->unique();
$table->foreignId('owner_id')->constrained('users')->cascadeOnDelete();
$table->json('settings')->nullable();
$table->enum('status', ['active', 'suspended', 'cancelled'])->default('active');
$table->timestamp('trial_ends_at')->nullable();
$table->timestamps();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('tenants');
}
};
Migration: Teams
// database/migrations/2026_03_15_000002_create_teams_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('teams', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->text('description')->nullable();
$table->timestamps();
$table->index('tenant_id');
});
Schema::create('team_user', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('role')->default('member');
$table->timestamps();
$table->unique(['team_id', 'user_id']);
});
}
public function down(): void
{
Schema::dropIfExists('team_user');
Schema::dropIfExists('teams');
}
};
Migration: Projects & Tasks
// database/migrations/2026_03_15_000003_create_projects_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('projects', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('team_id')->nullable()->constrained()->nullOnDelete();
$table->string('name');
$table->text('description')->nullable();
$table->enum('status', ['active', 'archived', 'completed'])->default('active');
$table->date('due_date')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['tenant_id', 'status']);
});
Schema::create('tasks', function (Blueprint $table) {
$table->id();
$table->foreignId('project_id')->constrained()->cascadeOnDelete();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('created_by')->constrained('users');
$table->string('title');
$table->text('description')->nullable();
$table->enum('status', ['todo', 'in_progress', 'review', 'done'])->default('todo');
$table->enum('priority', ['low', 'medium', 'high', 'urgent'])->default('medium');
$table->date('due_date')->nullable();
$table->integer('position')->default(0);
$table->timestamps();
$table->index(['project_id', 'status']);
$table->index(['tenant_id', 'assigned_to']);
});
}
public function down(): void
{
Schema::dropIfExists('tasks');
Schema::dropIfExists('projects');
}
};
Migration: Thêm tenant_id vào Users
// database/migrations/2026_03_15_000004_add_tenant_id_to_users.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->foreignId('current_tenant_id')
->nullable()
->after('id')
->constrained('tenants')
->nullOnDelete();
});
Schema::create('tenant_user', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('role')->default('member'); // owner, admin, member
$table->timestamps();
$table->unique(['tenant_id', 'user_id']);
});
}
public function down(): void
{
Schema::dropIfExists('tenant_user');
Schema::table('users', function (Blueprint $table) {
$table->dropConstrainedForeignId('current_tenant_id');
});
}
};
5. Models cơ bản
Tenant Model
// app/Domain/Tenant/Models/Tenant.php
<?php
declare(strict_types=1);
namespace App\Domain\Tenant\Models;
use App\Domain\Project\Models\Project;
use App\Domain\Team\Models\Team;
use App\Domain\User\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Tenant extends Model
{
use SoftDeletes;
protected $fillable = [
'name',
'slug',
'domain',
'owner_id',
'settings',
'status',
'trial_ends_at',
];
protected $casts = [
'settings' => 'array',
'trial_ends_at' => 'datetime',
];
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_id');
}
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class, 'tenant_user')
->withPivot('role')
->withTimestamps();
}
public function teams(): HasMany
{
return $this->hasMany(Team::class);
}
public function projects(): HasMany
{
return $this->hasMany(Project::class);
}
public function isActive(): bool
{
return $this->status === 'active';
}
public function onTrial(): bool
{
return $this->trial_ends_at !== null
&& $this->trial_ends_at->isFuture();
}
}
User Model (cập nhật)
// app/Domain/User/Models/User.php
<?php
declare(strict_types=1);
namespace App\Domain\User\Models;
use App\Domain\Tenant\Models\Tenant;
use App\Domain\Team\Models\Team;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Cashier\Billable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens, Notifiable, Billable;
protected $fillable = [
'name',
'email',
'password',
'current_tenant_id',
];
protected $hidden = [
'password',
'remember_token',
];
protected $casts = [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
public function tenants(): BelongsToMany
{
return $this->belongsToMany(Tenant::class, 'tenant_user')
->withPivot('role')
->withTimestamps();
}
public function currentTenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'current_tenant_id');
}
public function ownedTenants(): HasMany
{
return $this->hasMany(Tenant::class, 'owner_id');
}
public function teams(): BelongsToMany
{
return $this->belongsToMany(Team::class, 'team_user')
->withPivot('role')
->withTimestamps();
}
public function switchTenant(Tenant $tenant): void
{
if (! $this->tenants()->where('tenant_id', $tenant->id)->exists()) {
throw new \DomainException('User does not belong to this tenant.');
}
$this->update(['current_tenant_id' => $tenant->id]);
}
}
6. Base Service & Tenant Scope
Tạo một global scope để tự động filter data theo tenant:
// 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->getTable() . '.tenant_id', $tenant->id);
}
}
}
Tạo trait để dùng chung:
// 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) {
$tenant = app('current_tenant');
if ($tenant && ! $model->tenant_id) {
$model->tenant_id = $tenant->id;
}
});
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
}
7. Service Provider
Đăng ký tenant resolver trong Service Provider:
// app/Providers/TenantServiceProvider.php
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Domain\Tenant\Models\Tenant;
use Illuminate\Support\ServiceProvider;
class TenantServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton('current_tenant', fn () => null);
}
public function boot(): void
{
//
}
public static function setTenant(?Tenant $tenant): void
{
app()->instance('current_tenant', $tenant);
}
}
Đăng ký trong bootstrap/providers.php:
return [
App\Providers\AppServiceProvider::class,
App\Providers\TenantServiceProvider::class,
];
8. Action Classes
Thay vì đặt business logic trong Controller, sử dụng Action classes:
// app/Domain/Tenant/Actions/CreateTenantAction.php
<?php
declare(strict_types=1);
namespace App\Domain\Tenant\Actions;
use App\Domain\Tenant\Models\Tenant;
use App\Domain\User\Models\User;
use Illuminate\Support\Str;
class CreateTenantAction
{
public function execute(User $owner, string $name): Tenant
{
$slug = Str::slug($name);
// Đảm bảo slug là duy nhất
$originalSlug = $slug;
$counter = 1;
while (Tenant::where('slug', $slug)->exists()) {
$slug = $originalSlug . '-' . $counter++;
}
$tenant = Tenant::create([
'name' => $name,
'slug' => $slug,
'owner_id' => $owner->id,
'status' => 'active',
'trial_ends_at' => now()->addDays(14),
'settings' => [
'timezone' => 'Asia/Ho_Chi_Minh',
'locale' => 'vi',
],
]);
// Gán owner vào tenant
$tenant->users()->attach($owner->id, ['role' => 'owner']);
// Set tenant hiện tại cho user
$owner->update(['current_tenant_id' => $tenant->id]);
return $tenant;
}
}
9. Routes cơ bản
// routes/web.php
<?php
use App\Http\Controllers\Auth\RegisterController;
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\Onboarding\CreateTenantController;
use Illuminate\Support\Facades\Route;
// Public
Route::get('/', fn () => view('welcome'));
// Auth
Route::middleware('guest')->group(function () {
Route::get('/register', [RegisterController::class, 'show'])->name('register');
Route::post('/register', [RegisterController::class, 'store']);
});
// Authenticated - cần chọn tenant
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/onboarding', [CreateTenantController::class, 'show'])
->name('onboarding');
Route::post('/onboarding', [CreateTenantController::class, 'store']);
});
// Authenticated - trong context tenant
Route::middleware(['auth', 'verified', 'tenant'])->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index'])
->name('dashboard');
// Projects & Tasks sẽ thêm ở các phần tiếp theo
});
10. Middleware: Tenant Resolution
// app/Http/Middleware/ResolveTenant.php
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Providers\TenantServiceProvider;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ResolveTenant
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (! $user) {
return redirect()->route('login');
}
$tenant = $user->currentTenant;
if (! $tenant) {
return redirect()->route('onboarding');
}
if (! $tenant->isActive()) {
abort(403, 'Your organization has been suspended.');
}
TenantServiceProvider::setTenant($tenant);
return $next($request);
}
}
Đăng ký middleware trong bootstrap/app.php:
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'tenant' => \App\Http\Middleware\ResolveTenant::class,
]);
})
11. Testing nền tảng
Tạo test cơ bản để đảm bảo setup đúng:
// tests/Feature/TenantSetupTest.php
<?php
use App\Domain\Tenant\Actions\CreateTenantAction;
use App\Domain\User\Models\User;
test('can create a tenant for a user', function () {
$user = User::factory()->create();
$action = new CreateTenantAction();
$tenant = $action->execute($user, 'My Company');
expect($tenant)
->name->toBe('My Company')
->slug->toBe('my-company')
->status->toBe('active')
->and($tenant->onTrial())->toBeTrue()
->and($tenant->users)->toHaveCount(1)
->and($user->fresh()->current_tenant_id)->toBe($tenant->id);
});
test('generates unique slug when duplicate exists', function () {
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$action = new CreateTenantAction();
$tenant1 = $action->execute($user1, 'Test Company');
$tenant2 = $action->execute($user2, 'Test Company');
expect($tenant1->slug)->toBe('test-company')
->and($tenant2->slug)->toBe('test-company-1');
});
test('tenant middleware redirects when no tenant', function () {
$user = User::factory()->create(['current_tenant_id' => null]);
$this->actingAs($user)
->get('/dashboard')
->assertRedirect(route('onboarding'));
});
Kiến trúc tổng quan
┌─────────────────────────────────────────────┐
│ HTTP Layer │
│ Routes → Middleware → Controllers │
├─────────────────────────────────────────────┤
│ Application Layer │
│ Actions → Data Objects → Validation │
├─────────────────────────────────────────────┤
│ Domain Layer │
│ Models → Scopes → Traits → Events │
├─────────────────────────────────────────────┤
│ Infrastructure Layer │
│ Database → Cache → Queue → Mail │
└─────────────────────────────────────────────┘
Tổng kết
Ở phần 1 này, chúng ta đã:
- Khởi tạo project Laravel với các package cần thiết
- Thiết kế cấu trúc thư mục domain-oriented
- Tạo database schema nền tảng (tenants, teams, projects, tasks)
- Xây dựng Tenant model với global scope để isolation data
- Tạo middleware resolve tenant
- Viết Action class đầu tiên
- Setup test cơ bản
Phần tiếp theo, chúng ta sẽ đi sâu vào Multi-tenancy & Data Isolation — cách đảm bảo dữ liệu giữa các tenant hoàn toàn tách biệt, xử lý subdomain routing, và tenant switching.