REST API Development

API Design Patterns

16 min Lesson 32 of 35

API Design Patterns

Well-designed APIs follow established patterns that make code more maintainable, testable, and scalable. In this lesson, we'll explore essential design patterns for building robust APIs, including Repository pattern, DTOs, Action classes, and service layers.

The Repository Pattern

The Repository pattern separates data access logic from business logic, making your code more testable and maintainable:

app/Repositories/UserRepository.php:
<?php

namespace App\Repositories;

use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\LengthAwarePaginator;

class UserRepository
{
    /**
     * Find user by ID
     */
    public function find(int $id): ?User
    {
        return User::find($id);
    }

    /**
     * Get all users with pagination
     */
    public function paginate(int $perPage = 15): LengthAwarePaginator
    {
        return User::latest()->paginate($perPage);
    }

    /**
     * Create new user
     */
    public function create(array $data): User
    {
        return User::create($data);
    }

    /**
     * Update existing user
     */
    public function update(User $user, array $data): bool
    {
        return $user->update($data);
    }

    /**
     * Delete user
     */
    public function delete(User $user): bool
    {
        return $user->delete();
    }

    /**
     * Find user by email
     */
    public function findByEmail(string $email): ?User
    {
        return User::where('email', $email)->first();
    }

    /**
     * Get active users
     */
    public function getActive(): Collection
    {
        return User::where('is_active', true)
            ->orderBy('name')
            ->get();
    }

    /**
     * Search users by name or email
     */
    public function search(string $query): Collection
    {
        return User::where('name', 'LIKE', "%{$query}%")
            ->orWhere('email', 'LIKE', "%{$query}%")
            ->get();
    }

    /**
     * Get users created in date range
     */
    public function getCreatedBetween(string $startDate, string $endDate): Collection
    {
        return User::whereBetween('created_at', [$startDate, $endDate])
            ->get();
    }
}
Repository Benefits:
  • Single responsibility - repositories only handle data access
  • Testability - easy to mock in unit tests
  • Flexibility - can swap out data sources without changing business logic
  • Reusability - common queries defined once

Data Transfer Objects (DTOs)

DTOs encapsulate data transfer between layers, providing type safety and validation:

app/DTOs/CreateUserDTO.php:
<?php

namespace App\DTOs;

use Illuminate\Http\Request;

class CreateUserDTO
{
    public function __construct(
        public readonly string $name,
        public readonly string $email,
        public readonly string $password,
        public readonly ?string $phone = null,
        public readonly array $roles = [],
    ) {}

    /**
     * Create DTO from request
     */
    public static function fromRequest(Request $request): self
    {
        return new self(
            name: $request->input('name'),
            email: $request->input('email'),
            password: bcrypt($request->input('password')),
            phone: $request->input('phone'),
            roles: $request->input('roles', []),
        );
    }

    /**
     * Convert to array
     */
    public function toArray(): array
    {
        return [
            'name' => $this->name,
            'email' => $this->email,
            'password' => $this->password,
            'phone' => $this->phone,
        ];
    }

    /**
     * Get only model attributes (exclude roles)
     */
    public function getModelAttributes(): array
    {
        return array_filter([
            'name' => $this->name,
            'email' => $this->email,
            'password' => $this->password,
            'phone' => $this->phone,
        ]);
    }
}
app/DTOs/UpdateUserDTO.php:
<?php

namespace App\DTOs;

use Illuminate\Http\Request;

class UpdateUserDTO
{
    public function __construct(
        public readonly ?string $name = null,
        public readonly ?string $email = null,
        public readonly ?string $password = null,
        public readonly ?string $phone = null,
        public readonly ?bool $isActive = null,
    ) {}

    public static function fromRequest(Request $request): self
    {
        return new self(
            name: $request->input('name'),
            email: $request->input('email'),
            password: $request->has('password')
                ? bcrypt($request->input('password'))
                : null,
            phone: $request->input('phone'),
            isActive: $request->has('is_active')
                ? (bool) $request->input('is_active')
                : null,
        );
    }

    /**
     * Get only non-null attributes
     */
    public function toArray(): array
    {
        return array_filter([
            'name' => $this->name,
            'email' => $this->email,
            'password' => $this->password,
            'phone' => $this->phone,
            'is_active' => $this->isActive,
        ], fn($value) => $value !== null);
    }
}

Action Classes (Single Action Controllers)

Action classes follow the Single Action Controller pattern - one class, one responsibility:

app/Actions/CreateUserAction.php:
<?php

namespace App\Actions;

use App\DTOs\CreateUserDTO;
use App\Models\User;
use App\Repositories\UserRepository;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

class CreateUserAction
{
    public function __construct(
        private UserRepository $userRepository
    ) {}

    /**
     * Execute the action
     */
    public function execute(CreateUserDTO $dto): User
    {
        return DB::transaction(function () use ($dto) {
            // Create user
            $user = $this->userRepository->create($dto->getModelAttributes());

            // Assign roles if provided
            if (!empty($dto->roles)) {
                $user->roles()->attach($dto->roles);
            }

            // Send welcome email (queued)
            // SendWelcomeEmail::dispatch($user);

            Log::info('User created', [
                'user_id' => $user->id,
                'email' => $user->email,
            ]);

            return $user->fresh(['roles']);
        });
    }
}
app/Actions/UpdateUserAction.php:
<?php

namespace App\Actions;

use App\DTOs\UpdateUserDTO;
use App\Models\User;
use App\Repositories\UserRepository;
use Illuminate\Support\Facades\DB;

class UpdateUserAction
{
    public function __construct(
        private UserRepository $userRepository
    ) {}

    public function execute(User $user, UpdateUserDTO $dto): User
    {
        return DB::transaction(function () use ($user, $dto) {
            $attributes = $dto->toArray();

            if (empty($attributes)) {
                return $user;
            }

            $this->userRepository->update($user, $attributes);

            return $user->fresh();
        });
    }
}
app/Actions/DeleteUserAction.php:
<?php

namespace App\Actions;

use App\Models\User;
use App\Repositories\UserRepository;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;

class DeleteUserAction
{
    public function __construct(
        private UserRepository $userRepository
    ) {}

    public function execute(User $user): bool
    {
        return DB::transaction(function () use ($user) {
            // Delete user avatar if exists
            if ($user->avatar) {
                Storage::delete($user->avatar);
            }

            // Delete related records
            $user->posts()->delete();
            $user->comments()->delete();

            // Delete user
            return $this->userRepository->delete($user);
        });
    }
}

Using Patterns in Controllers

Now your controllers become thin orchestrators:

app/Http/Controllers/Api/UserController.php:
<?php

namespace App\Http\Controllers\Api;

use App\Actions\CreateUserAction;
use App\Actions\UpdateUserAction;
use App\Actions\DeleteUserAction;
use App\DTOs\CreateUserDTO;
use App\DTOs\UpdateUserDTO;
use App\Http\Controllers\Controller;
use App\Http\Requests\CreateUserRequest;
use App\Http\Requests\UpdateUserRequest;
use App\Http\Resources\UserResource;
use App\Models\User;
use App\Repositories\UserRepository;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;

class UserController extends Controller
{
    public function __construct(
        private UserRepository $userRepository,
        private CreateUserAction $createUserAction,
        private UpdateUserAction $updateUserAction,
        private DeleteUserAction $deleteUserAction,
    ) {}

    /**
     * Display a listing of users
     */
    public function index(): AnonymousResourceCollection
    {
        $users = $this->userRepository->paginate(15);

        return UserResource::collection($users);
    }

    /**
     * Display the specified user
     */
    public function show(User $user): UserResource
    {
        return new UserResource($user->load(['roles', 'posts']));
    }

    /**
     * Store a newly created user
     */
    public function store(CreateUserRequest $request): JsonResponse
    {
        $dto = CreateUserDTO::fromRequest($request);
        $user = $this->createUserAction->execute($dto);

        return (new UserResource($user))
            ->response()
            ->setStatusCode(201);
    }

    /**
     * Update the specified user
     */
    public function update(UpdateUserRequest $request, User $user): UserResource
    {
        $dto = UpdateUserDTO::fromRequest($request);
        $updatedUser = $this->updateUserAction->execute($user, $dto);

        return new UserResource($updatedUser);
    }

    /**
     * Remove the specified user
     */
    public function destroy(User $user): JsonResponse
    {
        $this->deleteUserAction->execute($user);

        return response()->json([
            'message' => 'User deleted successfully',
        ], 200);
    }
}
Controller Responsibilities: Controllers should only handle HTTP concerns - request validation, calling actions, returning responses. All business logic lives in actions, all data access in repositories.

Service Layer Pattern

For complex business logic, use service classes:

app/Services/UserService.php:
<?php

namespace App\Services;

use App\Models\User;
use App\Repositories\UserRepository;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;

class UserService
{
    public function __construct(
        private UserRepository $userRepository
    ) {}

    /**
     * Verify user credentials
     */
    public function verifyCredentials(string $email, string $password): ?User
    {
        $user = $this->userRepository->findByEmail($email);

        if (!$user || !Hash::check($password, $user->password)) {
            return null;
        }

        return $user;
    }

    /**
     * Get user statistics
     */
    public function getUserStats(User $user): array
    {
        return Cache::remember("user.{$user->id}.stats", 3600, function () use ($user) {
            return [
                'posts_count' => $user->posts()->count(),
                'comments_count' => $user->comments()->count(),
                'followers_count' => $user->followers()->count(),
                'following_count' => $user->following()->count(),
                'total_views' => $user->posts()->sum('views'),
            ];
        });
    }

    /**
     * Ban user and related cleanup
     */
    public function banUser(User $user, string $reason): void
    {
        $user->update([
            'is_active' => false,
            'banned_at' => now(),
            'ban_reason' => $reason,
        ]);

        // Revoke all tokens
        $user->tokens()->delete();

        // Hide all user posts
        $user->posts()->update(['is_visible' => false]);

        // Clear cache
        Cache::forget("user.{$user->id}.stats");
    }

    /**
     * Activate user account
     */
    public function activateUser(User $user): void
    {
        $user->update([
            'is_active' => true,
            'email_verified_at' => now(),
        ]);
    }
}

Query Builder Pattern

Create reusable, chainable query builders for complex queries:

app/QueryBuilders/UserQueryBuilder.php:
<?php

namespace App\QueryBuilders;

use Illuminate\Database\Eloquent\Builder;

class UserQueryBuilder extends Builder
{
    /**
     * Filter active users
     */
    public function active(): self
    {
        return $this->where('is_active', true);
    }

    /**
     * Filter verified users
     */
    public function verified(): self
    {
        return $this->whereNotNull('email_verified_at');
    }

    /**
     * Filter by role
     */
    public function withRole(string $role): self
    {
        return $this->whereHas('roles', function ($query) use ($role) {
            $query->where('name', $role);
        });
    }

    /**
     * Search by name or email
     */
    public function search(string $query): self
    {
        return $this->where(function ($q) use ($query) {
            $q->where('name', 'LIKE', "%{$query}%")
              ->orWhere('email', 'LIKE', "%{$query}%");
        });
    }

    /**
     * Order by creation date
     */
    public function latest(): self
    {
        return $this->orderBy('created_at', 'desc');
    }

    /**
     * With common relationships
     */
    public function withRelations(): self
    {
        return $this->with(['roles', 'profile']);
    }

    /**
     * Created in date range
     */
    public function createdBetween(string $from, string $to): self
    {
        return $this->whereBetween('created_at', [$from, $to]);
    }
}
Using Custom Query Builder in Model:
<?php

namespace App\Models;

use App\QueryBuilders\UserQueryBuilder;
use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    /**
     * Create a new Eloquent query builder
     */
    public function newEloquentBuilder($query): UserQueryBuilder
    {
        return new UserQueryBuilder($query);
    }
}

// Usage:
$users = User::query()
    ->active()
    ->verified()
    ->withRole('admin')
    ->search('john')
    ->latest()
    ->withRelations()
    ->paginate(15);

API Resource Transformers

Use API Resources with conditionals and relationships:

app/Http/Resources/UserResource.php:
<?php

namespace App\Http\Resources;

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

class UserResource extends JsonResource
{
    /**
     * Transform the resource into an array
     */
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'email' => $this->email,
            'avatar' => $this->avatar_url,
            'is_active' => $this->is_active,

            // Conditional fields
            'phone' => $this->when(
                $request->user()?->can('view-private-info', $this->resource),
                $this->phone
            ),

            // Relationships
            'roles' => RoleResource::collection($this->whenLoaded('roles')),
            'posts' => PostResource::collection($this->whenLoaded('posts')),

            // Computed fields
            'posts_count' => $this->whenCounted('posts'),
            'is_premium' => $this->subscriptions()->where('status', 'active')->exists(),

            // Dates
            'created_at' => $this->created_at->toIso8601String(),
            'updated_at' => $this->updated_at->toIso8601String(),
        ];
    }

    /**
     * Get additional data
     */
    public function with(Request $request): array
    {
        return [
            'meta' => [
                'version' => '1.0',
            ],
        ];
    }
}

Policy-Based Authorization

app/Policies/UserPolicy.php:
<?php

namespace App\Policies;

use App\Models\User;

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

    /**
     * Determine if user can view the model
     */
    public function view(User $user, User $model): bool
    {
        return $user->id === $model->id
            || $user->hasPermission('users.view');
    }

    /**
     * Determine if user can create users
     */
    public function create(User $user): bool
    {
        return $user->hasRole('admin');
    }

    /**
     * Determine if user can update the model
     */
    public function update(User $user, User $model): bool
    {
        return $user->id === $model->id
            || $user->hasPermission('users.update');
    }

    /**
     * Determine if user can delete the model
     */
    public function delete(User $user, User $model): bool
    {
        return $user->hasRole('admin') && $user->id !== $model->id;
    }
}
Practice Exercise:

Refactor an existing API controller to use these patterns:

  1. Create a repository for your model
  2. Create DTOs for create and update operations
  3. Extract business logic into action classes
  4. Update controller to use repository and actions
  5. Add a custom query builder for complex queries
  6. Create API resources for response transformation

Summary

API design patterns provide structure and maintainability:

  • Repository Pattern: Separates data access from business logic
  • DTOs: Type-safe data transfer between layers
  • Action Classes: Single-responsibility classes for business operations
  • Service Layer: Complex business logic encapsulation
  • Query Builders: Reusable, chainable query methods
  • API Resources: Consistent response transformation
  • Policies: Authorization logic separation

These patterns make your API code more testable, maintainable, and easier to understand. In the next lesson, we'll build a complete RESTful API from scratch using all these patterns.