Clean Architecture in Laravel 9, Project Layout
TL;DR — Laravel 9 + Clean: add
app/Domain/,app/UseCase/,app/Repository/(interfaces + impls). Keep Eloquent in the repository implementations only. Controllers stay thin — decode → use case → respond. The framework’s service container does the wiring.
After six posts on the Go side, switching to Laravel 9. The patterns translate. The ergonomics differ. This post is the Laravel layout I use.
The layout
billing/
├── app/
│ ├── Domain/
│ │ ├── Subscription.php # entity
│ │ ├── Plan.php # value object
│ │ ├── Status.php # enum
│ │ ├── Exceptions/
│ │ │ ├── InvalidPlanException.php
│ │ │ └── AlreadyCanceledException.php
│ │ └── Events/
│ │ └── SubscriptionStarted.php
│ ├── UseCase/
│ │ ├── StartSubscription/
│ │ │ ├── StartSubscription.php
│ │ │ ├── StartSubscriptionInput.php
│ │ │ └── StartSubscriptionOutput.php
│ │ └── CancelSubscription/
│ │ ├── CancelSubscription.php
│ │ └── CancelSubscriptionInput.php
│ ├── Repository/
│ │ ├── SubscriptionRepository.php # interface
│ │ └── Eloquent/
│ │ ├── EloquentSubscriptionRepository.php # impl
│ │ └── Models/
│ │ └── SubscriptionModel.php # Eloquent model
│ ├── Http/
│ │ ├── Controllers/
│ │ │ └── SubscriptionController.php
│ │ └── Requests/
│ │ └── CreateSubscriptionRequest.php # Laravel form request
│ └── Providers/
│ └── DomainServiceProvider.php # wires Repository → Eloquent impl
├── routes/
│ └── api.php
├── config/
└── composer.json
Three new top-level directories under app/: Domain/, UseCase/, Repository/. Laravel’s defaults stay: Http/, Providers/, etc.
Domain entity in PHP
<?php
declare(strict_types=1);
namespace App\Domain;
use App\Domain\Exceptions\InvalidPlanException;
use App\Domain\Exceptions\AlreadyCanceledException;
use DateTimeImmutable;
use Ramsey\Uuid\UuidInterface;
use Ramsey\Uuid\Uuid;
final class Subscription
{
private UuidInterface $id;
private UuidInterface $customerId;
private Plan $plan;
private Status $status;
private DateTimeImmutable $startedAt;
private DateTimeImmutable $renewsAt;
private ?DateTimeImmutable $canceledAt = null;
private function __construct(
UuidInterface $id,
UuidInterface $customerId,
Plan $plan,
Status $status,
DateTimeImmutable $startedAt,
DateTimeImmutable $renewsAt,
) {
$this->id = $id;
$this->customerId = $customerId;
$this->plan = $plan;
$this->status = $status;
$this->startedAt = $startedAt;
$this->renewsAt = $renewsAt;
}
public static function start(UuidInterface $customerId, Plan $plan, DateTimeImmutable $now): self
{
if ($plan->id === '') {
throw new InvalidPlanException();
}
return new self(
Uuid::uuid4(),
$customerId,
$plan,
Status::Active,
$now,
$now->modify('+1 month'),
);
}
public static function restore(/* all fields */): self
{
// for hydration from DB
}
public function cancel(DateTimeImmutable $now): void
{
if ($this->status === Status::Canceled) {
throw new AlreadyCanceledException();
}
$this->status = Status::Canceled;
$this->canceledAt = $now;
}
public function id(): UuidInterface { return $this->id; }
public function status(): Status { return $this->status; }
// ... other getters
}
PHP 8.1 + strict types. Private constructor; static factory methods. Same shape as Go. The verbosity is a PHP tax, not a Clean Architecture tax.
Repository interface + Eloquent impl
<?php
namespace App\Repository;
use App\Domain\Subscription;
use Ramsey\Uuid\UuidInterface;
interface SubscriptionRepository
{
public function save(Subscription $subscription): void;
public function getById(UuidInterface $id): ?Subscription;
}
<?php
namespace App\Repository\Eloquent;
use App\Domain\Plan;
use App\Domain\Status;
use App\Domain\Subscription;
use App\Repository\SubscriptionRepository;
use App\Repository\Eloquent\Models\SubscriptionModel;
use Ramsey\Uuid\UuidInterface;
use Ramsey\Uuid\Uuid;
final class EloquentSubscriptionRepository implements SubscriptionRepository
{
public function save(Subscription $subscription): void
{
SubscriptionModel::updateOrCreate(
['id' => $subscription->id()->toString()],
[
'customer_id' => $subscription->customerId()->toString(),
'plan_id' => $subscription->plan()->id,
'status' => $subscription->status()->value,
'started_at' => $subscription->startedAt(),
'renews_at' => $subscription->renewsAt(),
'canceled_at' => $subscription->canceledAt(),
],
);
}
public function getById(UuidInterface $id): ?Subscription
{
$row = SubscriptionModel::find($id->toString());
if (!$row) return null;
return Subscription::restore(
Uuid::fromString($row->id),
Uuid::fromString($row->customer_id),
new Plan($row->plan_id, /* price, currency, interval */),
Status::from($row->status),
new DateTimeImmutable($row->started_at),
new DateTimeImmutable($row->renews_at),
$row->canceled_at ? new DateTimeImmutable($row->canceled_at) : null,
);
}
}
SubscriptionModel is Eloquent. It’s only used inside the repository implementation. Domain code never sees it. Controllers never see it.
Service provider wires the binding
<?php
namespace App\Providers;
use App\Repository\SubscriptionRepository;
use App\Repository\Eloquent\EloquentSubscriptionRepository;
use Illuminate\Support\ServiceProvider;
class DomainServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(SubscriptionRepository::class, EloquentSubscriptionRepository::class);
}
}
Register in config/app.php. Now any class type-hinting SubscriptionRepository gets the Eloquent impl automatically.
Use case in Laravel
<?php
namespace App\UseCase\StartSubscription;
use App\Domain\Events\SubscriptionStarted;
use App\Domain\Plan;
use App\Domain\Subscription;
use App\Repository\PlanRepository;
use App\Repository\SubscriptionRepository;
use Illuminate\Contracts\Events\Dispatcher;
use Ramsey\Uuid\UuidInterface;
use DateTimeImmutable;
final class StartSubscription
{
public function __construct(
private SubscriptionRepository $subs,
private PlanRepository $plans,
private Dispatcher $events,
) {}
public function execute(StartSubscriptionInput $in): StartSubscriptionOutput
{
$plan = $this->plans->getById($in->planId);
if (!$plan) {
throw new InvalidPlanException();
}
$sub = Subscription::start($in->customerId, $plan, new DateTimeImmutable());
$this->subs->save($sub);
$this->events->dispatch(new SubscriptionStarted($sub));
return new StartSubscriptionOutput($sub);
}
}
Laravel auto-resolves constructor dependencies via the service container. The use case asks for SubscriptionRepository; it gets the Eloquent impl bound in the service provider. No manual wiring needed in main.
Controller stays thin
<?php
namespace App\Http\Controllers;
use App\Http\Requests\CreateSubscriptionRequest;
use App\UseCase\StartSubscription\StartSubscription;
use App\UseCase\StartSubscription\StartSubscriptionInput;
use Ramsey\Uuid\Uuid;
class SubscriptionController extends Controller
{
public function __construct(private StartSubscription $startSub) {}
public function store(CreateSubscriptionRequest $req)
{
$output = $this->startSub->execute(new StartSubscriptionInput(
customerId: Uuid::fromString($req->validated()['customer_id']),
planId: $req->validated()['plan_id'],
));
return response()->json($this->present($output->subscription), 201);
}
private function present($subscription): array
{
return [
'id' => $subscription->id()->toString(),
'status' => $subscription->status()->value,
// ...
];
}
}
Decode (via FormRequest) → use case → present → respond. Controller has 10 lines of meaningful code.
What this gives you in Laravel
Same wins as Go:
- Business logic isolated from framework
- Eloquent confined to one directory
- Unit tests don’t need the framework
- Replacing Eloquent (eg, switching to DBAL or another ORM) means changing one folder
What it costs:
- More files than the Laravel default
- Some Laravel idiom is left on the table (you don’t use Eloquent in controllers)
- Onboarding from “vanilla Laravel” takes adjustment
Common Pitfalls
Returning Eloquent models from use cases. Eloquent leaks. Always domain.
Using Auth::user() inside use cases. Auth is HTTP context; pass user ID into the use case input.
Validation in use cases instead of FormRequest. FormRequest is the right place for request-shape validation.
Treating events as cross-layer. Domain events are domain types; framework event dispatch is adapter. Domain emits events, adapter publishes via Laravel’s dispatcher.
Service providers doing too much. They wire bindings. Business logic in providers is a code smell.
Wrapping Up
Laravel 9 + Clean Architecture is more verbose but doable. Service container handles DI; controllers stay thin; Eloquent confined to repositories. Wednesday: the service container in detail.