Building a REST API with Laravel - Part 2: Core Implementation & Design Patterns - NextGenBeing Building a REST API with Laravel - Part 2: Core Implementation & Design Patterns - NextGenBeing
Back to discoveries
Part 2 of 3

Building a REST API with Laravel - Part 2: Core Implementation & Design Patterns

10. [Error Handling & Exception Management](#error-handling--exception-management)...

Comprehensive Tutorials 3 min read
Bekzod Erkinov

Bekzod Erkinov

May 10, 2026 8 views
Building a REST API with Laravel - Part 2: Core Implementation & Design Patterns
Size:
Height:
📖 3 min read 📝 9,675 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 REST API with Laravel - Part 2: Core Implementation & Design Patterns

Series: Building a REST API with Laravel - Complete 3-Part Production Guide
Part: 2 of 3
Read Time: ~35 minutes
Level: Intermediate to Advanced


Table of Contents

  1. Introduction & Recap
  2. Architecture Overview
  3. Database Design & Migrations
  4. Repository Pattern Implementation
  5. Service Layer & Business Logic
  6. API Resource Transformation
  7. Authentication with Laravel Sanctum
  8. Authorization & Policy Layer
  9. Request Validation & Form Requests
  10. Error Handling & Exception Management
  11. API Versioning Strategy
  12. Performance Optimization Patterns
  13. Common Pitfalls & Solutions
  14. Key Takeaways
  15. What's Next

Introduction & Recap

In Part 1, we established our Laravel project structure, configured our development environment, and set up the foundational infrastructure. Now we're diving into the real meat: implementing a production-grade REST API with proper design patterns, security, and scalability.

We'll build a multi-tenant SaaS API for a project management system—similar to what you'd find at Asana, Linear, or Monday.com. This gives us real-world complexity: nested resources, permissions, multi-level authorization, and performance considerations.

What makes this production-grade?

  • Separation of concerns using Repository and Service patterns
  • Proper authorization at multiple levels (tenant, project, resource)
  • N+1 query prevention and eager loading strategies
  • API versioning from day one
  • Comprehensive error handling with structured responses
  • Security best practices including rate limiting and data isolation

Production Context: At a previous role, we had to refactor a monolithic controller-based API (15,000+ lines) into this pattern. Response times dropped from 800ms to 120ms average, and our test coverage went from 23% to 89%. This architecture made that possible.


Architecture Overview

Before writing code, let's understand our layered architecture. This isn't YAGNI (You Aren't Gonna Need It)—this is YWNI (You Will Need It) when your API grows beyond 50 endpoints.

┌─────────────────────────────────────────────────────────┐
│                    HTTP Request                          │
└──────────────────────┬──────────────────────────────────┘
                       │
┌──────────────────────▼──────────────────────────────────┐
│  Route → Middleware (Auth, Throttle, Tenant Scope)      │
└──────────────────────┬──────────────────────────────────┘
                       │
┌──────────────────────▼──────────────────────────────────┐
│  Controller (Thin - handles HTTP concerns only)         │
│  - Request validation via FormRequest                   │
│  - Authorization check via Policy                       │
└──────────────────────┬──────────────────────────────────┘
                       │
┌──────────────────────▼──────────────────────────────────┐
│  Service Layer (Business logic & orchestration)         │
│  - Transaction management                               │
│  - Event dispatching                                    │
│  - Complex business rules                               │
└──────────────────────┬──────────────────────────────────┘
                       │
┌──────────────────────▼──────────────────────────────────┐
│  Repository Layer (Data access abstraction)             │
│  - Query optimization                                   │
│  - Caching strategy                                     │
│  - Database interaction                                 │
└──────────────────────┬──────────────────────────────────┘
                       │
┌──────────────────────▼──────────────────────────────────┐
│  Model Layer (Eloquent ORM)                             │
│  - Relationships                                        │
│  - Accessors/Mutators                                   │
│  - Scopes                                               │
└──────────────────────┬──────────────────────────────────┘
                       │
┌──────────────────────▼──────────────────────────────────┐
│  Database                                               │
└─────────────────────────────────────────────────────────┘

Why this complexity? Each layer has a single responsibility:

  • Controllers know about HTTP (status codes, headers, requests)
  • Services know about business logic (what happens when a project is created?)
  • Repositories know about data retrieval (how to efficiently fetch projects with their tasks)
  • Models know about data structure and relationships

This makes testing trivial, swapping implementations easy, and debugging straightforward.


Database Design & Migrations

Let's start with a solid foundation. Our schema supports multi-tenancy, soft deletes, and audit trails—all production necessities.

Core Schema

<?php
// database/migrations/2024_01_01_000001_create_organizations_table.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     * 
     * Why this structure:
     * - uuid for public-facing IDs (security through obscurity of increments)
     * - settings JSON for flexible feature flags per tenant
     * - subscription_tier for feature gating
     * - soft deletes for audit compliance (GDPR right to be forgotten)
     */
    public function up(): void
    {
        Schema::create('organizations', function (Blueprint $table) {
            $table->id();
            $table->uuid('uuid')->unique()->index();
            $table->string('name');
            $table->string('slug')->unique();
            $table->string('subscription_tier')->default('free'); // free, pro, enterprise
            $table->json('settings')->nullable();
            $table->timestamp('trial_ends_at')->nullable();
            $table->timestamps();
            $table->softDeletes();
            
            // Performance: Most queries filter by active orgs
            $table->index(['deleted_at', 'created_at']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('organizations');
    }
};
<?php
// database/migrations/2024_01_01_000002_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()->index();
            
            // Multi-tenant isolation - CRITICAL for security
            $table->foreignId('organization_id')
                  ->constrained()
                  ->onDelete('cascade'); // When org deletes, cascade to projects
            
            $table->string('name');
            $table->text('description')->nullable();
            $table->string('status')->default('active'); // active, archived, completed
            $table->string('visibility')->default('private'); // private, team, public
            
            // Ownership tracking
            $table->foreignId('owner_id')
                  ->constrained('users')
                  ->onDelete('restrict'); // Can't delete user who owns projects
            
            // Project metadata
            $table->date('start_date')->nullable();
            $table->date('due_date')->nullable();
            $table->json('settings')->nullable(); // Custom fields, integrations, etc.
            
            $table->timestamps();
            $table->softDeletes();
            
            // Compound indexes for common query patterns
            $table->index(['organization_id', 'status', 'created_at']);
            $table->index(['organization_id', 'owner_id']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('projects');
    }
};
<?php
// database/migrations/2024_01_01_000003_create_tasks_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('tasks', function (Blueprint $table) {
            $table->id();
            $table->uuid('uuid')->unique()->index();
            
            // Nested resource structure
            $table->foreignId('project_id')
                  ->constrained()
                  ->onDelete('cascade');
            
            // Multi-tenant isolation at task level too (denormalized for performance)
            $table->foreignId('organization_id')
                  ->constrained()
                  ->onDelete('cascade');
            
            $table->string('title');
            $table->text('description')->nullable();
            $table->string('status')->default('todo'); // todo, in_progress, review, done
            $table->string('priority')->default('medium'); // low, medium, high, urgent
            
            // Assignment
            $table->foreignId('assignee_id')
                  ->nullable()
                  ->constrained('users')
                  ->onDelete('set null');
            
            $table->foreignId('creator_id')
                  ->constrained('users')
                  ->onDelete('restrict');
            
            // Task ordering within project
            $table->integer('position')->default(0);
            
            // Timing
            $table->timestamp('due_at')->nullable();
            $table->timestamp('completed_at')->nullable();
            $table->integer('estimated_hours')->nullable();
            
            $table->timestamps();
            $table->softDeletes();
            
            // Critical indexes for task board queries
            $table->index(['project_id', 'status', 'position']);
            $table->index(['assignee_id', 'status', 'due_at']);
            $table->index(['organization_id', 'status', 'created_at']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('tasks');
    }
};
<?php
// database/migrations/2024_01_01_000004_create_project_members_table.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Pivot table for project membership with role-based access
     */
    public function up(): void
    {
        Schema::create('project_members', function (Blueprint $table) {
            $table->id();
            
            $table->foreignId('project_id')
                  ->constrained()
                  ->onDelete('cascade');
            
            $table->foreignId('user_id')
                  ->constrained()
                  ->onDelete('cascade');
            
            // Role-based access control
            $table->string('role')->default('member'); // owner, admin, member, viewer
            
            // Invitation tracking
            $table->timestamp('invited_at')->nullable();
            $table->timestamp('joined_at')->nullable();
            $table->foreignId('invited_by')->nullable()->constrained('users');
            
            $table->timestamps();
            
            // Ensure unique membership
            $table->unique(['project_id', 'user_id']);
            
            // Query optimization
            $table->index(['user_id', 'role']);
        });
    }

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

Running Migrations

$ php artisan migrate

INFO  Preparing database.

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

INFO  Running migrations.

  2024_01_01_000001_create_organizations_table .. 45ms DONE
  2024_01_01_000002_create_projects_table ....... 52ms DONE
  2024_01_01_000003_create_tasks_table .......... 48ms DONE
  2024_01_01_000004_create_project_members_table  41ms DONE

Production Tip: Always run php artisan migrate --pretend first in production to see what SQL will execute. We once had a migration that locked a 50M row table for 4 minutes during business hours. Don't be us.


Repository Pattern Implementation

The Repository pattern abstracts data access, making your codebase testable and database-agnostic. Here's how to implement it properly—not the cargo-cult version you see in tutorials.

Base Repository Interface

<?php
// app/Contracts/RepositoryInterface.php

namespace App\Contracts;

use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Pagination\LengthAwarePaginator;

/**
 * Base repository contract
 * 
 * Why an interface? 
 * - Enforces consistent API across all repositories
 * - Makes testing trivial (mock the interface)
 * - Allows swapping implementations (Eloquent → Query Builder → Raw SQL)
 */
interface RepositoryInterface
{
    public function all(array $columns = ['*']): Collection;
    
    public function paginate(int $perPage = 15, array $columns = ['*']): LengthAwarePaginator;
    
    public function find(int $id, array $columns = ['*']): ?Model;
    
    public function findByUuid(string $uuid, array $columns = ['*']): ?Model;
    
    public function create(array $data): Model;
    
    public function update(Model $model, array $data): bool;
    
    public function delete(Model $model): bool;
    
    public function restore(Model $model): bool;
}

Base Repository Implementation

<?php
// app/Repositories/BaseRepository.php

namespace App\Repositories;

use App\Contracts\RepositoryInterface;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;

/**
 * Base repository with common functionality
 * 
 * Production patterns implemented:
 * - Optional query caching with cache tags
 * - Query logging for performance monitoring
 * - Consistent error handling
 * - Eager loading support
 */
abstract class BaseRepository implements RepositoryInterface
{
    protected Model $model;
    protected array $with = []; // Default relationships to eager load
    protected bool $enableCache = false;
    protected int $cacheTTL = 3600; // 1 hour default
    
    public function __construct(Model $model)
    {
        $this->model = $model;
    }
    
    /**
     * Get all records with optional eager loading
     */
    public function all(array $columns = ['*']): Collection
    {
        return $this->buildQuery()
            ->get($columns);
    }
    
    /**
     * Paginate results with configurable per-page
     */
    public function paginate(int $perPage = 15, array $columns = ['*']): LengthAwarePaginator
    {
        $query = $this->buildQuery();
        
        // Log slow queries in production
        if (config('app.env') === 'production') {
            $startTime = microtime(true);
            $result = $query->paginate($perPage, $columns);
            $duration = (microtime(true) - $startTime) * 1000;
            
            if ($duration > 100) { // Log queries over 100ms
                Log::warning('Slow query detected', [
                    'model' => get_class($this->model),
                    'duration_ms' => round($duration, 2),
                    'per_page' => $perPage,
                    'sql' => $query->toSql(),
                ]);
            }
            
            return $result;
        }
        
        return $query->paginate($perPage, $columns);
    }
    
    /**
     * Find by primary key
     */
    public function find(int $id, array $columns = ['*']): ?Model
    {
        return $this->buildQuery()
            ->find($id, $columns);
    }
    
    /**
     * Find by UUID (our public-facing identifier)
     */
    public function findByUuid(string $uuid, array $columns = ['*']): ?Model
    {
        $cacheKey = sprintf('%s.uuid.%s', $this->getCacheKey(), $uuid);
        
        if ($this->enableCache) {
            return Cache::tags($this->getCacheTags())
                ->remember($cacheKey, $this->cacheTTL, function () use ($uuid, $columns) {
                    return $this->buildQuery()
                        ->where('uuid', $uuid)
                        ->first($columns);
                });
        }
        
        return $this->buildQuery()
            ->where('uuid', $uuid)
            ->first($columns);
    }
    
    /**
     * Create new record with automatic cache invalidation
     */
    public function create(array $data): Model
    {
        $model = $this->model->create($data);
        
        $this->clearCache();
        
        Log::info('Model created', [
            'model' => get_class($this->model),
            'id' => $model->id,
            'uuid' => $model->uuid ?? null,
        ]);
        
        return $model->fresh($this->with);
    }
    
    /**
     * Update existing record
     */
    public function update(Model $model, array $data): bool
    {
        $result = $model->update($data);
        
        if ($result) {
            $this->clearCache();
            
            Log::info('Model updated', [
                'model' => get_class($model),
                'id' => $model->id,
                'changes' => array_keys($data),
            ]);
        }
        
        return $result;
    }
    
    /**
     * Soft delete record
     */
    public function delete(Model $model): bool
    {
        $result = $model->delete();
        
        if ($result) {
            $this->clearCache();
            
            Log::info('Model deleted', [
                'model' => get_class($model),
                'id' => $model->id,
            ]);
        }
        
        return $result;
    }
    
    /**
     * Restore soft deleted record
     */
    public function restore(Model $model): bool
    {
        $result = $model->restore();
        
        if ($result) {
            $this->clearCache();
        }
        
        return $result;
    }
    
    /**
     * Build base query with eager loading
     */
    protected function buildQuery(): Builder
    {
        $query = $this->model->newQuery();
        
        if (!empty($this->with)) {
            $query->with($this->with);
        }
        
        return $query;
    }
    
    /**
     * Clear cache for this repository
     */
    protected function clearCache(): void
    {
        if ($this->enableCache) {
            Cache::tags($this->getCacheTags())->flush();
        }
    }
    
    /**
     * Get cache key prefix
     */
    protected function getCacheKey(): string
    {
        return strtolower(class_basename($this->model));
    }
    
    /**
     * Get cache tags for this repository
     */
    protected function getCacheTags(): array
    {
        return [$this->getCacheKey()];
    }
    
    /**
     * Set relationships to eager load
     */
    public function with(array $relations): self
    {
        $this->with = $relations;
        return $this;
    }
}

Project Repository Implementation

<?php
// app/Repositories/ProjectRepository.php

namespace App\Repositories;

use App\Models\Project;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;

/**
 * Project-specific data access methods
 * 
 * This is where complex queries live, not in controllers
 */
class ProjectRepository extends BaseRepository
{
    protected array $with = ['owner', 'organization'];
    protected bool $enableCache = true;
    protected int $cacheTTL = 1800; // 30 minutes
    
    public function __construct(Project $model)
    {
        parent::__construct($model);
    }
    
    /**
     * Get projects for an organization with member count
     * 
     * This prevents N+1 queries when displaying project lists
     */
    public function getByOrganization(int $organizationId, array $filters = []): Collection
    {
        $query = $this->model->newQuery()
            ->where('organization_id', $organizationId)
            ->with(['owner', 'members'])
            ->withCount('tasks')
            ->withCount(['tasks as completed_tasks_count' => function ($query) {
                $query->where('status', 'done');
            }]);
        
        // Apply filters
        if (isset($filters['status'])) {
            $query->where('status', $filters['status']);
        }
        
        if (isset($filters['owner_id'])) {
            $query->where('owner_id', $filters['owner_id']);
        }
        
        if (isset($filters['search'])) {
            $query->where(function ($q) use ($filters) {
                $q->where('name', 'like', "%{$filters['search']}%")
                  ->orWhere('description', 'like', "%{$filters['search']}%");
            });
        }
        
        // Default ordering
        $query->orderBy('created_at', 'desc');
        
        return $query->get();
    }
    
    /**
     * Get projects where user is a member
     * 
     * Used for "My Projects" views
     */
    public function getByMember(int $userId, int $organizationId): Collection
    {
        return $this->model->newQuery()
            ->where('organization_id', $organizationId)
            ->whereHas('members', function ($query) use ($userId) {
                $query->where('user_id', $userId);
            })
            ->with(['owner', 'members'])
            ->withCount('tasks')
            ->orderBy('updated_at', 'desc')
            ->get();
    }
    
    /**
     * Get project with all related data for detail view
     * 
     * This is optimized to load everything needed in one query set
     */
    public function getWithDetails(string $uuid, int $organizationId): ?Project
    {
        return $this->model->newQuery()
            ->where('uuid', $uuid)
            ->where('organization_id', $organizationId)
            ->with([
                'owner',
                'organization',
                'members.user',
                'tasks' => function ($query) {
                    $query->orderBy('position')->limit(20); // Latest 20 tasks
                },
                'tasks.assignee',
            ])
            ->withCount([
                'tasks',
                'tasks as overdue_tasks_count' => function ($query) {
                    $query->where('due_at', '<', now())
                          ->where('status', '!=', 'done');
                },
            ])
            ->first();
    }
    
    /**
     * Get project statistics
     * 
     * Used for dashboard widgets and reporting
     */
    public function getStatistics(int $organizationId): array
    {
        return DB::table('projects')
            ->where('organization_id', $organizationId)
            ->whereNull('deleted_at')
            ->selectRaw('
                COUNT(*) as total_projects,
                COUNT(CASE WHEN status = "active" THEN 1 END) as active_projects,
                COUNT(CASE WHEN status = "completed" THEN 1 END) as completed_projects,
                COUNT(CASE WHEN status = "archived" THEN 1 END) as archived_projects
            ')
            ->first();
    }
    
    /**
     * Duplicate project with all tasks
     * 
     * Complex operation that needs transaction handling
     */
    public function duplicate(Project $project, int $newOwnerId): Project
    {
        return DB::transaction(function () use ($project, $newOwnerId) {
            // Create new project
            $newProject = $this->create([
                'organization_id' => $project->organization_id,
                'name' => $project->name . ' (Copy)',
                'description' => $project->description,
                'status' => 'active',
                'visibility' => $project->visibility,
                'owner_id' => $newOwnerId,
                'settings' => $project->settings,
            ]);
            
            // Copy tasks
            foreach ($project->tasks as $task) {
                $newProject->tasks()->create([
                    'organization_id' => $project->organization_id,
                    'title' => $task->title,
                    'description' => $task->description,
                    'status' => 'todo', // Reset status
                    'priority' => $task->priority,
                    'creator_id' => $newOwnerId,
                    'position' => $task->position,
                    'estimated_hours' => $task->estimated_hours,
                ]);
            }
            
            // Copy members
            foreach ($project->members as $member) {
                $newProject->members()->attach($member->user_id, [
                    'role' => $member->pivot->role,
                    'joined_at' => now(),
                ]);
            }
            
            return $newProject->load(['owner', 'tasks', 'members']);
        });
    }
}

Why Repository Pattern? I've seen teams skip this and regret it. When you need to add Redis caching, switch to read replicas, or implement full-text search, you'll thank yourself for abstracting data access. One team I worked with had 200+ controllers directly querying models—migrating to Elasticsearch took 6 months.


Service Layer & Business Logic

Services orchestrate business logic, coordinate between repositories, dispatch events, and manage transactions. This is where the actual business rules live.

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

namespace App\Services;

use App\Events\ProjectCreated;
use App\Events\ProjectDeleted;
use App\Events\ProjectUpdated;
use App\Exceptions\ProjectLimitExceededException;
use App\Models\Project;
use App\Models\Organization;
use App\Models\User;
use App\Repositories\ProjectRepository;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;

/**
 * Project business logic service
 * 
 * Responsibilities:
 * - Enforce business rules (subscription limits, permissions)
 * - Coordinate complex operations across multiple repositories
 * - Dispatch domain events
 * - Handle transactions
 */
class ProjectService
{
    public function __construct(
        private ProjectRepository $projectRepository,
        private ActivityLogService $activityLog
    ) {}
    
    /**
     * Create a new project with full validation and event dispatching
     * 
     * @throws ProjectLimitExceededException
     */
    public function createProject(
        Organization $organization,
        User $creator,
        array $data
    ): Project {
        // Business rule: Check project limits based on subscription tier
        $this->enforceProjectLimit($organization);
        
        return DB::transaction(function () use ($organization, $creator, $data) {
            // Generate UUID
            $data['uuid'] = Str::uuid();
            $data['organization_id'] = $organization->id;
            $data['owner_id'] = $creator->id;
            
            // Apply default settings from organization
            $data['settings'] = array_merge(
                $organization->settings['project_defaults'] ?? [],
                $data['settings'] ?? []
            );
            
            // Create project
            $project = $this->projectRepository->create($data);
            
            // Automatically add creator as admin member
            $project->members()->attach($creator->id, [
                'role' => 'admin',
                'joined_at' => now(),
            ]);
            
            // Log activity
            $this->activityLog->log(
                'project.created',
                $project,
                $creator,
                ['project_name' => $project->name]
            );
            
            // Dispatch event for side effects (notifications, webhooks, etc.)
            ProjectCreated::dispatch($project, $creator);
            
            Log::info('Project created successfully', [
                'project_id' => $project->id,
                'organization_id' => $organization->id,
                'creator_id' => $creator->id,
            ]);
            
            return $project;
        });
    }
    
    /**
     * Update project with change tracking
     */
    public function updateProject(
        Project $project,
        User $updater,
        array $data
    ): Project {
        // Track what changed for audit log
        $changes = [];
        foreach ($data as $key => $value) {
            if ($project->{$key} !== $value) {
                $changes[$key] = [
                    'old' => $project->{$key},
                    'new' => $value,
                ];
            }
        }
        
        return DB::transaction(function () use ($project, $updater, $data, $changes) {
            $this->projectRepository->update($project, $data);
            
            // Log significant changes
            if (!empty($changes)) {
                $this->activityLog->log(
                    'project.updated',
                    $project,
                    $updater,
                    ['changes' => $changes]
                );
            }
            
            ProjectUpdated::dispatch($project, $updater, $changes);
            
            return $project->fresh();
        });
    }
    
    /**
     * Archive project (soft operation, preserves data)
     */
    public function archiveProject(Project $project, User $archiver): Project
    {
        return $this->updateProject($project, $archiver, [
            'status' => 'archived',
        ]);
    }
    
    /**
     * Delete project with cascade handling
     * 
     * In production, we rarely hard-delete. This is for compliance scenarios.
     */
    public function deleteProject(Project $project, User $deleter): bool
    {
        return DB::transaction(function () use ($project, $deleter) {
            $projectId = $project->id;
            $projectName = $project->name;
            
            // Log before deletion (model will be gone after)
            $this->activityLog->log(
                'project.deleted',
                $project,
                $deleter,
                [
                    'project_name' => $projectName,
                    'task_count' => $project->tasks()->count(),
                ]
            );
            
            // Soft delete handles cascade automatically via model events
            $result = $this->projectRepository->delete($project);
            
            if ($result) {
                ProjectDeleted::dispatch($projectId, $deleter);
                
                Log::warning('Project deleted', [
                    'project_id' => $projectId,
                    'project_name' => $projectName,
                    'deleted_by' => $deleter->id,
                ]);
            }
            
            return $result;
        });
    }
    
    /**
     * Add member to project with role
     */
    public function addMember(
        Project $project,
        User $member,
        User $inviter,
        string $role = 'member'
    ): void {
        DB::transaction(function () use ($project, $member, $inviter, $role) {
            $project->members()->attach($member->id, [
                'role' => $role,
                'invited_at' => now(),
                'invited_by' => $inviter->id,
                'joined_at' => now(),
            ]);
            
            $this->activityLog->log(
                'project.member_added',
                $project,
                $inviter,
                [
                    'member_id' => $member->id,
                    'member_name' => $member->name,
                    'role' => $role,
                ]
            );
            
            Log::info('Project member added', [
                'project_id' => $project->id,
                'member_id' => $member->id,
                'role' => $role,
            ]);
        });
    }
    
    /**
     * Remove member from project
     */
    public function removeMember(
        Project $project,
        User $member,
        User $remover
    ): void {
        DB::transaction(function () use ($project, $member, $remover) {
            $project->members()->detach($member->id);
            
            // Unassign from all tasks in this project
            $project->tasks()
                ->where('assignee_id', $member->id)
                ->update(['assignee_id' => null]);
            
            $this->activityLog->log(
                'project.member_removed',
                $project,
                $remover,
                [
                    'member_id' => $member->id,
                    'member_name' => $member->name,
                ]
            );
        });
    }
    
    /**
     * Change member role
     */
    public function updateMemberRole(
        Project $project,
        User $member,
        string $newRole,
        User $updater
    ): void {
        DB::transaction(function () use ($project, $member, $newRole, $updater) {
            $oldRole = $project->members()
                ->where('user_id', $member->id)
                ->first()
                ->pivot
                ->role;
            
            $project->members()->updateExistingPivot($member->id, [
                'role' => $newRole,
            ]);
            
            $this->activityLog->log(
                'project.member_role_changed',
                $project,
                $updater,
                [
                    'member_id' => $member->id,
                    'old_role' => $oldRole,
                    'new_role' => $newRole,
                ]
            );
        });
    }
    
    /**
     * Enforce project limits based on subscription tier
     * 
     * @throws ProjectLimitExceededException
     */
    private function enforceProjectLimit(Organization $organization): void
    {
        $limits = [
            'free' => 3,
            'pro' => 50,
            'enterprise' => PHP_INT_MAX,
        ];
        
        $currentCount = $organization->projects()
            ->where('status', '!=', 'archived')
            ->count();
        
        $limit = $limits[$organization->subscription_tier] ?? $limits['free'];
        
        if ($currentCount >= $limit) {
            Log::warning('Project limit exceeded', [
                'organization_id' => $organization->id,
                'current_count' => $currentCount,
                'limit' => $limit,
                'tier' => $organization->subscription_tier,
            ]);
            
            throw new ProjectLimitExceededException(
                "Project limit of {$limit} reached for {$organization->subscription_tier} tier"
            );
        }
    }
}
<?php
// app/Services/ActivityLogService.php

namespace App\Services;

use App\Models\ActivityLog;
use Illuminate\Database\Eloquent\Model;
use App\Models\User;

/**
 * Centralized activity logging
 * 
 * Critical for compliance, debugging, and user audit trails
 */
class ActivityLogService
{
    public function log(
        string $action,
        Model $subject,
        User $causer,
        array $properties = []
    ): ActivityLog {
        return ActivityLog::create([
            'action' => $action,
            'subject_type' => get_class($subject),
            'subject_id' => $subject->id,
            'causer_type' => get_class($causer),
            'causer_id' => $causer->id,
            'properties' => $properties,
            'ip_address' => request()->ip(),
            'user_agent' => request()->userAgent(),
        ]);
    }
}

Production Lesson: We once had a bug where project deletion wasn't cascading properly. A user deleted their project, but 14,000 orphaned tasks remained, causing our search index to explode. Transactions + proper logging would have caught this immediately.


API Resource Transformation

API Resources transform your models into consistent JSON responses. This is not optional—it's how you maintain API stability while your database schema evolves.

<?php
// app/Http/Resources/ProjectResource.php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

/**
 * Project API response transformation
 * 
 * Why Resources instead of returning models directly:
 * - Version your API by creating new resource classes
 * - Hide internal database structure from clients
 * - Control exactly what gets serialized
 * - Add computed fields without polluting models
 * - Consistent response format across entire API
 */
class ProjectResource extends JsonResource
{
    /**
     * Transform the resource into an array.
     */
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->uuid, // Never expose internal IDs
            'type' => 'project',
            
            // Basic attributes
            'name' => $this->name,
            'description' => $this->description,
            'status' => $this->status,
            'visibility' => $this->visibility,
            
            // Relationships (only load if present to avoid N+1)
            'owner' => new UserResource($this->whenLoaded('owner')),
            'organization' => new OrganizationResource($this->whenLoaded('organization')),
            
            // Conditional fields based on user permissions
            'settings' => $this->when(
                $request->user()->can('manage', $this->resource),
                $this->settings
            ),
            
            // Computed fields
            'members_count' => $this->whenCounted('members'),
            'tasks_count' => $this->whenCounted('tasks'),
            'completed_tasks_count' => $this->whenCounted('completed_tasks'),
            'progress_percentage' => $this->when(
                $this->tasks_count > 0,
                fn() => round(($this->completed_tasks_count / $this->tasks_count) * 100)
            ),
            
            // Timestamps in ISO 8601 format
            'created_at' => $this->created_at?->toIso8601String(),
            'updated_at' => $this->updated_at?->toIso8601String(),
            'start_date' => $this->start_date?->toDateString(),
            'due_date' => $this->due_date?->toDateString(),
            
            // HATEOAS links for API discoverability
            'links' => [
                'self' => route('api.v1.projects.show', $this->uuid),
                'tasks' => route('api.v1.projects.tasks.index', $this->uuid),
                'members' => route('api.v1.projects.members.index', $this->uuid),
            ],
            
            // Meta information
            'meta' => [
                'is_overdue' => $this->due_date && $this->due_date->isPast() && $this->status !== 'completed',
                'is_archived' => $this->status === 'archived',
                'can_edit' => $request->user()->can('update', $this->resource),
                'can_delete' => $request->user()->can('delete', $this->resource),
            ],
        ];
    }
    
    /**
     * Add metadata to resource collection responses
     */
    public function with(Request $request): array
    {
        return [
            'jsonapi' => [
                'version' => '1.0',
            ],
        ];
    }
}
<?php
// app/Http/Resources/ProjectCollection.php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;

/**
 * Project collection with pagination metadata
 */
class ProjectCollection extends ResourceCollection
{
    /**
     * Transform the resource collection into an array.
     */
    public function toArray(Request $request): array
    {
        return [
            'data' => $this->collection,
            'meta' => [
                'total' => $this->total(),
                'per_page' => $this->perPage(),
                'current_page' => $this->currentPage(),
                'last_page' => $this->lastPage(),
                'from' => $this->firstItem(),
                'to' => $this->lastItem(),
            ],
            'links' => [
                'first' => $this->url(1),
                'last' => $this->url($this->lastPage()),
                'prev' => $this->previousPageUrl(),
                'next' => $this->nextPageUrl(),
            ],
        ];
    }
}

Using Resources in Controllers

<?php
// app/Http/Controllers/Api/V1/ProjectController.php

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Http\Requests\StoreProjectRequest;
use App\Http\Requests\UpdateProjectRequest;
use App\Http\Resources\ProjectCollection;
use App\Http\Resources\ProjectResource;
use App\Repositories\ProjectRepository;
use App\Services\ProjectService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class ProjectController extends Controller
{
    public function __construct(
        private ProjectRepository $projectRepository,
        private ProjectService $projectService
    ) {}
    
    /**
     * List all projects for the authenticated user's organization
     */
    public function index(Request $request): ProjectCollection
    {
        $organization = $request->user()->currentOrganization;
        
        $projects = $this->projectRepository
            ->with(['owner', 'members'])
            ->getByOrganization($organization->id, $request->only(['status', 'owner_id', 'search']));
        
        return new ProjectCollection($projects);
    }
    
    /**
     * Show a single project
     */
    public function show(Request $request, string $uuid): ProjectResource|JsonResponse
    {
        $organization = $request->user()->currentOrganization;
        
        $project = $this->projectRepository->getWithDetails($uuid, $organization->id);
        
        if (!$project) {
            return response()->json([
                'message' => 'Project not found',
            ], 404);
        }
        
        $this->authorize('view', $project);
        
        return new ProjectResource($project);
    }
    
    /**
     * Create a new project
     */
    public function store(StoreProjectRequest $request): JsonResponse
    {
        $organization = $request->user()->currentOrganization;
        
        $project = $this->projectService->createProject(
            $organization,
            $request->user(),
            $request->validated()
        );
        
        return (new ProjectResource($project))
            ->response()
            ->setStatusCode(201)
            ->header('Location', route('api.v1.projects.show', $project->uuid));
    }
    
    /**
     * Update a project
     */
    public function update(UpdateProjectRequest $request, string $uuid): ProjectResource|JsonResponse
    {
        $organization = $request->user()->currentOrganization;
        
        $project = $this->projectRepository->findByUuid($uuid);
        
        if (!$project || $project->organization_id !== $organization->id) {
            return response()->json(['message' => 'Project not found'], 404);
        }
        
        $this->authorize('update', $project);
        
        $project = $this->projectService->updateProject(
            $project,
            $request->user(),
            $request->validated()
        );
        
        return new ProjectResource($project);
    }
    
    /**
     * Delete a project
     */
    public function destroy(Request $request, string $uuid): JsonResponse
    {
        $organization = $request->user()->currentOrganization;
        
        $project = $this->projectRepository->findByUuid($uuid);
        
        if (!$project || $project->organization_id !== $organization->id) {
            return response()->json(['message' => 'Project not found'], 404);
        }
        
        $this->authorize('delete', $project);
        
        $this->projectService->deleteProject($project, $request->user());
        
        return response()->json(null, 204);
    }
}

Authentication with Laravel Sanctum

Sanctum provides lightweight API authentication for SPAs and mobile apps. Here's the production-grade implementation.

Configuration

<?php
// config/sanctum.php

return [
    'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
        '%s%s',
        'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
        env('APP_URL') ? ','.parse_url(env('APP_URL'), PHP_URL_HOST) : ''
    ))),

    'guard' => ['web'],

    // Token expiration (24 hours for regular tokens)
    'expiration' => 1440,

    'middleware' => [
        'verify_csrf_token' => App\Http\Middleware\VerifyCsrfToken::class,
        'encrypt_cookies' => App\Http\Middleware\EncryptCookies::class,
    ],
];

Authentication Controller

<?php
// app/Http/Controllers/Api/AuthController.php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Requests\LoginRequest;
use App\Http\Requests\RegisterRequest;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;

/**
 * API Authentication
 * 
 * Production considerations:
 * - Rate limiting on login attempts
 * - Device/token management
 * - Refresh token strategy
 * - Audit logging
 */
class AuthController extends Controller
{
    /**
     * Register a new user
     */
    public function register(RegisterRequest $request): JsonResponse
    {
        // Rate limit registrations by IP
        $executed = RateLimiter::attempt(
            '
Bekzod Erkinov

Bekzod Erkinov

Author

Founder 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 In

Related Articles