Building SaaS with Laravel (Part 1): Project Setup & Foundation Architecture
This is the first part of the "Building SaaS with Laravel" series — a step-by-step guide to building a complete SaaS application from scratch. We'll build a simple but fully-featured project management SaaS application.
Series Overview
- Project Setup & Foundation Architecture ← You are here
- Multi-tenancy & Data Isolation
- Billing with Stripe Cashier
- Team Management & Permissions
- Plans, Limits & Usage Tracking
- Admin Dashboard & Metrics
- API & Webhooks for Third-party Integrations
- Production Deployment & Scaling
The Final Product
A complete SaaS application with:
- Multi-tenant architecture
- Subscription billing (monthly/yearly)
- Team collaboration
- Plan-based usage limits
- Admin dashboard
- REST API
- Production-ready deployment
Requirements
- PHP 8.3+
- Composer
- Node.js 20+
- MySQL 8.0 or PostgreSQL 16
- Redis
- Stripe account (test mode)
1. Initialize the Laravel Project
composer create-project laravel/laravel saas-app
cd saas-app
Install the necessary packages right from the start:
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. Folder Structure
Instead of the default structure, we organize by domain-oriented approach:
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/
Create the directories:
mkdir -p app/Domain/{Tenant,User,Team,Project,Billing}/{Models,Actions,Data}
3. Environment Configuration
The .env file for 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. Foundation Database Schema
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: Add tenant_id to 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. Core Models
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 (updated)
// 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
Create a global scope to automatically filter data by 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);
}
}
}
Create a shared trait:
// 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
Register the tenant resolver in the 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);
}
}
Register in bootstrap/providers.php:
return [
App\Providers\AppServiceProvider::class,
App\Providers\TenantServiceProvider::class,
];
8. Action Classes
Instead of putting business logic in Controllers, use 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);
// Ensure the slug is unique
$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' => 'UTC',
'locale' => 'en',
],
]);
// Assign the owner to the tenant
$tenant->users()->attach($owner->id, ['role' => 'owner']);
// Set the current tenant for the user
$owner->update(['current_tenant_id' => $tenant->id]);
return $tenant;
}
}
9. Basic Routes
// 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 - need to select tenant
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/onboarding', [CreateTenantController::class, 'show'])
->name('onboarding');
Route::post('/onboarding', [CreateTenantController::class, 'store']);
});
// Authenticated - within tenant context
Route::middleware(['auth', 'verified', 'tenant'])->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index'])
->name('dashboard');
// Projects & Tasks will be added in upcoming parts
});
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);
}
}
Register the middleware in bootstrap/app.php:
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'tenant' => \App\Http\Middleware\ResolveTenant::class,
]);
})
11. Foundation Tests
Create basic tests to ensure the setup is correct:
// 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'));
});
Architecture Overview
┌─────────────────────────────────────────────┐
│ HTTP Layer │
│ Routes → Middleware → Controllers │
├─────────────────────────────────────────────┤
│ Application Layer │
│ Actions → Data Objects → Validation │
├─────────────────────────────────────────────┤
│ Domain Layer │
│ Models → Scopes → Traits → Events │
├─────────────────────────────────────────────┤
│ Infrastructure Layer │
│ Database → Cache → Queue → Mail │
└─────────────────────────────────────────────┘
Summary
In Part 1, we have:
- Initialized a Laravel project with necessary packages
- Designed a domain-oriented folder structure
- Created the foundational database schema (tenants, teams, projects, tasks)
- Built the Tenant model with a global scope for data isolation
- Created the tenant resolution middleware
- Written the first Action class
- Set up basic tests
In the next part, we'll dive deep into Multi-tenancy & Data Isolation — ensuring data between tenants is completely separated, handling subdomain routing, and tenant switching.