API Design Patterns
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:
<?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();
}
}
- 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:
<?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,
]);
}
}
<?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:
<?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']);
});
}
}
<?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();
});
}
}
<?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:
<?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);
}
}
Service Layer Pattern
For complex business logic, use service classes:
<?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:
<?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]);
}
}
<?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:
<?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
<?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;
}
}
Refactor an existing API controller to use these patterns:
- Create a repository for your model
- Create DTOs for create and update operations
- Extract business logic into action classes
- Update controller to use repository and actions
- Add a custom query builder for complex queries
- 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.