Bekzod Erkinov
Listen to Article
Loading...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
- Introduction & Recap
- Architecture Overview
- Database Design & Migrations
- Repository Pattern Implementation
- Service Layer & Business Logic
- API Resource Transformation
- Authentication with Laravel Sanctum
- Authorization & Policy Layer
- Request Validation & Form Requests
- Error Handling & Exception Management
- API Versioning Strategy
- Performance Optimization Patterns
- Common Pitfalls & Solutions
- Key Takeaways
- 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 --pretendfirst 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
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