DEV Community

A0mineTV
A0mineTV

Posted on

Real-World Refactoring with Laravel 12 & PHP 8.4 – A Clean-Architecture Playbook

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/ 
Enter fullscreen mode Exit fullscreen mode

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; } } 
Enter fullscreen mode Exit fullscreen mode

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, ) {} } 
Enter fullscreen mode Exit fullscreen mode

5.2 Repository Contract

namespace App\Domain\User\Repositories; interface UserRepository { public function save(User $user): void; public function findByEmail(string $email): ?User; } 
Enter fullscreen mode Exit fullscreen mode

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; } } 
Enter fullscreen mode Exit fullscreen mode

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; } } 
Enter fullscreen mode Exit fullscreen mode

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); } } 
Enter fullscreen mode Exit fullscreen mode

6 | Refactoring a legacy codebase step-by-step

  1. Trace business rules currently hiding in controllers, jobs, views, etc.
  2. Create the new folder structure – no code changes needed yet.
  3. Move one use-case (e.g. “Register User”) behind a new Application service.
  4. Write unit tests for Domain + Application layers (Pest ships by default in Laravel 12).
  5. Replace Eloquent calls with a repository interface; implement it with Eloquent for now.
  6. Repeat with the next slice.
  7. 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)