TL;DR — Laravel 12 is a maintenance-focused release that runs effortlessly on PHP 8.4.
By embracing Clean Architecture (a.k.a. hexagonal / DDD-lite) you can isolate
business rules from Laravel details and refactor your application one slice at a time.
1 | Why bother restructuring at all? 🤔
- Controllers stuffed with SQL become a nightmare to test.
- Eloquent models owning business logic blur the line between “what” and “how.”
- Tight coupling to framework classes slows upgrades (remember the jump from 10 ➜ 11?).
Clean Architecture helps you keep policy (business rules) at the core and push details
(HTTP, DB, queues, Mailgun, …) to the edges.
2 | Target folder layout
app ├── Domain │ └── User │ ├── Entities/ │ ├── ValueObjects/ │ ├── Events/ │ └── Repositories/ ├── Application │ └── User │ ├── DTOs/ │ ├── UseCases/ │ └── Queries/ ├── Infrastructure │ ├── Persistence/Eloquent/ │ └── Services/Mailgun/ └── Interfaces ├── Http/Controllers/ ├── Http/Requests/ ├── Http/Resources/ └── Console/
Each vertical slice (e.g. User
, Billing
, Catalog
) owns its own Domain/Application
code.
Fewer cross-slice dependencies = easier parallel work.
3 | The layers at a glance
Layer | Purpose (what) | Allowed to depend on … |
---|---|---|
Domain | Pure business rules & invariants | None |
Application | Orchestrate a single use-case / transaction | Domain |
Infrastructure | DB, HTTP clients, external APIs, mail, … | Application → Domain |
Interfaces | Delivery (HTTP/CLI/Broadcast) | Application |
4 | Leveraging PHP 8.4’s Property Hooks
<?php declare(strict_types=1); namespace App\Domain\User\ValueObjects; final class Email { public string $value { set(string $v) { if (!filter_var($v, FILTER_VALIDATE_EMAIL)) { throw new \InvalidArgumentException('Invalid email.'); } $this->value = strtolower($v); } get => $this->value; } public function __construct(string $email) { $this->value = $email; // setter fires automatically } public function __toString(): string { return $this->value; } }
Property hooks remove a ton of boiler-plate getters/setters while keeping
validation close to the property itself.
5 | A full mini-flow: “Register User”
5.1 Domain Entity
final readonly class User { public function __construct( public string $id, public string $name, public Email $email, public string $passwordHash, ) {} }
5.2 Repository Contract
namespace App\Domain\User\Repositories; interface UserRepository { public function save(User $user): void; public function findByEmail(string $email): ?User; }
5.3 Eloquent Adapter
namespace App\Infrastructure\Persistence\Eloquent; use App\Domain\User\Entities\User; use App\Domain\User\Repositories\UserRepository; final class UserRepositoryEloquent implements UserRepository { public function __construct(private \App\Models\User $model) {} public function save(User $user): void { $this->model->forceFill([ 'id' => $user->id, 'name' => $user->name, 'email' => (string) $user->email, 'password' => $user->passwordHash, ])->save(); } public function findByEmail(string $email): ?User { $row = $this->model->where('email', $email)->first(); return $row ? new User( id: $row->id, name: $row->name, email: new \App\Domain\User\ValueObjects\Email($row->email), passwordHash: $row->password, ) : null; } }
5.4 Use-Case Service
namespace App\Application\User\UseCases; use App\Domain\User\Entities\User; use App\Domain\User\Repositories\UserRepository; use App\Domain\User\ValueObjects\Email; use Illuminate\Support\Facades\Hash; use Ramsey\Uuid\Uuid; final readonly class RegisterUserData { public function __construct( public string $name, public string $email, public string $password, ) {} } final class RegisterUser { public function __construct(private UserRepository $users) {} public function execute(RegisterUserData $dto): User { if ($this->users->findByEmail($dto->email)) { throw new \DomainException('Email already taken'); } $user = new User( id: Uuid::uuid7()->toString(), name: $dto->name, email: new Email($dto->email), passwordHash: Hash::make($dto->password), ); $this->users->save($user); event(new \App\Domain\User\Events\UserRegistered($user)); return $user; } }
5.5 HTTP Controller (thin!)
<?php namespace App\Interfaces\Http\Controllers; use App\Application\User\UseCases\RegisterUser; use App\Application\User\UseCases\RegisterUserData; use App\Interfaces\Http\Requests\RegisterUserRequest; use App\Interfaces\Http\Resources\UserResource; use Illuminate\Http\JsonResponse; use Illuminate\Routing\Attributes\Route; use Symfony\Component\HttpFoundation\Response; #[Route('POST', '/api/users')] final class UserController { public function __invoke( RegisterUserRequest $request, RegisterUser $action, ): JsonResponse { $user = $action->execute(RegisterUserData::fromRequest($request)); return UserResource::make($user) ->response() ->setStatusCode(Response::HTTP_CREATED); } }
6 | Refactoring a legacy codebase step-by-step
- Trace business rules currently hiding in controllers, jobs, views, etc.
- Create the new folder structure – no code changes needed yet.
- Move one use-case (e.g. “Register User”) behind a new Application service.
- Write unit tests for Domain + Application layers (Pest ships by default in Laravel 12).
- Replace Eloquent calls with a repository interface; implement it with Eloquent for now.
- Repeat with the next slice.
- Delete dead code as you go – you’ll be surprised how much falls away.
7 | Laravel 12 niceties worth using
- Slim Skeleton – no more
routes/api.php
by default; add only what you need. - Unified scaffolding (
php artisan make:usecase
) to generate DTO + UseCase stubs. - Nested
where()
helper increases readability for deep query conditions. - Starter Kits v2 (React, Vue, Livewire) if you choose to rewrite front-end pieces later.
8 | Takeaways
- Small, vertical slices ➜ less risk, easier testing.
- Pure PHP Domain ➜ independent of Laravel upgrades.
- PHP 8.4 property hooks ➜ goodbye, boiler-plate accessors.
- Laravel 12’s focus on stability provides the perfect window for an internal architecture overhaul.
Top comments (0)