Symfony Fundamentals: Routing, Controllers, Services, and Twig
Understand Symfony's request-handling model — attribute and YAML routing, controllers built on AbstractController, the dependency injection container and autowiring, application configuration, and Twig templating — well enough to build and reason about a working Symfony page end to end.
Symfony is a full-stack PHP framework built around a strict separation of concerns and a powerful, explicit dependency injection container — where Laravel favors convention, magic facades, and an batteries-included feel, Symfony favors configuration, explicitness, and reusable components that are equally at home standalone (many, like HttpFoundation and the DI container itself, quietly power other frameworks and even parts of Laravel's ecosystem tooling). Learning Symfony well means learning to think in terms of requests routed to controllers, controllers built out of injected services, services wired together by a container that resolves their dependencies automatically, and templates that render output while staying deliberately free of business logic. This lesson walks through that core request lifecycle — routing, controllers, the service container, configuration, and Twig — using the pieces you will touch in almost every Symfony file you write.
Routing: Attributes, YAML, and Route Parameters
A route in Symfony maps a URL pattern (plus optionally an HTTP method) to the controller action that should handle it. Since Symfony 6.2 made PHP attributes the default and recommended style, the most common way to define a route is to place a #[Route(...)] attribute directly above the controller method it maps to — this keeps the URL and the code that handles it in the same place, which is far easier to navigate than hunting through a separate routes file. Attribute routing requires no separate registration step beyond enabling attribute route loading in config/routes.yaml (the Symfony skeleton does this by default), and the router discovers every #[Route] attribute across the src/Controller/ directory automatically.
Symfony also supports defining routes centrally in YAML (config/routes.yaml), which some teams prefer for a single at-a-glance list of every URL the application exposes, independent of where the controller code happens to live. Both styles compile down to the same underlying route collection at runtime — mixing them in one project is possible but usually a sign to standardize on one for consistency; attributes are the modern default and are what this lesson (and most current Symfony documentation) leads with.
Route parameters are placeholders inside the URL pattern, written in curly braces ({id}), that get extracted from the matched URL and passed as arguments to the controller method with the same name. Parameters can be constrained with a requirements array or inline regex ({id<\d+>}) so a route like /product/{id} only matches when {id} is numeric, letting the router correctly disambiguate /product/42 from /product/edit. A parameter can also declare a default value, making it effectively optional, and Symfony's route parameters support automatic type-casting and even entity resolution — typing a controller argument as an entity class causes Symfony's ParamConverter-equivalent (built into recent versions) to fetch that row from the database automatically before the controller body even runs.
• #[Route('/path', name: 'route_name', methods: ['GET'])] — attribute placed directly above a controller method. • YAML routing in config/routes.yaml centralizes all routes in one file, at the cost of separating them from the handling code. • {id} in a path is a route parameter, passed to the controller method as a same-named argument. • requirements: ['id' => '\d+'] or inline {id<\d+>} constrains a parameter so ambiguous routes don't collide. • Typing a route parameter as an entity class lets Symfony auto-fetch that database row before the controller runs.
Controllers: AbstractController and Response Objects
A Symfony controller is, at its simplest, just a PHP class with methods that return a Response object — everything else is convenience. Most controllers extend Symfony\Bundle\FrameworkBundle\Controller\AbstractController, a base class that provides shortcut methods for the most common controller needs: $this->render('template.html.twig', [...]) renders a Twig template and wraps the result in a Response, $this->redirectToRoute('route_name') returns a redirect Response, $this->json($data) returns a JsonResponse with the correct Content-Type header, and $this->getUser() returns the currently authenticated user (or null) when the Security component is in play.
Every controller action must ultimately return a Response object (or a subclass like JsonResponse or RedirectResponse) — this is not optional, and it is the single most important rule to internalize about Symfony controllers: whatever business logic runs inside the method, the method's job is to end by producing an HTTP response. Symfony's HttpKernel takes that Response object and translates it into the actual raw HTTP output sent back to the client, handling headers, status codes, and the response body uniformly regardless of which controller produced it.
Because AbstractController is a thin convenience layer over the service container, a controller can also request any registered service by simply type-hinting it as a constructor or method argument — Symfony's autowiring resolves and injects it automatically, without the controller needing to know how that service is built. This is what lets a controller stay short and focused: it orchestrates calls to injected services and repositories, and lets Twig or a serializer handle producing the actual response body.
• Controllers commonly extend AbstractController for shortcuts like render(), redirectToRoute(), json(), getUser(). • Every action must return a Response (or subclass: JsonResponse, RedirectResponse). • Services can be injected directly as constructor or method parameters via autowiring — no manual instantiation. • A controller's job is to orchestrate services/repositories and hand off response-building to Twig, the Serializer, or json().
Services and the Dependency Injection Container
Symfony's service container is the heart of the framework: it is an object that knows how to construct every registered service, resolve each service's own dependencies recursively, and hand back a fully wired instance whenever one is requested. A 'service' in Symfony terminology is simply any PHP class that performs a job and is registered with the container — a mailer wrapper, a price calculator, a PDF generator, a repository. By default, in a modern Symfony skeleton, every class under src/ is automatically registered as a service (configured in config/services.yaml via the App\ namespace's resource: wildcard), so you rarely need to hand-register a plain class yourself.
Autowiring is the container's ability to look at a class's constructor, read its type-hinted parameters, and automatically inject the matching registered service for each one — no manual wiring required for the common case. Autowiring works reliably when there is exactly one service implementing a given type; when there are multiple candidates (say, two classes implementing the same interface), you either bind a specific one as the default implementation in services.yaml, or use a named argument with bind: to disambiguate, or tag services so a compiler pass can collect all of them.
Not every configuration need is covered by autowiring alone. config/services.yaml lets you set explicit constructor arguments (useful for scalar values like an API key that isn't itself a service), bind interfaces to concrete implementations, and apply service tags — a way of marking a service as belonging to a category (like console.command or a custom tag) so Symfony (or your own compiler pass) can find and collect every service with that tag at container-build time, without those services needing to know about each other. Tags are how features like Console commands, event subscribers, and Twig extensions register themselves with their respective subsystems.
• A service is any class registered with the container that performs a job — most classes under src/ are auto-registered. • Autowiring injects dependencies automatically by matching constructor type-hints to registered services. • config/services.yaml overrides autowiring defaults: explicit arguments, interface bindings, service tags. • Service tags (e.g. console.command) let Symfony or your own code collect a category of services without them referencing each other.
Configuration: Config Files, Environment Variables, Parameters
Symfony configuration lives under config/, split by concern: config/packages/*.yaml configures individual bundles (Doctrine, security, Twig, and so on) per environment, config/services.yaml configures the service container as described above, and config/routes.yaml configures routing when not using attributes exclusively. Configuration files support per-environment variants — config/packages/dev/, config/packages/prod/, config/packages/test/ — allowing, for example, a more verbose Doctrine SQL logger only in the dev environment.
Environment variables are the standard way to inject environment-specific secrets and settings (database URLs, API keys, mail credentials) without committing them to source control. Symfony reads a .env file at the project root by convention (with .env.local for uncommitted machine-specific overrides), and configuration files reference an environment variable with the %env(VAR_NAME)% syntax, e.g. dsn: '%env(DATABASE_URL)%' in Doctrine's configuration. The .env file itself is typically committed with safe placeholder defaults, while .env.local (git-ignored) or real server environment variables carry actual secrets in production.
Parameters are named configuration values, defined under the parameters: key in services.yaml (or a dedicated file), that can be referenced from within service definitions using the %parameter_name% syntax and injected into services as constructor arguments — useful for values like a default page size or an upload directory path that several services need to share without hardcoding, and that aren't themselves secrets requiring an environment variable.
• config/packages/*.yaml — per-bundle configuration, with dev/prod/test environment variants. • .env (committed, safe defaults) and .env.local (git-ignored, real secrets) supply environment variables. • %env(VAR_NAME)% syntax reads an environment variable inside a config file. • parameters: in services.yaml defines named, shareable configuration values referenced as %name%.
Twig Templating: Output, Logic, Inheritance, Filters
Twig is Symfony's default templating engine, and it deliberately looks like a restricted, safer PHP: {{ ... }} outputs the result of an expression, and — critically — auto-escapes it for HTML context by default, meaning a variable containing <script> renders as inert text rather than executing, closing off a huge class of XSS vulnerabilities that raw PHP templates are prone to unless the developer remembers to escape manually every time. {% ... %} is Twig's tag delimiter for logic and control structures — {% if %}, {% for %}, {% set %}, {% include %} — none of which produce output by themselves; they control what the template does.
Template inheritance is one of Twig's most valuable features for avoiding repeated layout markup: a base template (commonly base.html.twig) defines the overall page shell and marks out named regions with {% block content %}{% endblock %}, and child templates open with {% extends 'base.html.twig' %} and then override specific blocks with their own {% block content %}...{% endblock %}. This means the <html>, <head>, navigation, and footer live in exactly one file, and every page template only needs to supply the content that actually differs.
Filters transform a value as it's output, applied with the pipe syntax: {{ name|upper }}, {{ price|number_format(2) }}, {{ description|striptags|trim }} (filters can be chained). Common built-in filters include upper/lower, date (formats a DateTime), length, default (a fallback for null/undefined values, {{ nickname|default('Anonymous') }}), and raw (explicitly disables auto-escaping — used sparingly and only for content you trust, since it reopens the XSS risk Twig otherwise closes automatically).
• {{ expr }} — outputs a value, HTML-auto-escaped by default. • {% tag %} — logic/control structures (if, for, set, include) that produce no output themselves. • {% extends 'base.html.twig' %} + {% block name %}...{% endblock %} — template inheritance for shared layout. • Filters via |: upper, date, length, default('fallback'), number_format, and raw (disables escaping — use with care).
A Minimal Attribute-Routed Controller
The route requirement <\d+> ensures {id} only matches digits, so /product/edit doesn't accidentally match this route. show() returns a Response (via render(), which wraps the rendered Twig template in one) — this is mandatory for every controller action.
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class ProductController extends AbstractController
{
#[Route('/product/{id<\d+>}', name: 'product_show', methods: ['GET'])]
public function show(int $id): Response
{
return $this->render('product/show.html.twig', [
'productId' => $id,
]);
}
}Code Examples
PriceCalculator is never instantiated with new here — Symfony's autowiring sees the constructor's type-hint, resolves the registered PriceCalculator service (and, recursively, whatever PriceCalculator itself needs), and injects a ready-to-use instance automatically.
LoggerInterface is a built-in service Symfony's container already knows how to provide (Monolog under the hood), so it's autowired for free; $taxRate is a scalar, not a service, so it would need an explicit bind: entry in services.yaml to be autowired to a non-default value.
extends pulls in base.html.twig's overall page shell; only the title and content blocks are overridden here. The date filter formats a DateTime object, and default() supplies a fallback without needing an {% if %} check in the template.
<?php
namespace App\Controller;
use App\Service\PriceCalculator;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
class CartController extends AbstractController
{
public function __construct(
private readonly PriceCalculator $calculator,
) {}
#[Route('/cart/total', name: 'cart_total', methods: ['POST'])]
public function total(): JsonResponse
{
$total = $this->calculator->calculateForCart($this->getUser());
return $this->json(['total' => $total]);
}
}<?php
namespace App\Service;
use Psr\Log\LoggerInterface;
class PriceCalculator
{
public function __construct(
private readonly LoggerInterface $logger,
private readonly float $taxRate = 0.08,
) {}
public function calculateForCart(?\App\Entity\User $user): float
{
$subtotal = 49.99; // normally read from a cart repository
$total = $subtotal * (1 + $this->taxRate);
$this->logger->info('Calculated cart total', ['total' => $total]);
return round($total, 2);
}
}{# templates/product/show.html.twig #}
{% extends 'base.html.twig' %}
{% block title %}Product #{{ productId }}{% endblock %}
{% block content %}
<h1>Product {{ productId }}</h1>
<p>Listed: {{ listedAt|date('F j, Y') }}</p>
<p>Nickname: {{ nickname|default('Anonymous') }}</p>
{% if inStock %}
<span class="badge">In stock</span>
{% else %}
<span class="badge">Sold out</span>
{% endif %}
{% endblock %}Symfony Request Flow
The router picks a controller, the controller pulls in services via the container, and (usually) hands off to Twig to build the Response body.
HTTP request
│
▼
┌─────────┐ matches ┌────────────┐ calls ┌────────────┐
│ Router │ ──────────▶│ Controller │ ─────────▶│ Services │
│ (#[Route])│ │ (extends │ autowired│ (container │
└─────────┘ │AbstractCtrl)│ │ resolves) │
└────────────┘ └────────────┘
│
▼ render()
┌────────────┐
│ Twig │ → Response → back to browser
└────────────┘Important Rules
Every controller action must return a Response object (or a subclass) — there is no implicit output.
Prefer attribute routing (#[Route]) for new code; keep YAML routing only where a centralized route list is genuinely valuable.
Type-hint dependencies in the constructor and let autowiring inject them — avoid manually instantiating services with new.
Never use the raw Twig filter on untrusted or user-supplied content — it disables the auto-escaping that prevents XSS.
Keep real secrets out of the committed .env file; use .env.local or real server environment variables instead.
Why Large Teams Choose Symfony
Organizations with large PHP codebases and many contributors often favor Symfony precisely because its container-driven explicitness (no hidden facades, every dependency visible in a constructor signature) and enforced separation between controllers, services, and Twig views makes a multi-year, multi-team codebase easier to navigate and safely refactor than a more convention-driven, magic-heavy framework.
public function show(int $id) { echo "Product: ". $id; } public function show(int $id): Response { return $this->render('product/show.html.twig', ['productId' => $id]); }
Symfony controllers must return a Response object; echoing output directly bypasses the HttpKernel's response pipeline and will error or produce a broken/empty page rather than the expected output.
public function __construct() { $this->calculator = new PriceCalculator(); } public function __construct(private readonly PriceCalculator $calculator) {}
Manually instantiating a service with new bypasses the container entirely, silently losing that service's own injected dependencies (like the logger) and any configuration applied to it in services.yaml. Type-hint it and let autowiring handle construction.
{{ userComment|raw }} {{ userComment }} Applying |raw to user-supplied content disables Twig's automatic HTML escaping, opening a direct cross-site scripting hole. Let auto-escaping run unless the content is known-safe, pre-sanitized markup.
What must every Symfony controller action ultimately return? A. A plain string of HTML B. A Response object (or subclass like JsonResponse) C. An array that Symfony automatically converts D. Nothing — output is captured from echo statements
B. A Response object (or subclass like JsonResponse) — The HttpKernel expects a Response object from every controller action; render(), json(), and redirectToRoute() are all convenience methods that build and return one.
What does Symfony's autowiring do? A. Automatically writes routing YAML files for you B. Reads a constructor's type-hints and injects matching registered services automatically C. Converts all controllers into services D. Automatically escapes Twig output
B. Reads a constructor's type-hints and injects matching registered services automatically — Autowiring inspects a class's constructor (or setter) type-hints and resolves each one to a matching service registered in the container, injecting it without manual wiring.
In Twig, what is the key difference between {{ }} and {% %}? A. They are interchangeable syntax for the same thing B. {{ }} outputs an expression's value (auto-escaped); {% %} is for logic/control structures that produce no output themselves C. {% %} is only used inside comments D. {{ }} is used for filters; {% %} is used for values
B. {{ }} outputs an expression's value (auto-escaped); {% %} is for logic/control structures that produce no output themselves — {{ expr }} prints a value to the page with HTML auto-escaping by default; {% tag %} delimits control structures like if, for, set, extends, and block, none of which output anything by themselves.
Where should real production secrets (like a database password) be placed? A. Directly in config/services.yaml B. In the committed .env file C. In .env.local (git-ignored) or real server environment variables, referenced via %env(VAR)% D. Hardcoded into the controller
C. In .env.local (git-ignored) or real server environment variables, referenced via %env(VAR)% — The committed .env file should hold only safe placeholder defaults; actual secrets belong in the git-ignored .env.local or in real environment variables set on the server, read via the %env(VAR_NAME)% syntax.
Create a Symfony controller action at GET /greet/{name} that injects a simple GreetingService (with a method greet(string $name): string) and renders a Twig template passing the greeting through, using the |upper filter to display the name in uppercase inside an {% if %} check for whether the name is non-empty.
Constrain {name} in the route if needed, type-hint GreetingService in the controller constructor, and use {% if name %}...{% endif %} plus {{ name|upper }} in the template.