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)