Taming a Symfony Project: Structure, Conventions, and Practical Guidelines
When managing projects built on Symfony2, establishing a consistent set of rules early on pays dividends for every developer who touches the codebase afterward. The goal is straightforward: no matter how many contributors a project has, the code should read as if it came from a single author. Every developer needs to be able to pick up where another left off — whether or not they built a particular module. Consistency accelerates both implementation and code review.
Symfony's official documentation doesn't prescribe a complete set of conventions, and community bundles each bring their own preferences. There are common starting points, but they don't add up to a fully checkable standard. Learning from mistakes as you go is both costly and time-consuming. Having a tested baseline — something that has demonstrably worked across real projects — is a far better foundation. What follows is exactly that: a set of practical guidelines for structuring and writing Symfony projects.
Bundle Structure
The following principles drive the directory structure of a well-organized bundle:
- The total number of top-level directories should stay manageable — in most cases, a maximum of around 10.
- Opening a directory should not produce surprises. For that reason, first-level directories are generic rather than domain-specific.
- Unless a directory's description says otherwise, class definitions must be placed in a folder that reflects the structure of Symfony itself, or of the related component (Twig, Doctrine, etc.). When a component is involved, the top-level folder name must match that component's name.
Starting from a basic bundle generated by the generate:bundle task, the following directory layout emerges from those assumptions:
/Command — Symfony task classes.
/Controller — Standard Symfony folder. Controllers should have a small number of actions, and those actions should remain concise.
/DependencyInjection — Standard Symfony folder. Primarily holds bundle configuration, occasionally a CompilerPass.
/Document — Model classes managed by the ODM, along with their repositories.
/Entity — Model classes managed by an ORM, along with their repositories.
/Event — Event classes.
/EventListener — Listeners and subscribers. May contain subfolders for specific categories (Doctrine, Form, etc.). Many libraries organize this as Form/EventListener, but the preferred approach here is the reverse: EventListener/Form. This keeps the EventListener directory consolidated within a single bundle and aligns with the guideline favouring a small number of generic top-level directories.
/Form — Form-related classes. Typically contains subfolders such as Form/Type and Form/DataTransformer.
/Model — Model classes not managed by an ORM or ODM.
/Resources — Standard Symfony folder.
/Service — Service classes that are domain-specific to the bundle, are not listeners, and don't fit other folders. May contain subfolders.
/Tests — Unit tests. The folder structure here must mirror the bundle's folder structure. To test a class at Service/MyService.php, the test case belongs at Tests/Service/MyServiceTest.php.
/Twig/Extensions — Classes that extend Twig. This directory is also the canonical example of the third guideline above: the folder name matches the component name.
/Validator — Validators.
Events.php — A final class containing constants for all event names defined in the bundle.
Interfaces should be placed in the same directories that would normally hold their implementations. Deviations from this structure are worth considering only for specific, justified cases.
Doctrine
Flush only from controllers and commands. This rule is intentionally strict. Only a small number of services genuinely need to write data to the database. In most cases, a service should call persist and let the flush happen when execution returns to the controller. Occasionally an id is needed before proceeding further — that warrants an exception — but such cases are rarer than they appear. The benefit of this discipline is that flush calls don't end up scattered across multiple class types, and transaction handling doesn't need to be managed manually — flush wraps its queries in a transaction by default.
public function someAction($entity)
{
$this->container->get('my_service')->somethingHappensToEntity($entity);
$manager = $this->getDoctrine()->getManager();
$manager->flush($entity);
//...
}
Table names must use the plural form, with lowercase letters and underscores separating words.
entity 'UserGroup' -> table 'user_groups'
Column names must use lowercase letters and underscores separating words.
property 'createdAt' -> column 'created_at'
Entities should contain minimal or no business logic. The less logic in an entity the better; ideally none at all. Business logic belongs in services.
Queries must be constructed in a repository class using QueryBuilder. It is also good practice to separate the construction of a query from its execution by using distinct functions for each concern. Developers tend to fall into two camps — those who write pure DQL, and those who use QueryBuilder. QueryBuilder is preferred because it provides greater control over building correct DQL and makes it easier to isolate logic that is common to several queries, enabling reuse without code repetition.
public function getRate(Event $event, Member $member)
{
$qb = $this->getUserRateQueryBuilder($event, $member);
return $qb->getQuery()->getOneOrNullResult();
}
protected function getUserRateQueryBuilder(Event $event, Member $member)
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('er')
->from($this->getClassName(), 'er')
->leftJoin('er.event', 'e')
->leftJoin('er.owner', 'o')
->where('e.id = :eventId')
->andWhere('o.id = :ownerId');
$qb->setParameter('eventId', $event->getId())
->setParameter('ownerId', $member->getId());
return $qb;
}
Avoid the Query\Expr class. This isn't a question of whether the class is useful — it is — but overuse quickly destroys readability. A simple expression can balloon from a handful of characters to a dozen. A blanket rule against using it covers the vast majority of cases.
// good
$qb->where('op.status IS NULL')
// avoid
$qb->where($this->getQueryBuilder()->expr()->isNull('op.status'))
Twig
Use the {% include %} tag rather than the include() function. Pick one and stick to it — the codebase should look consistent. The tag is the better choice because the purpose of include (whether tag or function) is closely related to how the final template is assembled, which puts it in the same conceptual family as {% block %} and {% extends %}.
Don't overload templates with functions and filters. Twig extensions can pull logic into templates that properly belongs in the controller. A useful test for deciding which approach to take: "If the same data needed to be returned as JSON instead of HTML, would what's passed to the template be sufficient, or would something be missing?" If the answer is that it would be sufficient regardless of output format, then any function or filter involved should be applied in the controller — or more precisely, before the final response is assembled — not inside the template.
Services
Service names should incorporate the vendor name and bundle name (without the word "Bundle"), in the format vendor.bundle.service_name. The same pattern applies to parameter names.
class: Acme/DemoBundle/Service/MyService
service: acme.demo.my_service
List service arguments and method calls on separate lines in services.yml. Mixing single-line and multi-line call styles in the same file is a common readability problem. The multi-line format is the better choice:
services:
acme.demo.example:
class: Acme\DemoBundle\Service\Example
arguments:
- '@some_service'
- '@another_service'
- '%some_parameter%'
calls:
- [setSomething, [%some_value%]]
- [setSomethingElse, [%another_value%]]
This formatting makes it immediately clear which services and parameters belong to the bundle and which come from elsewhere.
In Practice
Projects that adopt these guidelines consistently report fewer questions about where code should go and end up with a tighter, more uniform codebase throughout. Onboarding a new team member becomes significantly faster when every module follows the same conventions. Combined with code review — which gives the team an opportunity to catch deviations promptly — many of these rules can also be partially automated, saving time on both sides.
Several of these checks lend themselves to static analysis tooling, which makes enforcement scalable even as a project grows. The conventions here are a starting point, not a ceiling; every project will surface edge cases worth discussing, and the rules can evolve accordingly.