V Volkanic
Architecture

Clean Architecture in PHP: what actually matters in production

A pragmatic take on Clean Architecture for PHP backends. Skipping the ceremony, focusing on the boundaries and rules that pay off long-term.

11 Min. Lesezeit
PHPLaravelArchitectureClean CodeBackend

Clean Architecture gets a reputation for being overly abstract and ceremony-heavy. The circles diagram. The dependency rule. Entities, use cases, adapters, frameworks. It can feel like a lot of structure for a CRUD API.

But the core idea is simple and genuinely useful: protect your business logic from your infrastructure. The rest is implementation detail.

Here’s how I apply it in production Laravel systems without the overhead.

The only rule that matters

Your domain logic — the code that encodes what your system does — should not depend on Laravel, on Eloquent, on HTTP, on your database driver, or on any external library.

That’s it. That’s the core rule.

If you can delete your framework and your business logic still compiles and makes sense, you’re doing it right.

Concretely: what belongs where

Domain layer — pure PHP, no dependencies:

// This is business logic. No framework imports.
class Reservation
{
    private ReservationStatus $status;
    private ReservationId $id;
    private TourId $tourId;
    private CustomerId $customerId;
    private DateTimeImmutable $startsAt;
    private Money $totalPrice;

    public function cancel(): void
    {
        if ($this->status->isCompleted()) {
            throw new CannotCancelCompletedReservation($this->id);
        }

        if ($this->status->isCancelled()) {
            return; // idempotent
        }

        $this->status = ReservationStatus::cancelled();
        $this->recordEvent(new ReservationWasCancelled($this->id));
    }

    public function isWithin24HoursOf(DateTimeImmutable $now): bool
    {
        return $this->startsAt->diff($now)->h < 24;
    }
}

No Eloquent, no Laravel facades, no HTTP. Just PHP and business rules.

Application layer — orchestration, no business logic:

class CancelReservationHandler
{
    public function __construct(
        private ReservationRepository $reservations,
        private EventBus $events,
    ) {}

    public function handle(CancelReservation $command): void
    {
        $reservation = $this->reservations->findOrFail($command->reservationId);

        $reservation->cancel();

        $this->reservations->save($reservation);
        $this->events->dispatch($reservation->pullEvents());
    }
}

This handler knows the sequence but not the rules. Rules live in the domain.

Infrastructure layer — framework code, adapters, implementations:

class EloquentReservationRepository implements ReservationRepository
{
    public function findOrFail(ReservationId $id): Reservation
    {
        $model = ReservationModel::findOrFail($id->value());
        return $this->toDomain($model);
    }

    public function save(Reservation $reservation): void
    {
        $model = ReservationModel::find($reservation->id()->value())
            ?? new ReservationModel();

        $model->fill($this->toDatabase($reservation));
        $model->save();
    }
}

Eloquent is infrastructure. The repository interface is the boundary.

The pragmatic version

For smaller systems or teams not yet bought in, here’s the minimum viable boundary:

  1. Keep business rules out of controllers. Controllers handle HTTP. That’s it.
  2. Keep business rules out of Eloquent models. Models are data containers. Put logic in dedicated classes.
  3. Define interfaces for external dependencies. Database, email, third-party APIs. Mock the interface in tests, swap implementations in production.

You don’t need the full layer cake on day one. The payoff increases as the system grows.

When it’s not worth it

Small CRUD applications with simple, stable business rules. If you’re building admin panels, simple listing APIs, or prototypes, full Clean Architecture adds cost with little benefit.

The signal to introduce more structure: when you find yourself testing controllers instead of business logic, when a framework upgrade terrifies you, or when a business rule change ripples through your entire codebase unexpectedly.

Testing is where you feel the payoff

When domain logic has no framework dependencies, unit tests run in milliseconds:

/** @test */
public function a_completed_reservation_cannot_be_cancelled(): void
{
    $reservation = ReservationBuilder::completed()->build();

    $this->expectException(CannotCancelCompletedReservation::class);

    $reservation->cancel();
}

No database, no HTTP, no mocks. Just fast, reliable feedback.

That speed compounds. A test suite that runs in 2 seconds gets run constantly. One that takes 2 minutes gets skipped.

The tradeoff

Clean Architecture asks you to write more code. More interfaces, more value objects, more mapping between layers. This is real cost.

The payoff is code that’s understandable in isolation, testable without infrastructure, and changeable without fear. In long-lived systems with complex business logic, that trade is worth it. In simple CRUD services, it probably isn’t.

Apply it where it matters. Not uniformly.