The Trap of Framework Coupling
When you start building a Laravel application, the framework provides an incredible set of tools to move quickly. Eloquent ORM makes database interactions feel like magic, and HTTP Controllers make routing a breeze. However, as your enterprise application scales over several years, this tight coupling becomes a massive liability. Your business logic becomes inextricably intertwined with Laravelβs specific implementations.
Consider a standard controller method that handles a complex user registration. It might validate the HTTP request, use Eloquent to save the user, dispatch a Laravel Job to send an email, and format a JSON response. If your business stakeholders suddenly decide they want to trigger this exact same registration logic from a CLI command, a background worker, or a newly acquired third-party system, you are trapped. The logic is locked inside an HTTP controller and strictly relies on Eloquent models. You cannot execute the business rule without faking an HTTP request or duplicating the code.
At Smart Tech Devs, we protect our core business logic from framework lock-in by implementing Hexagonal Architecture, also known as Ports and Adapters. Invented by Alistair Cockburn, this architectural pattern dictates that your core domain logic must not know anything about the database, the UI, or the framework. It sits at the center of your application, entirely agnostic and highly testable.
The Core Philosophy: Ports and Adapters
In Hexagonal Architecture, the application is divided into distinct layers, strictly enforcing the Dependency Inversion Principle.
- The Core Domain: This is the pure PHP code where your business rules live. It contains Entities (plain PHP objects, not Eloquent models) and Use Cases (the actions your application can perform). It has absolutely zero dependencies on Laravel.
-
Ports: These are the interfaces (contracts) defined by the Core Domain. If the Core needs to save a user, it defines a
UserRepositoryInterface(an Outbound Port). If an external system wants to trigger a Use Case, it calls an Inbound Port. -
Adapters: These are the concrete implementations that sit on the outside of the hexagon. An Eloquent Adapter implements the
UserRepositoryInterface. An HTTP Controller is an Adapter that triggers an Inbound Port.
Phase 1: Defining the Pure Domain
Let's architect an enterprise subscription activation flow. First, we define a pure PHP Entity. This is not an Eloquent model extending Illuminate\Database\Eloquent\Model. It is a plain class that encapsulates our business rules.
namespace Domain\Subscription\Entities;
use InvalidArgumentException;
class Subscription
{
private string $id;
private string $userId;
private string $status;
private \DateTimeImmutable $activatedAt;
public function __construct(string $id, string $userId, string $status)
{
$this->id = $id;
$this->userId = $userId;
$this->status = $status;
}
// Business Rule: A subscription can only be activated if it is currently pending
public function activate(): void
{
if ($this->status !== 'pending') {
throw new InvalidArgumentException("Only pending subscriptions can be activated.");
}
$this->status = 'active';
$this->activatedAt = new \DateTimeImmutable();
}
public function getStatus(): string
{
return $this->status;
}
}
Phase 2: Defining the Ports (Interfaces)
Our domain knows it needs to fetch and save subscriptions, but it refuses to know how that happens. It doesn't know about MySQL, Redis, or Eloquent. It defines a Port (an Interface) that the outside world must fulfill.
namespace Domain\Subscription\Ports;
use Domain\Subscription\Entities\Subscription;
interface SubscriptionRepositoryInterface
{
public function findById(string $id): ?Subscription;
public function save(Subscription $subscription): void;
}
Phase 3: Creating the Use Case (Application Service)
Now we create the Use Case. This class orchestrates the business logic. Notice how it only relies on the Port (the Interface), meaning it is completely decoupled from the database.
namespace Domain\Subscription\UseCases;
use Domain\Subscription\Ports\SubscriptionRepositoryInterface;
use Exception;
class ActivateSubscriptionUseCase
{
public function __construct(
private SubscriptionRepositoryInterface $repository
) {}
public function execute(string $subscriptionId): void
{
// 1. Fetch the entity via the Port
$subscription = $this->repository->findById($subscriptionId);
if (!$subscription) {
throw new Exception("Subscription not found.");
}
// 2. Execute the pure business logic
$subscription->activate();
// 3. Persist the changes via the Port
$this->repository->save($subscription);
}
}
Phase 4: Building the Adapters (The Infrastructure Layer)
Now we finally step outside the Hexagon and interact with Laravel. We build an Eloquent Adapter that fulfills the contract required by our Port. This adapter handles the translation between pure Domain Entities and Laravel's Eloquent Models.
namespace App\Infrastructure\Adapters;
use Domain\Subscription\Ports\SubscriptionRepositoryInterface;
use Domain\Subscription\Entities\Subscription as DomainSubscription;
use App\Models\EloquentSubscription; // The actual Laravel Model
class EloquentSubscriptionRepository implements SubscriptionRepositoryInterface
{
public function findById(string $id): ?DomainSubscription
{
$eloquentModel = EloquentSubscription::find($id);
if (!$eloquentModel) {
return null;
}
// Translate the Eloquent model back into a pure Domain Entity
return new DomainSubscription(
$eloquentModel->id,
$eloquentModel->user_id,
$eloquentModel->status
);
}
public function save(DomainSubscription $subscription): void
{
// Translate the pure Domain Entity into a database record
EloquentSubscription::updateOrCreate(
['id' => $subscription->getId()],
['status' => $subscription->getStatus()]
);
}
}
Phase 5: Dependency Injection and the Controller
We bind the interface to our concrete Eloquent implementation inside a Laravel Service Provider.
// App\Providers\AppServiceProvider
$this->app->bind(
\Domain\Subscription\Ports\SubscriptionRepositoryInterface::class,
\App\Infrastructure\Adapters\EloquentSubscriptionRepository::class
);
Finally, our HTTP Controller (an Inbound Adapter) simply injects the Use Case and executes it. The controller has no business logic whatsoever.
namespace App\Http\Controllers;
use Domain\Subscription\UseCases\ActivateSubscriptionUseCase;
use Illuminate\Http\Request;
class SubscriptionController extends Controller
{
public function activate(Request $request, string $id, ActivateSubscriptionUseCase $useCase)
{
try {
$useCase->execute($id);
return response()->json(['message' => 'Activated securely.']);
} catch (\Exception $e) {
return response()->json(['error' => $e->getMessage()], 400);
}
}
}
The Engineering ROI and Ultimate Testability
Adopting Hexagonal Architecture is not a trivial undertaking. It introduces significant boilerplate and requires engineers to map data between Domains and Adapters. However, the return on investment for enterprise applications is unparalleled.
First, your business logic becomes infinitely reusable. If you need to activate a subscription from an Artisan CLI command, you simply inject the ActivateSubscriptionUseCase. Second, testing becomes unbelievably fast. Because the Use Case relies on an interface, you can write unit tests using an In-Memory Array Adapter instead of hitting a real database. You can test your entire suite of complex business rules in milliseconds, completely divorced from Laravel's bootstrapping overhead. By keeping your core pure, you ensure that even if you swap your entire database technology ten years from now, your core business code remains completely untouched.
Top comments (0)