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