Architecting a Symfony application for production
From the service container to security, Doctrine and message queues: the architecture decisions that hold at scale, without over-engineering.
Organizing code into business modules
A Symfony application does not need DDD to be readable: organize code into business modules (Account, Quiz, Contact…) with their own controllers, DTOs, entities, services and repositories. Dependencies flow from modules toward shared infrastructure, never the reverse. This structure makes boundaries visible, eases testing and avoids god classes.
src/
Account/
Controller/
Dto/
Entity/
Repository/
Service/
Quiz/
State/
Service/
Shared/
EventListener/
Repository/Thin controllers, use-case oriented services
A controller validates input, delegates to a service named after the user action (`QuizAnswerService`) and turns the result into a response. Business logic lives in services and entities, never in controllers or API metadata. As a result, every use case is testable without HTTP and controllers become trivial to review.
#[Route('/api/quiz-sessions/{id}/answers', methods: ['POST'])]
public function answer(
QuizSession $session,
#[MapRequestPayload] AnswerPayload $payload,
QuizAnswerService $service,
): JsonResponse {
$result = $service->answer($session, $payload);
return $this->json($result, Response::HTTP_CREATED);
}Mastering the container: autowiring, tags and compiler passes
Autowiring covers most cases; tags organize service collections (event subscribers, validators, data transformers) without manual wiring code. Compiler passes modify definitions at compile time and remain a last-resort tool: prefer explicit factories and tags. A global public service is almost always an anti-pattern.
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
exclude: '../src/{Entity,Migrations,Tests,Kernel.php}'Doctrine at scale: reads, writes and indexes
At scale, full entities are not always the right answer: for lists and dashboards, array-hydrated queries or native SQL DTOs cost less. Write in short transactions via `transactional()`, index according to real predicates and avoid lazy loading in loops (N+1). A query profile (profiler, logs) guides better than intuition.
// Avoid N+1: join + DTO hydration
$query = $entityManager->createQuery(
'SELECT NEW App\Quiz\Dto\SessionSummary(s.id, s.score, u.email)
FROM App\Quiz\Entity\QuizSession s
JOIN s.user u
WHERE s.tenant = :tenant'
);Async work: Messenger and message queues
Long or non-blocking operations (emails, imports, notifications) must leave the request-response cycle. Messenger serializes messages, transports them (sync in dev, RabbitMQ or Redis in production) and processes them in workers. Each message is a contract: version it, make processing idempotent and handle failures with retry + backoff, then final logged failure.
final class SendQuizResultNotification
{
public function __construct(
public readonly int $sessionId,
public readonly int $userId,
) {}
}
// config/packages/messenger.yaml
messenger:
transports:
async: '%env(MESSENGER_TRANSPORT_DSN)%'
routing:
'App\Quiz\Message\SendQuizResultNotification': asyncSecurity: routes, ownership and least privilege
Every route accessing a resource must verify authentication AND ownership: a dedicated voter loads the entity and compares the owner, and the API Platform operation declares its rule explicitly. Admin areas are protected by a dedicated role, never by the absence of a menu link. Never store secrets in code: use Symfony configuration and environment variables.
#[Route('/admin', name: 'admin')]
#[IsGranted('ROLE_ADMIN')]
final class AdminDashboardController extends AbstractController
{
// The role is checked before execution, not by the frontend
}HTTP, application and Doctrine cache: the right layer
The first caching layer is HTTP: public responses with `Cache-Control` and ETag validation. Below it, the application cache (Redis, APCu) stores expensive computation results with a context-aware key (locale, page, tenant). The Doctrine second-level cache only serves rarely modified data. Never cache a personalized response without an identity key.
$cacheKey = sprintf('quiz.dashboard.%d.%s', $tenantId, $locale);
$dashboard = $cache->get($cacheKey, function (ItemInterface $item) use ($tenantId): array {
$item->expiresAfter(300);
return $this->dashboardBuilder->build($tenantId);
});Testing the architecture, not just happy paths
Integration tests with a real container validate configuration (services, routes, security), functional tests cover error cases and unit tests isolate business logic. Every security rule and transaction behavior deserves a dedicated test. A suite that covers failures protects better than hundreds of happy paths.
public function test_owner_cannot_answer_another_users_session(): void
{
$other = $this->createUser();
$session = $this->createSession($other);
$this->client->loginUser($this->user);
$this->client->request('POST', "/api/quiz-sessions/{$session->getId()}/answers", ...);
self::assertResponseStatusCodeSame(403);
}