DEV Community

Cover image for Another Way to Structure your Symfony Project
Etienne Lebarillier
Etienne Lebarillier

Posted on

Another Way to Structure your Symfony Project

The MVC + Services architecture is so common in Symfony projects that it feels like the only way. It’s simple, familiar, and works... until it doesn’t. As your project grows, cracks begin to show: your business logic is everywhere, app behavior is unclear, and maintaining the code becomes painful. While it is the most common approach, Symfony does not force you to stick with it.

What if there was a better way ?


The Frustrations of Using the MVC + Services Architecture

 

Domain Logic is Spread Everywhere

As a project grows, business logic tends to spread across the entire code base. Every layer of the project — controllers, services, forms, entities — ends up containing bits and pieces of the domain model. This makes it increasingly difficult to focus on any specific part.

 

The Project Boundaries Are Not Clear

When your architecture is organized around technical layers, it becomes harder to identify clear boundaries between different contexts as the project grows. This lack of clarity can lead to tightly coupled code and maintenance challenges.

Symfony default folder tree

 

The Project Behaviors Are Not Clear

Since the default architecture emphasizes technical layers, it becomes quite challenging to understand the behaviors of the project. You might infer that certain entities are managed by specific services or guess the database schema, but the actual behaviors of the project, which are the most important aspects, remain unclear and implicit.

Symfony default folder tree opened

 

Different Lifecycles

When business logic is scattered across the project and mixed with implementation details, it becomes difficult to evolve them independently. Over time, The lifecycle of business logic tends to be much longer than that of implementation details (such as frameworks, third-party APIs, or databases). This mismatch forces you to rewrite large portions of code whenever even minor changes occur in a dependency.


A Better Way to Structure Your Architecture

 

Isolate the Business Logic

To make it easier to focus on the business logic when needed, the first step is to isolate it from the rest of the project. To achieve this, create a Domain folder. This folder becomes the core of the project, where business logic is modeled using pure PHP objects, free from dependencies on implementation details. While the rest of the project depends on this folder, the Domain folder depends on no one.

Inside the Domain folder, files should be grouped by domain purpose rather than technical purpose. This means no Entities, Services, or Controllers folders here, only folder names that correspond to features or domain concepts.

Domain with modules inside opened

 

The Front Door of the Domain

The most important aspect of a project is what it does, the actions it can handle. These actions represent the project's behaviors and should serve as the only way to access the business logic. To reflect this, create an Application folder that explicitly showcases all the project's behaviors. For example, as a new developer on the project, I should be able to understand at a glance what the project is capable of doing by looking at this folder.

The Application folder opened with some queries and command inside

 

Connecting to the Outside World

With the App and Domain folders, it becomes easy to focus on the business logic. However, at some point, this business logic needs to interact with external systems. To handle this, create a third folder called Infrastructure, which contains all the implementation details such as framework-specific code, database connections, and libraries.

Files in the Infrastructure folder depend on the App and Domain files. For example, they might call an application handler from the App folder or implement an interface defined in the Domain folder.

The infrastructure folder opened

Concretely, in Symfony, this involves modifying the Controller folder and declaring which services implement which interfaces.

# config/routes.yaml

controllers:
    resource:
        path: ../src/Catalog/Infrastructure/Controller/
        namespace: App\Catalog\Infrastructure\Controller
    type: attribute
Enter fullscreen mode Exit fullscreen mode

 

Reveal the Boundaries

As your project evolves, you might notice that some parts of the business logic deserve their own space in the architecture. A good indicator is when the same term starts to have different meanings depending on the context. For example, the word Product might refer to a factory product, a warehouse product, or an e-commerce product, each requiring its own model. A god object can also be a good indicator; the User class often has this type of issue in Symfony projects.

When this happens, it’s time to extract it and let its business logic evolve independently.

Some contexts will form the core of your project, while others will support it. Generic contexts, such as Auth, can use a simpler architecture because they are not central to your domain

All the bounded contexts opened

In this picture, we can see that the Auth context uses a standard Symfony structure, the Order and Catalog contexts use a domain-focused architecture, and the Shipping context uses a feature-focused architecture.

 

Incremental Modularity

If a specific context grows to the point where it needs to scale independently, consider splitting it into a separate deployment unit.

However, don’t rush into this step. Start by making your project modular, and if you notice that a context needs to scale individually, then deploy it separately.
Only split the codebase if organizational challenges arise, such as two teams struggling to collaborate on the same codebase.

The different classes of project architectures, credits: Simon Brown

Go Further

The Concepts

As we explored these solutions, we applied several craftsmanship concepts. Let’s name and briefly explain them, so you can dive deeper into each one.

 

Ubiquitous Language

Behind this unusual term lies a very simple concept. The ubiquitous language is the vocabulary your team uses to describe your domain model. This vocabulary should be documented and consistently used everywhere, in product conversations, the codebase, and beyond.

Concretely, create a markdown file at the root of a Bounded Context and bring together product people, domain experts, and tech teams to define each concept of your project.

Go Further

 

Bounded Context

A bounded context defines the linguistic boundary within your project, separating parts of the system where the ubiquitous language no longer aligns. Tools like Context Maps and Event Storming can help identify these boundaries.

A bounded context is an abstract concept; it can be implemented in many ways, from a simple folder in a modular monolith to a cluster in a microservices architecture.

Go Further

  • Eric Evans - DDD - Part IV : Strategic Design
  • Vaughn Vernon - IDDD - Chapter 2 : Domains, Subdomains, and Bounded Context
  • Martin Fowler - Bounded Context

 

Ports and Adapters, Hexagonal, Onion, and Clean Architecture

All these architectures aim to isolate business logic from implementation details. Whether you use Ports and Adapters, Hexagonal, or Clean Architecture, the core idea is to make the business logic framework-agnostic and easy to test.

Once you have this in mind, there is a full spectrum of implementations, and the best one depends on your context and preferences. A major advantage of this architecture is that, by isolating your business logic, it enables much more efficient testing.

Go Further

 

Screaming Architecture

The idea of organizing folders and files to "scream" the business logic is known as Screaming Architecture. This concept emphasizes that the structure of your code should make the project’s purpose immediately clear. The goal is for a new developer to understand what the project does at a glance.

I highly recommend reading Uncle Bob’s article on the topic—his comparison to a house plan is particularly insightful.

Go Further

 

Vertical Slicing Architecture

Vertical slicing organizes your project by features, allowing each feature to evolve independently. It enables you to apply different architectures to different features based on complexity and maturity.

Although the idea is interesting, it requires highly skilled engineers to implement and maintain such an architecture effectively.

Go Further


Final Thoughts

The way you structure your Symfony project has a profound impact on its scalability, maintainability, and clarity. By isolating your business logic and making behaviors explicit, you’ll create a system that’s easier to understand and evolve.

If you’re new to these ideas, don’t worry, software craftsmanship is a journey, not a destination. The concepts might seem overwhelming at first, but each one will help you deliver more value to your business.

Have questions or want to share your experience ? Drop them in the comments ! And stay tuned for the next article 🚀

Top comments (3)

Collapse
 
mauro_chojrin_611d4ede743 profile image
Mauro Chojrin

This looks really interesting! I wonder what changes to the configuration, if any, you need to make to have this working.

Collapse
 
etienneleba profile image
Etienne Lebarillier • Edited

Thank you for your comment !

I didn’t go into much detail about the configuration because it depends a lot on what you want to achieve. However, since Symfony 4.4, the framework has become very flexible. For a basic implementation, you just need to adjust the folder paths in the routes.yaml and services.yaml files.

That said, I encourage using a more refined dependency injection strategy to maintain better control. For instance:

  • In the Application folder, allow only CommandHandler and QueryHandler classes to be injected.
  • Avoid dependency injection in the Domain folder entirely.
  • Enable full dependency injection in the Infrastructure folder.

This kind of setup takes less than 15 lines of configuration but provides a high degree of control.

I see two scenarios where the configuration might be a bit more complex:

  1. Using Symfony Messenger for commands, queries, and events:

    If you want to use Symfony Messenger as a bus, the documentation covers it here:

    Symfony Messenger: Multiple Buses

  2. Multiple adapters for domain interfaces:

    If your domain interfaces are implemented by multiple adapters (e.g., a real implementation and an in-memory one for tests), the Symfony docs provide everything you need:

    Symfony Service Container

I hope this helps!

Collapse
 
mauro_chojrin_611d4ede743 profile image
Mauro Chojrin

Thanks!