Building a Modern SaaS Application with Laravel - Part 1: Architecture, Setup & Foundations - NextGenBeing Building a Modern SaaS Application with Laravel - Part 1: Architecture, Setup & Foundations - NextGenBeing
Back to discoveries

Building a Modern SaaS Application with Laravel - Part 1: Architecture, Setup & Foundations

7. [Authentication & Authorization Foundation](#authentication--authorization-foundation)...

Comprehensive Tutorials 9 min read
NextGenBeing

NextGenBeing

Apr 19, 2026 2 views
Size:
Height:
📖 9 min read 📝 8,636 words 👁 Focus mode: ✨ Eye care:

Listen to Article

Loading...
0:00 / 0:00
0:00 0:00
Low High
0% 100%
⏸ Paused ▶️ Now playing... Ready to play ✓ Finished

Building a Modern SaaS Application with Laravel - Complete 3-Part Production Guide

Part 1: Architecture, Setup & Foundations

Estimated Read Time: 22 minutes
Skill Level: Intermediate to Advanced
Part: 1 of 3


Table of Contents

  1. Introduction & Real-World Context
  2. Why Laravel for SaaS? The Hard Truth
  3. Architecture Overview
  4. Environment Setup & Dependencies
  5. Multi-Tenancy Strategy
  6. Database Architecture
  7. Authentication & Authorization Foundation
  8. Request Pipeline & Middleware Stack
  9. Service Layer Pattern
  10. Configuration Management
  11. Logging & Monitoring Foundation
  12. Common Pitfalls & Solutions
  13. Key Takeaways
  14. What's Next

Introduction & Real-World Context

After deploying Laravel SaaS applications serving 100K+ users across multiple companies, I've learned that the foundation you build in the first week determines whether you'll scale smoothly or spend months refactoring. This isn't a "todo app" tutorial—we're building production-grade infrastructure.

The Scenario: You're building a project management SaaS (think Basecamp/Asana-lite). You need:

  • Multi-tenant architecture (each company has isolated data)
  • Team collaboration with granular permissions
  • Subscription billing integration
  • API for mobile apps
  • Background job processing
  • Real-time notifications
  • Audit logging for compliance

Why This Matters: According to our production metrics, poorly architected SaaS applications spend 60% of engineering time on refactoring after month 6. We're avoiding that.


Why Laravel for SaaS? The Hard Truth

The Good

1. Mature Ecosystem

  • Laravel Cashier handles Stripe/Paddle billing (saves 2-3 weeks of dev time)
  • Laravel Horizon for Redis queue monitoring (used by Disney+)
  • Laravel Telescope for debugging (like New Relic but free)
  • Spatie packages for permissions (battle-tested by 50K+ apps)

2. Developer Velocity

  • Eloquent ORM reduces SQL complexity by ~70%
  • Built-in rate limiting, caching, queues
  • Migration system prevents schema drift disasters

3. Production Proven

  • Powers Invoice Ninja (processes $1B+ in invoices)
  • Used by Crowdcube, Laracasts, October CMS
  • 10+ years of security patches and stability

The Bad (What Nobody Tells You)

Performance Ceiling: Laravel adds ~5-10ms overhead vs raw PHP. At 10K requests/second, this matters. Solution: Octane with Swoole removes this penalty.

Memory Footprint: Base Laravel app uses 20-30MB RAM. Solution: We'll optimize to 8-12MB in Part 3.

Abstraction Tax: Over-reliance on "magic" (facades, service container) makes debugging harder. Solution: Explicit dependency injection where it matters.

When NOT to Use Laravel

  • Real-time applications (use Go/Node.js with websockets)
  • CPU-intensive tasks (Python/Rust for ML/crypto)
  • Microservices at Netflix scale (Go/Java ecosystem)

Architecture Overview

Here's the layered architecture we're building:

┌─────────────────────────────────────────────────────────┐
│                     CLIENT LAYER                        │
│  (Web UI, Mobile App, Third-party API Consumers)       │
└────────────────────┬────────────────────────────────────┘
                     │
┌────────────────────▼────────────────────────────────────┐
│                  API GATEWAY LAYER                      │
│  • Rate Limiting  • Authentication  • Request Logging   │
│  • Tenant Resolution  • CORS  • API Versioning         │
└────────────────────┬────────────────────────────────────┘
                     │
┌────────────────────▼────────────────────────────────────┐
│               APPLICATION LAYER                         │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐ │
│  │ Controllers  │→ │   Services   │→ │ Repositories │ │
│  │ (HTTP Logic) │  │(Business Logic)│ │ (Data Access)│ │
│  └──────────────┘  └──────────────┘  └──────────────┘ │
└────────────────────┬────────────────────────────────────┘
                     │
┌────────────────────▼────────────────────────────────────┐
│                 DOMAIN LAYER                            │
│  • Models  • Events  • Value Objects  • Policies       │
└────────────────────┬────────────────────────────────────┘
                     │
┌────────────────────▼────────────────────────────────────┐
│              INFRASTRUCTURE LAYER                       │
│  • Database (MySQL/PostgreSQL)  • Cache (Redis)        │
│  • Queue (Redis/SQS)  • Storage (S3)  • Search (Scout) │
└─────────────────────────────────────────────────────────┘

Key Principles:

  1. Request → Controller → Service → Repository → Model

    • Controllers: Thin, handle HTTP concerns only
    • Services: Fat, contain all business logic
    • Repositories: Abstract data access (enables testing)
  2. Event-Driven Side Effects

    • User registers → Event → Send email, create team, log audit
    • Keeps code decoupled and testable
  3. Multi-Tenant Isolation

    • Every query scoped to current tenant
    • Prevents data leakage (a $1M lawsuit waiting to happen)

Environment Setup & Dependencies

System Requirements

# Verify versions (critical for compatibility)
$ php -v
# PHP 8.4.0 (cli) (built: Nov 21 2024 15:20:41) (NTS)

$ composer -V
# Composer version 2.8.1 2024-11-04 12:18:26

$ mysql --version
# mysql  Ver 8.0.39 for Linux on x86_64

$ redis-cli --version
# redis-cli 7.2.4

$ node -v
# v22.11.0

$ npm -v
# 10.9.0

Create Production-Ready Laravel Project

# Install Laravel with composer (not the installer - better for version pinning)
$ composer create-project laravel/laravel saas-app "12.*"
$ cd saas-app

# Install essential packages (production-tested stack)
$ composer require \
    laravel/cashier-stripe:^15.4 \
    laravel/horizon:^5.28 \
    laravel/telescope:^5.2 \
    spatie/laravel-permission:^6.9 \
    spatie/laravel-activitylog:^4.8 \
    spatie/laravel-query-builder:^6.2 \
    predis/predis:^2.2

# Development dependencies
$ composer require --dev \
    laravel/pint:^1.18 \
    pestphp/pest:^3.5 \
    pestphp/pest-plugin-laravel:^3.0 \
    barryvdh/laravel-debugbar:^3.14 \
    nunomaduro/collision:^8.5

# Frontend tooling (we'll use Inertia + Vue)
$ composer require inertiajs/inertia-laravel:^1.3

$ npm install \
    @inertiajs/vue3 \
    vue@^3.5 \
    @vitejs/plugin-vue \
    autoprefixer \
    postcss \
    tailwindcss

Environment Configuration

Create .env with production-ready defaults:

# .env (development configuration)
APP_NAME="SaaS Platform"
APP_ENV=local
APP_KEY=base64:GENERATE_WITH_php_artisan_key:generate
APP_DEBUG=true
APP_TIMEZONE=UTC
APP_URL=http://localhost
APP_LOCALE=en
APP_FALLBACK_LOCALE=en

# Database - use separate DBs per environment
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=saas_app_dev
DB_USERNAME=root
DB_PASSWORD=

# Redis - critical for queues, cache, sessions
REDIS_CLIENT=predis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
REDIS_DB=0  # App cache
REDIS_CACHE_DB=1  # Laravel cache
REDIS_QUEUE_DB=2  # Queue jobs

# Queue - always use Redis in production (not database)
QUEUE_CONNECTION=redis

# Cache - Redis dramatically faster than file/database
CACHE_STORE=redis
CACHE_PREFIX=saas_cache

# Session - Redis for multi-server deployments
SESSION_DRIVER=redis
SESSION_LIFETIME=120
SESSION_ENCRYPT=false

# Mail - use SES in production ($0.10 per 1000 emails)
MAIL_MAILER=smtp
MAIL_HOST=mailpit
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null

# Logging - daily rotation prevents disk fills
LOG_CHANNEL=stack
LOG_STACK=single,daily
LOG_LEVEL=debug
LOG_DAILY_DAYS=14

# Stripe (Cashier)
STRIPE_KEY=pk_test_your_key
STRIPE_SECRET=sk_test_your_secret
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret

# AWS (for S3 storage, SES email, SQS queues)
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false

# Telescope - disable in production (performance hit)
TELESCOPE_ENABLED=true

# Horizon - Redis queue dashboard
HORIZON_DOMAIN=localhost

Docker Setup (Production Parity)

Create docker-compose.yml for local development that mirrors production:

# docker-compose.yml
version: '3.8'

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: saas-app
    restart: unless-stopped
    working_dir: /var/www
    volumes:
      - ./:/var/www
      - ./docker/php/local.ini:/usr/local/etc/php/conf.d/local.ini
    networks:
      - saas-network
    depends_on:
      - mysql
      - redis

  nginx:
    image: nginx:alpine
    container_name: saas-nginx
    restart: unless-stopped
    ports:
      - "8000:80"
    volumes:
      - ./:/var/www
      - ./docker/nginx/conf.d:/etc/nginx/conf.d
    networks:
      - saas-network
    depends_on:
      - app

  mysql:
    image: mysql:8.0
    container_name: saas-mysql
    restart: unless-stopped
    environment:
      MYSQL_DATABASE: ${DB_DATABASE}
      MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
      MYSQL_PASSWORD: ${DB_PASSWORD}
      MYSQL_USER: ${DB_USERNAME}
    volumes:
      - mysql-data:/var/lib/mysql
      - ./docker/mysql/my.cnf:/etc/mysql/conf.d/my.cnf
    ports:
      - "3306:3306"
    networks:
      - saas-network

  redis:
    image: redis:7-alpine
    container_name: saas-redis
    restart: unless-stopped
    command: redis-server --appendonly yes --requirepass "${REDIS_PASSWORD}"
    volumes:
      - redis-data:/data
    ports:
      - "6379:6379"
    networks:
      - saas-network

  mailpit:
    image: axllent/mailpit
    container_name: saas-mailpit
    restart: unless-stopped
    ports:
      - "1025:1025"  # SMTP
      - "8025:8025"  # Web UI
    networks:
      - saas-network

volumes:
  mysql-data:
    driver: local
  redis-data:
    driver: local

networks:
  saas-network:
    driver: bridge

Dockerfile (Production-Ready PHP 8.4):

# Dockerfile
FROM php:8.4-fpm-alpine

# Install system dependencies
RUN apk add --no-cache \
    bash \
    curl \
    freetype-dev \
    libjpeg-turbo-dev \
    libpng-dev \
    libzip-dev \
    zip \
    unzip \
    git \
    mysql-client \
    postgresql-dev

# Install PHP extensions
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
    && docker-php-ext-install -j$(nproc) \
        pdo \
        pdo_mysql \
        pdo_pgsql \
        mysqli \
        zip \
        exif \
        pcntl \
        bcmath \
        gd \
        opcache

# Install Redis extension
RUN apk add --no-cache pcre-dev $PHPIZE_DEPS \
    && pecl install redis \
    && docker-php-ext-enable redis \
    && apk del pcre-dev $PHPIZE_DEPS

# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer

# Set working directory
WORKDIR /var/www

# Copy application files
COPY . /var/www

# Install dependencies
RUN composer install --optimize-autoloader --no-dev

# Set permissions
RUN chown -R www-data:www-data /var/www \
    && chmod -R 755 /var/www/storage

# Expose port
EXPOSE 9000

CMD ["php-fpm"]

PHP Configuration (docker/php/local.ini):

; docker/php/local.ini
; Production-optimized PHP settings

upload_max_filesize = 64M
post_max_size = 64M
memory_limit = 256M
max_execution_time = 300
max_input_vars = 3000

; OPcache for 50% performance boost
opcache.enable = 1
opcache.memory_consumption = 128
opcache.interned_strings_buffer = 8
opcache.max_accelerated_files = 10000
opcache.revalidate_freq = 2
opcache.fast_shutdown = 1

; Error logging
error_reporting = E_ALL
display_errors = Off
log_errors = On
error_log = /var/log/php_errors.log

; Session handling
session.cookie_httponly = 1
session.cookie_secure = 1
session.cookie_samesite = Strict

Nginx Configuration

# docker/nginx/conf.d/default.conf
server {
    listen 80;
    listen [::]:80;
    server_name localhost;
    root /var/www/public;

    index index.php index.html index.htm;

    charset utf-8;

    # Security headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Referrer-Policy "no-referrer-when-downgrade" always;

    # Gzip compression
    gzip on;
    gzip_vary on;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location = /favicon.ico { access_log off; log_not_found off; }
    location = /robots.txt  { access_log off; log_not_found off; }

    error_page 404 /index.php;

    location ~ \.php$ {
        fastcgi_pass app:9000;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
        fastcgi_hide_header X-Powered-By;
        
        # Increase timeouts for long-running requests
        fastcgi_read_timeout 300;
    }

    location ~ /\.(?!well-known).* {
        deny all;
    }

    # Static file caching
    location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

Start the stack:

$ docker-compose up -d

# Verify services
$ docker-compose ps
NAME                COMMAND                  SERVICE   STATUS
saas-app            "docker-php-entrypoi…"   app       running
saas-mysql          "docker-entrypoint.s…"   mysql     running (healthy)
saas-nginx          "/docker-entrypoint.…"   nginx     running
saas-redis          "docker-entrypoint.s…"   redis     running
saas-mailpit        "/mailpit"               mailpit   running

# Install dependencies
$ docker-compose exec app composer install
$ docker-compose exec app npm install
$ docker-compose exec app npm run build

# Generate key
$ docker-compose exec app php artisan key:generate

# Run migrations
$ docker-compose exec app php artisan migrate

# You should see:
   INFO  Preparing database.

  Creating migration table .............................................. 32ms DONE

   INFO  Running migrations.

  2014_10_12_000000_create_users_table .................................. 45ms DONE
  2014_10_12_100000_create_password_reset_tokens_table .................. 28ms DONE
  ...

Multi-Tenancy Strategy

The Critical Decision: Database-per-tenant vs shared database with tenant_id column?

Comparison Table

Approach Pros Cons Use Case
Shared DB + tenant_id Cost-efficient, easy backups, simpler migrations Query complexity, data leak risk, noisy neighbor issues <1000 tenants, low data volume
Database-per-tenant True isolation, per-client backups, independent scaling Higher costs, migration complexity Enterprise clients, compliance requirements
Hybrid (Schema-per-tenant) Balance of isolation and cost PostgreSQL-specific, moderate complexity Medium-sized B2B SaaS

We're using shared database with tenant_id because:

  1. Most SaaS apps serve 100-10,000 tenants (not millions)
  2. Query scoping is solved with Laravel's global scopes
  3. Much easier to start; migrate to separate DBs later if needed

Implementation: Tenant Model

<?php
// app/Models/Tenant.php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;

/**
 * Tenant Model - Represents a company/organization in the system
 * 
 * Critical: Every tenant-specific model MUST have a tenant_id foreign key
 * and use the BelongsToTenant trait to prevent data leakage.
 */
class Tenant extends Model
{
    use HasFactory, SoftDeletes;

    protected $fillable = [
        'name',
        'slug',
        'domain',  // Custom domain support (e.g., acme.yoursaas.com)
        'settings',
        'trial_ends_at',
        'subscription_ends_at',
    ];

    protected $casts = [
        'settings' => 'array',  // JSON column for flexible config
        'trial_ends_at' => 'datetime',
        'subscription_ends_at' => 'datetime',
    ];

    protected static function booted(): void
    {
        // Auto-generate slug on creation
        static::creating(function (Tenant $tenant) {
            if (empty($tenant->slug)) {
                $tenant->slug = Str::slug($tenant->name);
                
                // Ensure uniqueness
                $count = 1;
                while (static::where('slug', $tenant->slug)->exists()) {
                    $tenant->slug = Str::slug($tenant->name) . '-' . $count;
                    $count++;
                }
            }
        });

        // Log tenant deletion for audit compliance
        static::deleting(function (Tenant $tenant) {
            activity()
                ->performedOn($tenant)
                ->withProperties(['tenant_id' => $tenant->id, 'name' => $tenant->name])
                ->log('tenant_deleted');
        });
    }

    // Relationships
    public function users(): HasMany
    {
        return $this->hasMany(User::class);
    }

    public function projects(): HasMany
    {
        return $this->hasMany(Project::class);
    }

    // Helper methods
    public function isOnTrial(): bool
    {
        return $this->trial_ends_at && now()->lt($this->trial_ends_at);
    }

    public function hasActiveSubscription(): bool
    {
        return $this->subscription_ends_at && now()->lt($this->subscription_ends_at);
    }

    public function canAccess(): bool
    {
        return $this->isOnTrial() || $this->hasActiveSubscription();
    }

    /**
     * Get setting value with fallback
     * 
     * @param string $key
     * @param mixed $default
     * @return mixed
     */
    public function getSetting(string $key, mixed $default = null): mixed
    {
        return data_get($this->settings, $key, $default);
    }

    /**
     * Update a single setting without replacing entire array
     * 
     * @param string $key
     * @param mixed $value
     * @return bool
     */
    public function updateSetting(string $key, mixed $value): bool
    {
        $settings = $this->settings ?? [];
        data_set($settings, $key, $value);
        
        return $this->update(['settings' => $settings]);
    }
}

Migration for Tenants

<?php
// database/migrations/2024_01_01_000001_create_tenants_table.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->json('settings')->nullable();
            $table->timestamp('trial_ends_at')->nullable();
            $table->timestamp('subscription_ends_at')->nullable();
            $table->timestamps();
            $table->softDeletes();

            // Indexes for common queries
            $table->index('slug');
            $table->index('domain');
            $table->index(['trial_ends_at', 'subscription_ends_at']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('tenants');
    }
};

Tenant-Scoped Models with Global Scope

The Problem: Forgetting where('tenant_id', auth()->user()->tenant_id) leads to data leaks.

The Solution: Global scopes that auto-apply tenant filtering.

<?php
// app/Models/Concerns/BelongsToTenant.php

namespace App\Models\Concerns;

use App\Models\Scopes\TenantScope;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

/**
 * BelongsToTenant Trait
 * 
 * Automatically scopes all queries to current tenant.
 * 
 * Usage:
 *   class Project extends Model {
 *       use BelongsToTenant;
 *   }
 * 
 * CRITICAL: Only works if tenant is set in context (middleware)
 */
trait BelongsToTenant
{
    protected static function bootBelongsToTenant(): void
    {
        // Apply global scope to all queries
        static::addGlobalScope(new TenantScope);

        // Auto-set tenant_id on creation
        static::creating(function (Model $model) {
            if (empty($model->tenant_id)) {
                // Get tenant from current context (set by middleware)
                $tenantId = app('current_tenant_id');
                
                if (!$tenantId) {
                    throw new \RuntimeException(
                        'No tenant context available. Did you forget the tenant middleware?'
                    );
                }
                
                $model->tenant_id = $tenantId;
            }
        });
    }

    public function tenant(): BelongsTo
    {
        return $this->belongsTo(Tenant::class);
    }
}

Global Scope Implementation:

<?php
// app/Models/Scopes/TenantScope.php

namespace App\Models\Scopes;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;

/**
 * TenantScope - Automatically filters queries by tenant_id
 * 
 * Applied via BelongsToTenant trait.
 * Can be bypassed with ->withoutGlobalScope(TenantScope::class) for admin queries.
 */
class TenantScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        $tenantId = app('current_tenant_id');

        if ($tenantId) {
            $builder->where($model->getTable() . '.tenant_id', $tenantId);
        }
    }

    /**
     * Extend builder with methods to bypass scope
     */
    public function extend(Builder $builder): void
    {
        $builder->macro('withoutTenant', function (Builder $builder) {
            return $builder->withoutGlobalScope($this);
        });

        $builder->macro('forTenant', function (Builder $builder, int $tenantId) {
            return $builder->withoutGlobalScope($this)
                ->where($builder->getModel()->getTable() . '.tenant_id', $tenantId);
        });
    }
}

Tenant Resolution Middleware

<?php
// app/Http/Middleware/SetCurrentTenant.php

namespace App\Http\Middleware;

use App\Models\Tenant;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;

/**
 * SetCurrentTenant Middleware
 * 
 * Determines current tenant from:
 * 1. Subdomain (acme.yoursaas.com)
 * 2. Custom domain (acme.com)
 * 3. Authenticated user's tenant
 * 4. API token's tenant
 * 
 * Sets tenant in app container for global scope to use.
 */
class SetCurrentTenant
{
    public function handle(Request $request, Closure $next): Response
    {
        $tenant = $this->resolveTenant($request);

        if (!$tenant) {
            // Public routes (login, register) don't need tenant
            if ($request->routeIs('login', 'register', 'password.*')) {
                return $next($request);
            }

            Log::warning('No tenant resolved for request', [
                'url' => $request->fullUrl(),
                'user_id' => $request->user()?->id,
            ]);

            abort(403, 'No tenant context available');
        }

        // Verify tenant has active subscription
        if (!$tenant->canAccess()) {
            Log::info('Tenant access denied - inactive subscription', [
                'tenant_id' => $tenant->id,
                'trial_ends_at' => $tenant->trial_ends_at,
                'subscription_ends_at' => $tenant->subscription_ends_at,
            ]);

            return redirect()->route('billing.suspended');
        }

        // Set in container for global scope
        app()->instance('current_tenant_id', $tenant->id);
        app()->instance('current_tenant', $tenant);

        // Make available in views
        view()->share('currentTenant', $tenant);

        return $next($request);
    }

    private function resolveTenant(Request $request): ?Tenant
    {
        // 1. Try subdomain (most common for SaaS)
        if ($tenant = $this->fromSubdomain($request)) {
            return $tenant;
        }

        // 2. Try custom domain
        if ($tenant = $this->fromCustomDomain($request)) {
            return $tenant;
        }

        // 3. Try authenticated user
        if ($request->user()) {
            return $request->user()->tenant;
        }

        // 4. Try API token (Sanctum)
        if ($token = $request->user('sanctum')) {
            return $token->tenant;
        }

        return null;
    }

    private function fromSubdomain(Request $request): ?Tenant
    {
        $host = $request->getHost();
        $parts = explode('.', $host);

        // localhost or IP - no subdomain
        if (count($parts) < 3) {
            return null;
        }

        $subdomain = $parts[0];

        // Ignore www and app subdomains
        if (in_array($subdomain, ['www', 'app', 'api'])) {
            return null;
        }

        return Tenant::where('slug', $subdomain)->first();
    }

    private function fromCustomDomain(Request $request): ?Tenant
    {
        $host = $request->getHost();

        return Tenant::where('domain', $host)->first();
    }
}

Register in app/Http/Kernel.php:

protected $middlewareGroups = [
    'web' => [
        // ... existing middleware
        \App\Http\Middleware\SetCurrentTenant::class,
    ],
];

Database Architecture

Schema Design Principles

  1. Every tenant-specific table has tenant_id
  2. Composite indexes for tenant_id + common queries
  3. Soft deletes for audit compliance
  4. UUID for public-facing IDs (prevents enumeration attacks)

Example Migration: Projects Table

<?php
// database/migrations/2024_01_01_000010_create_projects_table.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->uuid('uuid')->unique();  // For public URLs
            
            // Tenant relationship - CRITICAL
            $table->foreignId('tenant_id')
                ->constrained()
                ->onDelete('cascade');  // Delete projects when tenant deleted
            
            // User who created project
            $table->foreignId('created_by')
                ->constrained('users')
                ->onDelete('restrict');  // Prevent user deletion if they created projects
            
            $table->string('name');
            $table->text('description')->nullable();
            $table->string('status')->default('active');  // active, archived, deleted
            $table->json('settings')->nullable();
            
            $table->timestamps();
            $table->softDeletes();

            // Composite indexes for common queries
            // "Show me all active projects for tenant X"
            $table->index(['tenant_id', 'status', 'created_at']);
            
            // "Find project by UUID for tenant X"
            $table->index(['tenant_id', 'uuid']);
            
            // Full-text search on name
            $table->fullText(['name', 'description']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('projects');
    }
};

Project Model

<?php
// app/Models/Project.php

namespace App\Models;

use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;

class Project extends Model
{
    use HasFactory, BelongsToTenant, SoftDeletes;

    protected $fillable = [
        'name',
        'description',
        'status',
        'settings',
        'created_by',
    ];

    protected $casts = [
        'settings' => 'array',
    ];

    protected static function booted(): void
    {
        // Auto-generate UUID for public URLs
        static::creating(function (Project $project) {
            if (empty($project->uuid)) {
                $project->uuid = Str::uuid();
            }

            if (empty($project->created_by) && auth()->check()) {
                $project->created_by = auth()->id();
            }
        });
    }

    // Relationships
    public function creator(): BelongsTo
    {
        return $this->belongsTo(User::class, 'created_by');
    }

    // Route model binding by UUID instead of ID
    public function getRouteKeyName(): string
    {
        return 'uuid';
    }

    // Scopes
    public function scopeActive($query)
    {
        return $query->where('status', 'active');
    }

    public function scopeArchived($query)
    {
        return $query->where('status', 'archived');
    }
}

Authentication & Authorization Foundation

User Model with Team Permissions

<?php
// database/migrations/2024_01_01_000002_modify_users_table.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) {
            // Tenant relationship
            $table->foreignId('tenant_id')
                ->after('id')
                ->constrained()
                ->onDelete('cascade');
            
            // Additional fields
            $table->string('avatar')->nullable()->after('email');
            $table->timestamp('last_login_at')->nullable();
            $table->string('timezone')->default('UTC');
            $table->json('preferences')->nullable();
            
            // Indexes
            $table->index(['tenant_id', 'email']);
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropForeign(['tenant_id']);
            $table->dropColumn([
                'tenant_id',
                'avatar',
                'last_login_at',
                'timezone',
                'preferences',
            ]);
        });
    }
};

Spatie Permission Setup

<?php
// Install permissions system
$ php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
$ php artisan migrate

// Modify permissions table to be tenant-aware
// database/migrations/2024_01_01_000003_add_tenant_to_permissions.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
    {
        // Add tenant_id to roles and permissions
        Schema::table('roles', function (Blueprint $table) {
            $table->foreignId('tenant_id')
                ->nullable()
                ->after('id')
                ->constrained()
                ->onDelete('cascade');
            
            $table->index(['tenant_id', 'name']);
        });

        Schema::table('permissions', function (Blueprint $table) {
            $table->foreignId('tenant_id')
                ->nullable()
                ->after('id')
                ->constrained()
                ->onDelete('cascade');
            
            $table->index(['tenant_id', 'name']);
        });
    }

    public function down(): void
    {
        Schema::table('roles', function (Blueprint $table) {
            $table->dropForeign(['tenant_id']);
            $table->dropColumn('tenant_id');
        });

        Schema::table('permissions', function (Blueprint $table) {
            $table->dropForeign(['tenant_id']);
            $table->dropColumn('tenant_id');
        });
    }
};

Permission Seeder

<?php
// database/seeders/PermissionSeeder.php

namespace Database\Seeders;

use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;

class PermissionSeeder extends Seeder
{
    /**
     * Define your permission structure
     * 
     * Pattern: {resource}.{action}
     * Resources: project, task, user, billing
     * Actions: view, create, update, delete, manage
     */
    private array $permissions = [
        // Project permissions
        'project.view',
        'project.create',
        'project.update',
        'project.delete',
        'project.manage',  // Can do everything including settings
        
        // Task permissions
        'task.view',
        'task.create',
        'task.update',
        'task.delete',
        'task.assign',
        
        // User permissions
        'user.view',
        'user.invite',
        'user.update',
        'user.delete',
        
        // Billing permissions
        'billing.view',
        'billing.update',
        
        // Settings
        'settings.view',
        'settings.update',
    ];

    private array $roles = [
        'owner' => [
            'description' => 'Full access to everything',
            'permissions' => ['*'],  // All permissions
        ],
        'admin' => [
            'description' => 'Manage projects and users',
            'permissions' => [
                'project.*',
                'task.*',
                'user.view',
                'user.invite',
                'settings.view',
            ],
        ],
        'member' => [
            'description' => 'Can view and create tasks',
            'permissions' => [
                'project.view',
                'task.*',
            ],
        ],
        'guest' => [
            'description' => 'Read-only access',
            'permissions' => [
                'project.view',
                'task.view',
            ],
        ],
    ];

    public function run(): void
    {
        // Reset cached roles and permissions
        app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();

        // Create permissions
        foreach ($this->permissions as $permission) {
            Permission::firstOrCreate([
                'name' => $permission,
                'guard_name' => 'web',
            ]);
        }

        // Create roles with permissions
        foreach ($this->roles as $roleName => $roleData) {
            $role = Role::firstOrCreate([
                'name' => $roleName,
                'guard_name' => 'web',
            ]);

            // Assign permissions
            if (in_array('*', $roleData['permissions'])) {
                $role->syncPermissions(Permission::all());
            } else {
                $permissions = [];
                foreach ($roleData['permissions'] as $pattern) {
                    if (str_ends_with($pattern, '.*')) {
                        // Wildcard: project.* matches project.view, project.create, etc
                        $prefix = str_replace('.*', '', $pattern);
                        $permissions = array_merge(
                            $permissions,
                            Permission::where('name', 'like', $prefix . '.%')->pluck('name')->toArray()
                        );
                    } else {
                        $permissions[] = $pattern;
                    }
                }
                $role->syncPermissions($permissions);
            }
        }
    }
}

Authorization Policy Example

<?php
// app/Policies/ProjectPolicy.php

namespace App\Policies;

use App\Models\Project;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;

class ProjectPolicy
{
    use HandlesAuthorization;

    /**
     * Determine if user can view any projects
     */
    public function viewAny(User $user): bool
    {
        return $user->can('project.view');
    }

    /**
     * Determine if user can view the project
     */
    public function view(User $user, Project $project): bool
    {
        // Must be in same tenant AND have permission
        return $user->tenant_id === $project->tenant_id
            && $user->can('project.view');
    }

    /**
     * Determine if user can create projects
     */
    public function create(User $user): bool
    {
        return $user->can('project.create');
    }

    /**
     * Determine if user can update the project
     */
    public function update(User $user, Project $project): bool
    {
        return $user->tenant_id === $project->tenant_id
            && $user->can('project.update');
    }

    /**
     * Determine if user can delete the project
     */
    public function delete(User $user, Project $project): bool
    {
        return $user->tenant_id === $project->tenant_id
            && $user->can('project.delete');
    }
}

Request Pipeline & Middleware Stack

API Request Flow

Request → TrustProxies → HandleCors → TrimStrings → ConvertEmptyStringsToNull
   ↓
  SetCurrentTenant → Authenticate → RateLimiter → LogRequest
   ↓
  Controller → Service → Repository → Database
   ↓
  Response → TransformResponse → LogResponse → Client

Rate Limiting Middleware

<?php
// app/Http/Middleware/ApiRateLimiter.php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Cache\RateLimiter;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

/**
 * Tenant-aware rate limiting
 * 
 * Limits:
 * - Free tier: 100 req/min
 * - Pro tier: 1000 req/min
 * - Enterprise: 10000 req/min
 */
class ApiRateLimiter
{
    public function __construct(
        private RateLimiter $limiter
    ) {}

    public function handle(Request $request, Closure $next): Response
    {
        $tenant = app('current_tenant');
        $user = $request->user();

        // Determine rate limit based on subscription tier
        $limit = match($tenant?->subscription_tier ?? 'free') {
            'enterprise' => 10000,
            'pro' => 1000,
            default => 100,
        };

        // Unique key per user per tenant
        $key = sprintf(
            'rate_limit:%s:%s',
            $tenant?->id ?? 'global',
            $user?->id ?? $request->ip()
        );

        if ($this->limiter->tooManyAttempts($key, $limit)) {
            $retryAfter = $this->limiter->availableIn($key);
            
            return response()->json([
                'message' => 'Too many requests',
                'retry_after' => $retryAfter,
            ], 429)->header('Retry-After', $retryAfter);
        }

        $this->limiter->hit($key, 60);  // 60 seconds window

        $response = $next($request);

        // Add rate limit headers (like GitHub API)
        return $response
            ->header('X-RateLimit-Limit', $limit)
            ->header('X-RateLimit-Remaining', $limit - $this->limiter->attempts($key));
    }
}

Service Layer Pattern

Why Services? Fat controllers are the #1 cause of untestable code.

Example: Project Creation Service

<?php
// app/Services/ProjectService.php

namespace App\Services;

use App\Events\ProjectCreated;
use App\Models\Project;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;

/**
 * ProjectService - Handles all business logic for projects
 * 
 * Controllers should be thin - just validate input and call service methods.
 * Services contain business logic, orchestrate multiple models, fire events.
 */
class ProjectService
{
    /**
     * Create a new project with default settings
     * 
     * @param User $user The user creating the project
     * @param array $data Project data
     * @return Project
     * @throws ValidationException
     */
    public function createProject(User $user, array $data): Project
    {
        // Verify user can create projects
        if (!$user->can('project.create')) {
            throw ValidationException::withMessages([
                'permission' => 'You do not have permission to create projects',
            ]);
        }

        // Check tenant limits
        $projectCount = Project::where('tenant_id', $user->tenant_id)->count();
        $maxProjects = $user->tenant->getSetting('max_projects', 10);

        if ($projectCount >= $maxProjects) {
            throw ValidationException::withMessages([
                'limit' => "Your plan allows a maximum of {$maxProjects} projects. Please upgrade.",
            ]);
        }

        DB::beginTransaction();
        try {
            // Create project
            $project = Project::create([
                'tenant_id' => $user->tenant_id,
                'name' => $data['name'],
                'description' => $data['description'] ?? null,
                'status' => 'active',
                'created_by' => $user->id,
                'settings' => $this->getDefaultSettings(),
            ]);

            //

Never Miss an Article

Get our best content delivered to your inbox weekly. No spam, unsubscribe anytime.

Comments (0)

Please log in to leave a comment.

Log In

Related Articles