Appearance
🏗️ Laravel Project Structure
Professional application architecture for maintainable and scalable Laravel projects. This guide establishes consistent organizational patterns across all projects.
📁 Directory Structure
Complete Application Layout
app/
├── Actions/ # Single-purpose action classes
│ ├── Users/
│ │ ├── CreateUser.php
│ │ ├── UpdateUser.php
│ │ └── DeleteUser.php
│ └── Orders/
│ ├── CreateOrder.php
│ └── ProcessOrder.php
├── Console/
│ └── Commands/ # Artisan commands
│ ├── SendDailyReport.php
│ └── CleanupOldData.php
├── Events/ # Event classes
│ ├── UserCreated.php
│ └── OrderShipped.php
├── Exceptions/ # Custom exception classes
│ ├── InvalidPaymentException.php
│ └── UserNotFoundException.php
├── Http/
│ ├── Controllers/ # API and web controllers
│ │ ├── Api/
│ │ │ ├── V1/
│ │ │ │ ├── UserController.php
│ │ │ │ └── OrderController.php
│ │ │ └── V2/
│ │ └── Web/
│ ├── Middleware/ # Custom middleware
│ │ ├── EnsureUserIsActive.php
│ │ └── LogApiRequests.php
│ ├── Requests/ # Form request validation
│ │ ├── CreateUserRequest.php
│ │ └── UpdateUserRequest.php
│ └── Resources/ # API resource transformers
│ ├── UserResource.php
│ └── OrderResource.php
├── Jobs/ # Queue jobs
│ ├── SendEmailNotification.php
│ └── ProcessBatchImport.php
├── Listeners/ # Event listeners
│ ├── SendWelcomeEmail.php
│ └── UpdateUserStatistics.php
├── Models/ # Eloquent models
│ ├── User.php
│ ├── Order.php
│ └── Product.php
├── Notifications/ # Notification classes
│ ├── OrderShippedNotification.php
│ └── WelcomeEmailNotification.php
├── Observers/ # Model observers
│ ├── UserObserver.php
│ └── OrderObserver.php
├── Policies/ # Authorization policies
│ ├── UserPolicy.php
│ └── OrderPolicy.php
├── Providers/ # Service providers
│ ├── AppServiceProvider.php
│ ├── AuthServiceProvider.php
│ └── RepositoryServiceProvider.php
├── Repositories/ # Data access layer
│ ├── Interfaces/
│ │ ├── UserRepositoryInterface.php
│ │ └── OrderRepositoryInterface.php
│ ├── UserRepository.php
│ └── OrderRepository.php
├── Services/ # Business logic services
│ ├── UserService.php
│ ├── OrderService.php
│ └── PaymentService.php
└── Traits/ # Reusable traits
├── Searchable.php
└── Sortable.php🎮 Controllers - Thin and Focused
Controllers should only handle HTTP concerns: receive requests, call services, and return responses.
✅ Good Example
php
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\CreateUserRequest;
use App\Http\Requests\UpdateUserRequest;
use App\Http\Resources\UserResource;
use App\Services\UserService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class UserController extends Controller
{
public function __construct(
private UserService $userService
) {}
/**
* Display a listing of users.
*/
public function index(): AnonymousResourceCollection
{
$users = $this->userService->getAllUsers();
return UserResource::collection($users);
}
/**
* Store a newly created user.
*/
public function store(CreateUserRequest $request): JsonResponse
{
$user = $this->userService->createUser($request->validated());
return response()->json([
'data' => new UserResource($user),
'message' => 'User created successfully',
], 201);
}
/**
* Display the specified user.
*/
public function show(int $id): JsonResponse
{
$user = $this->userService->getUserById($id);
if (!$user) {
return response()->json([
'message' => 'User not found',
], 404);
}
return response()->json([
'data' => new UserResource($user),
]);
}
/**
* Update the specified user.
*/
public function update(UpdateUserRequest $request, int $id): JsonResponse
{
$user = $this->userService->updateUser($id, $request->validated());
return response()->json([
'data' => new UserResource($user),
'message' => 'User updated successfully',
]);
}
/**
* Remove the specified user.
*/
public function destroy(int $id): JsonResponse
{
$this->userService->deleteUser($id);
return response()->json([
'message' => 'User deleted successfully',
], 204);
}
}❌ Bad Example
php
<?php
namespace App\Http\Controllers\Api;
use App\Models\User;
use Illuminate\Http\Request;
class UserController extends Controller
{
// Fat controller with business logic
public function store(Request $request)
{
// Validation logic in controller
$request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users',
'password' => 'required|min:8',
]);
// Business logic in controller
$user = User::create([
'name' => $request->name,
'email' => strtolower(string: $request->email),
'password' => Hash::make(value: $request->password),
'role' => 'user',
]);
// Email logic in controller
Mail::to(users: $user)->send(mailable: new WelcomeEmail($user));
// Statistics logic in controller
Cache::increment(key: 'total_users');
// Logging logic in controller
Log::info(message: 'User created', context: ['user_id' => $user->id]);
return response()->json(data: $user, status: 201);
}
}🔧 Services - Business Logic Layer
Services contain business logic and orchestrate between repositories, external services, and other components.
✅ Good Example
php
<?php
declare(strict_types=1);
namespace App\Services;
use App\Events\UserCreated;
use App\Exceptions\UserNotFoundException;
use App\Models\User;
use App\Repositories\Interfaces\UserRepositoryInterface;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
class UserService
{
public function __construct(
private UserRepositoryInterface $userRepository,
private EmailService $emailService
) {}
/**
* Get all active users.
*/
public function getAllUsers(): Collection
{
return $this->userRepository->getAllActive();
}
/**
* Get a user by ID.
*/
public function getUserById(int $id): ?User
{
return $this->userRepository->find($id);
}
/**
* Create a new user.
*/
public function createUser(array $userData): User
{
try {
DB::beginTransaction();
// Prepare user data
$userData['email'] = strtolower(string: trim(string: $userData['email']));
$userData['password'] = Hash::make(value: $userData['password']);
$userData['role'] = $userData['role'] ?? 'user';
// Create user
$user = $this->userRepository->create(data: $userData);
// Send welcome email
$this->emailService->sendWelcomeEmail(user: $user);
// Dispatch event
event(event: new UserCreated($user));
DB::commit();
Log::info(message: 'User created successfully', context: [
'user_id' => $user->id,
'email' => $user->email,
]);
return $user;
} catch (\Exception $e) {
DB::rollBack();
Log::error(message: 'Failed to create user', context: [
'error' => $e->getMessage(),
'data' => $userData,
]);
throw $e;
}
}
/**
* Update an existing user.
*/
public function updateUser(int $id, array $userData): User
{
$user = $this->userRepository->find($id);
if (!$user) {
throw new UserNotFoundException("User with ID {$id} not found");
}
// Hash password if provided
if (isset($userData['password'])) {
$userData['password'] = Hash::make(value: $userData['password']);
}
$this->userRepository->update(id: $id, data: $userData);
Log::info(message: 'User updated successfully', context: [
'user_id' => $id,
'updated_fields' => array_keys($userData),
]);
return $user->refresh();
}
/**
* Delete a user.
*/
public function deleteUser(int $id): bool
{
$user = $this->userRepository->find($id);
if (!$user) {
throw new UserNotFoundException("User with ID {$id} not found");
}
$deleted = $this->userRepository->delete(id: $id);
if ($deleted) {
Log::info(message: 'User deleted successfully', context: ['user_id' => $id]);
}
return $deleted;
}
/**
* Check if user has specific permission.
*/
public function hasPermission(User $user, string $permission): bool
{
return $user->permissions->contains('name', $permission);
}
}❌ Bad Example
php
<?php
namespace App\Services;
use App\Models\User;
class UserService
{
// Direct model access - no repository
public function createUser(array $data): User
{
return User::create($data);
}
// Too many responsibilities
public function processUser(array $data)
{
$user = User::create($data);
Mail::to($user)->send(new WelcomeEmail($user));
Cache::increment('users');
Log::info('User created');
event(new UserCreated($user));
// Payment processing?
if ($data['plan'] === 'premium') {
$this->processPayment($user, $data['payment']);
}
// Analytics?
Analytics::track('user_created', $user);
return $user;
}
}📦 Repositories - Data Access Layer
Repositories abstract data access and provide a clean interface for database operations.
✅ Good Example
php
<?php
declare(strict_types=1);
namespace App\Repositories;
use App\Models\User;
use App\Repositories\Interfaces\UserRepositoryInterface;
use Illuminate\Support\Collection;
class UserRepository implements UserRepositoryInterface
{
public function __construct(
private User $model
) {}
public function find(int $id): ?User
{
return $this->model->find($id);
}
public function findByEmail(string $email): ?User
{
return $this->model->where(column: 'email', operator: '=', value: $email)->first();
}
public function create(array $data): User
{
return $this->model->create(attributes: $data);
}
public function update(int $id, array $data): bool
{
return $this->model->where(column: 'id', operator: '=', value: $id)->update(values: $data);
}
public function delete(int $id): bool
{
return $this->model->destroy(ids: $id) > 0;
}
public function getAllActive(): Collection
{
return $this->model
->where(column: 'is_active', operator: '=', value: true)
->with(relations: ['role', 'permissions'])
->orderBy(column: 'created_at', direction: 'desc')
->get();
}
public function search(string $query): Collection
{
return $this->model
->where(column: 'name', operator: 'LIKE', value: "%{$query}%")
->orWhere(column: 'email', operator: 'LIKE', value: "%{$query}%")
->get();
}
public function paginate(int $perPage = 15): \Illuminate\Contracts\Pagination\LengthAwarePaginator
{
return $this->model
->with(relations: ['role'])
->orderBy(column: 'created_at', direction: 'desc')
->paginate(perPage: $perPage);
}
}📝 Form Requests - Validation Layer
Form requests handle all validation logic, keeping controllers clean.
✅ Good Example
php
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class CreateUserRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user()->can('create', User::class);
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
'password' => [
'required',
'string',
'min:12',
'regex:/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/',
],
'role' => ['required', 'string', 'in:user,moderator,admin'],
'phone' => ['nullable', 'string', 'max:20'],
];
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'password.regex' => 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character.',
'email.unique' => 'This email address is already registered.',
];
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation(): void
{
$this->merge([
'email' => strtolower(trim($this->email)),
'name' => trim($this->name),
]);
}
}📨 API Resources - Response Transformation
Resources transform your models into JSON responses consistently.
✅ Good Example
php
<?php
declare(strict_types=1);
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,
'role' => $this->role,
'is_active' => $this->is_active,
'email_verified_at' => $this->email_verified_at?->toIso8601String(),
'created_at' => $this->created_at->toIso8601String(),
'updated_at' => $this->updated_at->toIso8601String(),
// Conditional relationships
'profile' => ProfileResource::make($this->whenLoaded('profile')),
'orders' => OrderResource::collection($this->whenLoaded('orders')),
// Conditional fields based on permissions
'permissions' => $this->when(
$request->user()?->can('view-permissions'),
fn () => PermissionResource::collection($this->permissions)
),
];
}
}⚡ Action Classes - Single Responsibility
Action classes encapsulate a single business operation. They follow the Single Responsibility Principle strictly and are reusable across controllers, commands, jobs, and event listeners.
When to use Actions:
- Single, focused operations
- Reusable business logic across multiple contexts
- Complex operations that don't belong in services
- Operations used in controllers, commands, jobs, or events
Example 1: Process Order Refund
php
<?php
declare(strict_types=1);
namespace App\Actions\Orders;
use App\Events\OrderRefunded;
use App\Exceptions\RefundException;
use App\Models\Order;
use App\Services\PaymentGatewayService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class ProcessRefund
{
public function __construct(
private PaymentGatewayService $paymentGateway
) {}
/**
* Process refund for an order.
*
* @throws RefundException
*/
public function execute(Order $order, float $amount, string $reason): Order
{
// Validate refund eligibility
if ($order->status === 'refunded') {
throw new RefundException(message: 'Order is already refunded');
}
if ($amount > $order->total_amount) {
throw new RefundException(message: 'Refund amount exceeds order total');
}
if ($order->created_at->addDays(days: 30)->isPast()) {
throw new RefundException(message: 'Refund period has expired');
}
return DB::transaction(function () use ($order, $amount, $reason) {
// Process payment gateway refund
$refundId = $this->paymentGateway->refund(
transactionId: $order->payment_transaction_id,
amount: $amount
);
// Update order
$order->update(attributes: [
'status' => 'refunded',
'refund_amount' => $amount,
'refund_reason' => $reason,
'refund_transaction_id' => $refundId,
'refunded_at' => now(),
]);
// Restore inventory
foreach ($order->items as $item) {
$item->product->increment(column: 'stock', amount: $item->quantity);
}
// Dispatch event
event(event: new OrderRefunded($order));
Log::info(message: 'Order refunded successfully', context: [
'order_id' => $order->id,
'amount' => $amount,
'refund_id' => $refundId,
]);
return $order->fresh();
});
}
}Example 2: Send Invoice Email
php
<?php
declare(strict_types=1);
namespace App\Actions\Invoices;
use App\Mail\InvoiceMail;
use App\Models\Invoice;
use App\Services\PdfGeneratorService;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Log;
class SendInvoiceEmail
{
public function __construct(
private PdfGeneratorService $pdfGenerator
) {}
/**
* Generate PDF and send invoice email.
*/
public function execute(Invoice $invoice, ?string $customEmail = null): bool
{
try {
// Generate invoice PDF
$pdfContent = $this->pdfGenerator->generateInvoice(invoice: $invoice);
// Store PDF
$filename = "invoices/invoice-{$invoice->id}.pdf";
Storage::disk(name: 's3')->put(
path: $filename,
contents: $pdfContent
);
// Update invoice
$invoice->update(attributes: [
'pdf_path' => $filename,
'pdf_generated_at' => now(),
]);
// Send email
$recipient = $customEmail ?? $invoice->customer->email;
Mail::to(users: $recipient)
->send(mailable: new InvoiceMail(
invoice: $invoice,
pdfPath: $filename
));
// Update sent status
$invoice->update(attributes: [
'sent_at' => now(),
'sent_to' => $recipient,
]);
Log::info(message: 'Invoice email sent successfully', context: [
'invoice_id' => $invoice->id,
'recipient' => $recipient,
]);
return true;
} catch (\Exception $e) {
Log::error(message: 'Failed to send invoice email', context: [
'invoice_id' => $invoice->id,
'error' => $e->getMessage(),
]);
return false;
}
}
}Example 3: Generate Sales Report
php
<?php
declare(strict_types=1);
namespace App\Actions\Reports;
use App\DataTransferObjects\SalesReportData;
use App\Models\Order;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
class GenerateSalesReport
{
/**
* Generate comprehensive sales report for date range.
*/
public function execute(Carbon $startDate, Carbon $endDate, ?int $userId = null): SalesReportData
{
$cacheKey = "sales_report_{$startDate->format('Y-m-d')}_{$endDate->format('Y-m-d')}_{$userId}";
return Cache::remember(
key: $cacheKey,
ttl: 3600,
callback: fn () => $this->generateReport(
startDate: $startDate,
endDate: $endDate,
userId: $userId
)
);
}
private function generateReport(Carbon $startDate, Carbon $endDate, ?int $userId): SalesReportData
{
$query = Order::query()
->whereBetween(column: 'created_at', values: [$startDate, $endDate])
->where(column: 'status', operator: '!=', value: 'cancelled');
if ($userId) {
$query->where(column: 'user_id', operator: '=', value: $userId);
}
$orders = $query->with(relations: ['items.product'])->get();
return new SalesReportData(
startDate: $startDate,
endDate: $endDate,
totalOrders: $orders->count(),
totalRevenue: $orders->sum(callback: fn ($order) => $order->total_amount),
averageOrderValue: $orders->avg(callback: fn ($order) => $order->total_amount),
topProducts: $this->getTopProducts(orders: $orders),
dailySales: $this->getDailySales(orders: $orders),
customerCount: $orders->unique(key: 'user_id')->count(),
);
}
private function getTopProducts(Collection $orders): Collection
{
return $orders->flatMap(callback: fn ($order) => $order->items)
->groupBy(groupBy: 'product_id')
->map(callback: function ($items) {
return [
'product_id' => $items->first()->product_id,
'product_name' => $items->first()->product->name,
'quantity_sold' => $items->sum(callback: 'quantity'),
'revenue' => $items->sum(callback: fn ($item) => $item->price * $item->quantity),
];
})
->sortByDesc(callback: 'revenue')
->take(value: 10)
->values();
}
private function getDailySales(Collection $orders): Collection
{
return $orders->groupBy(groupBy: fn ($order) => $order->created_at->format('Y-m-d'))
->map(callback: function ($dailyOrders) {
return [
'date' => $dailyOrders->first()->created_at->format('Y-m-d'),
'orders' => $dailyOrders->count(),
'revenue' => $dailyOrders->sum(callback: 'total_amount'),
];
})
->sortBy(callback: 'date')
->values();
}
}Using Actions in Controllers
php
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Actions\Orders\ProcessRefund;
use App\Actions\Invoices\SendInvoiceEmail;
use App\Http\Controllers\Controller;
use App\Http\Requests\RefundOrderRequest;
use App\Http\Resources\OrderResource;
use App\Models\Order;
use Illuminate\Http\JsonResponse;
class OrderController extends Controller
{
public function refund(
RefundOrderRequest $request,
Order $order,
ProcessRefund $action
): JsonResponse {
try {
$order = $action->execute(
order: $order,
amount: $request->input(key: 'amount'),
reason: $request->input(key: 'reason')
);
return response()->success(
data: new OrderResource($order),
message: 'Order refunded successfully'
);
} catch (RefundException $e) {
return response()->error(
message: $e->getMessage(),
code: 'REFUND_FAILED',
status: 400
);
}
}
public function resendInvoice(Order $order, SendInvoiceEmail $action): JsonResponse
{
$sent = $action->execute(invoice: $order->invoice);
if ($sent) {
return response()->success(
data: null,
message: 'Invoice email sent successfully'
);
}
return response()->error(
message: 'Failed to send invoice email',
code: 'EMAIL_SEND_FAILED',
status: 500
);
}
}Using Actions in Commands
php
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Actions\Reports\GenerateSalesReport;
use Carbon\Carbon;
use Illuminate\Console\Command;
class GenerateDailyReportCommand extends Command
{
protected $signature = 'reports:daily-sales {--date=}';
protected $description = 'Generate daily sales report';
public function handle(GenerateSalesReport $action): int
{
$date = $this->option(key: 'date')
? Carbon::parse(time: $this->option(key: 'date'))
: Carbon::yesterday();
$this->info(string: "Generating sales report for {$date->format('Y-m-d')}");
$report = $action->execute(
startDate: $date->startOfDay(),
endDate: $date->endOfDay()
);
$this->info(string: "Total Orders: {$report->totalOrders}");
$this->info(string: "Total Revenue: \${$report->totalRevenue}");
$this->info(string: "Average Order Value: \${$report->averageOrderValue}");
return Command::SUCCESS;
}
}Using Actions in Jobs
php
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Actions\Invoices\SendInvoiceEmail;
use App\Models\Invoice;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class SendInvoiceJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
private Invoice $invoice,
private ?string $customEmail = null
) {}
public function handle(SendInvoiceEmail $action): void
{
$action->execute(
invoice: $this->invoice,
customEmail: $this->customEmail
);
}
}Action Best Practices
✅ Do's
php
// ✅ Single responsibility
class ProcessRefund { /* Only handles refunds */ }
// ✅ Descriptive names (verb + noun)
class SendInvoiceEmail
class GenerateSalesReport
class ProcessPayment
// ✅ Type-hint dependencies
public function __construct(
private PaymentGatewayService $paymentGateway
) {}
// ✅ Return meaningful results
public function execute(Order $order): Order
// ✅ Use transactions for data integrity
return DB::transaction(function () { /* ... */ });
// ✅ Reusable across contexts
// Can be used in: Controllers, Commands, Jobs, Event Listeners❌ Don'ts
php
// ❌ Multiple responsibilities
class OrderManager { /* Too broad */ }
// ❌ Generic names
class Handler
class Manager
// ❌ Direct model manipulation without dependency injection
class UpdateOrder {
public function execute() {
Order::where('id', 1)->update([...]); // ❌
}
}
// ❌ Framework coupling
class SendEmail {
public function execute() {
request()->input('email'); // ❌ No request() in actions
}
}📦 DTOs - Data Transfer Objects
Data Transfer Objects (DTOs) are simple objects designed to carry data between layers of your application. They provide type safety, validation, and clear contracts for data structures.
Why Use DTOs?
- Type Safety - Strongly typed data structures
- Immutability - Prevent accidental data modification
- Validation - Ensure data integrity at creation
- Documentation - Self-documenting data structures
- Refactoring Safety - IDE support for refactoring
- Clear Contracts - Explicit data requirements
When to Use DTOs
✅ Use DTOs when:
- Passing complex data between layers (Controller → Service → Repository)
- API request/response payloads
- Complex validation logic
- Data needs transformation before use
- Working with external APIs
- Complex business operations with multiple parameters
❌ Don't use DTOs when:
- Simple CRUD with 1-2 parameters
- Direct model operations
- Over-engineering simple operations
Basic DTO Implementation
Simple DTO
php
<?php
declare(strict_types=1);
namespace App\DataTransferObjects;
final readonly class CreateUserData
{
public function __construct(
public string $name,
public string $email,
public string $password,
public ?string $role = 'user',
public bool $isActive = true,
) {}
/**
* Create from Form Request
*/
public static function fromRequest(CreateUserRequest $request): self
{
return new self(
name: $request->input(key: 'name'),
email: $request->input(key: 'email'),
password: $request->input(key: 'password'),
role: $request->input(key: 'role', default: 'user'),
isActive: $request->boolean(key: 'is_active', default: true),
);
}
/**
* Create from array
*/
public static function fromArray(array $data): self
{
return new self(
name: $data['name'],
email: $data['email'],
password: $data['password'],
role: $data['role'] ?? 'user',
isActive: $data['is_active'] ?? true,
);
}
/**
* Convert to array
*/
public function toArray(): array
{
return [
'name' => $this->name,
'email' => $this->email,
'password' => $this->password,
'role' => $this->role,
'is_active' => $this->isActive,
];
}
}DTO with Validation
php
<?php
declare(strict_types=1);
namespace App\DataTransferObjects;
use InvalidArgumentException;
final readonly class UpdateUserData
{
public function __construct(
public ?string $name = null,
public ?string $email = null,
public ?string $password = null,
public ?string $role = null,
public ?bool $isActive = null,
) {
$this->validate();
}
private function validate(): void
{
if ($this->email !== null && !filter_var($this->email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException(message: 'Invalid email format');
}
if ($this->password !== null && strlen($this->password) < 12) {
throw new InvalidArgumentException(message: 'Password must be at least 12 characters');
}
if ($this->role !== null && !in_array($this->role, ['user', 'admin', 'moderator'])) {
throw new InvalidArgumentException(message: 'Invalid role');
}
}
public static function fromRequest(UpdateUserRequest $request): self
{
return new self(
name: $request->input(key: 'name'),
email: $request->input(key: 'email'),
password: $request->input(key: 'password'),
role: $request->input(key: 'role'),
isActive: $request->has(key: 'is_active')
? $request->boolean(key: 'is_active')
: null,
);
}
public function toArray(): array
{
return array_filter(
array: [
'name' => $this->name,
'email' => $this->email,
'password' => $this->password,
'role' => $this->role,
'is_active' => $this->isActive,
],
callback: fn ($value) => $value !== null
);
}
}Complex DTO with Nested Objects
php
<?php
declare(strict_types=1);
namespace App\DataTransferObjects;
final readonly class CreateOrderData
{
/**
* @param array<OrderItemData> $items
*/
public function __construct(
public int $userId,
public string $shippingAddress,
public string $billingAddress,
public array $items,
public ?string $couponCode = null,
public ?string $notes = null,
) {}
public static function fromRequest(CreateOrderRequest $request): self
{
return new self(
userId: $request->user()->id,
shippingAddress: $request->input(key: 'shipping_address'),
billingAddress: $request->input(key: 'billing_address'),
items: array_map(
callback: fn (array $item) => OrderItemData::fromArray(data: $item),
array: $request->input(key: 'items', default: [])
),
couponCode: $request->input(key: 'coupon_code'),
notes: $request->input(key: 'notes'),
);
}
public function toArray(): array
{
return [
'user_id' => $this->userId,
'shipping_address' => $this->shippingAddress,
'billing_address' => $this->billingAddress,
'items' => array_map(
callback: fn (OrderItemData $item) => $item->toArray(),
array: $this->items
),
'coupon_code' => $this->couponCode,
'notes' => $this->notes,
];
}
public function getTotalAmount(): float
{
return array_reduce(
array: $this->items,
callback: fn (float $total, OrderItemData $item) => $total + $item->getSubtotal(),
initial: 0.0
);
}
}php
<?php
declare(strict_types=1);
namespace App\DataTransferObjects;
final readonly class OrderItemData
{
public function __construct(
public int $productId,
public int $quantity,
public float $price,
) {}
public static function fromArray(array $data): self
{
return new self(
productId: (int) $data['product_id'],
quantity: (int) $data['quantity'],
price: (float) $data['price'],
);
}
public function toArray(): array
{
return [
'product_id' => $this->productId,
'quantity' => $this->quantity,
'price' => $this->price,
];
}
public function getSubtotal(): float
{
return $this->quantity * $this->price;
}
}Using DTOs in Controllers
php
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\DataTransferObjects\CreateUserData;
use App\DataTransferObjects\UpdateUserData;
use App\Http\Controllers\Controller;
use App\Http\Requests\CreateUserRequest;
use App\Http\Requests\UpdateUserRequest;
use App\Http\Resources\UserResource;
use App\Services\UserService;
use Illuminate\Http\JsonResponse;
class UserController extends Controller
{
public function __construct(
private UserService $userService
) {}
public function store(CreateUserRequest $request): JsonResponse
{
// Convert request to DTO
$userData = CreateUserData::fromRequest(request: $request);
// Pass DTO to service
$user = $this->userService->createUser(data: $userData);
return response()->created(
data: new UserResource($user),
message: 'User created successfully'
);
}
public function update(UpdateUserRequest $request, int $id): JsonResponse
{
// Convert request to DTO
$userData = UpdateUserData::fromRequest(request: $request);
// Pass DTO to service
$user = $this->userService->updateUser(id: $id, data: $userData);
return response()->success(
data: new UserResource($user),
message: 'User updated successfully'
);
}
}Using DTOs in Services
php
<?php
declare(strict_types=1);
namespace App\Services;
use App\DataTransferObjects\CreateUserData;
use App\DataTransferObjects\UpdateUserData;
use App\Events\UserCreated;
use App\Models\User;
use App\Repositories\Interfaces\UserRepositoryInterface;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
class UserService
{
public function __construct(
private UserRepositoryInterface $userRepository,
private EmailService $emailService
) {}
public function createUser(CreateUserData $data): User
{
return DB::transaction(function () use ($data) {
// Transform DTO to model data
$modelData = [
'name' => $data->name,
'email' => strtolower(string: $data->email),
'password' => Hash::make(value: $data->password),
'role' => $data->role,
'is_active' => $data->isActive,
];
$user = $this->userRepository->create(data: $modelData);
$this->emailService->sendWelcomeEmail(user: $user);
event(event: new UserCreated($user));
Log::info(message: 'User created successfully', context: [
'user_id' => $user->id,
'email' => $user->email,
]);
return $user;
});
}
public function updateUser(int $id, UpdateUserData $data): User
{
$user = $this->userRepository->find(id: $id);
if (!$user) {
throw new UserNotFoundException(message: "User with ID {$id} not found");
}
// Get only non-null values from DTO
$updateData = $data->toArray();
// Hash password if provided
if (isset($updateData['password'])) {
$updateData['password'] = Hash::make(value: $updateData['password']);
}
$this->userRepository->update(id: $id, data: $updateData);
Log::info(message: 'User updated successfully', context: [
'user_id' => $id,
'updated_fields' => array_keys($updateData),
]);
return $user->refresh();
}
}DTO with Spatie Laravel Data (Recommended)
For production applications, consider using spatie/laravel-data:
bash
composer require spatie/laravel-dataphp
<?php
declare(strict_types=1);
namespace App\DataTransferObjects;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Attributes\Validation\Email;
use Spatie\LaravelData\Attributes\Validation\Min;
use Spatie\LaravelData\Attributes\Validation\Max;
class CreateUserData extends Data
{
public function __construct(
#[Max(255)]
public string $name,
#[Email]
#[Max(255)]
public string $email,
#[Min(12)]
public string $password,
public ?string $role = 'user',
public bool $isActive = true,
) {}
}php
// In Controller
public function store(CreateUserRequest $request): JsonResponse
{
// Automatic conversion from request
$userData = CreateUserData::from($request);
$user = $this->userService->createUser(data: $userData);
return response()->created(
data: new UserResource($user),
message: 'User created successfully'
);
}DTO Collection
php
<?php
declare(strict_types=1);
namespace App\DataTransferObjects;
use Illuminate\Support\Collection;
final readonly class UserCollection
{
/**
* @param Collection<CreateUserData> $users
*/
public function __construct(
public Collection $users
) {}
public static function fromArray(array $data): self
{
$users = collect($data)->map(
callback: fn (array $userData) => CreateUserData::fromArray(data: $userData)
);
return new self(users: $users);
}
public function toArray(): array
{
return $this->users
->map(callback: fn (CreateUserData $user) => $user->toArray())
->toArray();
}
public function count(): int
{
return $this->users->count();
}
public function filter(callable $callback): self
{
return new self(
users: $this->users->filter(callback: $callback)
);
}
}Best Practices for DTOs
✅ Do's
php
// ✅ Use readonly for immutability (PHP 8.1+)
final readonly class UserData { /* ... */ }
// ✅ Use named constructors
public static function fromRequest(Request $request): self
// ✅ Use type declarations everywhere
public function __construct(public string $name, public int $age) {}
// ✅ Validate in constructor
private function validate(): void { /* ... */ }
// ✅ Use final to prevent inheritance
final class UserData { /* ... */ }
// ✅ Provide conversion methods
public function toArray(): array
public function toJson(): string
public static function fromArray(array $data): self
// ✅ Add business logic methods when appropriate
public function getTotalAmount(): float
public function isValid(): bool❌ Don'ts
php
// ❌ Don't make DTOs mutable (without readonly)
class UserData {
public string $name; // Can be changed after creation
}
// ❌ Don't add dependencies
public function __construct(
public string $name,
private UserRepository $repo // ❌ No!
) {}
// ❌ Don't include framework-specific logic
public function save(): void // ❌ DTOs should not persist
public function validate(): bool // ❌ Use validation in constructor
// ❌ Don't use magic methods
public function __get($key) // ❌ Be explicit
// ❌ Don't create anemic DTOs with only getters/setters
public function setName(string $name): void // ❌ Use readonly insteadDirectory Structure
app/
├── DataTransferObjects/
│ ├── User/
│ │ ├── CreateUserData.php
│ │ ├── UpdateUserData.php
│ │ └── UserFilterData.php
│ ├── Order/
│ │ ├── CreateOrderData.php
│ │ ├── OrderItemData.php
│ │ └── UpdateOrderData.php
│ └── Payment/
│ ├── ProcessPaymentData.php
│ └── RefundPaymentData.phpReal-World Example: Order Processing
php
<?php
declare(strict_types=1);
namespace App\Services;
use App\DataTransferObjects\CreateOrderData;
use App\Events\OrderCreated;
use App\Models\Order;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class OrderService
{
public function __construct(
private OrderRepository $orderRepository,
private InventoryService $inventoryService,
private PaymentService $paymentService,
private NotificationService $notificationService,
) {}
public function createOrder(CreateOrderData $data): Order
{
return DB::transaction(function () use ($data) {
// Validate inventory
foreach ($data->items as $item) {
if (!$this->inventoryService->checkStock(
productId: $item->productId,
quantity: $item->quantity
)) {
throw new InsufficientStockException(
message: "Product {$item->productId} out of stock"
);
}
}
// Create order
$order = $this->orderRepository->create(data: [
'user_id' => $data->userId,
'shipping_address' => $data->shippingAddress,
'billing_address' => $data->billingAddress,
'total_amount' => $data->getTotalAmount(),
'status' => 'pending',
]);
// Create order items
foreach ($data->items as $item) {
$order->items()->create(attributes: $item->toArray());
$this->inventoryService->decrementStock(
productId: $item->productId,
quantity: $item->quantity
);
}
// Apply coupon if provided
if ($data->couponCode) {
$this->applyCoupon(order: $order, code: $data->couponCode);
}
event(event: new OrderCreated($order));
$this->notificationService->sendOrderConfirmation(order: $order);
Log::info(message: 'Order created successfully', context: [
'order_id' => $order->id,
'user_id' => $data->userId,
'total' => $data->getTotalAmount(),
]);
return $order;
});
}
}Summary
DTOs provide:
- ✅ Type Safety - Catch errors at compile time
- ✅ Immutability - Prevent accidental changes
- ✅ Clear Contracts - Explicit data requirements
- ✅ Validation - Ensure data integrity
- ✅ Testability - Easy to mock and test
- ✅ Refactoring - IDE support for changes
- ✅ Documentation - Self-documenting code
Use DTOs to create clean, maintainable, and type-safe Laravel applications! 🚀
🛣️ Routes - API & Web Routing
Proper route organization is crucial for maintainable Laravel applications. This section covers best practices for defining, organizing, and managing routes.
Route File Structure
routes/
├── api.php # API routes (stateless, token-based)
├── web.php # Web routes (session-based)
├── console.php # Artisan commands
├── channels.php # Broadcasting channels
└── api/ # API version-specific routes
├── v1.php
└── v2.phpAPI Routes Organization
Basic API Structure
php
<?php
// routes/api.php
declare(strict_types=1);
use App\Http\Controllers\Api\V1\AuthController;
use App\Http\Controllers\Api\V1\UserController;
use App\Http\Controllers\Api\V1\ProductController;
use App\Http\Controllers\Api\V1\OrderController;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| API Routes (Version 1)
|--------------------------------------------------------------------------
*/
// Public routes
Route::prefix(prefix: 'v1')->group(callback: function () {
// Authentication
Route::post(uri: '/auth/register', action: [AuthController::class, 'register'])
->name(name: 'api.v1.auth.register');
Route::post(uri: '/auth/login', action: [AuthController::class, 'login'])
->name(name: 'api.v1.auth.login')
->middleware(middleware: 'throttle:5,1'); // 5 attempts per minute
Route::post(uri: '/auth/forgot-password', action: [AuthController::class, 'forgotPassword'])
->name(name: 'api.v1.auth.forgot-password');
});
// Protected routes
Route::prefix(prefix: 'v1')
->middleware(middleware: ['auth:sanctum'])
->group(callback: function () {
// Authentication
Route::post(uri: '/auth/logout', action: [AuthController::class, 'logout'])
->name(name: 'api.v1.auth.logout');
Route::get(uri: '/auth/user', action: [AuthController::class, 'user'])
->name(name: 'api.v1.auth.user');
// Users
Route::apiResource(name: 'users', controller: UserController::class)
->names(names: [
'index' => 'api.v1.users.index',
'store' => 'api.v1.users.store',
'show' => 'api.v1.users.show',
'update' => 'api.v1.users.update',
'destroy' => 'api.v1.users.destroy',
]);
// Products
Route::apiResource(name: 'products', controller: ProductController::class)
->names(names: 'api.v1.products');
// Orders
Route::apiResource(name: 'orders', controller: OrderController::class)
->names(names: 'api.v1.orders');
// Custom order actions
Route::post(uri: '/orders/{order}/cancel', action: [OrderController::class, 'cancel'])
->name(name: 'api.v1.orders.cancel');
Route::post(uri: '/orders/{order}/complete', action: [OrderController::class, 'complete'])
->name(name: 'api.v1.orders.complete');
});API Versioning Strategy
Option 1: Prefix-Based Versioning (Recommended)
php
<?php
// routes/api.php
declare(strict_types=1);
use Illuminate\Support\Facades\Route;
// Version 1
Route::prefix(prefix: 'v1')
->name(name: 'api.v1.')
->group(base_path(path: 'routes/api/v1.php'));
// Version 2
Route::prefix(prefix: 'v2')
->name(name: 'api.v2.')
->group(base_path(path: 'routes/api/v2.php'));php
<?php
// routes/api/v1.php
declare(strict_types=1);
use App\Http\Controllers\Api\V1\UserController;
use Illuminate\Support\Facades\Route;
Route::middleware(middleware: ['auth:sanctum'])->group(callback: function () {
Route::apiResource(name: 'users', controller: UserController::class);
Route::apiResource(name: 'products', controller: ProductController::class);
});php
<?php
// routes/api/v2.php
declare(strict_types=1);
use App\Http\Controllers\Api\V2\UserController;
use Illuminate\Support\Facades\Route;
Route::middleware(middleware: ['auth:sanctum'])->group(callback: function () {
// V2 endpoints with different implementation
Route::apiResource(name: 'users', controller: UserController::class);
Route::apiResource(name: 'products', controller: ProductController::class);
});Route Naming Conventions
Always name your routes for better maintainability and to use route() helper:
php
// ✅ Good: Named routes
Route::get(uri: '/users', action: [UserController::class, 'index'])
->name(name: 'api.v1.users.index');
Route::get(uri: '/users/{id}', action: [UserController::class, 'show'])
->name(name: 'api.v1.users.show');
// Usage in code
return redirect()->route(route: 'api.v1.users.show', parameters: ['id' => $user->id]);
$url = route(name: 'api.v1.users.index');
// ❌ Bad: Unnamed routes
Route::get(uri: '/users', action: [UserController::class, 'index']);Naming Convention Pattern:
{type}.{version}.{resource}.{action}
Examples:
- api.v1.users.index
- api.v1.users.show
- api.v1.orders.cancel
- web.dashboard.index
- web.profile.editRoute Groups with Middleware
php
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Route;
// Admin routes
Route::prefix(prefix: 'admin')
->name(name: 'admin.')
->middleware(middleware: ['auth:sanctum', 'admin'])
->group(callback: function () {
Route::get(uri: '/dashboard', action: [AdminController::class, 'dashboard'])
->name(name: 'dashboard');
Route::apiResource(name: 'users', controller: AdminUserController::class);
Route::apiResource(name: 'settings', controller: SettingsController::class);
});
// API with rate limiting
Route::prefix(prefix: 'api/v1')
->name(name: 'api.v1.')
->middleware(middleware: ['throttle:60,1']) // 60 requests per minute
->group(callback: function () {
Route::apiResource(name: 'posts', controller: PostController::class);
});
// API with multiple middleware
Route::middleware(middleware: ['auth:sanctum', 'verified', 'subscription'])
->prefix(prefix: 'premium')
->name(name: 'premium.')
->group(callback: function () {
Route::get(uri: '/features', action: [PremiumController::class, 'features'])
->name(name: 'features');
});Resource Routes
Laravel provides convenient resource routing:
php
// Full resource routes (7 routes)
Route::apiResource(name: 'users', controller: UserController::class);
// Generated routes:
// GET /users -> index
// POST /users -> store
// GET /users/{user} -> show
// PUT /users/{user} -> update
// PATCH /users/{user} -> update
// DELETE /users/{user} -> destroy
// Partial resource routes
Route::apiResource(name: 'posts', controller: PostController::class)
->only(methods: ['index', 'show']); // Only index and show
Route::apiResource(name: 'comments', controller: CommentController::class)
->except(methods: ['destroy']); // All except destroy
// Nested resources
Route::apiResource(name: 'posts.comments', controller: PostCommentController::class);
// Generates: /posts/{post}/commentsCustom Route Actions
php
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Route;
Route::middleware(middleware: ['auth:sanctum'])->group(callback: function () {
// Standard resource
Route::apiResource(name: 'orders', controller: OrderController::class);
// Custom actions
Route::post(uri: '/orders/{order}/cancel', action: [OrderController::class, 'cancel'])
->name(name: 'api.v1.orders.cancel');
Route::post(uri: '/orders/{order}/ship', action: [OrderController::class, 'ship'])
->name(name: 'api.v1.orders.ship');
Route::post(uri: '/orders/{order}/refund', action: [OrderController::class, 'refund'])
->name(name: 'api.v1.orders.refund')
->middleware(middleware: 'admin');
Route::get(uri: '/orders/{order}/invoice', action: [OrderController::class, 'invoice'])
->name(name: 'api.v1.orders.invoice');
});Route Model Binding
php
<?php
declare(strict_types=1);
// Implicit binding (automatically resolves model)
Route::get(uri: '/users/{user}', action: [UserController::class, 'show']);
// Controller method receives User model, not just ID
public function show(User $user): JsonResponse
{
return response()->success(
data: new UserResource($user),
message: 'User retrieved'
);
}
// Custom key binding
Route::get(uri: '/users/{user:email}', action: [UserController::class, 'show']);
// Now resolves by email instead of ID
// Scoped binding (nested resources)
Route::get(uri: '/users/{user}/posts/{post}', action: [PostController::class, 'show'])
->scopeBindings(); // Ensures post belongs to user
// Custom resolution logic in Model
public function resolveRouteBinding($value, $field = null)
{
return $this->where($field ?? 'id', $value)
->where(column: 'is_active', operator: '=', value: true)
->firstOrFail();
}Rate Limiting
php
<?php
// app/Providers/RouteServiceProvider.php
declare(strict_types=1);
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
class RouteServiceProvider extends ServiceProvider
{
public function boot(): void
{
$this->configureRateLimiting();
}
protected function configureRateLimiting(): void
{
// General API rate limit
RateLimiter::for(name: 'api', callback: function (Request $request) {
return Limit::perMinute(maxAttempts: 60)
->by(key: $request->user()?->id ?: $request->ip());
});
// Strict rate limit for authentication
RateLimiter::for(name: 'login', callback: function (Request $request) {
return Limit::perMinute(maxAttempts: 5)
->by(key: $request->email . '|' . $request->ip())
->response(callback: function () {
return response()->error(
message: 'Too many login attempts. Please try again later.',
code: 'TOO_MANY_ATTEMPTS',
status: 429
);
});
});
// Premium tier rate limit
RateLimiter::for(name: 'premium', callback: function (Request $request) {
return $request->user()?->isPremium()
? Limit::perMinute(maxAttempts: 1000)
: Limit::perMinute(maxAttempts: 60);
});
}
}php
// Using in routes
Route::post(uri: '/auth/login', action: [AuthController::class, 'login'])
->middleware(middleware: 'throttle:login');
Route::middleware(middleware: 'throttle:premium')->group(callback: function () {
Route::apiResource(name: 'analytics', controller: AnalyticsController::class);
});Route Caching
For production performance, cache your routes:
bash
# Cache routes (significant performance boost)
php artisan route:cache
# Clear route cache
php artisan route:clear
# List all registered routes
php artisan route:list
# Filter routes by name
php artisan route:list --name=users
# Filter routes by method
php artisan route:list --method=GET
# Show middleware
php artisan route:list --columns=name,method,uri,middlewareRoute Organization Best Practices
✅ Good Structure
php
<?php
// routes/api.php
declare(strict_types=1);
use Illuminate\Support\Facades\Route;
// ===================================
// Public Routes
// ===================================
Route::prefix(prefix: 'v1')->name(name: 'api.v1.')->group(callback: function () {
// Authentication (no auth required)
Route::prefix(prefix: 'auth')->name(name: 'auth.')->group(callback: function () {
Route::post(uri: '/register', action: [AuthController::class, 'register'])->name(name: 'register');
Route::post(uri: '/login', action: [AuthController::class, 'login'])->name(name: 'login')->middleware(middleware: 'throttle:5,1');
Route::post(uri: '/forgot-password', action: [AuthController::class, 'forgotPassword'])->name(name: 'forgot-password');
});
// Public product browsing
Route::get(uri: '/products', action: [ProductController::class, 'index'])->name(name: 'products.index');
Route::get(uri: '/products/{product}', action: [ProductController::class, 'show'])->name(name: 'products.show');
});
// ===================================
// Protected Routes
// ===================================
Route::prefix(prefix: 'v1')
->name(name: 'api.v1.')
->middleware(middleware: ['auth:sanctum'])
->group(callback: function () {
// User management
Route::apiResource(name: 'users', controller: UserController::class);
// Profile
Route::prefix(prefix: 'profile')->name(name: 'profile.')->group(callback: function () {
Route::get(uri: '/', action: [ProfileController::class, 'show'])->name(name: 'show');
Route::put(uri: '/', action: [ProfileController::class, 'update'])->name(name: 'update');
Route::post(uri: '/avatar', action: [ProfileController::class, 'uploadAvatar'])->name(name: 'avatar');
});
// Orders
Route::apiResource(name: 'orders', controller: OrderController::class);
Route::post(uri: '/orders/{order}/cancel', action: [OrderController::class, 'cancel'])->name(name: 'orders.cancel');
});
// ===================================
// Admin Routes
// ===================================
Route::prefix(prefix: 'v1/admin')
->name(name: 'api.v1.admin.')
->middleware(middleware: ['auth:sanctum', 'admin'])
->group(callback: function () {
Route::get(uri: '/dashboard', action: [AdminController::class, 'dashboard'])->name(name: 'dashboard');
Route::apiResource(name: 'users', controller: AdminUserController::class);
Route::apiResource(name: 'settings', controller: SettingsController::class);
});❌ Bad Structure
php
<?php
// ❌ No organization, mixed public/private, no versioning
Route::get('/users', [UserController::class, 'index']);
Route::get('/login', [AuthController::class, 'login']); // Should be POST
Route::post('/users', [UserController::class, 'store']); // No auth middleware
Route::get('/admin/dashboard', [AdminController::class, 'dashboard']); // No admin middleware
Route::get('/products/{id}', [ProductController::class, 'show']); // Use route model bindingRoute Testing
php
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class UserRoutesTest extends TestCase
{
use RefreshDatabase;
public function test_user_index_route_requires_authentication(): void
{
$response = $this->getJson(uri: '/api/v1/users');
$response->assertStatus(status: 401);
}
public function test_authenticated_user_can_access_users_endpoint(): void
{
$user = User::factory()->create();
$response = $this->actingAs(user: $user)
->getJson(uri: route(name: 'api.v1.users.index'));
$response->assertStatus(status: 200)
->assertJsonStructure(structure: [
'success',
'message',
'data' => [
'*' => ['id', 'name', 'email']
]
]);
}
public function test_admin_routes_require_admin_role(): void
{
$user = User::factory()->create(['role' => 'user']);
$response = $this->actingAs(user: $user)
->getJson(uri: route(name: 'api.v1.admin.dashboard'));
$response->assertStatus(status: 403);
}
public function test_rate_limiting_works(): void
{
for ($i = 0; $i < 6; $i++) {
$response = $this->postJson(uri: '/api/v1/auth/login', data: [
'email' => '[email protected]',
'password' => 'password'
]);
}
$response->assertStatus(status: 429); // Too many requests
}
}Route Documentation with OpenAPI/Swagger
php
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use OpenApi\Annotations as OA;
/**
* @OA\Info(
* title="API Documentation",
* version="1.0.0"
* )
*/
class UserController extends Controller
{
/**
* @OA\Get(
* path="/api/v1/users",
* summary="Get list of users",
* tags={"Users"},
* security={{"bearerAuth":{}}},
* @OA\Response(
* response=200,
* description="Successful operation"
* )
* )
*/
public function index(): JsonResponse
{
// Implementation
}
}Best Practices Summary
✅ Do's
- ✅ Use API versioning - Always version your API routes (
/api/v1/...) - ✅ Name all routes - Makes code maintainable and refactorable
- ✅ Use route groups - Organize by version, middleware, prefix
- ✅ Use route model binding - Automatic model resolution
- ✅ Apply rate limiting - Protect from abuse
- ✅ Use middleware - Authentication, authorization, throttling
- ✅ Use resource routes - For standard CRUD operations
- ✅ Cache routes in production -
php artisan route:cache - ✅ Document routes - OpenAPI/Swagger for API documentation
- ✅ Test routes - Feature tests for all endpoints
- ✅ Use named arguments - For better readability
❌ Don'ts
- ❌ Don't mix public/private routes - Separate clearly
- ❌ Don't skip authentication - Always protect private routes
- ❌ Don't use GET for mutations - Use POST/PUT/PATCH/DELETE
- ❌ Don't expose internal IDs - Consider UUIDs for public APIs
- ❌ Don't forget CORS - Configure properly for frontend apps
- ❌ Don't skip rate limiting - Especially for auth endpoints
- ❌ Don't hardcode URLs - Use
route()helper - ❌ Don't create unnamed routes - Always use
->name()
Real-World Example: Complete API Routes
php
<?php
// routes/api.php
declare(strict_types=1);
use App\Http\Controllers\Api\V1\{
AuthController,
UserController,
ProductController,
OrderController,
CategoryController
};
use Illuminate\Support\Facades\Route;
Route::prefix(prefix: 'v1')->name(name: 'api.v1.')->group(callback: function () {
// ==================== Public Routes ====================
Route::prefix(prefix: 'auth')->name(name: 'auth.')->group(callback: function () {
Route::post(uri: '/register', action: [AuthController::class, 'register'])
->name(name: 'register');
Route::post(uri: '/login', action: [AuthController::class, 'login'])
->name(name: 'login')
->middleware(middleware: 'throttle:5,1');
Route::post(uri: '/forgot-password', action: [AuthController::class, 'forgotPassword'])
->name(name: 'forgot-password')
->middleware(middleware: 'throttle:3,1');
});
// Public product browsing
Route::get(uri: '/products', action: [ProductController::class, 'index'])
->name(name: 'products.index');
Route::get(uri: '/products/{product}', action: [ProductController::class, 'show'])
->name(name: 'products.show');
Route::get(uri: '/categories', action: [CategoryController::class, 'index'])
->name(name: 'categories.index');
// ==================== Protected Routes ====================
Route::middleware(middleware: ['auth:sanctum', 'throttle:60,1'])->group(callback: function () {
// Auth
Route::prefix(prefix: 'auth')->name(name: 'auth.')->group(callback: function () {
Route::post(uri: '/logout', action: [AuthController::class, 'logout'])->name(name: 'logout');
Route::get(uri: '/user', action: [AuthController::class, 'user'])->name(name: 'user');
Route::put(uri: '/password', action: [AuthController::class, 'updatePassword'])->name(name: 'password');
});
// Users
Route::apiResource(name: 'users', controller: UserController::class);
// Orders
Route::apiResource(name: 'orders', controller: OrderController::class);
Route::post(uri: '/orders/{order}/cancel', action: [OrderController::class, 'cancel'])
->name(name: 'orders.cancel');
Route::get(uri: '/orders/{order}/invoice', action: [OrderController::class, 'invoice'])
->name(name: 'orders.invoice');
// Admin routes
Route::middleware(middleware: 'admin')->prefix(prefix: 'admin')->name(name: 'admin.')->group(callback: function () {
Route::get(uri: '/dashboard', action: [AdminController::class, 'dashboard'])->name(name: 'dashboard');
Route::apiResource(name: 'products', controller: ProductController::class)->except(methods: ['index', 'show']);
Route::apiResource(name: 'categories', controller: CategoryController::class)->except(methods: ['index']);
});
});
});Use this routing structure to create well-organized, maintainable, and secure Laravel APIs! 🚀
📋 Best Practices Summary
✅ Do's
- Keep controllers thin - only HTTP concerns
- Use services for business logic - orchestrate operations
- Use repositories for data access - abstract database operations
- Use form requests for validation - keep validation logic separate
- Use API resources for responses - consistent JSON transformation
- Use action classes for single operations - follow SRP
- Use dependency injection - inject dependencies via constructor
- Type-hint everything - parameters, return types, properties
- Handle errors properly - use try-catch with transactions
- Log important operations - for debugging and monitoring
❌ Don'ts
- Don't put business logic in controllers
- Don't access models directly from controllers
- Don't mix validation with business logic
- Don't skip error handling
- Don't create god classes that do too much
- Don't ignore the single responsibility principle
- Don't skip type declarations
- Don't use static calls (except facades when necessary)
🔗 Component Communication Flow
Request
↓
Controller (HTTP concerns only)
↓
Form Request (Validation)
↓
Service (Business logic)
↓
Repository (Data access)
↓
Model (Data representation)
↓
Database📝 Architecture Evolution: Start simple and add layers as needed. Don't over-engineer small projects, but establish patterns that can grow with your application.