How I Refactored My Symfony Application with Clean Architecture and DDD

How I moved a growing Symfony platform from technical folders to bounded contexts, inward dependencies, explicit ports, and architecture checks with Deptrac.
Ever opened a Symfony project where every new feature meant touching a controller, an entity, a repository, and a service from four different folders?
At the beginning, this structure feels simple. Then the application grows. The Service directory becomes a collection of unrelated business logic, controllers start making business decisions, and changing a payment provider or database detail requires modifications everywhere.
I experienced this directly while building Leganews, a legal information platform I co-founded. I had a real product, existing users, and legacy data that could not simply disappear while I redesigned the codebase.
So this was not a greenfield architecture exercise. I refactored the system to make its business boundaries visible, protect the domain from Symfony and Doctrine, and allow the platform to continue evolving without turning every change into a risky operation.
In this post, I’ll show you how I combined Clean Architecture, Domain-Driven Design, Symfony’s Dependency Injection container, Messenger, external Doctrine mappings, and Deptrac. More importantly, I’ll show you the configuration needed to reproduce the same approach in your own project.
The Problem with Organizing Everything by Technical Type
A conventional Symfony application often starts with a structure similar to this:
There is nothing inherently wrong with this structure. Symfony provides sensible defaults, and for a small application they are often enough.
The problem appears when the product contains several business areas. A billing change may be spread across Controller, Entity, Repository, and Service. The directory tree tells me which technical tool a class uses, but not which business capability it belongs to.
As the platform grew, I needed the code to answer more useful questions:
-
Is this part of Identity, Billing, Monitoring, or the legal Corpus?
-
Which rules belong to the business, and which are only Doctrine or Symfony details?
-
Can Billing change without accidentally breaking Monitoring?
-
Can a use case be tested without a database or an HTTP request?
That is where DDD and Clean Architecture became useful to me.
Clean Architecture and DDD in a Nutshell
Domain-Driven Design helps me organize the system around the language and boundaries of the business. Instead of one large application model, I define bounded contexts such as Identity, Billing, Access, Corpus, and Monitoring.
Clean Architecture gives me a dependency rule: source-code dependencies point toward the business rules, not away from them.
In my implementation, the direction looks like this:
The meaning is simple:
-
Domaincontains business concepts and rules. -
Applicationcoordinates use cases. -
Infrastructureimplements technical details such as Doctrine, Redis, and external APIs. -
Presentationtranslates HTTP or console input into application messages.
The domain does not know that Symfony, Doctrine, PostgreSQL, or Redis exists.
Step 1: Organize the Code by Bounded Context
Instead of keeping all entities together, I made the bounded context the first level of the namespace:
src/
├── Access/
├── Billing/
├── Compare/
├── Corpus/
├── Identity/
├── Monitoring/
├── Policy/
└── Shared/Each context then owns its layers:
src/Monitoring/
├── Application/
│ └── UseCase/
│ ├── Command/
│ ├── CommandHandler/
│ ├── Query/
│ └── QueryHandler/
├── Domain/
│ ├── Event/
│ ├── Model/
│ │ ├── Entity/
│ │ ├── Repository/
│ │ └── ValueObject/
│ └── Service/
├── Infrastructure/
│ ├── Persistence/
│ └── Search/
└── Presentation/
├── Console/
└── Web/Now, when an engineer works on bookmarks, most of the relevant code is under Monitoring. The folder structure communicates product language before it communicates framework details.
One important rule: Shared is not a place for anything I cannot classify. It contains concepts that are truly generic across contexts. If a class uses product language, it probably belongs to a bounded context.
Step 2: Move Symfony’s Kernel to the Infrastructure Layer
In a standard Symfony project, Kernel.php lives directly under src/. I moved it to:
src/Shared/Infrastructure/Framework/Kernel.phpThis does not magically create Clean Architecture, but it makes an important design decision visible: Symfony is the runtime that boots my application. It is infrastructure, not the center of the business model.
Because the class moved, the HTTP and console entry points import its new namespace.
<?php
use Leganews\Shared\Infrastructure\Framework\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return static function (array $context) {
return new Kernel(
$context['APP_ENV'],
(bool) $context['APP_DEBUG'],
);
};The same import is used in bin/console.
My Composer autoloading remains straightforward:
{
"autoload": {
"psr-4": {
"Leganews\\": "src/"
}
}
}Moving the Kernel is optional in your project. The essential part is the dependency rule. For me, the move made that rule easier to see and harder to forget.
Step 3: Let Dependency Injection Connect the Layers
Clean Architecture does not mean avoiding Symfony. It means using Symfony at the edges and depending on contracts toward the center.
My main service configuration registers application, infrastructure, and presentation classes automatically:
# config/services.yaml
imports:
- { resource: domains/ }
services:
_defaults:
autowire: true
autoconfigure: true
Leganews\:
resource: '../src/'
exclude:
- '../src/**/Infrastructure/Framework/DependencyInjection/*'
- '../src/**/Domain/Model/Entity/*'
- '../src/**/Domain/Model/ValueObject/*'
- '../src/Shared/Infrastructure/Framework/Kernel.php'Why exclude entities and value objects? Because they are domain objects, not container services. They should be created by the domain or application with explicit data, not fetched from Symfony’s container.
With autowiring enabled, a handler can depend on a domain interface:
final readonly class CreateBookmarkHandler
{
public function __construct(
private BookmarkRepository $bookmarks,
private Clock $clock,
private EventDispatcher $eventDispatcher,
) {}
}The application layer does not ask for EntityManagerInterface or a Doctrine repository. It asks for the capability it needs: a BookmarkRepository.
When there is only one implementation, Symfony can resolve it without me adding repetitive aliases. In my application, for example, the container resolves:
When multiple implementations exist, I configure the selection explicitly. My payment gateway is a good example: I have an in-memory implementation and a FlexPay implementation, then expose the configured gateway to the application.
# config/domains/billing.yaml
services:
billing.online_payment_gateway.in_memory:
alias: Leganews\Billing\Infrastructure\OnlinePayment\InMemoryOnlinePaymentGateway
billing.online_payment_gateway.flexpay:
alias: Leganews\Billing\Infrastructure\OnlinePayment\FlexPayOnlinePaymentGateway
Leganews\Billing\Domain\Service\OnlinePaymentGateway:
alias: Leganews\Billing\Infrastructure\OnlinePayment\ConfigurableOnlinePaymentGatewayThis is the practical role of Dependency Inversion: the use case owns the interface, while Symfony chooses the technical adapter at the composition root.
Step 4: Keep Business Rules in Plain PHP Objects
Let’s follow one real operation from the platform: creating a bookmark for legal research.
The Bookmark entity is a plain PHP object. It validates its own state and records a domain event:
final class Bookmark
{
use DomainEvents;
public readonly BookmarkId $id;
private function __construct(
?BookmarkId $id,
public readonly UserId $userId,
public private(set) string $name,
public private(set) ?string $description,
public private(set) BookmarkVisibility $visibility,
public readonly DateTimeImmutable $createdAt,
public private(set) DateTimeImmutable $updatedAt,
) {
$this->id = $id ?? new BookmarkId();
Assert::notEmpty($name);
}
public static function create(
UserId $userId,
string $name,
?string $description,
BookmarkVisibility $visibility,
DateTimeImmutable $now,
): self {
$bookmark = new self(
id: null,
userId: $userId,
name: $name,
description: $description,
visibility: $visibility,
createdAt: $now,
updatedAt: $now,
);
$bookmark->recordThat(
new BookmarkCreated($bookmark->id, $userId, $now),
);
return $bookmark;
}
}Notice what is absent: no controller, no request, no entity manager, and no Doctrine mapping attributes.
I also pass the current time into the domain. The entity does not call new DateTimeImmutable() to discover “now.” This makes time explicit and allows tests to control it.
Step 5: Define Persistence as a Domain Port
The domain declares the persistence operations it needs:
interface BookmarkRepository
{
public function save(Bookmark $bookmark): void;
public function remove(Bookmark $bookmark): void;
public function get(BookmarkId $id): Bookmark;
/** @return list<Bookmark> */
public function findByUser(UserId $userId): array;
}This interface is not an abstraction added “just in case.” It is a port required by an actual use case. The domain owns its language, while Infrastructure provides the implementation.
final class DoctrineBookmarkRepository extends ServiceEntityRepository
implements BookmarkRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Bookmark::class);
}
public function save(Bookmark $bookmark): void
{
$this->getEntityManager()->persist($bookmark);
}
public function get(BookmarkId $id): Bookmark
{
$bookmark = $this->find($id);
if (!$bookmark instanceof Bookmark) {
throw BookmarkNotFound::withId($id);
}
return $bookmark;
}
}Doctrine depends on the domain class. The domain class does not depend on Doctrine. That direction is the important part.
It also gives me a useful testing seam. A test can replace Doctrine with a small in-memory repository that implements the same interface.
Step 6: Keep Doctrine Mapping Outside the Domain
Doctrine attributes are convenient, but placing them on domain entities creates a direct dependency from the domain to the ORM.
I chose external XML mappings:
<!-- config/doctrine/Monitoring/Entity.Bookmark.orm.xml -->
<doctrine-mapping
xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
>
<entity
name="Leganews\Monitoring\Domain\Model\Entity\Bookmark"
repository-class="Leganews\Monitoring\Infrastructure\Persistence\ORM\DoctrineBookmarkRepository"
table="bookmarks"
>
<id name="id" type="bookmark_id" column="id">
<generator strategy="NONE"/>
</id>
<field name="userId" type="user_id" column="user_id"/>
<field name="name" length="120"/>
<field name="description" type="text" nullable="true"/>
<field name="visibility" enum-type="Leganews\Monitoring\Domain\Model\ValueObject\BookmarkVisibility"/>
<field name="createdAt" type="datetime_immutable" column="created_at"/>
<field name="updatedAt" type="datetime_immutable" column="updated_at"/>
</entity>
</doctrine-mapping>Then each bounded context gets its own Doctrine mapping configuration:
# config/packages/doctrine.yaml
doctrine:
orm:
entity_managers:
default:
mappings:
Monitoring:
is_bundle: false
type: xml
dir: '%kernel.project_dir%/config/doctrine/Monitoring'
prefix: 'Leganews\Monitoring\Domain\Model'
Billing:
is_bundle: false
type: xml
dir: '%kernel.project_dir%/config/doctrine/Billing'
prefix: 'Leganews\Billing\Domain\Model'Repeat the mapping block for the other contexts.
This configuration is slightly more verbose than attributes, but it keeps persistence metadata at the edge. I can read and unit-test the entity as an ordinary PHP object.
Step 7: Use Commands and Queries as Application Boundaries
The application layer coordinates a use case. It does not contain HTTP code, and it should not reimplement rules already owned by the domain.
First, I define the command:
final readonly class CreateBookmark
{
public function __construct(
public UserId $userId,
public string $name,
public ?string $description,
public BookmarkVisibility $visibility,
) {}
}Then the handler coordinates the domain, repository, clock, and events:
#[AsMessageHandler(bus: 'command.bus')]
final readonly class CreateBookmarkHandler
{
public function __construct(
private BookmarkRepository $bookmarks,
private Clock $clock,
private EventDispatcher $eventDispatcher,
) {}
public function __invoke(CreateBookmark $command): BookmarkId
{
$bookmark = Bookmark::create(
userId: $command->userId,
name: $command->name,
description: $command->description,
visibility: $command->visibility,
now: $this->clock->now(),
);
$this->bookmarks->save($bookmark);
$this->eventDispatcher->dispatch($bookmark->releaseEvents());
return $bookmark->id;
}
}Finally, the controller translates HTTP input into the application command:
#[Route(
'/monitoring/self-service/bookmarks',
name: 'monitoring.create_bookmark',
methods: ['POST'],
)]
final class CreateBookmarkController extends AbstractController
{
#[Serialize(code: Response::HTTP_CREATED)]
public function __invoke(
#[MapRequestPayload] CreateBookmarkModel $model,
): array {
$bookmarkId = $this->handleCommand(new CreateBookmark(
userId: $this->getSecurityUser()->userId,
name: $model->name,
description: $model->description,
visibility: BookmarkVisibility::from($model->visibility),
));
return ['id' => (string) $bookmarkId];
}
}The controller is intentionally boring. Authentication, request mapping, and response serialization belong here. The decision about how a bookmark is created does not.
Step 8: Configure Separate Message Buses
I use Symfony Messenger behind my own small CommandBus, QueryBus, MessageBus, and EventDispatcher contracts.
The Symfony adapters live in Infrastructure. This keeps handlers independent from Messenger’s runtime API while still allowing me to use useful attributes such as #[AsMessageHandler].
# config/packages/messenger.yaml
framework:
messenger:
default_bus: command.bus
buses:
command.bus:
middleware:
- Leganews\Shared\Infrastructure\Framework\Bus\CorrelationMiddleware
- Leganews\Shared\Infrastructure\Framework\Bus\TransactionMiddleware
query.bus:
middleware:
- Leganews\Shared\Infrastructure\Framework\Bus\CorrelationMiddleware
event.bus:
default_middleware:
allow_no_handlers: true
middleware:
- Leganews\Shared\Infrastructure\Framework\Bus\CorrelationMiddleware
message.bus:
middleware:
- Leganews\Shared\Infrastructure\Framework\Bus\CorrelationMiddlewareCommands change state, so my command bus adds a Doctrine transaction middleware. Queries read state and do not need that transaction. Events allow no handlers because announcing something should not require a subscriber to exist.
The correlation middleware carries one identifier through the message chain, which makes logs from a complete operation easier to follow.
This separation is not only stylistic. It gives every message a clear purpose and lets me apply different middleware policies to different operations.
Step 9: Enforce the Architecture with Deptrac
A directory convention is useful until somebody accidentally imports Doctrine inside the domain and the code review misses it.
Architecture must be executable. I use Deptrac to describe the allowed dependencies:
# deptrac.yaml
deptrac:
paths:
- ./src
layers:
- name: Presentation
collectors:
- type: directory
value: src/.*/Presentation/.*
- name: Infrastructure
collectors:
- type: directory
value: src/.*/Infrastructure/.*
- name: Application
collectors:
- type: directory
value: src/.*/Application/.*
- name: Domain
collectors:
- type: directory
value: src/.*/Domain/.*
- name: SymfonyAttributes
collectors:
- type: classNameRegex
value: '#^Symfony\\Component\\(?:Console|DependencyInjection|EventDispatcher|Messenger|Routing)\\Attribute\\.*#'
- name: SymfonyRuntime
collectors:
- type: classNameRegex
value: '#^Symfony\\(?:Component|Contracts)\\.*#'
ruleset:
Presentation:
- Domain
- Application
- Infrastructure
- SymfonyAttributes
- SymfonyRuntime
Infrastructure:
- Application
- Domain
- SymfonyAttributes
- SymfonyRuntime
Application:
- Domain
- SymfonyAttributes
Domain: ~The important line is Domain: ~. Domain classes are not allowed to depend on another architectural layer or on the Symfony runtime.
I deliberately model Symfony attributes separately. An application handler may use a passive #[AsMessageHandler] marker without receiving Symfony’s message bus as a runtime dependency.
Run the check with:
vendor/bin/deptrac --no-progressAt the time of writing, the project reports zero unskipped violations. I also keep a visible skip_violations list for accepted dependencies such as Symfony’s UUID implementation.
That list matters. A skipped violation is not the same as “the architecture is perfect.” It is an explicit compromise that can be reviewed and removed later instead of a dependency hidden in the codebase.
My current rules enforce the global layer direction. They do not yet enforce complete isolation between every bounded context. For example, a domain class in one context can still reference a domain value object from another context without Deptrac rejecting it.
If your system requires stronger context isolation, add a layer for each context or use additional collectors. Start with rules your team can understand and maintain, then make them stricter as the boundaries become stable.
Refactoring without Ignoring the Legacy System
This architecture was shaped by a real constraint: Leganews already had valuable data in a legacy MariaDB application, while the refactored platform uses PostgreSQL.
As a co-founder, I could not treat the rewrite as only a code-quality project. The architecture had to preserve users, legal content, subscriptions, orders, bookmarks, and their relationships.
I isolated the old database as a separate Doctrine DBAL connection:
doctrine:
dbal:
connections:
default:
url: '%env(resolve:DATABASE_URL)%'
legacy:
url: '%env(resolve:LEGACY_DATABASE_URL)%'Then I built the import as ordered plans instead of one enormous migration script:
parameters:
legacy.plan_order:
- users
- federated_identities
- classifications
- subjects
- works
- work_attachments
- expressions
- products
- prices
- orders
- payment_transactions
- organizations
- subscriptions
- organization_members
- access_grants
- bookmarks
- bookmark_itemsThe order represents data dependencies. Users must exist before their bookmarks; products and prices must exist before orders; organizations must exist before memberships.
Each plan implements an application-level migration contract, while SQL and legacy schema knowledge remain in Infrastructure. The importer owns orchestration, reporting, conflict handling, and row failures.
I also preserved stable identifiers where possible. That reduced broken references while the new model introduced clearer concepts and boundaries.
This gave me a practical refactoring path:
-
Model one business capability in its bounded context.
-
Put the new use cases behind application commands and queries.
-
Adapt persistence and external systems at the Infrastructure edge.
-
Import legacy data in dependency order.
-
Verify counts, conflicts, and rejected rows before switching traffic.
The lesson is that Clean Architecture is not only useful after migration. It helps create a safe place to migrate into.
Testing the Boundaries, Not Only the Classes
The repository interface lets my application tests run with in-memory adapters and a controllable clock.
A use-case scenario reads like this:
Scenario: Creating a bookmark starts a private collection
Given current time is "2026-07-13 10:00:00"
When a bookmark is created through the application with the following details:
| userId | name | description | visibility |
| ... | Research folder | | private |
Then the bookmark should keep the following presentation:
| name | visibility | updatedAt |
| Research folder | private | 2026-07-13 10:00:00 |
And the bookmark creation should be announcedThis test drives the real command bus and handler, but replaces the database with an in-memory repository. It verifies the application boundary without turning every business scenario into a slow browser test.
I use different tests for different risks:
-
PHPUnit for entities, value objects, policies, and isolated domain rules.
-
Behat for commands and queries at the application boundary.
-
Symfony functional tests for routing, security, serialization, Doctrine mappings, and other framework integrations.
Deptrac then checks something tests usually do not: whether the dependency direction is still valid.
Add the Architecture Check to Your Quality Command
An architecture rule that only runs on one developer’s machine will eventually be bypassed.
My main quality command includes formatting, Symfony configuration linting, static analysis, a Rector dry run, and Deptrac. The essential part for this article is simple:
{
"scripts": {
"app:architecture": "deptrac --no-progress",
"app:test": "phpunit",
"app:behat": "behat"
}
}Run the architecture command in CI on every change. A forbidden dependency should fail like a test failure, not become a discussion six months later.
What This Architecture Does Not Solve
Clean Architecture comes with trade-offs.
-
There are more files because commands, handlers, ports, and adapters have distinct responsibilities.
-
External Doctrine mappings are more verbose than entity attributes.
-
A team must agree on the domain language, or the bounded contexts become technical folders with better names.
-
Deptrac only protects the rules you configure. It cannot decide whether a business concept belongs in the correct context.
-
Not every CRUD operation needs a complex aggregate or a domain event.
I try to stay pragmatic. The goal is not to maximize interfaces and layers. The goal is to keep important business decisions independent from delivery and persistence details.
If a simple read model can answer a query, I use it. If only one adapter exists, I let Symfony autowire it. If I accept an architectural exception, I record it explicitly instead of pretending it does not exist.
Wrapping Up
Refactoring Leganews around Clean Architecture and DDD changed more than the directory tree.
-
Bounded contexts made business ownership visible.
-
Domain objects became plain PHP objects with explicit rules.
-
Repository and bus contracts inverted dependencies toward the application core.
-
Symfony’s DI container assembled the implementation at the edges.
-
External Doctrine mappings kept ORM details outside the domain.
-
Messenger gave commands, queries, and events distinct execution policies.
-
Deptrac turned architectural intent into an automated check.
-
Ordered migration plans allowed the new model to coexist with legacy data during the refactor.
The result is not a framework-free application. It is a Symfony application where Symfony is used deliberately, in the places where it is strongest, without allowing it to become the business model.
That is the real value of this architecture: I can keep changing the system without making every technical decision permanent.
Happy coding!