Bekzod Erkinov
Listen to Article
Loading...Building a REST API with Laravel - Complete 3-Part Production Guide
Part 1: Architecture, Setup & Foundations
Estimated Read Time: 22 minutes
Skill Level: Intermediate to Advanced
Laravel Version: 11.x | PHP Version: 8.3+
Table of Contents
- Introduction: Why Laravel for Production APIs?
- Architecture Decision Records
- Complete Development Environment Setup
- Project Initialization & Directory Structure
- Configuration Management for Multiple Environments
- Database Architecture & Migrations
- API Versioning Strategy
- Request/Response Pipeline Architecture
- First Working Endpoint: Complete Implementation
- Common Setup Mistakes & Solutions
- Performance Baseline & Monitoring
- What's Next in Part 2
1. Introduction: Why Laravel for Production APIs?
After building production APIs for companies processing 50M+ requests/day, I've learned that the framework choice matters less than your architecture decisions. However, Laravel provides battle-tested components that reduce the distance between "works on my machine" and "scales in production."
When Laravel Makes Sense for APIs
Choose Laravel when:
- You need to ship fast but maintain long-term code quality
- Your team values convention over configuration (to a point)
- You'll eventually need queues, scheduled jobs, websockets, or email
- You want an ecosystem that solves 80% of common problems
Consider alternatives when:
- You're building pure microservices with <5 endpoints each (Go/Rust might be better)
- You need absolute performance above developer experience (raw PHP/C extensions)
- Your team is already deep in another ecosystem (Node.js with existing libraries)
What We're Building
We'll create a multi-tenant task management API similar to what Asana or Todoist might use. This isn't a toy example—it includes:
- Multi-tenant data isolation (critical for SaaS)
- Role-based permissions (team owner, member, guest)
- Real-time updates via webhooks
- Rate limiting per tenant tier
- Audit logging for compliance
- Proper caching strategies
Key metrics we'll optimize for:
- Response time: p95 < 200ms, p99 < 500ms
- Throughput: 1000+ req/sec on modest hardware
- Database queries: N+1 elimination
- Memory: <50MB per worker process
2. Architecture Decision Records
Before writing code, let's document our architectural decisions. In production systems, Architecture Decision Records (ADRs) prevent "why did we do it this way?" questions six months later.
ADR-001: API Architecture Pattern
Decision: Repository + Service Layer Pattern
┌─────────────┐
│ Client │
└──────┬──────┘
│ HTTP Request
↓
┌─────────────────────────────────────┐
│ Routes & Middleware │ ← Rate limiting, auth, logging
└──────────────┬──────────────────────┘
│
↓
┌─────────────────────────────────────┐
│ Controllers (Thin Layer) │ ← Validation, HTTP concerns only
└──────────────┬──────────────────────┘
│
↓
┌─────────────────────────────────────┐
│ Services (Business Logic) │ ← Core business rules
└──────────────┬──────────────────────┘
│
↓
┌─────────────────────────────────────┐
│ Repositories (Data Access) │ ← Query optimization
└──────────────┬──────────────────────┘
│
↓
┌─────────────────────────────────────┐
│ Models & Database │
└─────────────────────────────────────┘
Why not alternatives?
| Pattern | Pros | Cons | Our Verdict |
|---|---|---|---|
| Fat Models (Rails style) | Simple initially | Untestable, breaks SRP | ❌ Doesn't scale |
| Action-Based (Single Purpose Controllers) | Very focused | Too many files | ✅ Use for complex operations |
| Repository Pattern | Testable, swappable | Over-engineering for simple CRUD | ✅ With pragmatism |
| CQRS | Excellent for complex domains | Overkill for most APIs | ⚠️ Part 3 for specific use cases |
Our implementation philosophy:
- Controllers validate requests and return responses
- Services contain business logic and orchestration
- Repositories handle complex queries (not simple
find()) - Models for relationships and attribute casting only
ADR-002: Authentication Strategy
Decision: Laravel Sanctum with Personal Access Tokens
// Why Sanctum over alternatives:
// - JWT: Stateless but can't revoke without database lookup anyway
// - Passport: OAuth2 overkill for most APIs
// - Sanctum: Balance of simplicity and features
We'll implement this in Part 2, but the decision affects our database schema now.
ADR-003: Multi-Tenancy Approach
Decision: Shared Database, Row-Level Isolation
Why shared DB?
- Most SaaS apps have <10GB/tenant—database-per-tenant creates operational nightmare
- Proper indexing on
tenant_idperforms excellently - Cost: Single RDS instance vs. hundreds
Trade-off: One bad query can affect all tenants (we'll address with query governors).
3. Complete Development Environment Setup
Let's set up an environment that matches production. No Docker in development introduces inconsistencies that waste hours debugging.
3.1 Docker Compose Configuration
File: docker-compose.yml
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: laravel_api
restart: unless-stopped
working_dir: /var/www
volumes:
- ./:/var/www
- ./docker/php/local.ini:/usr/local/etc/php/conf.d/local.ini
networks:
- laravel
depends_on:
- db
- redis
environment:
- DB_HOST=db
- REDIS_HOST=redis
nginx:
image: nginx:alpine
container_name: laravel_nginx
restart: unless-stopped
ports:
- "8080:80"
volumes:
- ./:/var/www
- ./docker/nginx/conf.d:/etc/nginx/conf.d
networks:
- laravel
depends_on:
- app
db:
image: postgres:16-alpine
container_name: laravel_db
restart: unless-stopped
environment:
POSTGRES_DB: laravel_api
POSTGRES_USER: laravel
POSTGRES_PASSWORD: secret
PGDATA: /var/lib/postgresql/data/pgdata
volumes:
- dbdata:/var/lib/postgresql/data
ports:
- "5432:5432"
networks:
- laravel
# Production-grade settings for development
command:
- "postgres"
- "-c"
- "max_connections=200"
- "-c"
- "shared_buffers=256MB"
- "-c"
- "effective_cache_size=1GB"
- "-c"
- "work_mem=16MB"
- "-c"
- "maintenance_work_mem=128MB"
redis:
image: redis:7-alpine
container_name: laravel_redis
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- redisdata:/data
networks:
- laravel
# Enable persistence for development
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
# Mailpit for email testing (replaces Mailtrap)
mailpit:
image: axllent/mailpit
container_name: laravel_mailpit
restart: unless-stopped
ports:
- "1025:1025" # SMTP
- "8025:8025" # Web UI
networks:
- laravel
networks:
laravel:
driver: bridge
volumes:
dbdata:
driver: local
redisdata:
driver: local
Why PostgreSQL over MySQL?
- Better JSON support for flexible schemas
- True JSONB indexing (critical for metadata fields)
- Superior full-text search without external engines
- LISTEN/NOTIFY for real-time features
File: Dockerfile
FROM php:8.3-fpm
# Arguments defined in docker-compose.yml
ARG user=laravel
ARG uid=1000
# Install system dependencies
RUN apt-get update && apt-get install -y \
git \
curl \
libpng-dev \
libonig-dev \
libxml2-dev \
libpq-dev \
libzip-dev \
zip \
unzip \
vim \
# For PostgreSQL
postgresql-client \
# For Redis debugging
redis-tools
# Clear cache
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
# Install PHP extensions required for production
RUN docker-php-ext-install \
pdo_pgsql \
pgsql \
mbstring \
exif \
pcntl \
bcmath \
opcache \
zip
# Install Redis extension
RUN pecl install redis && docker-php-ext-enable redis
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Create system user to run Composer and Artisan Commands
RUN useradd -G www-data,root -u $uid -d /home/$user $user
RUN mkdir -p /home/$user/.composer && \
chown -R $user:$user /home/$user
# Set working directory
WORKDIR /var/www
USER $user
File: docker/php/local.ini
; Production-grade PHP settings for development
upload_max_filesize = 40M
post_max_size = 40M
memory_limit = 256M
max_execution_time = 120
max_input_time = 120
; OPcache for performance (matches production)
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 reporting for development
display_errors = On
display_startup_errors = On
error_reporting = E_ALL
; Timezone
date.timezone = UTC
File: docker/nginx/conf.d/default.conf
server {
listen 80;
server_name localhost;
root /var/www/public;
index index.php;
# Logging for debugging
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
# Security headers (production-ready)
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_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/rss+xml font/truetype font/opentype application/vnd.ms-fontobject image/svg+xml;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass app:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
# Production-grade FastCGI settings
fastcgi_buffers 16 16k;
fastcgi_buffer_size 32k;
fastcgi_read_timeout 120;
}
location ~ /\.(?!well-known).* {
deny all;
}
# Cache static assets
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
3.2 Makefile for Developer Experience
File: Makefile
.PHONY: help setup up down shell tinker migrate fresh test logs
help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}'
setup: ## Initial project setup
docker-compose build
docker-compose up -d
docker-compose exec app composer install
docker-compose exec app cp .env.example .env
docker-compose exec app php artisan key:generate
docker-compose exec app php artisan migrate
@echo "✓ Setup complete! API running at http://localhost:8080"
@echo "✓ Mailpit UI at http://localhost:8025"
up: ## Start all containers
docker-compose up -d
down: ## Stop all containers
docker-compose down
shell: ## Access app container shell
docker-compose exec app bash
tinker: ## Laravel tinker REPL
docker-compose exec app php artisan tinker
migrate: ## Run migrations
docker-compose exec app php artisan migrate
fresh: ## Fresh database with seeders
docker-compose exec app php artisan migrate:fresh --seed
test: ## Run tests
docker-compose exec app php artisan test
logs: ## Show app logs
docker-compose logs -f app
db: ## Access PostgreSQL CLI
docker-compose exec db psql -U laravel laravel_api
redis-cli: ## Access Redis CLI
docker-compose exec redis redis-cli
3.3 Starting Your Environment
# Clone or create your project directory
mkdir laravel-api-production && cd laravel-api-production
# Create the Docker files above, then:
$ make setup
# Expected output:
✓ Creating network "laravel-api-production_laravel"
✓ Creating volume "laravel-api-production_dbdata"
✓ Creating volume "laravel-api-production_redisdata"
✓ Building app
✓ Starting laravel_db ... done
✓ Starting laravel_redis ... done
✓ Starting laravel_mailpit ... done
✓ Starting laravel_api ... done
✓ Starting laravel_nginx ... done
✓ Installing Composer dependencies...
✓ Generating application key...
✓ Running migrations...
✓ Setup complete! API running at http://localhost:8080
Performance Note: This setup provides development parity with production. On my M1 MacBook, cold start is ~15s, subsequent starts ~3s. If you're on older hardware and need faster starts, consider Laravel Sail's simpler setup—but you'll lose some production parity.
4. Project Initialization & Directory Structure
4.1 Install Laravel
$ docker-compose exec app composer create-project laravel/laravel .
# Install additional production dependencies
$ docker-compose exec app composer require --dev \
barryvdh/laravel-debugbar \
phpstan/phpstan \
laravel/pint
$ docker-compose exec app composer require \
laravel/sanctum \
spatie/laravel-query-builder \
spatie/laravel-permission \
predis/predis
Why these packages?
| Package | Purpose | Production Necessity |
|---|---|---|
laravel/sanctum |
API authentication | ✅ Required |
spatie/laravel-query-builder |
Filterable/sortable queries from URL params | ✅ Saves thousands of lines |
spatie/laravel-permission |
Role-based access control | ✅ Don't roll your own |
predis/predis |
Pure PHP Redis client | ⚠️ Use phpredis in production, Predis for compatibility |
laravel-debugbar |
Request debugging | 🔧 Dev-only |
phpstan |
Static analysis | 🔧 Dev-only, catches bugs |
laravel/pint |
Code formatting | 🔧 Dev-only, team consistency |
4.2 Production-Grade Directory Structure
app/
├── Console/
│ └── Commands/ # Artisan commands
├── Contracts/ # Interfaces (new)
│ ├── Repositories/
│ └── Services/
├── DTOs/ # Data Transfer Objects (new)
├── Enums/ # PHP 8.1+ Enums (new)
├── Exceptions/ # Custom exceptions
│ └── Handler.php
├── Http/
│ ├── Controllers/
│ │ └── Api/
│ │ └── V1/ # Versioned controllers
│ ├── Middleware/
│ ├── Requests/ # Form requests for validation
│ └── Resources/ # API resources (transformers)
├── Models/
├── Observers/ # Model observers (new)
├── Policies/ # Authorization policies
├── Repositories/ # Repository implementations (new)
├── Services/ # Business logic layer (new)
└── Support/ # Helper classes (new)
├── Traits/
└── helpers.php
config/ # Configuration files
database/
├── factories/
├── migrations/
└── seeders/
routes/
├── api.php # API routes only
├── console.php
└── web.php # Health checks only
tests/
├── Feature/
│ └── Api/
│ └── V1/
└── Unit/
├── Repositories/
└── Services/
storage/
└── logs/
└── laravel.log
Create the new directories:
$ docker-compose exec app bash
# Inside container:
$ mkdir -p app/{Contracts/{Repositories,Services},DTOs,Enums,Observers,Repositories,Services,Support/Traits}
$ mkdir -p tests/{Feature/Api/V1,Unit/{Repositories,Services}}
5. Configuration Management for Multiple Environments
5.1 Environment Configuration
File: .env.example (Template for all environments)
# Application
APP_NAME="Task Management API"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost:8080
# Logging
LOG_CHANNEL=stack
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
# Database
DB_CONNECTION=pgsql
DB_HOST=db
DB_PORT=5432
DB_DATABASE=laravel_api
DB_USERNAME=laravel
DB_PASSWORD=secret
# Redis
REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379
REDIS_CLIENT=predis # Use 'phpredis' in production
# Cache & Session
CACHE_DRIVER=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=redis
SESSION_LIFETIME=120
# Mail
MAIL_MAILER=smtp
MAIL_HOST=mailpit
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="noreply@taskapi.local"
MAIL_FROM_NAME="${APP_NAME}"
# API Configuration
API_RATE_LIMIT=60 # Requests per minute
API_VERSION=v1
API_PREFIX=api
# Multi-tenancy
TENANT_CACHE_TTL=3600 # Cache tenant info for 1 hour
TENANT_RESOLUTION=domain # domain|subdomain|header
# Feature Flags (for gradual rollouts)
FEATURE_WEBHOOKS_ENABLED=false
FEATURE_REALTIME_ENABLED=false
FEATURE_AI_SUGGESTIONS=false
# Monitoring & Observability
SENTRY_DSN= # Sentry for error tracking
DATADOG_API_KEY= # Datadog for metrics (production)
5.2 Custom Configuration Files
File: config/api.php
<?php
return [
/*
|--------------------------------------------------------------------------
| API Version
|--------------------------------------------------------------------------
| Current API version. When you create v2, increment this and maintain
| backward compatibility for v1 endpoints.
*/
'version' => env('API_VERSION', 'v1'),
/*
|--------------------------------------------------------------------------
| API Prefix
|--------------------------------------------------------------------------
| URL prefix for all API routes. Example: api/v1/tasks
*/
'prefix' => env('API_PREFIX', 'api'),
/*
|--------------------------------------------------------------------------
| Rate Limiting
|--------------------------------------------------------------------------
| Per-minute rate limits for different user tiers.
| In production, tie this to tenant's subscription tier.
*/
'rate_limits' => [
'guest' => env('API_RATE_LIMIT_GUEST', 10),
'authenticated' => env('API_RATE_LIMIT', 60),
'premium' => env('API_RATE_LIMIT_PREMIUM', 300),
'enterprise' => env('API_RATE_LIMIT_ENTERPRISE', 1000),
],
/*
|--------------------------------------------------------------------------
| Pagination
|--------------------------------------------------------------------------
| Default and maximum items per page for collection endpoints.
| Never return unbounded collections in production.
*/
'pagination' => [
'default_per_page' => 15,
'max_per_page' => 100, // Prevent abuse
],
/*
|--------------------------------------------------------------------------
| Response Configuration
|--------------------------------------------------------------------------
*/
'response' => [
// Include debug info in responses (dev only)
'include_debug_info' => env('APP_DEBUG', false),
// Include query count in responses (helps identify N+1)
'include_query_count' => env('APP_ENV') !== 'production',
// Standardize timestamp format across all responses
'timestamp_format' => 'Y-m-d\TH:i:s.u\Z', // ISO 8601 with microseconds
],
/*
|--------------------------------------------------------------------------
| CORS Configuration
|--------------------------------------------------------------------------
| Will be used in middleware for cross-origin requests
*/
'cors' => [
'allowed_origins' => explode(',', env('CORS_ALLOWED_ORIGINS', '*')),
'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
'allowed_headers' => ['Content-Type', 'Authorization', 'X-Requested-With', 'X-Tenant-ID'],
'exposed_headers' => ['X-RateLimit-Limit', 'X-RateLimit-Remaining'],
'max_age' => 86400, // 24 hours
'supports_credentials' => true,
],
];
File: config/tenant.php
<?php
return [
/*
|--------------------------------------------------------------------------
| Tenant Resolution Strategy
|--------------------------------------------------------------------------
| Supported: "domain", "subdomain", "header", "path"
|
| domain: tenant1.com, tenant2.com (requires DNS setup)
| subdomain: tenant1.api.com, tenant2.api.com
| header: X-Tenant-ID in request headers (most flexible for development)
| path: /api/v1/{tenant}/tasks (explicit but verbose)
*/
'resolution_strategy' => env('TENANT_RESOLUTION', 'header'),
/*
|--------------------------------------------------------------------------
| Tenant Cache Configuration
|--------------------------------------------------------------------------
| Tenant information is cached to avoid database lookup on every request.
| In production with thousands of tenants, this is critical.
*/
'cache' => [
'enabled' => true,
'ttl' => env('TENANT_CACHE_TTL', 3600), // 1 hour
'prefix' => 'tenant:',
],
/*
|--------------------------------------------------------------------------
| Database Configuration
|--------------------------------------------------------------------------
| tenant_column: Column name in all tenant-scoped tables
| central_tables: Tables that exist outside tenant context
*/
'database' => [
'tenant_column' => 'tenant_id',
// Tables that don't have tenant_id (global scope)
'central_tables' => [
'migrations',
'failed_jobs',
'personal_access_tokens',
'tenants', // The tenants table itself
],
],
/*
|--------------------------------------------------------------------------
| Tenant Limits
|--------------------------------------------------------------------------
| Per-tenant resource limits to prevent abuse
*/
'limits' => [
'free' => [
'max_users' => 3,
'max_projects' => 5,
'max_tasks_per_project' => 100,
'storage_mb' => 100,
],
'premium' => [
'max_users' => 25,
'max_projects' => 50,
'max_tasks_per_project' => 1000,
'storage_mb' => 10000, // 10GB
],
'enterprise' => [
'max_users' => null, // Unlimited
'max_projects' => null,
'max_tasks_per_project' => null,
'storage_mb' => null,
],
],
];
6. Database Architecture & Migrations
6.1 Core Schema Design
Let's create a production-grade multi-tenant schema with proper indexes and constraints.
Migration: 2024_01_01_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
{
/**
* Run the migrations.
*
* Tenants table is the foundation of our multi-tenancy.
* Each tenant represents a company/organization using our API.
*/
public function up(): void
{
Schema::create('tenants', function (Blueprint $table) {
$table->uuid('id')->primary();
// Tenant identification
$table->string('name');
$table->string('slug')->unique(); // URL-friendly identifier
$table->string('domain')->nullable()->unique(); // Custom domain support
// Subscription & billing
$table->enum('tier', ['free', 'premium', 'enterprise'])->default('free');
$table->timestamp('trial_ends_at')->nullable();
$table->timestamp('subscribed_at')->nullable();
// Status management
$table->boolean('is_active')->default(true);
$table->timestamp('suspended_at')->nullable();
$table->text('suspension_reason')->nullable();
// Settings stored as JSON for flexibility
$table->jsonb('settings')->default('{}');
$table->jsonb('metadata')->default('{}'); // Analytics, tracking, etc.
// Rate limiting & quotas
$table->integer('rate_limit_per_minute')->default(60);
$table->bigInteger('monthly_request_quota')->default(10000);
$table->bigInteger('requests_this_month')->default(0);
$table->timestamps();
$table->softDeletes(); // Soft delete for data retention compliance
// Indexes for performance
$table->index('slug');
$table->index('domain');
$table->index(['is_active', 'tier']); // Composite index for admin queries
$table->index('created_at'); // For growth analytics
});
// Create index on JSONB settings for fast lookups
DB::statement('CREATE INDEX tenants_settings_gin_idx ON tenants USING gin (settings)');
}
public function down(): void
{
Schema::dropIfExists('tenants');
}
};
Migration: 2024_01_01_000002_create_users_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('users', function (Blueprint $table) {
$table->uuid('id')->primary();
// Multi-tenancy: Every user belongs to a tenant
$table->uuid('tenant_id');
// User identity
$table->string('name');
$table->string('email');
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
// Profile
$table->string('avatar_url')->nullable();
$table->string('timezone')->default('UTC');
$table->string('locale')->default('en');
// Security
$table->timestamp('last_login_at')->nullable();
$table->ipAddress('last_login_ip')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamp('deactivated_at')->nullable();
// Notifications preferences
$table->jsonb('notification_preferences')->default('{}');
$table->rememberToken();
$table->timestamps();
$table->softDeletes();
// Foreign key with cascade delete
$table->foreign('tenant_id')
->references('id')
->on('tenants')
->onDelete('cascade'); // Delete users when tenant is deleted
// Composite unique constraint: email is unique per tenant
$table->unique(['tenant_id', 'email']);
// Critical indexes for multi-tenant queries
$table->index('tenant_id'); // Most queries filter by tenant first
$table->index(['tenant_id', 'is_active']);
$table->index('email'); // For login lookups
$table->index('created_at');
});
}
public function down(): void
{
Schema::dropIfExists('users');
}
};
Migration: 2024_01_01_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->uuid('id')->primary();
$table->uuid('tenant_id'); // Required for all tenant-scoped tables
$table->uuid('owner_id'); // User who created the project
// Project details
$table->string('name');
$table->text('description')->nullable();
$table->string('color', 7)->default('#3B82F6'); // Hex color code
$table->string('icon')->nullable(); // Icon identifier
// Status & visibility
$table->enum('status', ['active', 'archived', 'completed'])->default('active');
$table->enum('visibility', ['private', 'team', 'public'])->default('team');
// Dates
$table->date('start_date')->nullable();
$table->date('due_date')->nullable();
// Metadata for extensibility
$table->jsonb('metadata')->default('{}');
// Statistics (denormalized for performance)
$table->integer('task_count')->default(0);
$table->integer('completed_task_count')->default(0);
$table->timestamps();
$table->softDeletes();
// Foreign keys
$table->foreign('tenant_id')
->references('id')
->on('tenants')
->onDelete('cascade');
$table->foreign('owner_id')
->references('id')
->on('users')
->onDelete('cascade');
// Indexes: tenant_id should ALWAYS be first in composite indexes
$table->index(['tenant_id', 'status']);
$table->index(['tenant_id', 'owner_id']);
$table->index(['tenant_id', 'created_at']);
// Full-text search on name and description
DB::statement('CREATE INDEX projects_search_idx ON projects USING gin(to_tsvector(\'english\', name || \' \' || COALESCE(description, \'\')))');
});
}
public function down(): void
{
Schema::dropIfExists('projects');
}
};
Migration: 2024_01_01_000004_create_tasks_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('tasks', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->uuid('tenant_id');
$table->uuid('project_id');
$table->uuid('created_by_id');
$table->uuid('assigned_to_id')->nullable();
// Task details
$table->string('title');
$table->text('description')->nullable();
// Status tracking
$table->enum('status', ['todo', 'in_progress', 'review', 'completed', 'blocked'])
->default('todo');
$table->enum('priority', ['low', 'medium', 'high', 'urgent'])
->default('medium');
// Dates
$table->timestamp('due_at')->nullable();
$table->timestamp('started_at')->nullable();
$table->timestamp('completed_at')->nullable();
// Organization
$table->integer('position')->default(0); // For drag-and-drop ordering
$table->string('tags')->nullable(); // Comma-separated for simplicity
// Estimation & tracking
$table->integer('estimated_hours')->nullable();
$table->integer('actual_hours')->nullable();
// Relationships
$table->uuid('parent_task_id')->nullable(); // For subtasks
// Metadata
$table->jsonb('metadata')->default('{}');
$table->timestamps();
$table->softDeletes();
// Foreign keys
$table->foreign('tenant_id')
->references('id')
->on('tenants')
->onDelete('cascade');
$table->foreign('project_id')
->references('id')
->on('projects')
->onDelete('cascade');
$table->foreign('created_by_id')
->references('id')
->on('users')
->onDelete('cascade');
$table->foreign('assigned_to_id')
->references('id')
->on('users')
->onDelete('set null'); // Keep task if assignee deleted
$table->foreign('parent_task_id')
->references('id')
->on('tasks')
->onDelete('cascade');
// Critical indexes for query performance
$table->index(['tenant_id', 'project_id', 'status']);
$table->index(['tenant_id', 'assigned_to_id', 'status']);
$table->index(['tenant_id', 'due_at']);
$table->index(['tenant_id', 'created_at']);
$table->index('position'); // For ordering queries
// Full-text search
DB::statement('CREATE INDEX tasks_search_idx ON tasks USING gin(to_tsvector(\'english\', title || \' \' || COALESCE(description, \'\')))');
});
}
public function down(): void
{
Schema::dropIfExists('tasks');
}
};
Migration: 2024_01_01_000005_create_audit_logs_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Audit logs for compliance and debugging.
* Never delete records from this table—archive to cold storage instead.
*/
public function up(): void
{
Schema::create('audit_logs', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->uuid('tenant_id');
$table->uuid('user_id')->nullable(); // System actions have null user_id
// What happened
$table->string('event'); // e.g., 'task.created', 'user.deleted'
$table->string('auditable_type'); // Model class name
$table->uuid('auditable_id'); // Model ID
// Changes tracking
$table->jsonb('old_values')->nullable();
$table->jsonb('new_values')->nullable();
// Request context
$table->string('ip_address', 45)->nullable(); // Support IPv6
$table->string('user_agent')->nullable();
$table->string('url')->nullable();
$table->string('method', 10)->nullable(); // GET, POST, etc.
// Metadata
$table->jsonb('metadata')->default('{}');
$table->timestamp('created_at'); // No updates, only created_at
// Foreign key
$table->foreign('tenant_id')
->references('id')
->on('tenants')
->onDelete('cascade');
// Indexes optimized for common queries
$table->index(['tenant_id', 'created_at']);
$table->index(['tenant_id', 'user_id', 'created_at']);
$table->index(['tenant_id', 'auditable_type', 'auditable_id']);
$table->index('event');
// Partition by month for better performance (PostgreSQL 10+)
// Implement partitioning in production based on created_at
});
}
public function down(): void
{
Schema::dropIfExists('audit_logs');
}
};
6.2 Run Migrations
$ docker-compose exec app php artisan migrate
# Expected output:
Migrating: 2024_01_01_000001_create_tenants_table
Migrated: 2024_01_01_000001_create_tenants_table (45.23ms)
Migrating: 2024_01_01_000002_create_users_table
Migrated: 2024_01_01_000002_create_users_table (38.91ms)
Migrating: 2024_01_01_000003_create_projects_table
Migrated: 2024_01_01_000003_create_projects_table (42.18ms)
Migrating: 2024_01_01_000004_create_tasks_table
Migrated: 2024_01_01_000004_create_tasks_table (51.33ms)
Migrating: 2024_01_01_000005_create_audit_logs_table
Migrated: 2024_01_01_000005_create_audit_logs_table (35.67ms)
# Verify tables and indexes
$ make db
psql> \dt
psql> \d+ tasks # Detailed view of tasks table with indexes
Production Tip: These migrations include production-grade indexes. In a real system with millions of rows, you'd add indexes concurrently (
CREATE INDEX CONCURRENTLY) to avoid locking tables during deployment.
7. API Versioning Strategy
7.1 URL-Based Versioning
We use URL-based versioning (/api/v1/tasks) because:
- Clear & Explicit: Clients know exactly which version they're using
- Easy Caching: CDNs can cache different versions separately
- Simple Routing: Laravel handles this elegantly
File: routes/api.php
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\V1\TaskController;
use App\Http\Controllers\Api\V1\ProjectController;
use App\Http\Controllers\Api\V1\AuthController;
/*
|--------------------------------------------------------------------------
| API Routes - Version 1
|--------------------------------------------------------------------------
|
| All routes are automatically prefixed with /api/v1
| Authentication via Sanctum tokens (implemented in Part 2)
*/
Route::prefix('v1')->group(function () {
// Public routes (no authentication required)
Route::post('/auth/register', [AuthController::class, 'register']);
Route::post('/auth/login', [AuthController::class, 'login']);
// Protected routes (require authentication)
Route::middleware(['auth:sanctum', 'tenant.context'])->group(function () {
// Authentication
Route::post('/auth/logout', [AuthController::class, 'logout']);
Route::get('/auth/me', [AuthController::class, 'me']);
// Projects (resourceful routes)
Route::apiResource('projects', ProjectController::class);
// Tasks (resourceful routes with custom actions)
Route::apiResource('tasks', TaskController::class);
Route::prefix('tasks/{task}')->group(function () {
Route::post('/assign', [TaskController::class, 'assign']);
Route::post('/complete', [TaskController::class, 'complete']);
Route::post('/reopen', [TaskController::class, 'reopen']);
});
// Nested resource: Project tasks
Route::get('/projects/{project}/tasks', [ProjectController::class, 'tasks']);
});
});
// Health check endpoint (no versioning, always available)
Route::get('/health', function () {
return response()->json([
'status' => 'healthy',
'timestamp' => now()->toIso8601String(),
'version' => config('api.version'),
]);
});
7.2 Versioning Middleware
File: app/Http/Middleware/ApiVersion.php
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ApiVersion
{
/**
* Add API version to response headers.
* Helps clients debug version-specific issues.
*/
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$response->headers->set('X-API-Version', config('api.version'));
$response->headers->set('X-API-Deprecation-Warning', $this->getDeprecationWarning($request));
return $response;
}
/**
* Check if this endpoint version is deprecated.
*/
private function getDeprecationWarning(Request $request): ?string
{
// Example: Warn about v1 deprecation
if (str_contains($request->path(), 'api/v1')) {
$sunsetDate = '2025-12-31';
// Only warn if we're within 6 months of sunset
if (now()->diffInMonths($sunsetDate) < 6) {
return "API v1 will be sunset on {$sunsetDate}. Please migrate to v2.";
}
}
return null;
}
}
8. Request/Response Pipeline Architecture
8.1 Base Controller with Standardized Responses
File: app/Http/Controllers/Api/ApiController.php
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Http\Resources\Json\ResourceCollection;
use Symfony\Component\HttpFoundation\Response;
abstract class ApiController extends Controller
{
/**
* Success response with data.
*/
protected function successResponse(
mixed $data = null,
string $message = '',
int $code = Response::HTTP_OK
): JsonResponse {
$response = [
'success' => true,
'message' => $message,
];
if ($data !== null) {
$response['data'] = $data;
}
// Add debug info in non-production
if (config('api.response.include_debug_info')) {
$response['debug'] = $this->getDebugInfo();
}
return response()->json($response, $code);
}
/**
* Error response.
*/
protected function errorResponse(
string $message,
int $code = Response::HTTP_BAD_REQUEST,
array $errors = []
): JsonResponse {
$response = [
'success' => false,
'message' => $message,
];
if (!empty($errors)) {
$response['errors'] = $errors;
}
if (config('api.response.include_debug_info')) {
$response['debug'] = $this->getDebugInfo();
}
return response()->json($response, $code);
}
/**
* Resource response (auto-handles single item vs collection).
*/
protected function resourceResponse(
JsonResource|ResourceCollection $resource,
string $message = '',
int $code = Response::HTTP_OK
): JsonResponse {
return $this->successResponse($resource, $message, $code);
}
/**
* Get debug information for responses.
*/
private function getDebugInfo(): array
{
$debug = [
'timestamp' => now()->toIso8601String(),
'memory_usage' => round(
Bekzod Erkinov
AuthorFounder of NextGenBeing. Software engineer with 8+ years building production systems in Laravel, Python, and cloud infrastructure. I write about the patterns I've seen work (and fail) in real deployments. Based in Tashkent, Uzbekistan.
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
Building a Modern SaaS Application with Laravel - Part 3: Advanced Features & Configuration
Apr 25, 2026
Building a Modern SaaS Application with Laravel - Part 1: Architecture, Setup & Foundations
Apr 25, 2026
Optimizing Database Performance with Indexing and Caching: What We Learned Scaling to 100M Queries/Day
Apr 18, 2026