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.phpby 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)