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)