Skip to content

4. The View and Its Model

Up to this point, actions generated their own responses: text, JSON, and a few HTML tags written as a string. In a MVC application, this is the role of a view: a file that describes the page HTML, with placesholders for the data passed to it by the action—the view’s model.

[Symfony] uses the [Twig] view engine, created by the same team. A [Twig] view is a file whose name ends with [.html.twig] (the output format, followed by the engine), stored in the application’s [templates] folder. It is a text file—in this case, HTML—in which three types of tags are interpreted on the server:

  • [{{ expression }}]: the value of an expression, inserted into the page;
  • [{% instruction %}]: an instruction (tag); a condition ([{% if %}]); loop ([{% for %}]), template inheritance ([{% extends %}], [{% block %}]), inclusion ([{% include %}])...;
  • [{# comment #}]: a comment, which does not appear on the generated page.

Everything else is copied as-is. The expressions are not written in [PHP], but in the language of [Twig], which is simpler and safer: [model.title] refers to the property [title] of the object [model] (or its method [getTitle()], or the key [title] of an array: [Twig] looks it up itself); filters transform a value ([name|upper] capitalizes a name); functions perform calculations ([path('home')] generates a URL). [Twig] thus resembles [Jinja2] ([Flask]), from which it drew inspiration, or the engine of [Django]; it differs from [Blade] ([Laravel]) and [Razor] ([ASP.NET Core]), which combine HTML with host language code. A [Twig] view cannot execute arbitrary [PHP] code: it can only display the data provided to it. This is by design: the application logic remains within the classes. A view is not interpreted with every request: when it is first used, [Twig] compiles it into a [PHP] class, stored in the [var/cache/dev/twig] folder, which it then reuses as long as the view remains unchanged. We’ll see what this file looks like.

4.1. Example 13: A First View

13-first-view/
├── config/
│   ├── packages/
│   │   ├── framework.yaml
│   │   └── twig.yaml
│   └── bundles.php
├── src/
│   ├── Controller/
│   │   └── ViewsController.php
│   ├── Model/
│   │   └── PageModel.php
│   └── Kernel.php
├── templates/
│   └── views/
│       ├── unknown.html.twig
│       └── index.html.twig
└── ...

4.1.1. The configuration: config/bundles.php and config/packages/twig.yaml

[Twig] is integrated into [Symfony] via a bundle, TwigBundle, which must be added to the application’s bundle list (Example 01):

1
2
3
4
5
6
7
<?php
// config/bundles.php: the bundles of the application; the TwigBundle integrates the
// Twig view engine
return [
    Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
    Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
];

and configure:

1
2
3
4
# config/packages/twig.yaml: the configuration of the TwigBundle
twig:
    # the folder of the views
    default_path: '%kernel.project_dir%/templates'

The only key specifies the views folder: [templates], located in the project folder. This is also the default value. In a standard project, these two files are created by [Symfony Flex] when you install [Twig] ([composer require twig], see introduction).

4.1.2. The view template: src/Model/PageModel.php

<?php
// src/Model/PageModel.php: the model of the view, the data that the action passes to the view

namespace App\Model;

readonly class PageModel
{
    public function __construct(
        public string $title,
        public string $message,
        public string $time,
    ) {
    }
}

The view model is a simple immutable class (see Example 03): the three pieces of information that the view will display. There is no requirement to define a view’s model as a class: three separate variables could be passed to the view. However, a class documents what the view expects and allows the editor to verify the action’s code. This is the approach the case study will follow, where each important view has its own model class.

4.1.3. The controller: src/Controller/ViewsController.php

<?php
// src/Controller/ViewsController.php: actions that return a VIEW

namespace App\Controller;

use App\Model\PageModel;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

#[Route(methods: ['GET'])]
class ViewsController extends AbstractController
{
    // render: the Twig engine builds the page from the view
    // templates/views/index.html.twig and from the variables passed in the array (here
    // a single one, [model]); the produced page is the body of the response
    #[Route('/', name: 'index')]
    public function index(): Response
    {
        $model = new PageModel(
            'My first view',
            'This page was built by Twig, on the server.',
            date('H:i:s'));
        return $this->render('views/index.html.twig', ['model' => $model]);
    }

    // the same view with another model; or another view, with the 404 code
    #[Route('/hello/{name}', name: 'hello')]
    public function hello(string $name): Response
    {
        if ($name === 'unknown') {
            // the third parameter of render: the response to complete, here with the 404 code
            return $this->render('views/unknown.html.twig', ['name' => $name],
                new Response(status: Response::HTTP_NOT_FOUND));
        }
        $model = new PageModel(
            "Hello $name",
            'This time, the title of the page comes from the URL.',
            date('H:i:s'));
        return $this->render('views/index.html.twig', ['model' => $model]);
    }
}
  • Lines 20–23: The action builds the view template. The function [date], derived from [PHP], formats the current time: [H] formats the hours, [i] formats the minutes, and [s] formats the seconds;
  • Line 24: The [render] method of [AbstractController] instructs [Twig] to generate the page. The first parameter is the name of the view: the file path, relative to the [templates] folder. The second parameter is an associative array, where each key becomes a variable in the view—in this case, a single one: [model]. [render] returns an object [Response], whose body is the generated page (of type [text/html]);
  • lines 31–35: another view, with another variable ([name]). The third parameter of [render] is a response to be populated: in this case, a 404 response for a custom error page. [new Response(status: ...)] passes the code via a named parameter, while the other parameters retain their default values;
  • Lines 36–40: the same view as the [index] action, but with a different template. This is the benefit of separating actions from views: a single view can be used by multiple actions.

4.1.4. The view: templates/views/index.html.twig

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  {# {{ ... }}: the value of an expression, inserted into the page #}
  <title>{{ model.title }}</title>
</head>
<body>
  <h1>{{ model.title }}</h1>
  <p>{{ model.message }}</p>
  <p>Page built at {{ model.time }}.</p>
</body>
</html>
  • line 5: a comment [Twig];
  • line 6: [{{ model.title }}] inserts the value of the [title] property of the [model] variable. The period is the access operator for [Twig], where [PHP] writes [$model->title].

Everything inserted by [{{ }}] is escaped: the characters [<], [>], [&], ["], and ['] are replaced by their entities HTML. This is the role that function [htmlspecialchars] played in Example 09, but this time it is automatic: a view therefore cannot inadvertently insert the HTML provided by a user into the page.

4.1.5. The view: templates/views/unknown.html.twig

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Unknown</title>
</head>
<body>
  <h1>Nobody is called [{{ name }}] here.</h1>
</body>
</html>

A view is not required to receive an object: the variable [name] is a string.

4.1.6. Execution

Image

Image

The displayed time corresponds to the time zone configured in [PHP] (the [date.timezone] directive in the [php.ini] file), which is Coordinated Universal Time (UTC) in this case; the case study will show how to set it for the application. The raw response shows the work done by [Twig]: the [{{ }}] tags have been replaced with their values, and the comment has been removed.

curl -i http://localhost:8000/
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
    <title>My first view</title>
</head>
<body>
  <h1>My first view</h1>
  <p>This page was built by Twig, on the server.</p>
  <p>Page built at 09:31:47.</p>
</body>
</html>

The “home” error page, followed by a name containing HTML ([<b>martin], encoded in URL):

curl -i http://localhost:8000/hello/unknown
curl http://localhost:8000/hello/%3Cb%3Emartin
HTTP/1.1 404 Not Found
Content-Type: text/html; charset=UTF-8

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Unknown</title>
</head>
<body>
  <h1>Nobody is called [unknown] here.</h1>
</body>
</html>
...
    <title>Hello &lt;b&gt;martin</title>
...
  <h1>Hello &lt;b&gt;martin</h1>
  <p>This time, the title of the page comes from the URL.</p>
...

The tag [<b>] was escaped, even though neither the action nor the view had anything to do with it (the apostrophe was also escaped: [&#039;]). Here, finally, is the view compiled by [Twig], as found in the [var/cache/dev/twig] folder (under a generated name, such as [f1/f1e0ff0a8a0dc68657a0b1aa94f3ef4b.php]), stripped down to the essentials:

/* views/index.html.twig */
class __TwigTemplate_9c9473b678a753b61e2666e1a8e3a2a4 extends Template
{
    ...
    protected function doDisplay(array $context, array $blocks = []): iterable
    {
        // line 1
        yield "<!doctype html>
<html lang=\"en\">
<head>
  <meta charset=\"utf-8\">
  ";
        // line 6
        yield "  <title>";
        yield (string) $this->escaper->escape(CoreExtension::getAttribute($this->env, $this->source,
            ... $context["model"] ..., "title", ...), "html", null, true);
        yield "</title>
...

The view has become a class named [PHP], whose method [doDisplay] generates the page piece by piece ([yield]). Each [{{ model.title }}] has become a call to [CoreExtension::getAttribute] (which looks up the property, method, or key [title]), passed to the method [escape] (escaping). A [Twig] view is therefore simply a safer and more readable way to write a [PHP] file that generates HTML.

4.2. Example 14: The Twig Language

This example combines the main constructs of [Twig] into a single view.

4.2.1. The Template and the Action

The view’s template is defined by three immutable classes, each in its own file ([PHP] does not require one class per file, but the class loader in [Composer] looks for the class [App\Model\Product] in the file [src/Model/Product.php]):

<?php
// src/Model/Product.php: a product

namespace App\Model;

readonly class Product
{
    public function __construct(
        public string $name,
        public float $price,
        public bool $inStock,
    ) {
    }
}
<?php
// src/Model/Address.php: an address

namespace App\Model;

readonly class Address
{
    public function __construct(
        public string $street,
        public string $city,
    ) {
    }
}
<?php
// src/Model/BasicsModel.php: the model of the view of example 14

namespace App\Model;

readonly class BasicsModel
{
    /**
     * @param list<Product> $products
     * @param list<Product> $cart
     */
    public function __construct(
        public string $title,
        public string $name,
        public ?string $nickname,
        public string $html,
        public bool $loggedIn,
        public string $currency,
        public array $products,
        public array $cart,
        public Address $address,
    ) {
    }
}

The model contains values of various types: strings, [null], Booleans, arrays of objects, empty arrays, and objects containing other objects. The prices here are of type [float], to simplify calculations in the view (Example 06 showed why we prefer whole cents).

<?php
// src/Controller/BasicsController.php: a view that uses the main constructs of Twig

namespace App\Controller;

use App\Model\Address;
use App\Model\BasicsModel;
use App\Model\Product;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class BasicsController extends AbstractController
{
    #[Route('/', name: 'index', methods: ['GET'])]
    public function index(): Response
    {
        $model = new BasicsModel(
            title: 'Twig basics',
            name: 'Martin',
            nickname: null,
            html: '<b>this is HTML</b>',
            loggedIn: true,
            currency: '€',
            products: [
                new Product('Keyboard', 25, true),
                new Product('Mouse', 12.5, false),
                new Product('Screen', 149, true),
            ],
            cart: [],
            address: new Address('12 rue des Lilas', 'Angers'));
        return $this->render('basics/index.html.twig', ['model' => $model]);
    }
}

The action builds the model using named parameters (see Example 02): each value is preceded by the name of the parameter it is intended for, which makes the call readable despite the number of parameters.

4.2.2. The view: templates/basics/index.html.twig

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>{{ model.title }}</title>
</head>
<body>
  {# set: a variable of the view; the reduce filter adds up the prices, the length filter counts the products #}
  {% set total = model.products|reduce((sum, p) => sum + p.price, 0) %}
  {% set count = model.products|length %}
  <h1>{{ model.title }}</h1>
  {# 1. display a value of the model; null is displayed as an empty string;
     the default filter replaces an empty value. This Twig comment does not appear
     in the produced page #}
  <p>1. Name: {{ model.name }} - nickname: [{{ model.nickname }}] [{{ model.nickname|default('none') }}]</p>
  {# 2. {{ }} escapes HTML; the raw filter inserts it as is #}
  <p>2a. {{ model.html }}</p>
  <p>2b. {{ model.html|raw }}</p>
  {# 3. a condition #}
  {% if model.loggedIn %}
    <p>3. You are logged in.</p>
  {% else %}
    <p>3. You are not logged in.</p>
  {% endif %}
  {# 4. a loop: the loop variable describes the state of the loop #}
  <p>4. Our products:</p>
  <ul>
    {% for product in model.products %}
      <li>no. {{ loop.index0 }}: {{ product.name }}, {{ product.price }} {{ model.currency }}
        {{ product.inStock ? '(in stock)' : '(sold out)' }}
        {% if loop.first %} - the first one{% endif %}
        {% if loop.last %} - the last one, seen by {{ model.name }}{% endif %}
      </li>
    {% endfor %}
  </ul>
  {# 5. the empty list: the else block of a for loop #}
  <p>5. Your cart:
    {% for p in model.cart %}<span>{{ p.name }}</span>{% else %} it is empty.{% endfor %}
  </p>
  {# 6. computed expressions, the set variables, and the number_format filter #}
  <p>6. {{ count }} products, for a total of {{ total }} {{ model.currency }};
    average price: {{ (total / count)|number_format(2, '.', ',') }} {{ model.currency }}</p>
  {# 7. an object inside an object, and a filter that converts to uppercase #}
  <p>7. Address: {{ model.address.street }}, {{ model.address.city|upper }}</p>
  {# 8. verbatim: a text that Twig copies without interpreting it #}
  <p>8. Braces displayed as is: {% verbatim %}{{ model.name }}{% endverbatim %}</p>
</body>
</html>
  • Lines 9–10: The [{% set %}] statement creates a variable in the view. The total price is calculated by the filter [reduce], which reduces an array to a single value (like [array_reduce], example 06): its function receives the accumulated value and the current element; [(sum, p) => sum + p.price] is an arrow function of [Twig]. The filter [length] returns the number of elements in an array (or characters in a string). These calculations should be reserved for presentation purposes: the calculation normally belongs to the action;
  • line 15: a [null] value is displayed as an empty string. The [default] filter replaces an empty (or missing) value with a default value;
  • lines 17–18: [{{ }}] escapes HTML (2a); the filter [raw], on the other hand, inserts the value as-is, without escaping it (2b). [raw] is reserved for HTML generated by the application itself, never for user input;
  • Lines 20–24: a condition: [{% if %}], [{% elseif %}], [{% else %}], [{% endif %}]. Each block is closed by a [{% end... %}] statement;
  • Lines 28–34: a loop [{% for ... in ... %}] ... [{% endfor %}]. Within the loop, the special variable [loop] describes the loop’s state: [loop.index0] (the iteration number, starting at 0), [loop.index] (starting at 1), [loop.first], [loop.last] (true on the first and last iterations), [loop.length] (the number of elements). Variables outside the loop ([model.name]) remain accessible;
  • line 30: the ternary operator [condition ? a : b] ([Twig] also accepts [condition ? a], which returns an empty string if the condition is false);
  • line 38: a loop [for] can have a block [{% else %}], which is executed if the list is empty. This is simpler than a [{% if %}] that would check the length of the list;
  • lines 41–42: calculated expressions ([total / count]) and the variables from [set]. The filter [number_format] formats a number: two decimal places, a point as the decimal separator, and a comma as the thousands separator. In Example 16, we’ll see a much better way to format numbers and amounts;
  • line 44: the period passes through objects ([model.address.city]), and the filter [upper] capitalizes a string (while correctly handling accented characters);
  • line 46: the [{% verbatim %}] instruction copies its content without interpreting it: this is useful when a page contains curly braces intended for a [JavaScript] framework.

[Twig] has about fifty filters ([lower], [capitalize], [trim], [join], [split], [slice], [sort], [date], [json_encode], [escape]...), about fifteen functions ([range], [min], [max], [random]...) and tests ([is defined], [is empty], [is null], [is iterable], [is even]...); the bundles for [Symfony] add some ([path], [asset], [form_row], [is_granted]...), and the application can write its own (Example 16).

4.2.3. Execution

Image

The generated page:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Twig basics</title>
</head>
<body>
        <h1>Twig basics</h1>
    <p>1. Name: Martin - nickname: [] [none]</p>
    <p>2a. &lt;b&gt;this is HTML&lt;/b&gt;</p>
  <p>2b. <b>this is HTML</b></p>
        <p>3. You are logged in.</p>
      <p>4. Our products:</p>
  <ul>
          <li>no. 0: Keyboard, 25 €
        (in stock)
         - the first one              </li>
          <li>no. 1: Mouse, 12.5 €
        (sold out)
                      </li>
          <li>no. 2: Screen, 149 €
        (in stock)
                 - the last one, seen by Martin      </li>
      </ul>
    <p>5. Your cart:
     it is empty.  </p>
    <p>6. 3 products, for a total of 186.5 €;
    average price: 62.17 €</p>
    <p>7. Address: 12 rue des Lilas, ANGERS</p>
    <p>8. Braces displayed as is: {{ model.name }}</p>
</body>
</html>

The comments and instructions have disappeared, but they leave behind their spaces: [Twig] removes the line break that follows a tag, but not the indentation that precedes it. This has no effect: the browser ignores these spaces. (A hyphen inside a tag—such as [{%- ... -%}] or [{{- ... -}}]—would remove the whitespace surrounding it.) A number [float] inserted into the page is written with a decimal point ([12.5]); [number_format] uses the separators passed to it.

4.3. Example 15: Templates, fragments, static files

The pages of a website usually share the same structure: a header, a navigation bar, a footer, and the same style sheets. Rather than repeating this structure in every view, it is written once in a template (layout), which the pages extend. This example also uses [Bootstrap], the most widely used CSS framework, for the layout.

15-layout/
├── config/
│   ├── packages/
│   │   └── twig.yaml
│   └── services.yaml
├── public/
│   └── css/
│       └── styles.css
├── src/
│   ├── Controller/
│   │   └── PagesController.php
│   ├── Model/
│   │   └── Member.php
│   └── Kernel.php
├── templates/
│   ├── fragments/
│   │   └── navbar.html.twig
│   ├── layout.html.twig
│   ├── macros.html.twig
│   └── pages/
│       ├── home.html.twig
│       ├── raw.html.twig
│       └── team.html.twig
└── ...

4.3.1. Static Files

A HTML page references other files: style sheets, [JavaScript] scripts, and images. These files, which the server sends as-is, are called static files. With [Symfony], they are stored in the [public] folder, which the web server serves as-is (example 01): The file [public/css/styles.css] is available at URL [/css/styles.css], without [Symfony] being involved.

/* a few settings in addition to Bootstrap */
body {
  background-color: #f5f7fa;
}

/* the footer of the layout */
.footer {
  margin-top: 2rem;
  font-size: 0.85rem;
  color: #6c757d;
}

/* the error messages, at the bottom of the page, on a light pink background */
.error-area {
  margin-top: 1.5rem;
  padding: 0.75rem 1rem;
  background-color: #fdecef;
  border: 1px solid #f5c2c7;
  border-radius: 0.375rem;
  color: #842029;
}

A few additional CSS rules in addition to [Bootstrap]: a light gray background, the footer style, and the style for an error area that will be used in the following examples.

[Bootstrap] is used in examples 15 through 28. Rather than placing a copy in each user’s [public] folder, the examples share it: it is located in the [examples/common/bootstrap] folder. This folder is not served by the web server: a controller, shared by all these examples, serves its files. This is the file [examples/common/BootstrapController.php]:

<?php
// common/BootstrapController.php: a controller that serves the Bootstrap files,
// shared by examples 15 to 28 (the examples/common/bootstrap folder), under
// the /bootstrap/... URL. Each of these examples declares it in its
// config/services.yaml file

namespace Common;

use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Requirement\Requirement;

class BootstrapController
{
    #[Route('/bootstrap/{file}', name: 'bootstrap', requirements: ['file' => Requirement::CATCH_ALL],
        methods: ['GET'])]
    public function serve(string $file): BinaryFileResponse
    {
        $folder = realpath(__DIR__.'/bootstrap');
        // realpath: the absolute path of the requested file, without the . and the ..;
        // false if the file does not exist
        $path = realpath($folder.'/'.$file);
        // a file that exists, and that is indeed IN the folder (no ../ to get out of it)
        if ($path === false || ! str_starts_with($path, $folder.DIRECTORY_SEPARATOR) || ! is_file($path)) {
            throw new NotFoundHttpException("unknown file: $file");
        }
        // a response that sends the content of a file; its type, according to its extension
        $response = new BinaryFileResponse($path);
        $types = ['css' => 'text/css', 'js' => 'text/javascript', 'map' => 'application/json'];
        $response->headers->set('Content-Type', $types[pathinfo($path, PATHINFO_EXTENSION)] ?? 'application/octet-stream');
        return $response;
    }
}
  • line 7: the class is in the [Common] namespace, which the file [examples/composer.json] associates with the folder [examples/common] (see introduction): the class loader in [Composer] therefore finds it, regardless of the example;
  • Lines 16–17: a route whose parameter [{file}] accepts any path ending, including slashes (example 02): the URL [/bootstrap/css/bootstrap.min.css] refers to the [css/bootstrap.min.css] file in the folder. The route is named [bootstrap];
  • Lines 20–27: Serving files designated by URL requires caution: a URL such as [/bootstrap/..%2F..%2F15-layout%2F.env] (where [/] is encoded as [%2F]) must not grant access to a file located outside the folder. The [realpath] function of [PHP] calculates the absolute path of a file, stripping away its [.] and [..] components, or returns [false] if the file does not exist. We verify that the resulting path begins with the folder path ([str_starts_with]) and that it refers to a file ([is_file]); otherwise, a 404 error is returned. [DIRECTORY_SEPARATOR] is the system’s path separator: [\] under [Windows], [/] elsewhere;
  • lines 29–32: a [BinaryFileResponse] object is a response that sends the contents of a file. The content type is determined based on the file extension (provided by [pathinfo]), so that a stylesheet is correctly declared as [text/css]: a browser will reject a stylesheet declared as something else.

Each example that uses [Bootstrap] declares this controller as a service in its [config/services.yaml] file:

# config/services.yaml: the services of the application (see example 06)
services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\:
        resource: '../src/'

    # the controller that serves Bootstrap, shared by examples 15 to 28 (see example 15)
    Common\BootstrapController: ~
  • line 11: the [Common\BootstrapController] service, with no specific configuration ([~] is the value of [null] in [YAML]): it receives the ones from [_defaults], and [autoconfigure] turns it into a controller, whose route joins those of the application (example 02).

4.3.2. The template: templates/layout.html.twig

<!doctype html>
{# the layout of the pages: the pages EXTEND it, and fill its blocks #}
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  {# block: a slot that the page can fill; here with a default content #}
  <title>{% block title %}Home{% endblock %} - {{ site_name }}</title>
  {# path(): the URL of the route named [bootstrap]; asset(): the URL of a file of public/ #}
  <link rel="stylesheet" href="{{ path('bootstrap', {file: 'css/bootstrap.min.css'}) }}">
  <link rel="stylesheet" href="{{ asset('css/styles.css') }}">
</head>
<body>
  <div class="container py-3">
    {# insertion of the fragments/navbar.html.twig file #}
    {% include 'fragments/navbar.html.twig' %}
    <main>
      {# the content of the page requested by the action is inserted here #}
      {% block content %}{% endblock %}
    </main>
    {# app: the variable that Symfony gives to all the views (the current request...);
       site_name: a global variable (see config/packages/twig.yaml) #}
    <footer class="footer">Page [{{ app.request.pathInfo }}] - © {{ 'now'|date('Y') }} {{ site_name }}</footer>
  </div>
</body>
</html>
  • line 6: the [viewport] tag instructs mobile browsers to display the page at the screen’s width, which is what [Bootstrap] expects;
  • line 8: the [{% block %}] directive marks a named slot, which pages that extend the template can fill by defining a block with the same name. The content written between [{% block title %}] and [{% endblock %}] is the default content for the placeholder, used by a page that does not define this block. [site_name] is a global variable, present in all views (see [config/packages/twig.yaml], below);
  • lines 10–11: the function [path] generates the URL for a named route (example 04), using the values of its parameters, which are provided in an associative array of [Twig] ([{file: '...'}], the equivalent of [['file' => '...']] in [PHP]). The [asset] function generates the URL from a file in the [public] folder. Writing the URL files manually ([/css/styles.css]) would also work, but [path] and [asset] will work if the application is installed under the URL prefix;
  • line 16: [{% include %}] inserts the content of another view, in this case the navigation bar. The included view has access to the variables of the view that includes it;
  • line 19: the space that each page fills with its own content;
  • line 23: the variable [app] is present in all views: [Symfony] stores the current request ([app.request]), the session ([app.session]), the logged-in user ([app.user], see the section on security), the route name ([app.current_route])... [app.request.pathInfo] is the path to the requested URL. The filter [date] formats a date; the string ['now'] denotes the current time.

The [container] class of [Bootstrap] centers the content and limits its width; [py-3] adds an inner vertical margin (padding): [Bootstrap] provides hundreds of classes of this type, which are combined in the [class] attribute. The global variable [site_name] is declared in the configuration of [Twig]:

1
2
3
4
5
6
# config/packages/twig.yaml: the configuration of the TwigBundle
twig:
    default_path: '%kernel.project_dir%/templates'
    # GLOBAL variables, present in all the views
    globals:
        site_name: 'My site'

Variables under the key [globals] are added to all views without the need for actions to provide them. A global variable can also be a service (['@App\Service\...']), whose methods will be called by the views.

4.3.3. The navigation bar: templates/fragments/navbar.html.twig

{# a fragment: a piece of page, inserted by include #}
<nav class="navbar navbar-expand bg-light rounded mb-3 px-3">
  <span class="navbar-brand">{{ site_name }}</span>
  <div class="navbar-nav">
    {# path(): the URL of a route, designated by its name #}
    <a class="nav-link" href="{{ path('home') }}">Home</a>
    <a class="nav-link" href="{{ path('team') }}">Team</a>
    <a class="nav-link" href="{{ path('raw') }}">Page without layout</a>
  </div>
</nav>

A fragment: a piece of a page that has no meaning on its own. The classes [navbar], [navbar-brand], and [nav-link] are those of the navigation bar for [Bootstrap]. The links are generated by [path], based on the road names.

4.3.4. A macro: templates/macros.html.twig

{# MACROS: pieces of page that have parameters, like functions #}

{# a Bootstrap card, with a name and a role #}
{% macro card(name, role = '') %}
  <div class="card">
    <div class="card-body">
      <h5 class="card-title">{{ name }}</h5>
      <p class="card-text">{{ role }}</p>
    </div>
  </div>
{% endmacro %}
  • Line 4: A macro is a section of a page that has parameters; this is the function of [Twig]. It is declared by [{% macro name(parameters) %}] ... [{% endmacro %}]; a parameter can have a default value ([role = '']). A file can contain multiple macros. The card ([card]) is a component of [Bootstrap].

A macro can only access its own parameters (and global variables); it cannot access the variables of the view that calls it. This is what makes it reusable. (The [Symfony UX Twig Component] library offers more feature-rich components—[PHP] classes associated with a view—that are used as HTML tags: [<twig:Card name="..." />]; we won’t need them.)

4.3.5. The pages

{# this page EXTENDS the layout: it only provides the content of its blocks #}
{% extends 'layout.html.twig' %}
{# the macros of the macros.html.twig file, under the name m #}
{% import 'macros.html.twig' as m %}

{% block content %}
  <h1>Welcome</h1>
  <p>This page only contains its own content: the header, the navigation
    bar and the footer come from the <code>layout.html.twig</code> layout.</p>
  {# the [card] macro, called with two parameters written on the spot #}
  {{ m.card('A macro', 'called with parameters written on the spot') }}
{% endblock %}
  • line 2: the page extends the template: [Twig] generates the template page, replacing its blocks with those defined by the page. In a view that extends a template, all content must be within blocks: text written outside of them does not appear in the generated page;
  • line 4: [{% import %}] imports macros from a file under a name of your choice ([m]);
  • lines 6–12: the block [content], which fills the slot of the same name. The page does not define the block [title]: the slot retains its default content, “Home”;
  • line 11: the call to the macro [card], as a function: [m.card(...)], within [{{ }}], since it produces text to be inserted. Its output is not escaped a second time: [Twig] knows that it is already safe from HTML (its parameters, however, were escaped within the macro).
{% extends 'layout.html.twig' %}
{% import 'macros.html.twig' as m %}

{% block title %}Our team{% endblock %}

{% block content %}
  <h1>Our team</h1>
  <div class="row g-3">
    {% for member in members %}
      <div class="col-md-4">
        {# the parameters of the macro are any expressions #}
        {{ m.card(member.name, member.role) }}
      </div>
    {% endfor %}
  </div>
{% endblock %}
  • line 4: the [title] block, on a single line;
  • lines 8–15: one card per team member, in a grid of [Bootstrap] ([row], [col-md-4]: three columns on a medium or large screen, one column on a narrow screen);
  • line 12: the macro’s parameters are arbitrary expressions.

The controller contains no new features:

<?php
// src/Controller/PagesController.php: three pages

namespace App\Controller;

use App\Model\Member;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

#[Route(methods: ['GET'])]
class PagesController extends AbstractController
{
    #[Route('/', name: 'home')]
    public function home(): Response
    {
        return $this->render('pages/home.html.twig');
    }

    #[Route('/team', name: 'team')]
    public function team(): Response
    {
        $members = [
            new Member('Marie Pélissier', 'Director'),
            new Member('Jacques Bromard', 'Developer'),
            new Member('Philippe Jandot', 'Graphic designer'),
        ];
        return $this->render('pages/team.html.twig', ['members' => $members]);
    }

    #[Route('/raw', name: 'raw')]
    public function raw(): Response
    {
        return $this->render('pages/raw.html.twig');
    }
}

Finally, the third page does not use the template:

<!doctype html>
{# this view does not use the layout: it is a complete page #}
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Raw page</title>
</head>
<body>
  <p>This page does not use the layout: no Bootstrap, no navigation bar.</p>
  <p><a href="{{ path('home') }}">Back to home</a></p>
</body>
</html>

4.3.6. Execution

Image

Image

Image

The team page, as received by the browser:

curl http://localhost:8000/team
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Our team - My site</title>
    <link rel="stylesheet" href="/bootstrap/css/bootstrap.min.css">
  <link rel="stylesheet" href="/css/styles.css">
</head>
<body>
  <div class="container py-3">
        <nav class="navbar navbar-expand bg-light rounded mb-3 px-3">
  <span class="navbar-brand">My site</span>
  <div class="navbar-nav">
        <a class="nav-link" href="/">Home</a>
    <a class="nav-link" href="/team">Team</a>
    <a class="nav-link" href="/raw">Page without layout</a>
  </div>
</nav>
    <main>
              <h1>Our team</h1>
  <div class="row g-3">
          <div class="col-md-4">
                  <div class="card">
    <div class="card-body">
      <h5 class="card-title">Marie Pélissier</h5>
      <p class="card-text">Director</p>
    </div>
  </div>

      </div>
...
      </div>
    </main>
        <footer class="footer">Page [/team] - © 2026 My site</footer>
  </div>
</body>
</html>

The browser received a complete page: the template, fragment, page, and macros were assembled on the server. The URL files for the style sheets were generated by [path] and [asset]: these are URL files related to the site. You can verify that the style sheets are being served correctly (the [-I] option of [curl] displays only the response headers):

curl -I http://localhost:8000/bootstrap/css/bootstrap.min.css
curl -I http://localhost:8000/css/styles.css
HTTP/1.1 200 OK
Cache-Control: public
Last-Modified: Sun, 27 Sep 2026 09:32:39 GMT
Content-Type: text/css; charset=UTF-8
Content-Length: 232111
Accept-Ranges: bytes
...
HTTP/1.1 200 OK
Content-Type: text/css; charset=UTF-8
Content-Length: 435

The first file was sent by our controller ([BinaryFileResponse] adds the file’s modification date and allows it to be cached: [Cache-Control: public]); the second was sent directly by the development server, without going through [Symfony]. Finally, an attempt to exit the folder:

curl -s -o /dev/null -w "%{http_code}\n" "http://localhost:8000/bootstrap/..%2F..%2F15-layout%2F.env"
404

(The [-s -o /dev/null -w "%{http_code}\n"] options display only the response code; under [Windows], enter [-o NUL]).

4.4. Example 16: Twig Extensions

A view often needs to format values: an amount with its currency, a date written out in full, or text in uppercase. This code does not belong in the action (which does not handle presentation), and [Twig] does not allow it to be written in the view (which cannot execute [PHP]; see the introduction to this chapter). It is written as [PHP], in an extension of [Twig]: a class that adds its own filters, functions, or tests to the view language. Libraries provide ready-made ones, such as [twig/intl-extra], installed with the examples, which formats dates and amounts according to the language. Finally, macros create pieces of HTML (Example 15).

16-extensions/
├── config/
│   └── services.yaml
├── src/
│   ├── Controller/
│   │   └── PagesController.php
│   ├── EventListener/
│   │   └── SetLanguage.php
│   ├── Model/
│   │   └── Product.php
│   ├── Twig/
│   │   └── FormatsExtension.php
│   └── Kernel.php
├── templates/
│   ├── fragments/
│   │   └── navbar.html.twig
│   ├── layout.html.twig
│   ├── macros.html.twig
│   └── pages/
│       ├── home.html.twig
│       └── products.html.twig
└── ...

4.4.1. The extension: src/Twig/FormatsExtension.php

<?php
// src/Twig/FormatsExtension.php: a Twig EXTENSION, which adds two filters
// to the language of the views: money and long_date

namespace App\Twig;

use DateTimeInterface;
use IntlDateFormatter;
use NumberFormatter;
use Symfony\Component\HttpFoundation\RequestStack;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;

class FormatsExtension extends AbstractExtension
{
    // the request stack, to know the language of the current request
    public function __construct(private RequestStack $requests)
    {
    }

    // the filters of the extension: their name in the views, and the method that implements them
    public function getFilters(): array
    {
        return [
            new TwigFilter('money', $this->money(...)),
            new TwigFilter('long_date', $this->longDate(...)),
        ];
    }

    // an amount in cents, in a currency, in the format of the language of the
    // request: 114990 -> "€1,149.90" in English, "1 149,90 €" in French.
    // PHP's intl extension knows the conventions of each country
    public function money(int $cents, string $currency = 'EUR'): string
    {
        $formatter = new NumberFormatter($this->language(), NumberFormatter::CURRENCY);
        return $formatter->formatCurrency($cents / 100, $currency);
    }

    // a date in full words, in the language of the request: "Sunday, September 27, 2026"
    public function longDate(DateTimeInterface $day): string
    {
        $formatter = new IntlDateFormatter($this->language(), IntlDateFormatter::FULL, IntlDateFormatter::NONE);
        return $formatter->format($day);
    }

    private function language(): string
    {
        return $this->requests->getCurrentRequest()?->getLocale() ?? 'en';
    }
}
  • Line 14: An extension of [Twig] inherits from the [AbstractExtension] class. It is a service, like every class in the [src] folder; [Symfony] recognizes it as an extension ([autoconfigure]) and passes it to [Twig];
  • Lines 17–19: The extension needs the language of the current request. It receives the request stack ([RequestStack]), a service from [Symfony] that knows the current request (there is a stack because one request can trigger another internally);
  • lines 22–28: The method [getFilters] returns the extension’s filters: for each one, its name in the views and the function that implements it. [$this->money(...)] (with the ellipsis) does not call the method: it turns it into a value—a function that can be called later (a first-class callable). An extension can also provide functions ([getFunctions]) and tests ([getTests]);
  • lines 33–37: formatting an amount received in cents (see Example 06). This depends on the language: in French, [1 149,90 €] (space to separate thousands, decimal comma, symbol after the amount); in English, [€1,149.90]. The class [NumberFormatter], part of the [intl] extension of [PHP], supports the conventions of all countries (it uses data from the Unicode project CLDR): it is created for a language and a style (here, [CURRENCY], for amounts), and [formatCurrency] formats an amount, with the currency designated by its international code ([EUR], [USD]). The first parameter of the filter is the filtered value; the subsequent parameters are its properties in the view ([priceCents|money('USD')]);
  • lines 40–44: formatting a date using class [IntlDateFormatter] from the [intl] extension: style [FULL] writes the date in full, including the day of the week ([Sunday, September 27, 2026]); the style [NONE], for the time, does not include it. The type [DateTimeInterface] accepts all dates from [PHP];
  • lines 46–49: the language ([fr], [en]) of the current query, or English if there is no query (in a console command, for example). We’ll see who sets it.

4.4.2. The language of each request: src/EventListener/SetLanguage.php

<?php
// src/EventListener/SetLanguage.php: the language of each request

namespace App\EventListener;

use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;

// priority: the order of the listeners of the same event (the highest first);
// 20: after the routing (32), before Symfony's listeners that use the language (16, 15)
#[AsEventListener(event: KernelEvents::REQUEST, priority: 20)]
class SetLanguage
{
    // the ?lang=fr parameter of the URL, English otherwise
    public function __invoke(RequestEvent $event): void
    {
        $request = $event->getRequest();
        $request->setLocale($request->query->get('lang') === 'fr' ? 'fr' : 'en');
    }
}

Each request has a language (locale), which defaults to the language [default_locale] specified in the configuration ([en] if nothing is configured). Here, the parameter [?lang=fr] of URL selects French; English is the default language. A listener for the [kernel.request] event (Example 12) sets this before each action using the [setLocale] method of the request. The [priority] parameter of the attribute sets the order of listeners for the same event: the highest-priority ones are processed first (default is 0). [Symfony] itself has several listeners of [kernel.request]: routing (priority 32), followed by those that prepare language-dependent services (16 and 15). Ours must run between the two: 20. The command [php bin/console debug:event-dispatcher kernel.request] displays all listeners for the event, in order. The language set in this way applies only to the current request: the next one will revert to the default language. The chapter on internationalization will use this same language to translate the texts.

4.4.3. The configuration: config/services.yaml

# config/services.yaml: the services of the application (see example 06)
services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\:
        resource: '../src/'

    # the controller that serves Bootstrap, shared by examples 15 to 28 (see example 15)
    Common\BootstrapController: ~

    # Twig's Intl extension (twig/intl-extra library): the filters
    # format_currency, format_date, format_number...
    Twig\Extra\Intl\IntlExtension: ~
  • Line 15: The filters for the [twig/intl-extra] library are provided by the [IntlExtension] extension. Its class is not in the [src] folder: it is declared as a service, and [Symfony] recognizes it as an extension of [Twig] ([autoconfigure]). (In a standard project, the [twig/extra-bundle] package, installed by [Symfony Flex], handles this itself.)

4.4.4. The controller: src/Controller/PagesController.php

<?php
// src/Controller/PagesController.php: two pages

namespace App\Controller;

use App\Model\Product;
use DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

#[Route(methods: ['GET'])]
class PagesController extends AbstractController
{
    #[Route('/', name: 'home')]
    public function home(): Response
    {
        return $this->render('pages/home.html.twig', ['today' => new DateTimeImmutable('today')]);
    }

    #[Route('/products', name: 'products')]
    public function products(): Response
    {
        return $this->render('pages/products.html.twig', ['products' => [
            new Product('keyboard', 2500, 12),
            new Product('mouse', 1250, 0),
            new Product('screen', 114990, 3),
        ]]);
    }
}

Actions pass raw values to the views: a date ([new DateTimeImmutable('today')], today’s date at midnight) and amounts in cents. Formatting is handled by the views. The [Product] class combines a name, a price (in cents; see Example 06), and a stock level.

4.4.5. The views

{# a link of the navigation bar, which receives the [active] class if the
   current page is under the [path] path. app.request.pathInfo: the path of the
   current request; starts with: a Twig test #}
{% macro link(href, path, text) %}
  <a class="nav-link{{ app.request.pathInfo starts with path ? ' active' }}" href="{{ href }}">{{ text }}</a>
{% endmacro %}

{# a Bootstrap badge: green if the product is in stock, red otherwise #}
{% macro badge(stock) %}
  <span class="badge {{ stock > 0 ? 'text-bg-success' : 'text-bg-danger' }}">{{ stock > 0 ? stock ~ ' in stock' : 'sold out' }}</span>
{% endmacro %}
  • lines 4–6: a macro that creates a link for the navigation bar. The link for the current page is assigned the class [active] from [Bootstrap], which highlights it. A macro accesses the global variable [app] (Example 15): [app.request.pathInfo] is the path of the current request; [starts with] is an operator of [Twig], which checks for the start of a string. The ternary operator without an “else” clause ([condition ? ' active']) returns an empty string if the condition is false (Example 14);
  • Lines 9–11: a badge from [Bootstrap], green ([text-bg-success]) or red ([text-bg-danger]) depending on inventory. The [~] operator of [Twig] concatenates two strings (the [.] of [PHP]).
{% import 'macros.html.twig' as m %}
<nav class="navbar navbar-expand bg-light rounded mb-3 px-3">
  <span class="navbar-brand">Extensions</span>
  <div class="navbar-nav">
    {# 1. a condition written in the view; app.current_route: the name of the current route #}
    <a class="nav-link{{ app.current_route == 'home' ? ' active' }}" href="{{ path('home') }}">Home</a>
    {# 2. the [link] macro #}
    {{ m.link(path('products'), '/products', 'Products') }}
    {# 3. path() builds the URL of a named route; a parameter that is not a
         parameter of the route is added at the end of the URL: /products?lang=fr #}
    <a class="nav-link" href="{{ path('products', {lang: 'fr'}) }}">Produits (fr)</a>
  </div>
</nav>
  • line 1: an included fragment (example 15) does not inherit imports from the view that includes it: it imports the macros it needs on its own;
  • line 6: the same as with the macro, written directly in the view, using the ternary operator. [app.current_route] is the name of the current request path. The macro avoids repeating the condition for each link;
  • line 8: the macro call;
  • line 11: a parameter of [path] that is not a route parameter is added to URL as a parameter: [/products?lang=fr].
{% extends 'layout.html.twig' %}
{% block title %}Home{% endblock %}

{% block content %}
  <h1>Home</h1>
  {# our long_date filter #}
  <p>Today is {{ today|long_date }}.</p>
  {# app.request.locale: the language of the request, set by the SetLanguage listener #}
  <p>Formatting language: {{ app.request.locale }}
    (<a href="{{ path('home', {lang: 'fr'}) }}">in French</a>, <a href="{{ path('home') }}">in English</a>)</p>
{% endblock %}
  • line 7: our filter [long_date];
  • line 9: [app.request.locale], the request language, set by the listener [SetLanguage].
{% extends 'layout.html.twig' %}
{% import 'macros.html.twig' as m %}
{% block title %}Products{% endblock %}

{% block content %}
  <h1>Products</h1>
  <table class="table table-sm w-auto">
    <thead><tr><th>Product</th><th>Price</th><th>Price in dollars</th><th>Stock</th></tr></thead>
    <tbody>
      {% for product in products %}
        <tr>
          {# a Twig filter (upper), then our money filter, without then with a parameter #}
          <td>{{ product.name|upper }}</td>
          <td>{{ product.priceCents|money }}</td>
          <td>{{ product.priceCents|money('USD') }}</td>
          {# the [badge] macro builds the HTML of the badge #}
          <td>{{ m.badge(product.stock) }}</td>
        </tr>
      {% endfor %}
    </tbody>
  </table>
  {# the format_currency filter of the Intl extension: an amount (here in units), a
     currency, and the language of the request #}
  <p>Total stock value: {{ (products|reduce((s, p) => s + p.priceCents * p.stock, 0) / 100)|format_currency('EUR', locale: app.request.locale) }}</p>
{% endblock %}
  • line 13: a filter for [Twig];
  • Lines 14–15: our [money] filter, first with its default currency, then with a parameter;
  • line 17: the call to the [badge] macro;
  • line 24: the [format_currency] filter from the Intl extension: an amount (here in euros: the total inventory, calculated by [reduce], divided by 100), a currency, and the query language, passed via a named parameter from [Twig] ([locale: ...]). This extension also provides [format_date], [format_datetime], [format_number], [country_name], [language_name]... Our filter [money] therefore serves a dual purpose: we wrote it to demonstrate how to write an extension.

The template [layout.html.twig] is a simplified version of the one in Example 15; its attribute [lang] receives the language of the request ([<html lang="{{ app.request.locale }}">]).

4.4.6. Execution

Image

Image

The same page, with the parameter [?lang=fr]. Only the formatting of the numbers changes: the text itself is hard-coded in the views. The chapter on internationalization will also translate the text.

Image

An excerpt from HTML produces:

curl "http://localhost:8000/products?lang=en"
...
              <tr>
                    <td>KEYBOARD</td>
          <td>€25.00</td>
          <td>$25.00</td>
                    <td>  <span class="badge text-bg-success">12 in stock</span>
</td>
        </tr>
...
    <p>Total stock value: €3,749.70</p>
...

4.5. Example 17: A GET form

A HTML form sends its fields to the server in one of two ways, depending on its [method] attribute:

  • [method="get"]: The fields are added to the URL as parameters ([/search?keyword=le&genre=novel]). This is the method used by a form that performs a query: a search or a filter. The URL containing the results can be bookmarked, shared, or reloaded safely;
  • [method="post"]: The fields are included in the request body (see Example 11). This is the method used by a form that modifies data: a registration, an addition, or a deletion.

This first form is a book search. It is hand-coded in HTML within the view; the following examples will use the Form component from [Symfony] for the forms.

4.5.1. The template: src/Model/Book.php and SearchModel.php

<?php
// src/Model/Book.php: a book

namespace App\Model;

readonly class Book
{
    public function __construct(
        public string $title,
        public string $author,
        public string $genre,
    ) {
    }
}
<?php
// src/Model/SearchModel.php: the model of the view: the input (to display it
// again), the genres, the results

namespace App\Model;

readonly class SearchModel
{
    /**
     * @param list<string> $genres
     * @param list<Book> $results
     */
    public function __construct(
        public string $keyword,
        public string $genre,
        public array $genres,
        public array $results,
    ) {
    }
}
  • Lines 14–19: The view template contains the user’s input, which the form displays, the list of suggested genres, and the results.

4.5.2. The controller: src/Controller/BooksController.php

<?php
// src/Controller/BooksController.php: a search form, sent by GET

namespace App\Controller;

use App\Model\Book;
use App\Model\SearchModel;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class BooksController extends AbstractController
{
    // class CONSTANTS: the data, written in the code for the example
    private const GENRES = ['novel', 'poetry', 'drama'];

    /** @return list<Book> */
    private static function books(): array
    {
        return [
            new Book('Le Rouge et le Noir', 'Stendhal', 'novel'),
            new Book('Les Fleurs du mal', 'Baudelaire', 'poetry'),
            new Book('Madame Bovary', 'Flaubert', 'novel'),
            new Book('Alcools', 'Apollinaire', 'poetry'),
            new Book('Le Cid', 'Corneille', 'drama'),
        ];
    }

    // GET / and GET /search?keyword=...&genre=...: the form AND its results;
    // two routes for the same action: the #[Route] attribute can be repeated
    #[Route('/', name: 'books_home', methods: ['GET'])]
    #[Route('/search', name: 'books_search', methods: ['GET'])]
    public function search(Request $request): Response
    {
        // getString: the parameter, in the form of a string ('' if it is missing)
        $keyword = $request->query->getString('keyword');
        $genre = $request->query->getString('genre');
        $word = mb_strtolower($keyword);
        $results = array_values(array_filter(self::books(), fn (Book $book) =>
            ($word === '' || str_contains(mb_strtolower($book->title), $word)
                || str_contains(mb_strtolower($book->author), $word))
            && ($genre === '' || $book->genre === $genre)));
        return $this->render('books/search.html.twig', [
            'model' => new SearchModel($keyword, $genre, self::GENRES, $results),
        ]);
    }
}
  • Line 16: A private class constant named [self::GENRES] ([self] refers to the current class). A constant cannot contain objects: the books are therefore provided by a static method (lines 19–28). In a real application, they would come from a database;
  • lines 32–33: two routes for the same action: the [#[Route]] attribute can be repeated. On the first visit ([/]), there are no parameters, and all books are displayed;
  • lines 37–38: The form fields are read as parameters for URL (example 07). The [getString] method returns the parameter as a string, empty if it is absent;
  • lines 40–43: filtering. The [array_filter] function retains the elements of an array for which the given function returns [true]: a book is selected if the keyword is empty or appears in its title or author (in lowercase on both sides; [mb_strtolower] correctly handles accented characters), and if the genre is empty or matches the book’s genre. [array_filter] retains the keys of the selected items (0, 2...): [array_values] renumbers them starting from 0;
  • lines 44–46: the view template.

4.5.3. The view: templates/books/search.html.twig

{% extends 'layout.html.twig' %}

{% block content %}
  <h1 class="h3">Book search</h1>
  {# method="get": the fields are sent in the URL (?keyword=...&genre=...) #}
  <form method="get" action="{{ path('books_search') }}" class="row g-2 align-items-end mb-3">
    <div class="col-auto">
      <label class="form-label" for="keyword">Title or author</label>
      {# value: the field displays again what was typed (escaped by {{ }}) #}
      <input class="form-control" id="keyword" name="keyword" value="{{ model.keyword }}">
    </div>
    <div class="col-auto">
      <label class="form-label" for="genre">Genre</label>
      <select class="form-select" id="genre" name="genre">
        <option value="">(all)</option>
        {% for genre in model.genres %}
          {# the boolean selected attribute, written if the condition is true, omitted otherwise #}
          <option value="{{ genre }}"{{ genre == model.genre ? ' selected' }}>{{ genre }}</option>
        {% endfor %}
      </select>
    </div>
    <div class="col-auto">
      <button class="btn btn-primary" type="submit">Search</button>
    </div>
  </form>
  <p>{{ model.results|length }} book(s) found.</p>
  <ul>
    {% for book in model.results %}
      <li><i>{{ book.title }}</i>, {{ book.author }} ({{ book.genre }})</li>
    {% endfor %}
  </ul>
{% endblock %}
  • Line 6: the [<form>] tag. The [action] attribute is the URL to which the form is submitted; [method="get"] places the fields in the URL. The classes of [Bootstrap] arrange the fields on a single line;
  • Line 8: a label. Its [for] attribute specifies the identifier ([id]) of the field it describes: clicking on the label places the cursor in the field, and screen readers read the label along with the field;
  • line 10: an input field. Its attribute [name] is the name of the parameter sent to the server: this is the link between the form and the action ([$request->query->getString('keyword')]). The attribute [value] is the displayed value: the previous entry, redisplayed (and escaped by [{{ }}], which is essential in an attribute: an entry containing a quotation mark ["] would otherwise close the attribute);
  • lines 14–20: a drop-down list. Each [<option>] has a value ([value]), sent to the server, and text, displayed. The first option, with an empty value, means “all genres”;
  • line 18: the option selected in the previous request is assigned the attribute [selected]. This is a Boolean attribute: its presence alone is sufficient; it has no value. The ternary operator without the “else” part writes it if the condition is true, and writes nothing otherwise. [==] is the equality of [Twig];
  • line 23: the submit button;
  • line 26: the number of results.

4.5.4. Execution

Image

Let’s enter [le] and select the type [novel]:

Image

The URL on the results page contains the form fields: [/search?keyword=le&genre=novel]. The response from the form:

curl "http://localhost:8000/search?keyword=le&genre=novel"
...
    <form method="get" action="/search" class="row g-2 align-items-end mb-3">
    <div class="col-auto">
      <label class="form-label" for="keyword">Title or author</label>
            <input class="form-control" id="keyword" name="keyword" value="le">
    </div>
    <div class="col-auto">
      <label class="form-label" for="genre">Genre</label>
      <select class="form-select" id="genre" name="genre">
        <option value="">(all)</option>
                            <option value="novel" selected>novel</option>
                            <option value="poetry">poetry</option>
                            <option value="drama">drama</option>
              </select>
    </div>
...
  </form>
  <p>1 book(s) found.</p>
  <ul>
          <li><i>Le Rouge et le Noir</i>, Stendhal (novel)</li>
      </ul>
...

“Madame Bovary” is not selected: “le” does not appear in either the title or the author’s name.

4.6. Example 18: Form Fields

This example presents all the field types in a HTML form, this time submitted by POST. It shows how to redisplay each field type with its current value, how the browser sends the values for each one, and how the server verifies that a received value is indeed one of the available options. Writing all of this by hand, as in Example 17, would be time-consuming and repetitive. [Symfony] delegates this task to its Form component: a form is described by a class, its type; [Symfony] generates the HTML, reads the submitted fields, converts them, validates them, and redisplays the form with the user’s input and any errors. This is the counterpart to [WTForms] in [Python].

18-fields/
├── config/
│   └── packages/
│       ├── framework.yaml
│       └── twig.yaml
├── src/
│   ├── Controller/
│   │   └── FormController.php
│   ├── Form/
│   │   └── FieldsType.php
│   ├── Service/
│   │   └── ListsService.php
│   └── Kernel.php
├── templates/
│   ├── form/
│   │   └── form.html.twig
│   └── layout.html.twig
└── ...

4.6.1. The lists service: src/Service/ListsService.php

<?php
// src/Service/ListsService.php: the lists offered by the form. In a
// real application, they would come from a database.

namespace App\Service;

class ListsService
{
    // each list: an associative array displayed text => sent value
    public function titles(): array
    {
        return ['Mr' => 'Mr', 'Mme' => 'Mme', 'Melle' => 'Melle'];
    }

    public function countries(): array
    {
        return ['France' => 'fr', 'Belgium' => 'be', 'Switzerland' => 'ch', 'Canada' => 'ca'];
    }

    public function hobbies(): array
    {
        return ['reading' => 'reading', 'music' => 'music', 'sport' => 'sport', 'cinema' => 'cinema'];
    }

    public function languages(): array
    {
        return ['French' => 'French', 'English' => 'English', 'Spanish' => 'Spanish',
            'German' => 'German'];
    }
}

The form’s dropdown lists (titles, countries, hobbies, languages) are provided by a service, following the model in Example 06: in a real application, they would come from a database. Each list is an associative array: the key is the displayed text, and the value is what is sent to the server (this is the order expected by the Form component).

4.6.2. The configuration

# config/packages/framework.yaml: the configuration of the FrameworkBundle
framework:
    secret: '%env(APP_SECRET)%'
    # the messages (errors of the forms) in English (see example 09)
    default_locale: en
    translator:
        default_path: '%kernel.project_dir%/translations'
    # the SESSION: the anti-CSRF token of the forms is kept there (see example 20)
    session: true
    # the anti-CSRF protection of the forms (see further on)
    csrf_protection: true
  • lines 5–7: the language and translator (Example 09): error messages from the Form component, like those from the validator, will be in English;
  • line 9: the user’s session, which we’ll discuss further in Example 20: the Form component stores the anti-CSRF token there (see below);
  • line 11: anti-CSRF protection for forms.
1
2
3
4
5
# config/packages/twig.yaml: the configuration of the TwigBundle
twig:
    default_path: '%kernel.project_dir%/templates'
    # the THEME of the forms: the fields are drawn with the Bootstrap 5 classes
    form_themes: ['bootstrap_5_layout.html.twig']
  • Line 5: The forms theme. [Symfony] renders each field using [Twig] blocks; a theme provides the version suitable for a CSS framework. The [bootstrap_5_layout.html.twig] theme, included with [Symfony], uses the classes from [Bootstrap] 5 ([form-control], [form-label], [is-invalid]...). The application can write its own themes or override certain blocks of an existing theme.

4.6.3. Form type: src/Form/FieldsType.php

<?php
// src/Form/FieldsType.php: the DESCRIPTION of the form: its fields, their type, their options

namespace App\Form;

use App\Service\ListsService;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints\NotBlank;

class FieldsType extends AbstractType
{
    // a form type is a service: it receives the lists service
    public function __construct(private ListsService $lists)
    {
    }

    // the fields of the form, in the order in which they are displayed
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            // radio buttons: a list of choices (ChoiceType), "expanded";
            // constraints: the validation constraints of the field (see example 08)
            ->add('title', ChoiceType::class, [
                'choices' => $this->lists->titles(),
                'expanded' => true,
                'label_attr' => ['class' => 'radio-inline'],
                'constraints' => [new NotBlank(message: 'The title is required.')],
            ])
            // an input field; required => false: the field is not required
            ->add('name', TextType::class, ['required' => false])
            // a password: never displayed again in the page
            ->add('password', PasswordType::class, ['label' => 'Password', 'required' => false])
            // a date: an <input type="date"> field; the value is a yyyy-mm-dd string
            ->add('birth_date', DateType::class, [
                'label' => 'Date of birth',
                'widget' => 'single_text',
                'input' => 'string',
                'required' => false,
            ])
            // checkboxes: an expanded list of choices, with MULTIPLE choices
            ->add('hobbies', ChoiceType::class, [
                'choices' => $this->lists->hobbies(),
                'expanded' => true,
                'multiple' => true,
                'label_attr' => ['class' => 'checkbox-inline'],
                'required' => false,
            ])
            // drop-down list: a list of choices that is not expanded
            ->add('country', ChoiceType::class, ['choices' => $this->lists->countries()])
            // multiple-selection list, displayed on four rows
            ->add('languages', ChoiceType::class, [
                'label' => 'Spoken languages',
                'choices' => $this->lists->languages(),
                'multiple' => true,
                'attr' => ['size' => 4],
                'required' => false,
            ])
            ->add('comment', TextareaType::class, ['required' => false, 'attr' => ['rows' => 2]])
            // a hidden field
            ->add('token', HiddenType::class);
    }
}
  • line 17: a form type inherits from the [AbstractType] class. It is a service (located in the [src] folder): it can accept other services, in this case the list service (line 20);
  • line 25: the method [buildForm] describes the fields by adding them ([add]) to a form builder: for each field, its name, type, and options. The field name is also the name of the data it displays; its label, by default, is its name formatted as text ([name] becomes “Name”);
  • Lines 30–35: A [ChoiceType] field is a drop-down list, defined by the [choices] option. With the [expanded] option, it becomes a group of radio buttons (a single-selection field). The option [label_attr] assigns a class to the group label: [radio-inline] specifies side-by-side buttons in the theme. The option [constraints] sets the field’s validation constraints (example 08): a title is required;
  • line 37: a [TextType] field is an input field. The option [required] defaults to [true]: [Symfony] then sets the attribute HTML to [required], which the browser checks (Example 19). It does not send any information to the server: it is a constraint ([NotBlank]) that makes a field required for the server;
  • line 39: a password ([PasswordType]), which is never returned to the page: its field is always empty when displayed;
  • lines 41–46: a date ([DateType]), displayed by a single field [<input type="date">] (option [widget]). Such a field exchanges its value with the server in the [yyyy-mm-dd] format, regardless of the format in which the browser displays it; the [input] option specifies that the data is a string (rather than a [DateTime] object, which is the default);
  • lines 48–54: a drop-down list with multiple choices ([multiple]): checkboxes;
  • line 56: a non-expanded multiple-choice list: a drop-down list;
  • lines 58–64: a non-expanded, multiple-choice list: a multi-select list, displayed across four lines (the attribute HTML [size], specified by the option [attr]);
  • line 65: a text box ([TextareaType]), spanning two lines;
  • line 67: a hidden field ([HiddenType]).

[Symfony] provides about thirty field types: [EmailType], [IntegerType], [MoneyType], [UrlType], [CheckboxType], [FileType], [CountryType], [RepeatedType] (example 19), [CollectionType] (a list of subforms)...

4.6.4. The controller: src/Controller/FormController.php

<?php
// src/Controller/FormController.php: a form that uses all the types of fields

namespace App\Controller;

use App\Form\FieldsType;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class FormController extends AbstractController
{
    // GET: the form, with initial values; POST: the sent fields
    // are read, validated, then displayed again by the same form
    #[Route('/form', name: 'form', methods: ['GET', 'POST'])]
    public function form(Request $request): Response
    {
        // the initial values (one per field, an array for a multiple field)
        $initial = ['title' => 'Mme', 'country' => 'fr', 'languages' => ['French'], 'token' => 'A7X2'];
        // createForm: the form, built from its description
        $form = $this->createForm(FieldsType::class, $initial);
        // handleRequest: if the request is a POST, the received fields are copied
        // into the form, then validated
        $form->handleRequest($request);
        $model = ['form' => $form];
        if ($form->isSubmitted()) {
            // what the browser actually sent, field by field
            $received = [];
            foreach ($request->request->all() as $name => $value) {
                foreach ((array) $value as $key => $v) {
                    $received[] = "{$name}[$key] = [".implode(', ', (array) $v).']';
                }
            }
            $model += [
                'received' => implode("\n", $received),
                'valid' => $form->isValid(),
                // getErrors(true): the errors of all the fields
                'errors' => $form->getErrors(true),
                // getData(): the values of the form, converted by each field
                'data' => $form->getData(),
            ];
        }
        return $this->render('form/form.html.twig', $model);
    }
}
  • line 16: a single action for both methods: GET displays the form, POST processes it. This is the standard way to write a form action with [Symfony];
  • line 20: the form’s initial values: an associative array, one value per field (an array for a multi-value field);
  • line 22: [createForm] creates the form—a [Form] object—based on its type and initial data;
  • line 25: [handleRequest] examines the request. For a GET, it does nothing. For a POST that contains the form fields, it copies them into the form: each field converts its value (the date, the lists, etc.), verifies that a selection is among those that were offered, and then the form validates the constraints and the anti-CSRF token;
  • line 27: [isSubmitted]: Was the form submitted?;
  • lines 29–34: for this example, the list of what the browser actually sent. The fields of a [Symfony] form are named after the form: [fields[name]], [fields[hobbies][]]. [PHP] therefore organizes them into an array named [fields] (Example 10), which is then iterated over. [(array) $value] converts a single value into a one-element array (an array remains an array); [implode] combines the elements of an array into a string, separated by the first parameter;
  • lines 35–42: the view template receives what the server received: the validation result ([isValid]), the errors ([getErrors(true)]: those for all fields), and the form data ([getData]), converted by each field. The [+=] operator adds these keys to the [$model] array;
  • line 44: the form is passed to the view, which will display it, along with the user’s input and any errors.

4.6.5. The view: templates/form/form.html.twig

{% extends 'layout.html.twig' %}

{% block content %}
  <h1 class="h3">A complete form</h1>
  {# form_start: the <form> tag, here with the POST method and the URL of the page #}
  {{ form_start(form, {attr: {style: 'max-width: 40rem'}}) }}
    {# form_row: a complete field (label, input field, errors) #}
    {{ form_row(form.title) }}
    <div class="row g-2">
      <div class="col">{{ form_row(form.name) }}</div>
      <div class="col">{{ form_row(form.password) }}</div>
      <div class="col">{{ form_row(form.birth_date) }}</div>
    </div>
    {{ form_row(form.hobbies) }}
    <div class="row g-2">
      <div class="col">{{ form_row(form.country) }}</div>
      <div class="col">{{ form_row(form.languages) }}</div>
    </div>
    {{ form_row(form.comment) }}
    <button class="btn btn-primary" type="submit">Send</button>
  {# form_end: the remaining fields (the hidden field, the anti-CSRF token), then </form> #}
  {{ form_end(form) }}

  {# is defined: true if the received variable was passed to the view (form sent) #}
  {% if received is defined %}
    <h2 class="h5 mt-3">What the server received</h2>
    <pre class="bg-light p-2 border">{{ received }}</pre>
    <p>Valid form: {{ valid ? 'yes' : 'no' }}</p>
    <ul>
      {% for error in errors %}
        {# error.origin: the field in error; error.message: the message #}
        <li>{{ error.origin.getName() }}: {{ error.message }}</li>
      {% endfor %}
    </ul>
    {% if valid %}
      <p>The data of the form:</p>
      <ul>
        {# a loop over an associative array: the key and the value #}
        {% for key, value in data %}
          <li>{{ key }}: {{ value is iterable ? value|join(', ') : value }}</li>
        {% endfor %}
      </ul>
    {% endif %}
  {% endif %}
{% endblock %}
  • line 6: the function [form_start] writes the tag [<form>]: the method POST and the URL of the current page, by default; the second parameter adds HTML attributes;
  • Line 8: The [form_row] function writes a complete field: its label, its input field (with its current value), and its errors, using the theme’s classes. [form.title] refers to the [title] field in the form. You can also write the parts separately: [form_label], [form_widget], [form_errors];
  • lines 9–19: the other fields, arranged by the grid classes of [Bootstrap];
  • line 22: [form_end] writes the fields that have not yet been written (in this case, the hidden field [token] and the hidden field [_token], the anti-CSRF token), then closes the [</form>] tag;
  • line 25: the [is defined] check: the variable [received] only exists after a submission;
  • lines 30–33: the errors. Each is a [FormError] object, where [origin] is the field containing the error;
  • Lines 39–41: A [for] loop on an associative array, which iterates over both the keys and the values. The [is iterable] test distinguishes between arrays (grouped by the [join] filter) and single values.

The entire form could have been written in a single line: [{{ form(form) }}]. The functions [form_start], [form_row], and [form_end] allow you to arrange the fields as desired.

4.6.6. Execution

Image

The default values are visible: the “Mme” radio button, the country “France,” and the language “French.” HTML produces the following (excerpts, displayed across multiple lines):

<form name="fields" method="post" style="max-width: 40rem">
  <fieldset class="mb-3"><legend class="col-form-label required">Title</legend>
    <div id="fields_title">
      <div class="form-check form-check-inline">
        <input type="radio" id="fields_title_0" name="fields[title]" required="required" class="form-check-input" value="Mr" />
        <label class="form-check-label required" for="fields_title_0">Mr</label></div>
      <div class="form-check form-check-inline">
        <input type="radio" id="fields_title_1" name="fields[title]" required="required" class="form-check-input" value="Mme" checked="checked" />
...
  <div class="mb-3"><label for="fields_name" class="form-label">Name</label>
    <input type="text" id="fields_name" name="fields[name]" class="form-control" /></div>
...
    <input type="checkbox" id="fields_hobbies_0" name="fields[hobbies][]" class="form-check-input" value="reading" />
...
  <select id="fields_country" name="fields[country]" class="form-select">
    <option value="fr" selected="selected">France</option><option value="be">Belgium</option>...
  <select id="fields_languages" name="fields[languages][]" size="4" class="form-select" multiple="multiple">
    <option value="French" selected="selected">French</option>...
...
  <input type="hidden" id="fields_token" name="fields[token]" value="A7X2" />
  <input type="hidden" id="fields__token" name="fields[_token]" data-controller="csrf-protection" value="9863be7bac.KU7NzSfFyCspR2sfBG_fTqNV6UuMueZ2pOsuPACnvFo.X32P9UOQm1FPIxhHUVqHGvcBmWbl36Il8KJsCVnS8A1ZJYGudJSrRmwoWw" />
</form>

[Symfony] has written everything: the identifiers ([fields_title_1]), the labels associated with their fields, the names ending in [[]] for multiple fields (example 10), the [checked] and [selected] attributes of common values, and the classes of [Bootstrap]. The last hidden field, [fields[_token]], contains the anti-CSRF token. A CSRF attack (Cross-Site Request Forgery) involves a malicious site tricking its victim’s browser into submitting a form to our application. The browser includes our application’s cookies in the request—and thus the logged-in user’s session—making the request appear legitimate. The countermeasure: every form in our application contains a random token linked to the user’s session, which the malicious site cannot know. The Form component adds this token to every form, and [handleRequest] rejects any form whose token is missing or incorrect: no form can omit it. (The [data-controller] attribute is used by an optional script for [Symfony], which enhances protection; it has no effect here.) Let’s fill out the form and submit it:

Image

The page shows what the server received: one parameter per field, with multiple values for the checkboxes and the dropdown list; the password was sent but is not displayed again in its field. The hidden field [token] was returned as-is: this is the way to send information back and forth that the user cannot see (the case study uses this for a record’s version number). However, a hidden field is not a protected field: the user can modify it using their browser’s developer tools, and the server must validate it just like any other field. The anti-CSRF token is also included among the received fields; it is not part of the form data.

Now let’s send a form without a token, using [curl], and with a country that wasn’t listed as an option:

curl -g -d "fields[name]=Martin&fields[country]=xx" http://localhost:8000/form
...
        <h2 class="h5 mt-3">What the server received</h2>
    <pre class="bg-light p-2 border">fields[name] = [Martin]
fields[country] = [xx]</pre>
    <p>Valid form: no</p>
    <ul>
                      <li>fields: The CSRF token is invalid. Please try to resubmit the form.</li>
                      <li>title: The title is required.</li>
                      <li>country: The selected choice is invalid.</li>
          </ul>
...

The form is invalid for three reasons: the anti-CSRF token is missing (the error is associated with the form itself), no title was submitted, and the country [xx] is not among the available options. This last check is performed by the [ChoiceType] field itself, without us having requested it: a malicious user could submit anything, not just what the page offered. The messages are in English: those from the Form component are translated by [Symfony], as are those from the validator (example 09). The response has status code 422 (Unprocessable Content): [render] selects it automatically when presented with a submitted, invalid form. Note the difference from other frameworks: [Symfony] does not reject the request before the action; it is the form that declares it invalid, and the action that decides how to proceed.

For the token to be accepted, [curl] must behave like a browser: first request the form, while retaining the session cookie (option [-c], which saves received cookies to a file), then resend the cookie (option [-b]) and the token read from the page. The option [--data-urlencode] encodes the token value itself:

curl -c cookies.txt http://localhost:8000/form
curl -b cookies.txt -g --data-urlencode "fields[_token]=9863be7bac.KU7N..." -d "fields[name]=Martin&fields[country]=xx" http://localhost:8000/form
1
2
3
4
5
6
...
    <ul>
                      <li>title: The title is required.</li>
                      <li>country: The selected choice is invalid.</li>
          </ul>
...

(the token is the one from the page received by the first command). Only the two input errors remain.

4.7. Example 19: Form Validation

A registration form, where each field has its own validation rules. If an error occurs, the form is redisplayed with the user’s input and a message below each field containing an error. This example uses the standard approach of [Symfony]: the form data is an object, whose class contains the constraints (Example 08); the form populates the object and then validates it.

19-form-validation/
├── src/
│   ├── Controller/
│   │   └── RegistrationController.php
│   ├── Form/
│   │   └── RegistrationType.php
│   ├── Model/
│   │   └── Registration.php
│   └── Kernel.php
├── templates/
│   ├── registration/
│   │   ├── welcome.html.twig
│   │   └── registration.html.twig
│   └── layout.html.twig
└── ...

The files [config/packages/framework.yaml] and [config/packages/twig.yaml] are those from Example 18.

4.7.1. Data and Its Constraints: src/Model/Registration.php

<?php
// src/Model/Registration.php: the data of the registration form, and their constraints

namespace App\Model;

use Symfony\Component\Validator\Constraints as Assert;

class Registration
{
    #[Assert\NotBlank(message: 'The name is required.')]
    #[Assert\Length(max: 20, maxMessage: 'The name must not exceed 20 characters.')]
    public ?string $name = null;

    // Email: a valid address, with a complete domain name (example.com, and not example)
    #[Assert\NotBlank(message: 'The email address is required.')]
    #[Assert\Email(message: 'Invalid email address.')]
    public ?string $email = null;

    #[Assert\NotBlank(message: 'The age is required.')]
    #[Assert\Range(notInRangeMessage: 'The age must be between 18 and 120 years.', min: 18, max: 120)]
    public ?int $age = null;

    #[Assert\NotBlank(message: 'The password is required.')]
    #[Assert\Length(min: 6, minMessage: 'The password must have at least 6 characters.')]
    public ?string $password = null;
}
  • Line 8: a regular class whose properties are public and editable; the form will populate them one by one. All are initialized to [null]; the object is empty when first displayed;
  • lines 10–12: the name is required and must not exceed 20 characters. Each constraint is assigned its own message here, in English (example 09): [message] for [NotBlank], [maxMessage] for exceeding [Length];
  • lines 15–17: The constraint [Email] verifies that an address is valid and requires a fully qualified domain name ([example.com], not [example]);
  • lines 19–21: age is an integer between 18 and 120 ([notInRangeMessage]: the message for [Range] when there is a minimum and a maximum). The form will have converted the input to an integer;
  • lines 23–25: The password must be at least 6 characters long.

4.7.2. The form: src/Form/RegistrationType.php

<?php
// src/Form/RegistrationType.php: the fields of the registration form

namespace App\Form;

use App\Model\Registration;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class RegistrationType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            // attr: additional HTML attributes on the input field (for the
            // validation by the browser, see further on)
            ->add('name', TextType::class, ['attr' => ['maxlength' => 20]])
            ->add('email', EmailType::class, ['label' => 'Email address'])
            // IntegerType: an integer; an input that is not one produces
            // the invalid_message error
            ->add('age', IntegerType::class, [
                'label' => 'Age',
                'attr' => ['min' => 18, 'max' => 120],
                'invalid_message' => 'An integer is expected.',
            ])
            // RepeatedType: TWO fields that must receive the same value (the
            // password and its confirmation); a single value in the data
            ->add('password', RepeatedType::class, [
                'type' => PasswordType::class,
                'first_options' => ['label' => 'Password', 'attr' => ['minlength' => 6]],
                'second_options' => ['label' => 'Confirmation'],
                'invalid_message' => 'The confirmation does not match the password.',
            ]);
    }

    // the options of the form: its data are a Registration object
    public function configureOptions(OptionsResolver $options): void
    {
        $options->setDefaults(['data_class' => Registration::class]);
    }
}
  • line 23: the [attr] option adds HTML attributes to the input field: [maxlength] limits the input in the browser;
  • line 24: a [EmailType] field generates a [<input type="email">], which the browser can validate;
  • lines 27–31: a [IntegerType] field generates a [<input type="number">] and converts the input to an integer. An entry that is not an integer cannot be converted: this is a conversion error (not a validation error; see example 08), for which the error message is provided by the [invalid_message] option;
  • Lines 34–39: A [RepeatedType] field consists of two fields (here, two [PasswordType] fields), which must contain the same value: the password and its confirmation. Each has its own options ([first_options], [second_options]); if the two entries differ, the error is [invalid_message]. The data has only one value: the password;
  • lines 43–46: the method [configureOptions] sets the options for the form itself: [data_class] indicates that its data is a [Registration] object. Each field reads and writes the property of the same name.

[Symfony] can also infer a field’s type based on the constraints and the property’s type: [->add('age')], which has no type, would produce a [IntegerType], with the attributes [min] and [max] inferred from [Range]. We’ve written this out explicitly for clarity.

4.7.3. The controller: src/Controller/RegistrationController.php

<?php
// src/Controller/RegistrationController.php: a form validated by the server

namespace App\Controller;

use App\Form\RegistrationType;
use App\Model\Registration;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
use Symfony\Component\Routing\Attribute\Route;

class RegistrationController extends AbstractController
{
    // GET /register: the form; ?html5=true: we let the browser
    // validate it before sending. POST /register: the sent form
    #[Route('/register', name: 'register', methods: ['GET', 'POST'])]
    public function register(Request $request, #[MapQueryParameter] bool $html5 = false): Response
    {
        $registration = new Registration();
        $form = $this->createForm(RegistrationType::class, $registration);
        $form->handleRequest($request);
        // isValid: the fields could be converted, and the Registration object
        // complies with its constraints
        if ($form->isSubmitted() && $form->isValid()) {
            return $this->render('registration/welcome.html.twig', ['registration' => $registration]);
        }
        // the first display, or an invalid form, displayed again with the input
        // and the errors (render then sends it back with the 422 code)
        return $this->render('registration/registration.html.twig',
            ['form' => $form, 'html5' => $html5]);
    }
}
  • line 19: the parameter [?html5=true] enables browser validation (see below). [#[MapQueryParameter]] converts it to a Boolean (example 07);
  • lines 21–23: The empty object [Registration] is passed to the form; [handleRequest] will populate it using the submitted fields;
  • lines 26–28: [isValid] is true if every field could be converted and if the [Registration] object meets its constraints. The welcome page then receives the filled-in object (the following example will show that it is better to redirect after a successful POST);
  • lines 31–32: otherwise, the form is displayed: empty on the first display; after an invalid submission, it shows the input and errors, along with code 422 (example 18).

The invalid form is therefore redisplayed by the POST action itself, which retains the input and errors: this is the approach taken by [Symfony]. Other frameworks, such as [Laravel], instead respond by redirecting to the form page, while retaining the input and errors in the session for the next request.

4.7.4. The views

{% extends 'layout.html.twig' %}

{% block content %}
  <h1 class="h3">Registration</h1>
  {% if html5 %}
    <p>Browser validation <b>enabled</b> - <a href="{{ path('register') }}">disable it</a></p>
  {% else %}
    <p>Browser validation <b>disabled</b> - <a href="{{ path('register', {html5: 'true'}) }}">enable it</a></p>
  {% endif %}
  {# novalidate: the browser ignores the required, min... attributes and sends
     the form as is; only the server validates #}
  {{ form_start(form, {attr: html5 ? {} : {novalidate: 'novalidate'}}) }}
    {# form_widget: all the fields of the form, each one with its label and its errors #}
    {{ form_widget(form) }}
    <button class="btn btn-primary" type="submit">Register</button>
  {{ form_end(form) }}
{% endblock %}

{% block page_bottom %}
  {# the global error, at the bottom of the page, on a light pink background. vars: the
     information of the form (sent? valid?) #}
  {% if form.vars.submitted and not form.vars.valid %}
    <div class="error-area" role="alert">The form contains errors.</div>
  {% endif %}
{% endblock %}
  • Line 12: The attribute [novalidate] disables browser validation: the form is submitted as-is, and only the server validates it. This is what we want to observe first. The ternary operator chooses between two arrays of attributes;
  • line 14: [form_widget(form)], applied to the entire form, writes all its fields, each with its label, input, and error messages: the [Bootstrap] theme highlights an invalid field in red (class [is-invalid]) and displays its message below it (class [invalid-feedback]). We didn’t have to write any components for this;
  • lines 19–25: the [page_bottom] block, defined by the template (see below). [form.vars] contains the form data, calculated for the view: [submitted] (submitted?), [valid] (valid?), [value], [errors]... If the form has been submitted and is invalid, a general message appears in a field whose style is defined by [public/css/styles.css] (example 15). The [role="alert"] attribute flags this to screen readers.
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Registration</title>
  <link rel="stylesheet" href="{{ path('bootstrap', {file: 'css/bootstrap.min.css'}) }}">
  <link rel="stylesheet" href="{{ asset('css/styles.css') }}">
</head>
<body>
  <div class="container py-3" style="max-width: 36rem">
    <main>
      {% block content %}{% endblock %}
    </main>
    {# a block that the pages can fill, at the bottom of the page #}
    {% block page_bottom %}{% endblock %}
  </div>
</body>
</html>
  • Line 15: a second block, [page_bottom], which is empty by default: a template can have as many blocks as needed.
1
2
3
4
5
6
7
8
{% extends 'layout.html.twig' %}

{% block content %}
  {# registration: the Registration object, filled by the form #}
  <h1 class="h3">Welcome {{ registration.name }}</h1>
  <p>Your registration is saved: address <b>{{ registration.email }}</b>,
    age <b>{{ registration.age }}</b> years.</p>
{% endblock %}

[registration] is the object [Registration], populated and validated: the name has had its spaces removed (see below), and the age is an integer.

4.7.5. Execution

Let’s submit a form full of errors: a name consisting of spaces, an address without a full domain, an age of 12, a password that’s too short, and a different confirmation:

Image

The input has been retained (except for the passwords), and each error is flagged below its corresponding field. The name consisting of spaces is flagged as missing: the Form component removes the spaces surrounding each entry (field option [trim], set to true by default). The password, however, has only one error, which is in the confirmation: when the two entries differ, the [RepeatedType] field returns no value, and the length is not checked. URL is the form’s ID, and [/register] indicates that the page was displayed by the action in response to POST. With [curl], by providing the anti-CSRF token as in Example 18, you can also send something a browser would not send—a non-numeric age:

curl -i -b cookies.txt -g --data-urlencode "registration[_token]=..." -d "registration[name]=Martin&registration[email]=martin@example.com&registration[age]=abc&registration[password][first]=abcdef&registration[password][second]=abcdef" http://localhost:8000/register
1
2
3
4
5
HTTP/1.1 422 Unprocessable Content
...
<input type="number" id="registration_age" name="registration[age]" required="required" min="18" max="120" aria-describedby="registration_age_error1" aria-invalid="true" class="form-control is-invalid" value="abc" />
<div class="invalid-feedback d-block" id="registration_age_error1">An integer is expected.</div>
...

We see the input redisplayed, along with the attributes HTML5 ([required], [min], [max]), the class [is-invalid], the conversion message, and the attributes [aria-...], which link the field to its message for screen readers.

4.7.6. Browser validation

Let’s remove the [novalidate] attribute by following the “enable it” link (parameter [?html5=true]). The browser then checks the attributes [required], [type="email"], [min], [maxlength]... before submitting the form, and displays its own messages in a tooltip below the first field with an error (“Please fill in this field,” “The value must be greater than or equal to 18,” etc.): the form is not submitted as long as there is an error. [Symfony] automatically generates these attributes: [required] for any field (option [required], e.g., 18), [type="email"] and [type="number"] based on the field type; [maxlength], [min], [max], and [minlength] come from our [attr] options. They must remain consistent with the constraints.

This validation is convenient for the user: it’s immediate, with no back-and-forth with the server. But it does not replace server-side validation: a user can disable it, and a program like [curl] ignores it completely. The rule is absolute: the server always validates; browser-side validation is merely an added convenience.

A correct entry, at last. The name, entered with spaces on either side, was cleaned up by the form:

Image

4.8. Example 20: The Post/Redirect/Get Pattern

When a POST action responds with a page, that page is the result of a POST. If the user reloads the page (presses [F5]), the browser sends the POST request, after displaying a warning that few people read: the command is sent twice, and the message is posted twice. The Post/Redirect/Get pattern (PRG) avoids this problem: a successful POST action does not return a page; it responds with a redirect (example 04) to a page GET, which the browser requests immediately. Reloading this page simply re-executes the GET action.

One problem remains: the GET page would like to display a message (“the task has been added”), but it is the POST action that knows this message, and it cannot pass a variable to a request that has not yet occurred. The solution: the flash message, placed in the session by the action POST, is retained until it is read, displayed, and then discarded.

4.8.1. Configuration: config/packages/framework.yaml

# config/packages/framework.yaml: the configuration of the FrameworkBundle
framework:
    secret: '%env(APP_SECRET)%'
    # the SESSION: its data are stored on the server, one file per session,
    # in the var/sessions/dev folder (handler_id: null: PHP's own files)
    session:
        handler_id: null
        save_path: '%kernel.project_dir%/var/sessions/%kernel.environment%'
    # the LOCKS (Lock component): locked files (flock) of the var/lock folder
    lock: 'flock://%kernel.project_dir%/var/lock'
  • Lines 6–8: the user’s session (chapter on scopes): browser-specific data, retained from one request to the next. It is stored on the server; the browser holds only an identifier for it, in a cookie. [handler_id: null] delegates the task of storing this data to [PHP] itself, which stores each session in a file; [save_path] selects the folder—[var/sessions/dev]—so that we can examine them. [%kernel.environment%] is the environment ([dev]);
  • line 10: the locks (the Lock component, described below), stored in the folder [var/lock].

4.8.2. The service: src/Service/TasksService.php

<?php
// src/Service/TasksService.php: the list of the tasks, shared by all the
// users. In PHP, the objects do not survive the request: the list is
// stored in the CACHE of the application (here, a file of the var/cache/dev/pools folder)

namespace App\Service;

use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\Lock\LockFactory;

class TasksService
{
    private const KEY = 'tasks';

    // the cache of the application (the cache.app service) and the lock factory
    public function __construct(
        private CacheItemPoolInterface $cache,
        private LockFactory $locks,
    ) {
    }

    /** @return list<string> */
    public function list(): array
    {
        // the cache item stored under the [tasks] key; isHit: it exists
        $item = $this->cache->getItem(self::KEY);
        return $item->isHit() ? $item->get() : ['Read chapter 3'];
    }

    public function add(string $label): void
    {
        // several requests can run AT THE SAME TIME (the server handles
        // them in several processes): a lock guarantees that only one at a
        // time reads, modifies and rewrites the list
        $lock = $this->locks->createLock('tasks');
        // acquire(true): wait until the lock is free, then take it
        $lock->acquire(true);
        try {
            $tasks = $this->list();
            $tasks[] = $label;
            // the cache item receives its new value, without any time limit
            $item = $this->cache->getItem(self::KEY);
            $this->cache->save($item->set($tasks));
        } finally {
            // finally: the lock is released in all cases, even after an exception
            $lock->release();
        }
    }
}

The task list is shared by all users and must persist from one request to the next. In [Java], [C#], or [Python], it would be kept in memory within a single service. In [PHP], this is impossible: as we saw in examples 01 and 06, nothing survives the request. The list must therefore be stored outside of [PHP]: in a database, a file... or in the application cache, which we are using here.

  • Lines 16–20: The service receives two services from [Symfony]. The first is the application cache (the [cache.app] service, from the Cache component), a store of key-value pairs, represented by the standardized interface [CacheItemPoolInterface] (the PSR-6 standard). By default, it stores its values in files within the [var/cache/dev/pools] folder; in production, a dedicated server is preferred, such as [Redis] (a configuration line). The second is the lock factory of the Lock component;
  • lines 23–28: [getItem] retrieves the cache item stored under a key; [isHit] checks if it exists, and [get] returns its value. Any value (in this case, an array of strings) can be stored: [Symfony] serializes it—that is, it converts it to text—using the function [serialize] from [PHP];
  • Lines 35–47: The development server, like a production server, can process multiple requests at the same time, across multiple processes (the environment variable [PHP_CLI_SERVER_WORKERS] sets the number of processes to [php -S]). Two simultaneous additions could read the same list, each add its own task to it, and then write it back: the second write would overwrite the first task. A lock ensures that only one request at a time executes the code it protects: [createLock('tasks')] creates a named lock; [acquire(true)] waits for it to become available, then acquires it; [release()] releases it. Here, the lock is a file in the [var/lock] folder, locked by the operating system (the [flock] function); alternatively, multiple servers would share a lock stored in [Redis] or in a database;
  • lines 38–47: the block [try] … [finally] ensures that the lock is released in all cases, even if an exception interrupts the code it protects;
  • lines 42–43: the cache element receives its new value ([set]), then it is saved ([save]). Without a specified lifetime, it is retained indefinitely ([expiresAfter(3600)] would retain it for one hour).

4.8.3. The controller: src/Controller/TasksController.php

<?php
// src/Controller/TasksController.php: the Post / Redirect / Get pattern

namespace App\Controller;

use App\Service\TasksService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class TasksController extends AbstractController
{
    public function __construct(private TasksService $tasks)
    {
    }

    // GET /tasks: the list and the add form
    #[Route('/tasks', name: 'tasks', methods: ['GET'])]
    public function display(): Response
    {
        return $this->render('tasks/tasks.html.twig', ['tasks' => $this->tasks->list()]);
    }

    // POST /tasks: the Post / Redirect / Get pattern
    #[Route('/tasks', name: 'tasks_add', methods: ['POST'])]
    public function add(Request $request): Response
    {
        $label = trim($request->request->getString('label'));
        if ($label === '' || mb_strlen($label) > 40) {
            // invalid input: we display the page again, without a redirect
            return $this->render('tasks/tasks.html.twig', ['tasks' => $this->tasks->list(),
                'label' => $label,
                'error' => 'The label must have 1 to 40 characters.']);
        }
        $this->tasks->add($label);
        // addFlash leaves a "flash" message in the session: it will survive the
        // redirect, for the NEXT request
        $this->addFlash('success', "The task [$label] has been added.");
        // no page: a redirect to the GET page
        return $this->redirectToRoute('tasks');
    }

    // POST /tasks/without-prg: the counter-example (a page in response to the POST)
    #[Route('/tasks/without-prg', name: 'tasks_without_prg', methods: ['POST'])]
    public function addWithoutPrg(Request $request): Response
    {
        $this->tasks->add(trim($request->request->getString('label')) ?: '(untitled)');
        return $this->render('tasks/tasks.html.twig', ['tasks' => $this->tasks->list(), 'withoutPrg' => true]);
    }
}
  • lines 29–35: The input is validated manually to keep the example concise ([trim] removes the surrounding spaces). An invalid entry is redisplayed without redirection: there is nothing to protect against a duplicate submission, since nothing has been modified;
  • line 39: the method [addFlash] of [AbstractController] places a flash message of type [success] in the session (the type is arbitrary: it allows distinguishing between success and error messages, etc.);
  • line 41: the redirection to the page GET, named [tasks] (example 04);
  • lines 45–50: the counterexample: a page in response to POST. The operator [?:] returns its left operand if it is “true” (here, a non-empty string), and the right operand otherwise.

4.8.4. The views

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Tasks</title>
  <link rel="stylesheet" href="{{ path('bootstrap', {file: 'css/bootstrap.min.css'}) }}">
  <link rel="stylesheet" href="{{ asset('css/styles.css') }}">
</head>
<body>
  <div class="container py-3" style="max-width: 36rem">
    {# the [success] flash messages, at the top of the page: left BEFORE the redirect;
       app.flashes REMOVES them from the session while reading them #}
    {% for message in app.flashes('success') %}
      <div class="alert alert-success" role="status">{{ message }}</div>
    {% endfor %}
    <main>
      {% block content %}{% endblock %}
    </main>
    {% if error is defined %}
      <div class="error-area" role="alert">{{ error }}</div>
    {% endif %}
  </div>
</body>
</html>
  • Lines 13–15: [app.flashes('success')] returns the list of flash messages of type [success] and removes them from the session: a flash message is displayed only once. The [alert alert-success] class of [Bootstrap] displays the message in a green frame;
  • lines 19–21: the error message, at the bottom of the page, when the action has generated one.
{% extends 'layout.html.twig' %}

{% block content %}
  <h1 class="h3">My tasks</h1>
  <ol>
    {% for task in tasks %}
      <li>{{ task }}</li>
    {% endfor %}
  </ol>
  <form method="post" action="{{ path('tasks_add') }}" class="d-flex gap-2">
    <input class="form-control" name="label" value="{{ label ?? '' }}" placeholder="New task">
    <button class="btn btn-primary text-nowrap" type="submit">Add</button>
    {# formaction: this button sends the SAME form to another address #}
    <button class="btn btn-outline-secondary text-nowrap" type="submit" formaction="{{ path('tasks_without_prg') }}">Add without PRG</button>
  </form>
  {% if withoutPrg is defined %}
    <p class="text-danger mt-2">Page displayed in response to a POST: press F5 and the task will be added a second time.</p>
  {% endif %}
{% endblock %}
  • line 11: the operator [??] of [Twig] returns its right-hand operand if the left-hand operand does not exist (or is equal to [null]): the variable [label] exists only after an invalid entry;
  • Line 14: A button’s [formaction] attribute submits the form to an address different from that specified by its [action] attribute: a single form can thus trigger two different actions.

This hand-coded form lacks an anti-CSRF token: to keep the example simple, we’ll add it in the next example.

4.8.5. Where are flash messages stored?

Between the request POST and the subsequent request GET, the message must be stored somewhere: in the user’s session or on the server. Let’s examine this with [curl], while storing the cookies in a file (options [-c] and [-b], Example 18):

curl -i -b cookies.txt -c cookies.txt -d "label=Do%20the%20exercises" http://localhost:8000/tasks
1
2
3
4
5
6
7
HTTP/1.1 302 Found
Cache-Control: max-age=0, must-revalidate, private
Location: /tasks
Content-Type: text/html; charset=utf-8
Expires: Sun, 27 Sep 2026 09:36:41 GMT
Set-Cookie: PHPSESSID=1e1f574eccd93268b1eb829dd714a029; path=/; httponly; samesite=lax
...

The response to POST is a redirect (status code 302, header [Location]), accompanied by the session cookie, [PHPSESSID] (the name that [PHP] assigns to this cookie), which contains the session ID: 32 random characters that are impossible to guess. This cookie is [httponly] (the [JavaScript] on the page cannot read it) and [samesite=lax] (the browser does not send it with a POST request originating from another site). The headers [Cache-Control] and [Expires] prevent the caching of a response that depends on a session. The identifier is also the name of the session file on the server: [var/sessions/dev/sess_1e1f574eccd93268b1eb829dd714a029]. Here is that file, following the request POST:

_symfony_flashes|a:1:{s:7:"success";a:1:{i:0;s:43:"The task [Do the exercises] has been added.";}}_sf2_meta|a:3:{s:1:"u";i:1790501801;s:1:"c";i:1790501801;s:1:"l";i:0;}

This is the serialization format for sessions in [PHP]: each piece of session data is written in the form [name|serialized value]. [a:1:{...}] declares an array with 1 element, [s:7:"success"] a 7-byte string... It contains flash messages ([_symfony_flashes]), sorted by type, and metadata from [Symfony] ([_sf2_meta]: the session’s creation and last-used dates). Let’s follow the redirect by sending the cookie back:

curl -i -b cookies.txt -c cookies.txt http://localhost:8000/tasks
1
2
3
4
5
6
7
HTTP/1.1 200 OK
...
              <div class="alert alert-success" role="status">The task [Do the exercises] has been added.</div>
...
          <li>Read chapter 3</li>
          <li>Do the exercises</li>
...

The message is displayed and removed from the session by [app.flashes]. The session, now empty, has even disappeared: [Symfony] does not store an empty session, and the file has been deleted. A new request, GET, would no longer display the message. The task list, however, is stored in a cache file, whose name is calculated based on the key ([var/cache/dev/pools/app/VT2eLxVxyZ/P/W/CElt3eODhIKkushDZW1A]):

1
2
3
0
tasks
a:2:{i:0;s:14:"Read chapter 3";i:1;s:16:"Do the exercises";}

The first line is the value’s expiration date (0: never), the second is the key, and the third is the serialized list. As for the lock, it is an empty file, [var/lock/sf.tasks.CFFUCEx.lock], which the system locks for the duration of a modification.

4.8.6. Execution

Adding a task: the browser followed the redirect; URL is the page corresponding to GET, which can be safely reloaded.

Image

An invalid entry, redisplayed with its error message:

Image

The counterexample: the page is the response to POST, and its URL is [/tasks/without-prg]. If you click on [F5], the browser asks if you want to resubmit the form; if you agree, the task is added a second time.

Image

A principle of the HTTP protocol: a GET request does not change anything. Browsers, search engines, and tools that preload pages freely follow links without asking the user for permission. A “delete” link ([<a href="/contacts/2/delete">]) would therefore be dangerous: a bot crawling the site would delete everything. In a web application:

  • viewing is done via a link ([<a href>]), which sends a GET request;
  • editing (save, duplicate, delete) is done via a button placed in a form, protected by an anti-CSRF token.

This example manages a contact list and displays a confirmation window before each deletion.

4.9.1. Configuration: config/packages/framework.yaml

# config/packages/framework.yaml: the configuration of the FrameworkBundle
framework:
    secret: '%env(APP_SECRET)%'
    # a POST form that contains a _method field (DELETE, PUT...) is handled
    # as a request of that method
    http_method_override: true
    # the session (flash messages, anti-CSRF tokens), stored in var/sessions (see example 20)
    session:
        handler_id: null
        save_path: '%kernel.project_dir%/var/sessions/%kernel.environment%'
    csrf_protection: true
    lock: 'flock://%kernel.project_dir%/var/lock'
  • Line 6: A HTML form can only send GET and POST requests. However, the HTTP protocol provides for other methods: DELETE for deletion, and PUT and PATCH for modification. [Symfony] circumvents this limitation: with the [http_method_override] option, a POST form containing a hidden field [_method] with a value of [DELETE] (or [PUT], [PATCH]), is treated as a request for that method. This is method spoofing;
  • the other lines are those from examples 18 and 20: the session, CSRF protection, and locks.

4.9.2. The service: src/Service/ContactsService.php

<?php
// src/Service/ContactsService.php: the contacts, shared by all, stored in
// the cache of the application (see example 20)

namespace App\Service;

use App\Model\Contact;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\Lock\LockFactory;

class ContactsService
{
    private const KEY = 'contacts';

    public function __construct(
        private CacheItemPoolInterface $cache,
        private LockFactory $locks,
    ) {
    }

    /** @return array{contacts: list<Contact>, lastId: int} */
    private function data(): array
    {
        $item = $this->cache->getItem(self::KEY);
        return $item->isHit() ? $item->get() : [
            'contacts' => [
                new Contact(1, 'Marie Durand', '01 23 45 67 89'),
                new Contact(2, 'Paul Martin', '06 12 34 56 78'),
                new Contact(3, 'Philippe Jandot', '02 98 76 54 32'),
            ],
            'lastId' => 3,
        ];
    }

    // a modification of the data, under lock: the function receives the data,
    // modifies them (&), and returns the result of the operation
    private function editData(callable $operation): mixed
    {
        $lock = $this->locks->createLock('contacts');
        $lock->acquire(true);
        try {
            $data = $this->data();
            $result = $operation($data);
            $this->cache->save($this->cache->getItem(self::KEY)->set($data));
            return $result;
        } finally {
            $lock->release();
        }
    }

    /** @return list<Contact> */
    public function list(): array
    {
        return $this->data()['contacts'];
    }

    // the contact, or null if it does not exist
    public function read(int $id): ?Contact
    {
        return array_find($this->list(), fn (Contact $c) => $c->id === $id);
    }

    public function edit(int $id, string $name, string $phone): ?Contact
    {
        return $this->editData(function (array &$data) use ($id, $name, $phone) {
            foreach ($data['contacts'] as $i => $contact) {
                if ($contact->id === $id) {
                    // a contact is immutable (readonly): we build a new one,
                    // which replaces the old one in the array
                    return $data['contacts'][$i] = new Contact($id, $name, $phone);
                }
            }
            return null;
        });
    }

    public function add(string $name, string $phone): Contact
    {
        return $this->editData(function (array &$data) use ($name, $phone) {
            $contact = new Contact(++$data['lastId'], $name, $phone);
            $data['contacts'][] = $contact;
            return $contact;
        });
    }

    public function delete(int $id): ?Contact
    {
        return $this->editData(function (array &$data) use ($id) {
            foreach ($data['contacts'] as $i => $contact) {
                if ($contact->id === $id) {
                    // array_splice removes item i from the array, and renumbers the following ones
                    array_splice($data['contacts'], $i, 1);
                    return $contact;
                }
            }
            return null;
        });
    }
}

The contacts are stored in the cache, just like the tasks in Example 20. Each contact is an immutable object [Contact] (an ID, a name, a phone number), stored in an array, with the most recently assigned ID. A few points about [PHP]:

  • lines 37–49: All modifications follow the same pattern: acquire the lock, read the data, modify it, write it back, and release the lock. This method writes to the array once; each modification passes it a function (of type [callable], “callable”), which receives the data and modifies it. The function must modify the array itself, not a copy: its parameter is declared as [array &$data], where [&] specifies a pass-by-reference (without [&], an array is copied; see Example 06). The keyword [use] in an anonymous function gives it access to variables in the containing function (line 65: [$id], [$name], [$phone]);
  • line 44: the calls follow one after another: [getItem] returns the element, [set] assigns a value to it and returns the element itself, [save] stores it;
  • lines 63–75: A contact is immutable: a new one is created, which replaces the old one in the array. An assignment is an expression whose value is the assigned value: [return $data['contacts'][$i] = new Contact(...)] replaces the contact and returns it;
  • line 80: [++] placed before a variable increments it by 1 and returns the new value;
  • line 92: [array_splice] removes an element from the array and renumbers the subsequent elements;
  • The methods return [null] for a nonexistent contact: it is up to the controller to decide on the response.

4.9.3. The form for the record: src/Form/ContactType.php

<?php
// src/Form/ContactType.php: the form of the details of a contact, with TWO submit buttons

namespace App\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints\NotBlank;

class ContactType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('name', TextType::class, ['constraints' => [new NotBlank()]])
            ->add('phone', TextType::class, ['label' => 'Phone', 'constraints' => [new NotBlank()]])
            // two submit buttons: the form will know which one was clicked
            ->add('save', SubmitType::class, ['attr' => ['class' => 'btn-primary']])
            ->add('duplicate', SubmitType::class, ['attr' => ['class' => 'btn-outline-primary']]);
    }
}
  • lines 17–18: the two fields are required;
  • lines 20–21: two submit buttons ([SubmitType]) are part of the form. When the form is submitted, the browser sends the name of the button that was clicked, and the form knows which one it is. The option [attr] assigns them their classes from [Bootstrap].

4.9.4. The controller: src/Controller/ContactsController.php

<?php
// src/Controller/ContactsController.php: links (GET) and buttons (POST, DELETE)

namespace App\Controller;

use App\Form\ContactType;
use App\Model\Contact;
use App\Service\ContactsService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\SubmitButton;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/contacts', name: 'contacts')]
class ContactsController extends AbstractController
{
    public function __construct(private ContactsService $contacts)
    {
    }

    // the contact, or a 404 response if it does not exist
    private function contactOr404(?Contact $contact): Contact
    {
        return $contact ?? throw $this->createNotFoundException('unknown contact');
    }

    // a LINK (<a href>): a GET request, which modifies nothing
    #[Route('', name: '', methods: ['GET'])]
    public function list(): Response
    {
        return $this->render('contacts/list.html.twig', ['contacts' => $this->contacts->list()]);
    }

    // GET /contacts/2: the details; POST /contacts/2: one of its two buttons
    #[Route('/{id<\d+>}', name: '_details', methods: ['GET', 'POST'])]
    public function details(Request $request, int $id): Response
    {
        $contact = $this->contactOr404($this->contacts->read($id));
        $form = $this->createForm(ContactType::class,
            ['name' => $contact->name, 'phone' => $contact->phone]);
        $form->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {
            ['name' => $name, 'phone' => $phone] = $form->getData();
            $duplicate = $form->get('duplicate');
            // isClicked: the button that sent the form
            if ($duplicate instanceof SubmitButton && $duplicate->isClicked()) {
                $copy = $this->contacts->add("$name (copy)", $phone);
                $this->addFlash('success', "The contact [$copy->name] has been created.");
            } else {
                $this->contacts->edit($id, $name, $phone);
                $this->addFlash('success', "The contact [$name] has been edited.");
            }
            return $this->redirectToRoute('contacts');
        }
        return $this->render('contacts/details.html.twig', ['contact' => $contact, 'form' => $form]);
    }

    // a DELETE request, simulated by a POST form (hidden _method field)
    #[Route('/{id<\d+>}', name: '_delete', methods: ['DELETE'])]
    public function delete(Request $request, int $id): Response
    {
        // the anti-CSRF token of the form, specific to this contact
        if (! $this->isCsrfTokenValid("delete-contact-$id", $request->request->getString('_token'))) {
            throw new AccessDeniedHttpException('invalid anti-CSRF token');
        }
        $contact = $this->contactOr404($this->contacts->delete($id));
        $this->addFlash('success', "The contact [$contact->name] has been deleted.");
        return $this->redirectToRoute('contacts');
    }
}
  • line 16: the name prefix [contacts]: the route for the list, whose name is empty (line 30), is therefore called [contacts], and the one for the record is [contacts_details]...;
  • lines 24–27: a utility function: a nonexistent contact stops the action with a 404 response;
  • lines 30–34: the GET action in the list, which does not modify anything;
  • lines 37–58: a contact’s record. In GET, it displays the form, populated with the contact’s information; in POST, it processes it. The same URL and [/contacts/2] are thus used for both viewing and saving;
  • line 45: the form data, an unstructured associative array: [['name' => $name, 'phone' => $phone] = ...] stores each element in a variable;
  • lines 46–54: [get('duplicate')] renders the [duplicate] button on the form; [isClicked()] determines whether it was the one that submitted the form (the [instanceof SubmitButton] test confirms the object type to the editor). Depending on the button, the action either duplicates or saves the contact;
  • line 55: in both cases, the PRG schema (example 20), with a flash message;
  • lines 61–71: deletion, handled by a DELETE request (the same as URL, but using a different method). The delete form is hard-coded in the view: the action verifies its own anti-CSRF token using the [isCsrfTokenValid] method from [AbstractController]. A token is linked to an identifier (here, [delete-contact-2]): a form’s token is valid only for that form. A missing or invalid token stops the action with a 403 error (Forbidden). (The [#[IsCsrfTokenValid('...')]] attribute, set on the action, would perform the same validation.)

4.9.5. The views

{% extends 'layout.html.twig' %}

{% block content %}
  <h1 class="h3">Contacts</h1>
  <table class="table align-middle">
    <tbody>
      {% for contact in contacts %}
        <tr>
          <td>{{ contact.name }}</td>
          <td>{{ contact.phone }}</td>
          <td class="text-end">
            {# view: a simple link; path() places the id in the path: /contacts/2 #}
            <a class="btn btn-sm btn-outline-primary" href="{{ path('contacts_details', {id: contact.id}) }}">View</a>
            {# delete: a form, whose submission will be confirmed #}
            <form method="post" class="d-inline" action="{{ path('contacts_delete', {id: contact.id}) }}"
                  data-confirmation="Delete the contact {{ contact.name }}?">
              {# the DELETE method, which an HTML form cannot send #}
              <input type="hidden" name="_method" value="DELETE">
              {# the anti-CSRF token, specific to this contact #}
              <input type="hidden" name="_token" value="{{ csrf_token('delete-contact-' ~ contact.id) }}">
              <button class="btn btn-sm btn-outline-danger" type="submit">Delete</button>
            </form>
          </td>
        </tr>
      {% endfor %}
    </tbody>
  </table>
{% endblock %}
  • Line 13: a link, displayed as a button by the classes [Bootstrap] ([btn]...): the appearance has nothing to do with the method HTTP. [path('contacts_details', {id: contact.id})] places the ID in the path: [/contacts/2];
  • lines 15–22: one form for each “Delete” button, with no fields other than the method and the token. The [d-inline] class displays it on the same line as the link. The [data-confirmation] attribute contains the confirmation message: the [data-...] attributes are unassigned, intended for the [JavaScript] on the page;
  • line 18: the hidden field [_method], which turns this POST into a DELETE request;
  • line 20: the anti-CSRF token, generated by the [csrf_token] function of [Twig], for the identifier [delete-contact-2] (the [~] operator concatenates, example 16).
{% extends 'layout.html.twig' %}

{% block content %}
  <h1 class="h3">Contact no. {{ contact.id }}</h1>
  {{ form_start(form) }}
    {{ form_row(form.name) }}
    {{ form_row(form.phone) }}
    {# two submit buttons for the same form, side by side #}
    <div class="d-flex gap-2">
      {{ form_widget(form.save) }}
      {{ form_widget(form.duplicate) }}
      <a class="btn btn-link" href="{{ path('contacts') }}">Back to the list</a>
    </div>
  {{ form_end(form) }}
{% endblock %}
  • lines 10–11: the two form buttons, rendered by [form_widget], side by side;
  • line 12: the link back to the list. [form_end] will generate the anti-CSRF token, which the Form component will validate itself (example 18).

4.9.6. The confirmation window

The browser can display a confirmation dialog (functions [JavaScript] and [confirm]), but it is not very attractive, and its appearance depends on the browser. Here we use a modal window from [Bootstrap], displayed using a few lines of [JavaScript]. The window is a fragment included in the template and is therefore present—though hidden—on all pages:

{# a single window for the whole page, hidden (d-none) at first #}
<div id="confirmation-modal" class="d-none">
  <div class="modal-backdrop fade show"></div>
  <div class="modal fade show d-block" tabindex="-1" role="alertdialog" aria-modal="true">
    <div class="modal-dialog modal-dialog-centered">
      <div class="modal-content">
        <div class="modal-header">
          <h5 class="modal-title">Confirmation</h5>
        </div>
        <div class="modal-body">
          <p class="mb-0" id="confirmation-modal-message"></p>
        </div>
        <div class="modal-footer">
          <button type="button" class="btn btn-secondary" data-action="no">No, go back</button>
          <button type="button" class="btn btn-danger" data-action="yes">Yes, confirm</button>
        </div>
      </div>
    </div>
  </div>
</div>
  • line 2: the [d-none] class from [Bootstrap] hides the element;
  • lines 3–4: the gray background, followed by the window, are rendered by the classes of [Bootstrap];
  • line 11: the message's position;
  • lines 14–15: the two buttons, of type [button] (not [submit]: they do not submit any form), distinguished by their [data-action] attribute.
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Contacts</title>
  <link rel="stylesheet" href="{{ path('bootstrap', {file: 'css/bootstrap.min.css'}) }}">
  <link rel="stylesheet" href="{{ asset('css/styles.css') }}">
</head>
<body>
  <div class="container py-3" style="max-width: 40rem">
    {% for message in app.flashes('success') %}
      <div class="alert alert-success" role="status">{{ message }}</div>
    {% endfor %}
    <main>
      {% block content %}{% endblock %}
    </main>
  </div>
  {# the confirmation window, present in all the pages #}
  {% include 'fragments/confirmation.html.twig' %}
  {# the script, at the end of the page: the elements it looks for already exist #}
  <script src="{{ asset('js/app.js') }}"></script>
</body>
</html>
  • line 19: the window, included on all pages;
  • line 21: the script, placed at the end of the page: when it runs, the elements it searches for already exist. It is stored in the [public/js] folder.

The script [public/js/app.js]:

// confirmation of the forms that carry the data-confirmation="..." attribute
// Without JavaScript, these forms are simply sent without confirmation.
(() => {
  const modal = document.getElementById('confirmation-modal');
  const message = document.getElementById('confirmation-modal-message');
  let pending = null; // the form to send if the user confirms

  // 1. we intercept the submission of the forms to confirm
  document.querySelectorAll('form[data-confirmation]').forEach((form) => {
    form.addEventListener('submit', (event) => {
      event.preventDefault(); // the submission is suspended
      pending = form;
      message.textContent = form.dataset.confirmation;
      modal.classList.remove('d-none'); // the window appears
    });
  });

  // 2. the two buttons of the window
  modal.addEventListener('click', (event) => {
    const action = event.target.dataset.action;
    if (action === 'yes') {
      pending.submit(); // submission, WITHOUT firing the 'submit' event again
    } else if (action === 'no') {
      modal.classList.add('d-none');
      pending = null;
    }
  });
})();
  • line 3: the code is enclosed in an anonymous function, which is executed immediately; its variables do not interfere with those of the other scripts on the page;
  • lines 4–6: the window, the message location, and the form awaiting confirmation;
  • lines 9–17: for each form that has a [data-confirmation] attribute, the [submit] event—triggered when the form is submitted—is intercepted. [preventDefault()] suspends submission; the form is put on hold, and its message ([dataset.confirmation], the value of the [data-confirmation] attribute) is displayed in the window, which appears;
  • lines 20–28: a single handler for clicks in the window: [event.target] is the element clicked; its attribute [data-action] specifies which button it is. "Yes" submits the pending form using the [submit()] method, which—unlike clicking a button—does not trigger the [submit] event (otherwise, the window would reappear); "No" hides the window.

Without [JavaScript], the form is simply submitted without confirmation: the page remains usable. This is incremental improvement: [JavaScript] adds convenience, but the application does not depend on it.

4.9.7. Execution

The contact list:

Image

Clicking “Delete” displays the confirmation window:

Image

After confirmation, the (simulated) query DELETE, the redirection, and the pop-up message:

Image

A contact’s profile, with its two send buttons:

Image

A request without an anti-CSRF token is rejected, and a nonexistent contact returns a 404 error:

curl -i -X POST -d "_method=DELETE" http://localhost:8000/contacts/3
curl -i http://localhost:8000/contacts/9
1
2
3
4
5
6
HTTP/1.1 403 Forbidden
X-Debug-Exception: invalid%20anti-CSRF%20token
...
HTTP/1.1 404 Not Found
X-Debug-Exception: unknown%20contact
...

The error pages are still the detailed ones from the debug mode of [Symfony]. The chapter on the request lifecycle will show how to replace them with custom pages.