Skip to content

🏗️ 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?

  1. Type Safety - Strongly typed data structures
  2. Immutability - Prevent accidental data modification
  3. Validation - Ensure data integrity at creation
  4. Documentation - Self-documenting data structures
  5. Refactoring Safety - IDE support for refactoring
  6. 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();
    }
}

For production applications, consider using spatie/laravel-data:

bash
composer require spatie/laravel-data
php
<?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 instead

Directory Structure

app/
├── DataTransferObjects/
│   ├── User/
│   │   ├── CreateUserData.php
│   │   ├── UpdateUserData.php
│   │   └── UserFilterData.php
│   ├── Order/
│   │   ├── CreateOrderData.php
│   │   ├── OrderItemData.php
│   │   └── UpdateOrderData.php
│   └── Payment/
│       ├── ProcessPaymentData.php
│       └── RefundPaymentData.php

Real-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.php

API 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

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.edit

Route 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}/comments

Custom 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,middleware

Route 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 binding

Route 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.

Built with VitePress