NextGenBeing
Listen to Article
Loading...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
- Introduction & Real-World Context
- Why Laravel for SaaS? The Hard Truth
- Architecture Overview
- Environment Setup & Dependencies
- Multi-Tenancy Strategy
- Database Architecture
- Authentication & Authorization Foundation
- Request Pipeline & Middleware Stack
- Service Layer Pattern
- Configuration Management
- Logging & Monitoring Foundation
- Common Pitfalls & Solutions
- Key Takeaways
- 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:
-
Request → Controller → Service → Repository → Model
- Controllers: Thin, handle HTTP concerns only
- Services: Fat, contain all business logic
- Repositories: Abstract data access (enables testing)
-
Event-Driven Side Effects
- User registers → Event → Send email, create team, log audit
- Keeps code decoupled and testable
-
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:
- Most SaaS apps serve 100-10,000 tenants (not millions)
- Query scoping is solved with Laravel's global scopes
- 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
- Every tenant-specific table has tenant_id
- Composite indexes for tenant_id + common queries
- Soft deletes for audit compliance
- 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 InRelated Articles
Optimizing Database Performance with Indexing and Caching: What We Learned Scaling to 100M Queries/Day
Apr 18, 2026
Building a Modern SaaS Application with Laravel - Part 2: Core Implementation & Design Patterns
Apr 19, 2026
Building a Modern SaaS Application with Laravel - Part 3: Advanced Features & Configuration
Apr 19, 2026