How would you structure a large-scale Symfony app?
Symfony Developer
answer
A scalable Symfony application architecture is “bundle-less” at the app level with feature-oriented modules, thin controllers, and business logic in services. Depend on interfaces and inject via autowiring/autoconfiguration. Orchestrate flows with event subscribers and the Messenger bus (CQRS where helpful). Keep Doctrine models lean and isolate transactions in application services. Encapsulate cross-cutting concerns with compiler passes, middleware, voters, and cache pools. Test with a pyramid: unit → integration → functional.
Long Answer
Designing a large-scale Symfony application architecture is about drawing clean seams: isolate business logic from framework glue, prefer composition over inheritance, and keep modules autonomous. Symfony’s strengths—DI container, EventDispatcher, Messenger, HttpKernel—let you scale code and teams without entropy.
1) App shape: bundle-less core, feature modules
Modern Symfony favors an application without custom bundles by default. Instead, group code by feature (e.g., src/Catalog, src/Checkout, src/Billing) rather than by layer. Each module contains its Application (use cases/handlers), Domain (entities, value objects, domain events, repositories as interfaces), and Infrastructure (Doctrine repositories, HTTP clients, CLI, framework adapters). Reusable libraries become standalone bundles (separate packages published via Composer), but your app remains bundle-less to avoid global coupling.
2) Dependency Injection: interfaces first
Express dependencies via interfaces in Domain/Application; wire concrete adapters in Infrastructure. Use autowiring + autoconfiguration with attributes (or service tags) to register handlers, subscribers, and console commands. Keep public services explicit; default to private to minimize surface area. For configuration, rely on config/packages/*.yaml and per-env overrides; expose only the minimal parameters required. Aliases bind abstractions to implementations per environment (e.g., fake payment gateway in test).
3) Controllers thin, services thick
Controllers should translate HTTP ↔ DTOs and delegate to application services or Messenger command/query handlers. Validation lives in Symfony Validator on request DTOs; mapping with forms or custom mappers keeps the domain pure. Controllers return view models or API resources (e.g., API Platform) while business rules remain framework-free. This maximizes testability and reuse (same use case callable from HTTP, CLI, or a job).
4) Persistence and Doctrine discipline
Keep Doctrine entities as anemic persistence models or rich domain entities—choose intentionally. Either way, prevent bidirectional spaghetti; favor aggregates with clear invariants. Use repositories as interfaces in Domain and adapters with Doctrine in Infrastructure. Encapsulate transactions in application services or via Transaction middleware (e.g., wrapping command handlers). For read-heavy flows, apply CQRS: queries return read models (DTOs) via optimized DQL/SQL without dragging aggregates into view logic.
5) Events, subscribers, and process orchestration
Publish domain events inside aggregates (e.g., OrderPlaced) and dispatch them after commit through an outbox or transactional listener. Use Symfony EventDispatcher for application/technical events and Messenger for async processing, retries, and failure transports. Cross-cutting behaviors (idempotency, metrics, audit) sit in middleware or event subscribers tagged appropriately. Keep subscribers small and single-purpose; avoid hidden coupling by documenting event contracts.
6) Boundaries and security
Enforce modularity with namespaces, package boundaries, and code owners. Symfony voters implement authorization close to the domain (e.g., CanRefundOrder). Authenticators handle tokens/sessions without leaking security checks into controllers. Expose only stable contracts (DTOs, commands, queries); keep internal types module-private to prevent accidental reach-through.
7) Caching, HTTP, and performance
Use HTTP cache (ETags, cache headers) for APIs and assets; within the app, define PSR-6/PSR-16 cache pools per concern (pricing, catalog, permissions). Memoize expensive queries behind repositories; invalidate by event. Add rate limiting at the firewall/RateLimiter. Profile with Stopwatch/WebProfiler in dev and Blackfire in performance runs. Keep boot time fast by trimming container autowire scopes and splitting configs.
8) Configuration, environment, and ops
Inject configuration via env vars and Symfony Secrets; avoid container parameters for secrets. Separate app config per environment; enable feature flags for risky changes. Use Messenger transports (AMQP/Redis) for workloads; configure failure queues and dashboards. Log through Monolog with correlation IDs and channel-based handlers (business vs tech).
9) Testing strategy and data builders
Adopt a test pyramid. Unit tests target domain services and value objects. Integration tests exercise repositories (with a transactional SQLite or Testcontainers). Functional tests rely on KernelTestCase/WebTestCase, fixtures via Foundry/FixturesBundle, and HTTP client mocking with MockHttpClient. Snapshots or API Platform tests verify contracts. Keep tests fast and deterministic; reset the container between tests with the test container features.
10) Extensibility: compiler passes and bundles
For reusable concerns (e.g., billing metrics, audit trail), consider a custom bundle exposing a DI extension and compiler passes to auto-register tagged services or decorate framework handlers. In the host app, opt-in by tags rather than inheritance. This pattern keeps shared logic pluggable and avoids copy-paste.
The outcome is a Symfony application architecture that is modular (feature slices), maintainable (clear seams, thin controllers), and testable (interfaces, deterministic orchestration), with events and messaging enabling resilience and scale.
Table
Common Mistakes
- Re-introducing “everything bundle” and scattering logic across bundles instead of feature modules.
- Fat controllers that perform querying, validation, and business rules inline.
- Letting Doctrine entities leak everywhere (no DTOs/use cases), tightly coupling UI to persistence.
- Global, public services and shared state; skipping autowiring discipline and interfaces.
- Synchronous event storms with side effects in listeners, causing hidden failures and retries.
- No transaction boundaries around multi-repo operations; partial writes on error.
- Overusing annotations/attributes for logic (e.g., security rules) instead of voters/services.
- Slow tests that depend on full kernels and real networks; brittle fixtures and nondeterministic data.
Sample Answers (Junior / Mid / Senior)
Junior:
“I keep controllers thin and move logic to services. I use autowiring and validation on DTOs, then call a use-case service. Doctrine stays in repositories; I write functional tests with WebTestCase and fixtures to cover the main flows.”
Mid:
“My Symfony application architecture uses feature modules with Domain/Application/Infrastructure folders. I wire command/query handlers with Messenger, publish domain events, and process long-running tasks async. Repositories are interfaces; implementations sit in Infrastructure. I add voters for authorization, cache hot queries, and test with unit + integration + functional layers.”
Senior:
“I run bundle-less apps organized by feature, with contracts (DTOs, interfaces) at the boundary. Controllers adapt transport; application services or handlers own transactions. Domain events outbox into Messenger with retries and failure transports. Cross-cuts live in middleware and subscribers; compiler passes enable pluggable modules. We enforce private services, aliases per env, Foundry fixtures, test containers, and Blackfire budgets.”
Evaluation Criteria
Strong answers define a Symfony application architecture that is bundle-less, feature-oriented, and interface-driven. Look for thin controllers, business logic in application services/handlers, repositories as interfaces, and Doctrine adapters in Infrastructure. Events are split into domain vs application, with Messenger for async and retries. Security via voters/authenticators, caching via PSR-6 pools and HTTP cache, and observability via Monolog and correlation IDs. Testing spans unit/integration/functional with fast fixtures. Red flags: fat controllers, entity anarchy, synchronous side-effect chains, no transaction boundaries, public service sprawl, or tests that require full stacks for every case.
Preparation Tips
- Create a small feature module (Orders) with Domain/Application/Infrastructure and a thin controller.
- Add a command handler (PlaceOrderHandler) and a query (GetOrderSummary), both wired via Messenger.
- Implement repository as interface + Doctrine adapter; add a transaction middleware.
- Publish a domain event (OrderPlaced) and process it async (email, analytics) with failure transport.
- Write unit tests for domain rules, integration tests for the repository, and functional tests for the HTTP flow with Foundry fixtures.
- Add a voter (OrderRefundVoter) and cache a hot query with a dedicated PSR-6 pool.
- Profile with Blackfire and enforce a performance budget on the main endpoint.
- Extract a cross-cutting concern (audit) into a small internal bundle with a compiler pass and tags.
Real-world Context
A marketplace migrated from controller-heavy code to feature modules with thin adapters. Introducing repository interfaces and a transaction middleware eliminated partial writes during checkout. Moving email/webhook side effects to Messenger cut P95 latency and made retries observable via failure transports. Authorization moved into voters, simplifying controller code and reducing leaks. With Foundry fixtures and test containers, CI stabilized and test time dropped. A small “audit bundle” with a compiler pass standardized event tagging across modules. The result: faster iteration, fewer regressions, and a Symfony application architecture that scaled to multiple squads without merge wars.
Key Takeaways
- Prefer bundle-less, feature-oriented modules with clear Domain/Application/Infrastructure layers.
- Keep controllers thin; put business logic in services/handlers with transactional boundaries.
- Depend on interfaces; wire adapters with autowiring/autoconfiguration and aliases.
- Use EventDispatcher/Messenger for decoupling, async work, and retries.
- Secure with voters, cache with PSR-6 pools, and test across unit/integration/functional layers.
Practice Exercise
Scenario:
You are building a multi-team Symfony platform with Catalog, Orders, and Billing. Traffic spikes during promotions; background jobs send emails, generate invoices, and notify partners. The codebase must remain modular, testable, and easy to evolve.
Tasks:
- Propose a bundle-less, feature-oriented folder layout for Catalog, Orders, Billing with {Domain, Application, Infrastructure} subfolders. Identify public contracts (DTOs, interfaces).
- Define two use cases: PlaceOrder (command) and GetOrderSummary (query). Show how controllers delegate to handlers and how validation is applied on request DTOs.
- Model repositories as interfaces in Domain and Doctrine adapters in Infrastructure. Add a transaction middleware that wraps command handlers.
- Publish a domain event OrderPlaced; consume it via Messenger to trigger email and invoice generation. Configure failure transport and retries.
- Add a voter for RefundOrder and demonstrate how controllers stay authorization-free.
- Configure a PSR-6 cache pool for a hot Catalog query; outline invalidation on relevant events.
- Write a test plan: unit tests for domain rules, integration tests for repositories (Testcontainers/SQLite), and functional tests with Foundry fixtures and WebTestCase.
- Provide an ADR summarizing why the architecture is bundle-less and how modules communicate (commands, queries, events), including guidelines for adding a new feature module.
Deliverable:
A concise architecture note and skeleton code showing a modular, maintainable, and testable Symfony application architecture that multiple teams can extend safely.

