Building SaaS with Laravel (Part 1): Project Setup & Foundation Architecture

· 12 min read

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

  1. Project Setup & Foundation Architecture ← You are here
  2. Multi-tenancy & Data Isolation
  3. Billing with Stripe Cashier
  4. Team Management & Permissions
  5. Plans, Limits & Usage Tracking
  6. Admin Dashboard & Metrics
  7. API & Webhooks for Third-party Integrations
  8. 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.

Comments