Laravel Framework

Laravel Project Architecture & Patterns

20 min Lesson 45 of 45

Laravel Project Architecture & Patterns

As Laravel applications grow, proper architecture becomes critical. This lesson covers advanced architectural patterns and best practices to build scalable, maintainable applications that follow SOLID principles and clean code practices.

Understanding SOLID Principles in Laravel

SOLID Principles:
  • Single Responsibility Principle - Each class should have one reason to change
  • Open/Closed Principle - Open for extension, closed for modification
  • Liskov Substitution Principle - Derived classes must be substitutable for their base classes
  • Interface Segregation Principle - Many specific interfaces are better than one general-purpose interface
  • Dependency Inversion Principle - Depend on abstractions, not concretions

Repository Pattern

The Repository pattern abstracts data access logic, making your code more testable and maintainable:

// app/Contracts/Repositories/UserRepositoryInterface.php namespace App\Contracts\Repositories; use App\Models\User; use Illuminate\Database\Eloquent\Collection; interface UserRepositoryInterface { public function all(): Collection; public function find(int $id): ?User; public function create(array $data): User; public function update(int $id, array $data): User; public function delete(int $id): bool; public function findByEmail(string $email): ?User; public function getActiveUsers(): Collection; } // app/Repositories/UserRepository.php namespace App\Repositories; use App\Contracts\Repositories\UserRepositoryInterface; use App\Models\User; use Illuminate\Database\Eloquent\Collection; class UserRepository implements UserRepositoryInterface { public function __construct(protected User $model) { } public function all(): Collection { return $this->model->all(); } public function find(int $id): ?User { return $this->model->find($id); } public function create(array $data): User { return $this->model->create($data); } public function update(int $id, array $data): User { $user = $this->find($id); $user->update($data); return $user->fresh(); } public function delete(int $id): bool { return $this->model->destroy($id) > 0; } public function findByEmail(string $email): ?User { return $this->model->where('email', $email)->first(); } public function getActiveUsers(): Collection { return $this->model->where('is_active', true) ->orderBy('created_at', 'desc') ->get(); } } // Bind in AppServiceProvider use App\Contracts\Repositories\UserRepositoryInterface; use App\Repositories\UserRepository; public function register(): void { $this->app->bind(UserRepositoryInterface::class, UserRepository::class); } // Usage in controller public function __construct( protected UserRepositoryInterface $userRepository ) {} public function index() { $users = $this->userRepository->getActiveUsers(); return view('users.index', compact('users')); }
Repository Pattern Benefits:
  • Decouples business logic from data access
  • Makes testing easier with mock repositories
  • Centralizes database queries
  • Easier to switch data sources
  • Follows Dependency Inversion Principle

Service Layer Pattern

Services encapsulate business logic that doesn't belong in controllers or models:

// app/Services/UserService.php namespace App\Services; use App\Contracts\Repositories\UserRepositoryInterface; use App\Events\UserRegistered; use App\Mail\WelcomeEmail; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Mail; class UserService { public function __construct( protected UserRepositoryInterface $userRepository, protected RoleService $roleService ) {} public function registerUser(array $data): User { return DB::transaction(function () use ($data) { // Create user $user = $this->userRepository->create([ 'name' => $data['name'], 'email' => $data['email'], 'password' => Hash::make($data['password']), ]); // Assign default role $defaultRole = $this->roleService->getDefaultRole(); $user->roles()->attach($defaultRole); // Send welcome email Mail::to($user)->queue(new WelcomeEmail($user)); // Dispatch event event(new UserRegistered($user)); return $user; }); } public function updateProfile(int $userId, array $data): User { $user = $this->userRepository->find($userId); if (isset($data['avatar'])) { $data['avatar'] = $this->uploadAvatar($data['avatar']); } return $this->userRepository->update($userId, $data); } public function deactivateUser(int $userId): bool { return DB::transaction(function () use ($userId) { $user = $this->userRepository->find($userId); // Log out all sessions $user->tokens()->delete(); // Mark as inactive $this->userRepository->update($userId, [ 'is_active' => false, 'deactivated_at' => now(), ]); return true; }); } protected function uploadAvatar($file): string { // Avatar upload logic return $file->store('avatars', 'public'); } } // Usage in controller public function __construct( protected UserService $userService ) {} public function register(RegisterRequest $request) { $user = $this->userService->registerUser($request->validated()); return redirect()->route('dashboard') ->with('success', 'Welcome to our platform!'); }

Action Classes (Single-purpose Classes)

Action classes follow Single Responsibility Principle - one class, one action:

// app/Actions/User/CreateUserAction.php namespace App\Actions\User; use App\Models\User; use Illuminate\Support\Facades\Hash; class CreateUserAction { public function execute(array $data): User { return User::create([ 'name' => $data['name'], 'email' => $data['email'], 'password' => Hash::make($data['password']), ]); } } // app/Actions/User/SendWelcomeEmailAction.php namespace App\Actions\User; use App\Mail\WelcomeEmail; use App\Models\User; use Illuminate\Support\Facades\Mail; class SendWelcomeEmailAction { public function execute(User $user): void { Mail::to($user)->queue(new WelcomeEmail($user)); } } // app/Actions/User/RegisterUserAction.php namespace App\Actions\User; use App\Models\User; use Illuminate\Support\Facades\DB; class RegisterUserAction { public function __construct( protected CreateUserAction $createUser, protected AssignDefaultRoleAction $assignRole, protected SendWelcomeEmailAction $sendEmail ) {} public function execute(array $data): User { return DB::transaction(function () use ($data) { $user = $this->createUser->execute($data); $this->assignRole->execute($user); $this->sendEmail->execute($user); return $user; }); } } // Usage in controller public function register( RegisterRequest $request, RegisterUserAction $registerUser ) { $user = $registerUser->execute($request->validated()); auth()->login($user); return redirect()->route('dashboard'); }
When to Use Action Classes:
  • Use for complex operations with multiple steps
  • When the same logic is used in multiple places
  • For better testability of individual steps
  • Avoid over-abstracting simple operations
  • Don't create actions for trivial one-liners

Data Transfer Objects (DTOs)

DTOs are simple objects that carry data between processes:

// app/DataTransferObjects/UserData.php namespace App\DataTransferObjects; readonly class UserData { public function __construct( public string $name, public string $email, public string $password, public ?string $phone = null, public bool $isActive = true, ) {} public static function fromRequest(array $data): self { return new self( name: $data['name'], email: $data['email'], password: $data['password'], phone: $data['phone'] ?? null, isActive: $data['is_active'] ?? true, ); } public static function fromModel(User $user): self { return new self( name: $user->name, email: $user->email, password: $user->password, phone: $user->phone, isActive: $user->is_active, ); } public function toArray(): array { return [ 'name' => $this->name, 'email' => $this->email, 'password' => $this->password, 'phone' => $this->phone, 'is_active' => $this->isActive, ]; } } // Usage public function store(RegisterRequest $request, CreateUserAction $createUser) { $userData = UserData::fromRequest($request->validated()); $user = $createUser->execute($userData->toArray()); return redirect()->route('users.show', $user); } // With Spatie Laravel Data package (recommended) composer require spatie/laravel-data // app/Data/UserData.php namespace App\Data; use Spatie\LaravelData\Data; class UserData extends Data { public function __construct( public string $name, public string $email, public string $password, public ?string $phone, ) {} } // Automatic validation and transformation public function store(UserData $data) { $user = User::create($data->toArray()); return UserData::from($user); }

Domain-Driven Design in Laravel

Organize code by business domains rather than technical layers:

// Domain-based structure app/ ├── Domain/ │ ├── User/ │ │ ├── Models/ │ │ │ └── User.php │ │ ├── Actions/ │ │ │ ├── CreateUserAction.php │ │ │ └── UpdateUserAction.php │ │ ├── DataTransferObjects/ │ │ │ └── UserData.php │ │ ├── Events/ │ │ │ └── UserRegistered.php │ │ ├── Exceptions/ │ │ │ └── UserNotFoundException.php │ │ ├── Policies/ │ │ │ └── UserPolicy.php │ │ └── QueryBuilders/ │ │ └── UserQueryBuilder.php │ │ │ ├── Order/ │ │ ├── Models/ │ │ │ ├── Order.php │ │ │ └── OrderItem.php │ │ ├── Actions/ │ │ │ ├── CreateOrderAction.php │ │ │ ├── ProcessPaymentAction.php │ │ │ └── SendOrderConfirmationAction.php │ │ ├── States/ │ │ │ ├── OrderState.php │ │ │ ├── PendingState.php │ │ │ ├── ProcessingState.php │ │ │ └── CompletedState.php │ │ └── ValueObjects/ │ │ ├── Money.php │ │ └── OrderNumber.php │ │ │ └── Billing/ │ ├── Models/ │ ├── Actions/ │ └── Services/ │ └── Application/ ├── Http/ │ └── Controllers/ │ ├── UserController.php │ └── OrderController.php ├── Console/ └── Providers/ // Example Value Object namespace App\Domain\Order\ValueObjects; readonly class Money { public function __construct( public float $amount, public string $currency = 'USD' ) { if ($amount < 0) { throw new \InvalidArgumentException('Amount cannot be negative'); } } public function add(Money $other): self { $this->ensureSameCurrency($other); return new self($this->amount + $other->amount, $this->currency); } public function format(): string { return match ($this->currency) { 'USD' => '$' . number_format($this->amount, 2), 'EUR' => '€' . number_format($this->amount, 2), default => $this->currency . ' ' . number_format($this->amount, 2), }; } protected function ensureSameCurrency(Money $other): void { if ($this->currency !== $other->currency) { throw new \InvalidArgumentException('Currencies must match'); } } }

Modular Monolith Architecture

Structure your application as independent modules within a monolith:

// Modular structure using Laravel Modules package composer require nwidart/laravel-modules php artisan module:make Blog php artisan module:make Shop php artisan module:make Forum // Structure Modules/ ├── Blog/ │ ├── Config/ │ ├── Console/ │ ├── Database/ │ │ ├── Migrations/ │ │ └── Seeders/ │ ├── Entities/ │ │ └── Post.php │ ├── Http/ │ │ ├── Controllers/ │ │ ├── Middleware/ │ │ └── Requests/ │ ├── Providers/ │ │ └── BlogServiceProvider.php │ ├── Resources/ │ │ ├── assets/ │ │ ├── lang/ │ │ └── views/ │ ├── Routes/ │ │ ├── api.php │ │ └── web.php │ ├── Tests/ │ ├── composer.json │ └── module.json │ └── Shop/ └── (similar structure) // Module service provider namespace Modules\Blog\Providers; use Illuminate\Support\ServiceProvider; class BlogServiceProvider extends ServiceProvider { protected string $moduleName = 'Blog'; public function boot(): void { $this->registerTranslations(); $this->registerConfig(); $this->registerViews(); $this->loadMigrationsFrom(module_path($this->moduleName, 'Database/Migrations')); } public function register(): void { $this->app->register(RouteServiceProvider::class); } }
Choosing the Right Architecture: Small Applications (<10 models):
  • Default Laravel structure with services is sufficient
  • Don't over-engineer simple applications
Medium Applications (10-50 models):
  • Repository pattern for complex queries
  • Service layer for business logic
  • Action classes for reusable operations
Large Applications (>50 models):
  • Domain-driven design
  • Modular monolith
  • Consider microservices for specific domains

Query Object Pattern

// app/Queries/UserQueries/ActiveUsersQuery.php namespace App\Queries\UserQueries; use App\Models\User; use Illuminate\Database\Eloquent\Builder; class ActiveUsersQuery { public function __construct( protected ?string $search = null, protected ?string $role = null, protected ?string $sortBy = 'created_at', protected ?string $sortDirection = 'desc' ) {} public function get() { return $this->query()->get(); } public function paginate(int $perPage = 15) { return $this->query()->paginate($perPage); } protected function query(): Builder { return User::query() ->where('is_active', true) ->when($this->search, fn($q) => $q->where('name', 'like', "%{$this->search}%\")) ->when($this->role, fn($q) => $q->whereHas('roles', fn($q) => $q->where('name', $this->role))) ->orderBy($this->sortBy, $this->sortDirection); } } // Usage in controller public function index(Request $request) { $users = (new ActiveUsersQuery( search: $request->input('search'), role: $request->input('role'), sortBy: $request->input('sort_by', 'created_at'), sortDirection: $request->input('sort_direction', 'desc') ))->paginate(); return view('users.index', compact('users')); }
Exercise 1: Refactor a controller using architectural patterns:
  1. Take an existing controller with 5+ methods
  2. Extract business logic into service classes
  3. Create repository interfaces and implementations
  4. Use DTOs for data transfer
  5. Create action classes for complex operations
  6. Write tests for all layers
Exercise 2: Implement a domain-driven design structure:
  1. Choose a business domain (e.g., Order, Inventory, User)
  2. Create domain folder structure
  3. Implement value objects (Money, Email, Address)
  4. Create domain events and listeners
  5. Build aggregate roots with business rules
  6. Separate application layer from domain layer
Exercise 3: Build a modular blog system:
  1. Install Laravel Modules package
  2. Create Blog module with posts, categories, comments
  3. Implement service providers for the module
  4. Add module-specific routes and controllers
  5. Create module tests
  6. Build another module (e.g., Media) and integrate them

Tutorial Complete!

Congratulations! You have completed all lessons in this tutorial.