Clean Architecture in PHP Applications

Philip Rehberger Nov 5, 2025 9 min read

How to structure your PHP applications for long-term maintainability and testability.

Clean Architecture in PHP Applications

When codebases grow, they tend toward disorder. Features become tangled together. Changing one thing breaks another. Testing becomes difficult or impossible. Clean Architecture offers a way out;a set of principles for organizing code that remains maintainable as applications scale.

What Is Clean Architecture?

Clean Architecture, popularized by Robert C. Martin, organizes code into concentric layers. Each layer has specific responsibilities and dependencies that only point inward, toward the core business logic.

The key insight is separating what your application does (business rules) from how it does it (frameworks, databases, APIs). This separation makes your code easier to test, modify, and understand.

The Dependency Rule

The fundamental rule of Clean Architecture: source code dependencies must only point inward. Inner layers cannot know anything about outer layers.

Your business logic shouldn't know whether data comes from MySQL or MongoDB. It shouldn't know whether requests come via HTTP or CLI. These are details that belong in outer layers.

This rule creates flexibility. You can swap databases, change frameworks, or add new interfaces without touching your core business logic.

Understanding the Layers

Entities (Domain Layer)

Entities encapsulate enterprise-wide business rules. They're the most stable part of your application;the logic that exists independent of any particular system.

In a project management application, entities might include a Project class that enforces core business rules. The following example demonstrates how you can model a Project entity with value objects for identifiers and money, along with methods that encapsulate business logic. Notice how this entity contains no framework dependencies and focuses purely on domain logic.

class Project
{
    private ProjectId $id;
    private string $name;
    private ProjectStatus $status;
    private Money $budget;
    private Collection $tasks;

    public function markComplete(): void
    {
        if ($this->hasIncompleteTasks()) {
            throw new CannotCompleteProjectException('All tasks must be complete');
        }
        $this->status = ProjectStatus::Completed;
    }

    public function isOverBudget(): bool
    {
        return $this->totalSpent()->greaterThan($this->budget);
    }
}

Entities contain business rules that would exist even if there were no software system. "A project cannot be marked complete while it has incomplete tasks" is a business rule, not a framework concern. The markComplete() method encapsulates this rule, throwing an exception when the precondition isn't met rather than silently allowing invalid state. You'll find that this pattern of validating preconditions before state changes becomes second nature once you start thinking in terms of domain invariants.

Use Cases (Application Layer)

Use cases orchestrate the flow of data to and from entities. They implement application-specific business rules.

Here's a use case that handles completing a project. You'll use this pattern whenever you need to coordinate multiple operations, such as validating input, calling entity methods, persisting changes, and triggering side effects like notifications. The use case acts as the conductor, ensuring each step happens in the right order.

class CompleteProjectUseCase
{
    public function __construct(
        private ProjectRepository $projects,
        private NotificationService $notifications,
    ) {}

    public function execute(CompleteProjectRequest $request): CompleteProjectResponse
    {
        $project = $this->projects->findById($request->projectId);

        if (!$project) {
            throw new ProjectNotFoundException();
        }

        $project->markComplete();

        $this->projects->save($project);
        $this->notifications->notifyProjectComplete($project);

        return new CompleteProjectResponse($project);
    }
}

Use cases know about entities but don't know about controllers, databases, or frameworks. They work with interfaces (like ProjectRepository) rather than concrete implementations. Notice how the constructor accepts interfaces, allowing you to inject mock implementations during testing or swap out infrastructure components without changing business logic. This dependency inversion is what makes Clean Architecture so powerful for long-lived applications.

Interface Adapters

This layer converts data between the format used by use cases and the format needed by external systems.

Controllers adapt HTTP requests to use case inputs. This thin controller does nothing but translate between Laravel's HTTP layer and your application's use case, keeping framework-specific code isolated from business logic. You'll appreciate this separation when you need to add a CLI command or API endpoint that performs the same action.

class ProjectController
{
    public function complete(Request $request, CompleteProjectUseCase $useCase)
    {
        $response = $useCase->execute(
            new CompleteProjectRequest($request->route('id'))
        );

        return response()->json([
            'id' => $response->project->id()->toString(),
            'status' => $response->project->status()->value,
        ]);
    }
}

Repositories implement the interfaces defined in inner layers. The following implementation maps between Eloquent models and domain entities, keeping your domain layer ignorant of database details. You'll write this kind of mapping code frequently when adopting Clean Architecture.

class EloquentProjectRepository implements ProjectRepository
{
    public function findById(ProjectId $id): ?Project
    {
        $model = ProjectModel::find($id->toString());

        return $model ? $this->toDomain($model) : null;
    }

    private function toDomain(ProjectModel $model): Project
    {
        return new Project(
            ProjectId::fromString($model->id),
            $model->name,
            ProjectStatus::from($model->status),
            Money::fromCents($model->budget_cents),
        );
    }
}

The toDomain() method is crucial here. It transforms the flat database representation into rich domain objects with proper value objects and enums. This mapping code lives in the infrastructure layer so your domain remains pure. You can think of this as a translator that speaks both "database" and "domain" languages.

Frameworks and Drivers

The outermost layer contains frameworks, tools, and delivery mechanisms. Laravel itself lives here. So do your database drivers, web servers, and external APIs.

This layer contains glue code that connects everything together;service providers, route definitions, middleware configuration.

Implementing in Laravel

Laravel's conventions don't naturally align with Clean Architecture, but they can coexist.

Directory Structure

Organize code by layer, not by Laravel convention. This structure makes dependencies visible at the file system level and helps enforce the dependency rule. When you open the Domain folder, you should see only pure PHP classes with no Laravel imports.

app/
├── Domain/
│   ├── Project/
│   │   ├── Project.php
│   │   ├── ProjectId.php
│   │   ├── ProjectStatus.php
│   │   └── ProjectRepository.php (interface)
│   └── Task/
│       └── ...
├── Application/
│   └── Project/
│       ├── CompleteProjectUseCase.php
│       ├── CompleteProjectRequest.php
│       └── CompleteProjectResponse.php
├── Infrastructure/
│   ├── Persistence/
│   │   └── EloquentProjectRepository.php
│   └── Notifications/
│       └── MailNotificationService.php
└── Http/
    └── Controllers/
        └── ProjectController.php

Service Container Binding

Use Laravel's service container to inject implementations. This is where you wire up your interfaces to concrete classes, and it's the only place that knows about these mappings. You'll typically configure these bindings in a service provider during application bootstrap.

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(
            ProjectRepository::class,
            EloquentProjectRepository::class
        );

        $this->app->bind(
            NotificationService::class,
            MailNotificationService::class
        );
    }
}

These bindings act as a composition root for your application. When testing, you can override these bindings to inject fakes or mocks without changing any business logic code. This is one of the key benefits you gain from programming to interfaces rather than concrete implementations.

DTOs and Value Objects

Use Data Transfer Objects to cross boundaries. DTOs carry data between layers without coupling them together. The static factory method shown here handles the translation from Laravel's Request object, giving you a clean API for creating DTOs from various sources.

readonly class CreateProjectRequest
{
    public function __construct(
        public string $name,
        public string $description,
        public int $budgetCents,
        public string $clientId,
    ) {}

    public static function fromHttpRequest(Request $request): self
    {
        return new self(
            $request->input('name'),
            $request->input('description'),
            (int) ($request->input('budget') * 100),
            $request->input('client_id'),
        );
    }
}

Value Objects encapsulate validation and behavior. Unlike DTOs, value objects are part of your domain and enforce invariants through their constructors. When you need to represent a concept like money, email addresses, or identifiers, value objects ensure that invalid values can never exist in your system.

readonly class Money
{
    private function __construct(private int $cents) {}

    public static function fromCents(int $cents): self
    {
        if ($cents < 0) {
            throw new InvalidArgumentException('Money cannot be negative');
        }
        return new self($cents);
    }

    public function greaterThan(Money $other): bool
    {
        return $this->cents > $other->cents;
    }
}

The private constructor forces all creation through the fromCents() factory method, ensuring validation always runs. This pattern makes it impossible to create invalid Money instances anywhere in your codebase. You can trust that any Money object you receive has already passed validation.

When Clean Architecture Is Overkill

Clean Architecture adds indirection. That indirection has costs: more files, more abstractions, more complexity to navigate.

For simple CRUD applications, the overhead often exceeds the benefit. If you're building a straightforward admin panel with minimal business logic, Laravel's default structure works fine.

Consider Clean Architecture when:

  • Business logic is complex and likely to change
  • You need to support multiple interfaces (web, API, CLI)
  • The application will be maintained for years
  • Multiple teams work on the codebase
  • High test coverage is required

Skip it when:

  • Building a prototype or MVP
  • Business logic is minimal (data in, data out)
  • The team is small and development speed is critical
  • The application has a limited lifespan

Practical Example

Here's a complete flow for creating a project. This example ties together all the layers we've discussed, showing how data flows from the HTTP request through to the domain and back. Study this carefully to see how each piece connects.

// Domain: app/Domain/Project/Project.php
class Project
{
    public static function create(string $name, Money $budget, ClientId $clientId): self
    {
        return new self(
            ProjectId::generate(),
            $name,
            ProjectStatus::Draft,
            $budget,
            $clientId,
        );
    }
}

// Application: app/Application/Project/CreateProjectUseCase.php
class CreateProjectUseCase
{
    public function execute(CreateProjectRequest $request): CreateProjectResponse
    {
        $project = Project::create(
            $request->name,
            Money::fromCents($request->budgetCents),
            ClientId::fromString($request->clientId),
        );

        $this->projects->save($project);

        return new CreateProjectResponse($project);
    }
}

// Infrastructure: app/Http/Controllers/ProjectController.php
class ProjectController
{
    public function store(Request $request, CreateProjectUseCase $useCase)
    {
        $response = $useCase->execute(
            CreateProjectRequest::fromHttpRequest($request)
        );

        return response()->json(['id' => $response->project->id()->toString()], 201);
    }
}

Follow the data flow: the controller transforms the HTTP request into a DTO, the use case coordinates creation through the domain's factory method, and the response wraps the result for the controller to serialize. Each layer handles its specific concern without leaking details to other layers. You'll notice that the domain layer has no idea it's being called from an HTTP context.

Conclusion

Clean Architecture isn't about following rules for their own sake. It's about creating boundaries that protect your most valuable code;your business logic;from the volatility of frameworks and infrastructure.

Start small. Extract business rules into domain objects. Introduce interfaces where you need flexibility. Add layers as complexity demands. The goal isn't architectural purity; it's maintainable code that you can confidently change and test.

Clean Architecture scales with your needs. A small application might only have domain objects and use cases. A complex system might have multiple bounded contexts, each with full layering. Let your application's complexity drive how much architecture you need.

Share this article

Related Articles

Need help with your project?

Let's discuss how we can help you build reliable software.