Framework Agnostic: Hexagonal Architecture in Laravel 🛡️

php dev.to

The Trap of Framework Coupling

Laravel is arguably the most productive web framework in existence. However, its greatest strength—rapid development via tools like Eloquent ORM and Facades—is also its greatest architectural vulnerability at enterprise scale. In a standard Laravel application, your business logic is inherently tightly coupled to the database. You write User::where('status', 'active')->get() directly inside your controllers.

This creates a massive dependency issue. Your core business rules (e.g., "How does a premium user qualify for a loan?") become hopelessly entangled with Laravel's infrastructure (HTTP requests, database connections, cache drivers). If you ever need to swap your MySQL database for MongoDB, or if you want to execute your business logic from a CLI command instead of a web controller, you are forced to rewrite massive portions of your application. You cannot unit test your business logic without booting up the entire Laravel framework and a testing database, dragging your CI/CD pipeline down to a crawl.

At Smart Tech Devs, we build software designed to outlive the framework it was written on. For our most complex enterprise domains, we abandon the standard MVC pattern and implement Hexagonal Architecture (also known as Ports and Adapters), invented by Alistair Cockburn.

The Philosophy of Ports and Adapters

Hexagonal Architecture envisions your application as a series of concentric layers. At the absolute center is the Core Domain. This layer contains your pure business logic. It must be written in pure PHP. It is forbidden from importing any Laravel-specific classes, Eloquent models, or HTTP libraries.

To communicate with the outside world (like a database or an external API), the Core Domain defines Ports (standard PHP Interfaces). The outside layers (the Adapters) implement these interfaces.

  • Primary Adapters (Driving): Things that trigger your application (e.g., a Laravel HTTP Controller, an Artisan CLI Command).
  • Secondary Adapters (Driven): Things your application triggers (e.g., an Eloquent MySQL Repository, a Stripe API client).

Phase 1: Architecting the Pure Core Domain

Let's build a service that approves enterprise loans. We first define our pure Domain Object and the Port (Interface) required to fetch data. Notice there is absolutely no mention of Laravel, Eloquent, or SQL here.


namespace App\Domain\Loans\Entities;

// 1. The Pure Domain Entity (Not an Eloquent Model!)
class LoanApplication
{
    public function __construct(
        private string $id,
        private float $requestedAmount,
        private int $creditScore,
        private string $status = 'pending'
    ) {}

    public function evaluate(): void
    {
        if ($this->creditScore >= 750 && $this->requestedAmount <= 100000) {
            $this->status = 'approved';
        } else {
            $this->status = 'rejected';
        }
    }

    public function getStatus(): string
    {
        return $this->status;
    }
}

namespace App\Domain\Loans\Ports;

use App\Domain\Loans\Entities\LoanApplication;

// 2. The Port (An interface defining the contract for the outside world)
interface LoanRepositoryInterface
{
    public function findById(string $id): ?LoanApplication;
    public function save(LoanApplication $loan): void;
}

Phase 2: The Core Application Service (Use Case)

Now we build the Use Case. This class orchestrates the business logic. It relies purely on the Interface (Port), meaning it has no idea if the data is coming from MySQL, an external API, or an in-memory array.


namespace App\Domain\Loans\UseCases;

use App\Domain\Loans\Ports\LoanRepositoryInterface;
use Exception;

class EvaluateLoanUseCase
{
    // Dependency Injection of the Interface
    public function __construct(
        private LoanRepositoryInterface $repository
    ) {}

    public function execute(string $loanId): string
    {
        $loan = $this->repository->findById($loanId);

        if (!$loan) {
            throw new Exception("Loan application not found.");
        }

        $loan->evaluate(); // Execute pure business logic
        $this->repository->save($loan); // Save via the interface

        return $loan->getStatus();
    }
}

Phase 3: Architecting the Driven Adapter (Eloquent)

Now we step outside the hexagon. We must build a concrete implementation of our LoanRepositoryInterface using Laravel's Eloquent ORM. This Adapter translates database rows into our pure Domain Entities.


namespace App\Infrastructure\Persistence\Eloquent;

use App\Domain\Loans\Entities\LoanApplication;
use App\Domain\Loans\Ports\LoanRepositoryInterface;
use App\Models\EloquentLoan; // The actual Laravel Active Record Model

class EloquentLoanRepository implements LoanRepositoryInterface
{
    public function findById(string $id): ?LoanApplication
    {
        $record = EloquentLoan::find($id);
        
        if (!$record) return null;

        // Map the database row to our pure Domain Entity
        return new LoanApplication(
            $record->id,
            $record->amount,
            $record->credit_score,
            $record->status
        );
    }

    public function save(LoanApplication $loan): void
    {
        // Map the Domain Entity back to Eloquent to save it
        EloquentLoan::updateOrCreate(
            ['id' => $loan->getId()],
            ['status' => $loan->getStatus()]
        );
    }
}

Phase 4: Binding and the Driving Adapter (Controller)

Finally, we tell Laravel's Service Container to inject our EloquentLoanRepository whenever the LoanRepositoryInterface is requested. Then, our standard Laravel HTTP Controller simply triggers the Use Case.


// App\Providers\AppServiceProvider.php
public function register()
{
    $this->app->bind(
        \App\Domain\Loans\Ports\LoanRepositoryInterface::class,
        \App\Infrastructure\Persistence\Eloquent\EloquentLoanRepository::class
    );
}

// App\Http\Controllers\LoanController.php
namespace App\Http\Controllers;

use App\Domain\Loans\UseCases\EvaluateLoanUseCase;
use Illuminate\Http\JsonResponse;

class LoanController extends Controller
{
    public function evaluate(string $id, EvaluateLoanUseCase $useCase): JsonResponse
    {
        $status = $useCase->execute($id);
        
        return response()->json(['message' => "Loan was {$status}"]);
    }
}

The Engineering ROI and Ultimate Testability

Architecting Hexagonal Architecture in Laravel introduces significant initial boilerplate, but the enterprise ROI is staggering. Your business logic is now mathematically decoupled from Laravel. If you want to write a unit test for EvaluateLoanUseCase, you can simply pass an in-memory mock array into the constructor. The test will run in 0.001 seconds because it never touches a database or boots the framework.

Furthermore, if a massive architectural shift occurs—such as migrating from MySQL to a gRPC microservice architecture—your core business logic remains entirely untouched. You simply write a new GrpcLoanRepository, bind it in the Service Provider, and the system continues operating flawlessly. By protecting the core domain with Ports and Adapters, you elevate your Laravel codebase from a standard web script into a resilient, immortal enterprise asset.

Source: dev.to

arrow_back Back to Tutorials