background-shape
Clean Architecture in Laravel 9, Project Layout
June 20, 2022 · 5 min read · by Muhammad Amal programming

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.