Skip to content

11. Case Study: The RdvMedecins Application

11.1. The Problem

The RdvMedecins application manages appointments for a medical practice. Each doctor has time slots (from 8:00 a.m. to 12:00 p.m. in 20-minute increments, for example); an appointment consists of a day, a time slot, and a client (a patient). This is the exact port to [Symfony] of the [Laravel] application from the previous lesson (which was itself ported from versions [Flask], [Spring MVC], and [ASP.NET Core MVC]): same database, same business rules, same pages, same URLs, same text. The HTML pages are generated by the server using [Twig] views; database access is handled by [Doctrine].

Three user roles share the application:

ADMIN
the administrator ([admin])
view schedules, make and cancel appointments for any patient; manage (list, add, edit, delete) doctors and clients
DOCTOR
Doctors
View schedules, make appointments, and cancel appointments for any patient
USER
clients (patients)
view schedules without seeing other patients’ names; book an appointment for themselves; cancel their appointment; manage their account; create an account

Key management rules:

  • no past appointments;
  • a time slot is reserved only once per day;
  • a patient can have only one upcoming appointment at a time;
  • Two doctors (or two clients) cannot have the same first and last names, and a login belongs to only one account;
  • A doctor with appointments cannot be deleted; A client is deleted along with their appointments;
  • the login page is protected against bots (CAPTCHA, limited attempts), and creating a patient account requires first solving the CAPTCHA on that page.

11.2. Using the Application

11.2.1. The login page

Image

The page features two buttons: Log in and Create a patient account. Both require you to enter the characters shown in the image. The buttons FR / EN change the language:

Image

An error (in this case, an incorrectly entered CAPTCHA) is displayed at the bottom of the page on a light pink background:

Image

11.2.2. The calendar, as seen by the administrator and doctors

Once logged in, the administrator is taken to the calendar page. They select a doctor and a day:

Image

Image

Each time slot is either available (Book button) or occupied (patient’s name, Cancel button). Clicking Book opens a window asking for the patient’s information. This window is generated by the server, just like the rest of the page: the URL now contains the parameter [book=4], which is the number of the selected time slot:

Image

After the booking, a message confirms the appointment:

Image

Clicking “Cancel” prompts a confirmation (here by Dr. Pélissier, whose navigation bar does not include the administration menus):

Image

11.2.3. The Patient Portal

A patient (role USER) arrives at “My area”: their upcoming appointment, which they can cancel, and links to manage their account and view their calendar. Here, they do not yet have an appointment:

Image

In the calendar, they do not see the names of other patients; these are replaced by “Booked”: this name does not even appear in the page’s source code. They can book an available time slot:

Image

The booking window does not prompt him to select a patient: he books the appointment for himself.

Image

Image

His dashboard now shows his upcoming appointment, which he can cancel:

Image

Since he has an upcoming appointment, he cannot schedule another one: the “Book” button is not displayed, and on the calendar for another day or another doctor, a message explains why:

Image

Account management: edit their information, change their password, or delete their account (and appointments). The current password is required for any changes:

Image

A patient who attempts to open a page reserved for the administrator (here, [/doctors], typed manually) receives an error page:

Image

11.2.4. Managing Doctors and Patients

The administrator has two additional menus, Doctors and Patients:

Image

Image

The form for adding a doctor, submitted with errors:

Image

A name that is the same as an existing doctor’s is rejected, even when written in lowercase and with an accent (the database itself compares names without distinguishing between uppercase and lowercase letters or accents; see example 28):

Image

Deleting a patient requires confirmation:

Image

11.2.5. Creating a patient account

From the login page, the “Create a patient account” button only opens the form if the CAPTCHA has been entered correctly:

Image

Once the account is created, the user is redirected to the login page with their username already filled in:

Image

11.2.6. Logout

The login token is valid for two hours. After that time (or if the token has been tampered with), the requested page redirects to the login page with the following message:

Image

11.3. Installation and Execution

The application is located in the [rdvmedecins2-symfony] folder, next to the [examples] folder. It is a standalone project, with its own [composer.json] and [.env] files, and its own [vendor] folder. The [README.md] file in the folder contains all of the following. The introduction to this document has already described the installation of [PHP] and [Composer]; we’ll expand on that here.

11.3.1. Required Software

Three software programs are all you need:

  • [PHP] 8.4 or later, with the extensions [pdo_mysql] and [intl] ([Symfony] 8 requires [PHP] 8.4, and the application uses features introduced in that version, such as [array_find]). Verify the installation in a terminal:
php -v
php -m

The second command lists the extensions: [pdo_mysql] and [intl] should be listed there;

  • [Composer], the library manager for [PHP] (see appendices);
  • a [MySQL] 8 server, for example, the one for [Laragon] under [Windows] (see appendices). [MariaDB] is also suitable.

The recommended editor is [Visual Studio Code], with the extensions [PHP Intelephense] and [Twig Language 2] (see appendices); however, everything can be done via the command line.

11.3.2. Install the libraries

The [vendor] folder is not provided: create it once and for all in a terminal window opened in the [rdvmedecins2-symfony] folder (you must be connected to the Internet):

composer install

[Composer] reads the file [composer.json] (discussed later), downloads [Symfony], [Doctrine], [Twig], [firebase/php-jwt], and the libraries they depend on, and generates the class loader [vendor/autoload.php], as well as the file [vendor/autoload_runtime.php] for the component [Runtime] (discussed below).

11.3.3. Create the database

The [dbrdvmedecins2] database is created by the [database/dbrdvmedecins2.sql] script. Using [Laragon], open the [HeidiSQL] tool (Database button), then run the script via the File / Run SQL menu. At the command line, in the application folder:

mysql -u root -p < database/dbrdvmedecins2.sql

The [-p] option prompts for the password for [root]; omit it if the account does not have a password (as is the case with [Laragon]). This is the same database as in other versions of the application: if it already exists, this step is optional. The script can be run at any time to revert to the original data.

11.3.4. Configure Database Access

All installation configuration is contained in the file [.env] (discussed below). By default, the application connects to [MySQL] on [127.0.0.1], port 3306, using the user [root] without a password: these are the default settings for [Laragon]. If your [root] account has a password, enter it in this file, in the URL [DATABASE_URL] line, after [root:]. A system environment variable takes precedence over the file (example 25): this allows you to specify the database’s URL without writing it to a project file.

11.3.5. Launch the application

In a terminal window open to the [rdvmedecins2-symfony] folder:

php -S localhost:8081 -t public
[Sun Sep 27 18:40:12 2026] PHP 8.4.21 Development Server (http://localhost:8081) started

Next, open the URL [http://localhost:8081] in a browser, and stop the application using [Ctrl-C]. Port 8081 allows you to run a sample from the course (port 8000) at the same time. The command-line tool [symfony] (see appendices) provides a more comprehensive server that can also serve the application in HTTPS: [symfony server:start --port=8081].

11.3.6. Demo Accounts

The password for each account is its username; you must also enter the characters from the image, in uppercase or lowercase:

[admin]
ADMIN (the administrator)
[pelissier], [tatou], [dupont]
DOCTOR (the doctors)
[martin], [durand], [bistrou], [german]
USER (patients)

A guide to exploring the app:

  • [admin]: Schedule, Ms. Marie PELISSIER; today: a time slot is already booked by Ms. Brigitte BISTROU. Book a slot for Mr. Jean MARTIN, then try to book a second appointment for him (declined). Doctors and Patients menus: add, edit, delete (Dr. PELISSIER, who has appointments, cannot be deleted);
  • [pelissier]: his daily schedule is displayed directly; the address [/doctors], entered manually, returns a 403 error page;
  • [martin]: My area, their appointment, the calendar where other patients appear as “Booked”; Manage my account;
  • login page: Create a patient account, then log in with the new account;
  • Buttons FR / EN: The entire interface changes languages, including dates.

11.3.7. Development Mode

The provided file [.env] configures the application for development ([APP_ENV=dev]):

  • The server console displays the log for [Symfony] (example 24): the selected route, security steps, and each SQL command sent to the database (example 28). For example, to display a calendar (abbreviated lines):
[info] Matched route "schedule".
[debug] Checking for authenticator support.
[debug] Checking support on authenticator.
[info] Authenticator successful!
[debug] Authenticator set no success response: request continues.
[info] Connecting with parameters [array]
[debug] Executing statement: SELECT d0_.id AS id_0, ..., u1_.login AS login_7, ... FROM doctors d0_ INNER JOIN users u1_ ON d0_.`user` = u1_.`id` WHERE d0_.id = ? (parameters: [array], types: [array])
[debug] Executing statement: SELECT a0_.id AS id_0, ... FROM appointments a0_ INNER JOIN clients c1_ ON a0_.client_id = c1_.id INNER JOIN slots s2_ ON a0_.slot_id = s2_.id WHERE a0_.day = ? AND s2_.doctor_id = ? (parameters: [array], types: [array])
[debug] Executing statement: SELECT s0_.id AS id_0, ... FROM slots s0_ WHERE s0_.doctor_id = ? ORDER BY s0_.start_hour ASC, s0_.start_minute ASC (parameters: [array], types: [array])
127.0.0.1:55220 [200]: GET /schedule?doctorId=1&day=2026-09-27
[info] Disconnecting

Here we can see the routing, the authenticator, and then three requests: the doctor and their account, today’s appointments and their patients, and the doctor’s available time slots. The list of doctors in the drop-down menu, however, is not read; it comes from the cache (discussed later);

  • an unexpected exception displays the [Symfony] debug page (example 03), with the exception message and the call stack. In production, the application displays its own 500 error page instead;
  • there is nothing to restart after a change: each request re-reads the [PHP] files (example 01), and, in [dev] mode, [Symfony] rebuilds its own cache (the container, routes, and compiled views) when a file has changed.

The [Xdebug] debugger also allows you to step through the application from [Visual Studio Code] ([PHP Debug] extension): This is an extension of [PHP], which must be installed separately (see xdebug.org). The debug bar in [Symfony] ([WebProfilerBundle], [composer require --dev symfony/web-profiler-bundle]) displays the query details at the bottom of each page: the route, the user, the SQL commands and their parameters, translations, execution times...

11.3.8. A production server

The [PHP] development server is not intended for production. In production, the application is served by a web server ([nginx], [Apache]), which forwards requests to [PHP-FPM], a process manager ([PHP]). The site root is the [public] folder: only its files are accessible from the browser; the [var] folder must be writable by the server. In the [.env] file, you must specify production mode, change the secret keys, and declare that the site is served from HTTPS:

1
2
3
4
APP_ENV=prod
APP_SECRET=<a long random string>
JWT_SECRET=<another long random string>
COOKIE_SECURE=1

A suitable random string can be obtained from [PHP] itself: [php -r "echo bin2hex(random_bytes(32));"]. The application is then prepared using two commands:

composer install --no-dev --optimize-autoloader
php bin/console cache:clear

The second command generates the production cache (the compiled container, routes, compiled views, etc.): in [prod] mode, [Symfony] no longer checks whether the files have changed, which is faster; therefore, it must be rerun after every modification. The [composer dump-env prod] command also converts the [.env] file into a [PHP] ([.env.local.php]) file, which is read more quickly.

11.3.9. Common Issues

A [PHP] application does not “start”: each request rebuilds the application (example 01). An inaccessible database therefore does not prevent the server from starting: the error appears on the first page that queries the database (the login page does not query the database; the connection process does). In [dev] mode, the error is displayed on the [Symfony] debug page; in all cases, it is logged in the log file, which the development server displays in the console. For example, with a database that does not exist:

[critical] Uncaught PHP Exception Doctrine\DBAL\Exception\ConnectionException: "An exception occurred in the driver: SQLSTATE[HY000] [1049] Unknown database 'dbunknown'" at ExceptionConverter.php line 91

The most common messages:

[Failed opening required '.../vendor/autoload_runtime.php']
The command [composer install] was not executed
[Your Composer dependencies require a PHP version ">= 8.4.0"]
The version of [PHP] is older than 8.4
[could not find driver]
The extension [pdo_mysql] for [PHP] is not enabled (file [php.ini], or menu PHP / Extensions menu in [Laragon])
[SQLSTATE[HY000] [2002] Connection refused]
[MySQL] has not started, or is not listening on [127.0.0.1:3306]
[SQLSTATE[HY000] [1045] Access denied for user 'root'@'localhost']
The password for [MySQL] must be entered in [DATABASE_URL] (see “Configuring Database Access”)
[SQLSTATE[HY000] [1049] Unknown database 'dbrdvmedecins2']
The script [database/dbrdvmedecins2.sql] was not executed
[JWT_SECRET must be at least 32 characters long (see .env).]
The secret key for the tokens is too short in [.env]
[Failed to listen on localhost:8081 (reason: Address already in use)]
Port 8081 is already in use (is another version of the application running?): stop it, or choose a different port

11.4. Application Architecture

Image

  • The browser sends its requests to the server ([php -S], or [nginx] + [PHP-FPM]), which forwards them to [Symfony] via the file [public/index.php]. The core of [Symfony] publishes events related to request processing (see the chapter on the lifecycle), to which listeners respond: the request language ([SetLanguage]), routing, the session, and then the security component’s firewall: The user is identified based on the JWT token from their cookie ([TokenAuthenticator]), and the [access_control] rules deny access to protected pages for visitors who are not authorized. Errors result in pages HTML ([ErrorPage]);
  • [Controller]: the web layer controllers. They all pass through a single object, [ApplicationModel] (folder [Web]), which caches the lists of doctors and clients and forwards other requests to the business layer; [Form]: forms and their constraints; [Views]: view templates;
  • [Security]: tokens, JWT, the authenticator, the CAPTCHA, the attempt limit, and the account creation pass;
  • [Web]: What controllers have in common: the cache and the facade [ApplicationModel], the parent class of controllers, flash messages, and a few utilities;
  • [Business]: business rules and transactions;
  • [Dao]: the DAO layer, one class per table;
  • [Entity]: the entities [Doctrine], which the DAO layer reads from and writes to the database; [Domain]: the calendar objects, calculated by the business layer;
  • [templates]: the [Twig] views, which generate the HTML pages; [translations]: the dictionaries; [public]: the stylesheet, the page script, and [Bootstrap];
  • the [config] folder and the [.env] file: the configuration, which brings everything together.

The application folder (excluding [vendor] and [var]):

rdvmedecins2-symfony/
├── bin/
│   └── console
├── composer.json
├── config/
│   ├── bundles.php
│   ├── packages/
│   │   ├── doctrine.yaml
│   │   ├── framework.yaml
│   │   ├── security.yaml
│   │   └── twig.yaml
│   ├── routes.yaml
│   └── services.yaml
├── database/
│   ├── dbrdvmedecins2.sql
│   └── README.md
├── .env
├── public/
│   ├── css/
│   │   └── styles.css
│   ├── index.php
│   ├── js/
│   │   └── app.js
│   └── lib/
│       └── bootstrap/
├── README.md
├── src/
│   ├── Controller/
│   │   ├── ScheduleController.php
│   │   ├── AuthController.php
│   │   ├── ClientsController.php
│   │   ├── PatientAreaController.php
│   │   ├── RegistrationController.php
│   │   ├── LanguageController.php
│   │   ├── DoctorsController.php
│   │   ├── PersonsController.php
│   │   └── README.md
│   ├── Dao/
│   │   ├── ClientDao.php
│   │   ├── SlotDao.php
│   │   ├── DoctorDao.php
│   │   ├── PersonDao.php
│   │   ├── README.md
│   │   ├── AppointmentDao.php
│   │   └── UserDao.php
│   ├── Domain/
│   │   ├── DoctorDaySchedule.php
│   │   ├── DoctorDaySlot.php
│   │   └── README.md
│   ├── Entity/
│   │   ├── Client.php
│   │   ├── Slot.php
│   │   ├── Doctor.php
│   │   ├── Person.php
│   │   ├── README.md
│   │   ├── Role.php
│   │   ├── Appointment.php
│   │   └── User.php
│   ├── EventListener/
│   │   ├── SetLanguage.php
│   │   ├── ErrorPage.php
│   │   ├── README.md
│   │   └── CheckRegistrationPass.php
│   ├── Form/
│   │   ├── PersonFieldsType.php
│   │   ├── RegistrationType.php
│   │   ├── MyAccountType.php
│   │   ├── PersonType.php
│   │   └── README.md
│   ├── Kernel.php
│   ├── Business/
│   │   ├── Dates.php
│   │   ├── Descriptor.php
│   │   ├── PersonData.php
│   │   ├── Business.php
│   │   ├── BusinessException.php
│   │   ├── BusinessService.php
│   │   └── README.md
│   ├── Security/
│   │   ├── AuthService.php
│   │   ├── Captcha.php
│   │   ├── CaptchaService.php
│   │   ├── CaptchaDrawing.php
│   │   ├── TokenAuthenticator.php
│   │   ├── AttemptLimiter.php
│   │   ├── RegistrationPass.php
│   │   ├── RegistrationPassRequired.php
│   │   ├── README.md
│   │   ├── InvalidSession.php
│   │   └── ConnectedUser.php
│   ├── Twig/
│   │   └── ViewsExtension.php
│   ├── Views/
│   │   ├── ScheduleView.php
│   │   ├── PersonKeys.php
│   │   ├── ScheduleRow.php
│   │   ├── OptionView.php
│   │   ├── README.md
│   │   ├── AppointmentView.php
│   │   └── BookingView.php
│   └── Web/
│       ├── ApplicationModel.php
│       ├── BaseController.php
│       ├── Flash.php
│       ├── Message.php
│       ├── Utils.php
│       └── README.md
├── templates/
│   ├── schedule.html.twig
│   ├── account.html.twig
│   ├── error.html.twig
│   ├── area.html.twig
│   ├── fragments/
│   │   ├── captcha.html.twig
│   │   ├── confirmation.html.twig
│   │   ├── errors.html.twig
│   │   ├── navbar.html.twig
│   │   ├── booking.html.twig
│   │   └── success.html.twig
│   ├── registration.html.twig
│   ├── layout.html.twig
│   ├── login.html.twig
│   ├── macros.html.twig
│   ├── persons/
│   │   ├── form.html.twig
│   │   └── list.html.twig
│   └── README.md
└── translations/
    ├── messages+intl-icu.en.yaml
    └── messages+intl-icu.fr.yaml

This is the structure of a standard [Symfony] application—the one created by the [composer create-project symfony/skeleton] command (Example 01)—supplemented with the bundles the application requires. Two differences from the examples: the application has its own [vendor] folder (the examples shared the one in the [examples] folder), and its entry point uses the [Runtime] component from [Symfony] (discussed later). The application’s classes are organized in the [src] folder, according to the PSR-4 rule (example 01): the [App\Business\BusinessService] class is in the [src/Business/BusinessService.php] file. [Symfony] does not require any of these files: [Controller], [Entity], [EventListener], [Form] and [Twig] are the ones in use (the ones used by [MakerBundle]), while the others ([Dao], [Business], [Security]...) are part of the layered architecture in Example 28. [Bootstrap] is part of the application (folder [public/lib/bootstrap]) this time: the case study is self-contained.

11.5. The database

The [dbrdvmedecins2] database has five tables:

  • [users]: users authorized to log in;
  • [doctors] and [clients]: doctors and patients, each linked to their user account;
  • [slots]: each doctor’s available time slots;
  • [appointments]: appointments, one client per time slot on a given day.

11.5.1. database/dbrdvmedecins2.sql

-- =========================================================================
-- Database [dbrdvmedecins2] - MySQL 8
--
-- Evolution of [dbrdvmedecins]:
--   1. the [doctors] and [clients] tables get a [user] column,
--      foreign key to users(id): every doctor and every client is
--      now also a user able to log in;
--   2. a third role appears: DOCTOR (in addition to ADMIN and USER);
--   3. the script creates a [users] row for each doctor (role DOCTOR)
--      and for each client (role USER):
--        - login        = last name in lower case
--        - password     = last name in lower case, hashed with bcrypt (10 rounds)
--        - display name = first name + last name
--
--   4. uniqueness constraints, all NAMED (the application recognizes
--      the name of the violated constraint in the MySQL error message):
--        - unq_users_login                   : a login belongs to one account only;
--        - unq_doctors_first_name_last_name  : two doctors cannot have the
--                                              same first and last names;
--        - unq_clients_first_name_last_name  : same for the clients.
--      Beware: the utf8mb4_unicode_ci collation compares regardless of
--      case and accents - "Hélène DUPONT" and "helene dupont"
--      are therefore considered identical.
--
-- The [users] table must now be created BEFORE [doctors] and [clients]
-- (they reference it), and dropped AFTER them.
-- =========================================================================

-- the script is written in UTF-8
SET NAMES utf8mb4;

-- we start again from an empty database on each run of the script (handy in development)
CREATE DATABASE IF NOT EXISTS dbrdvmedecins2 CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE dbrdvmedecins2;

-- drop order: from the "child" tables to the "parent" tables
DROP TABLE IF EXISTS appointments;
DROP TABLE IF EXISTS slots;
DROP TABLE IF EXISTS clients;
DROP TABLE IF EXISTS doctors;
DROP TABLE IF EXISTS users;

-- -------------------------------------------------------------------------
-- table [users]: the users allowed to log in to the application
-- -------------------------------------------------------------------------
CREATE TABLE users (
  id       INT AUTO_INCREMENT PRIMARY KEY,
  version  INT NOT NULL DEFAULT 0,
  login    VARCHAR(30) NOT NULL,                 -- login identifier
  password VARCHAR(100) NOT NULL,                -- HASHED password (bcrypt) - never in clear
  name     VARCHAR(40) NOT NULL,                 -- name displayed once logged in
  role     VARCHAR(10) NOT NULL,                 -- 'ADMIN', 'DOCTOR' or 'USER'
  -- a login can belong to one account only
  CONSTRAINT unq_users_login UNIQUE (login)
);

-- -------------------------------------------------------------------------
-- table [doctors]: the doctors of the practice
-- -------------------------------------------------------------------------
CREATE TABLE doctors (
  id         INT AUTO_INCREMENT PRIMARY KEY,   -- primary key (technical identifier)
  version    INT NOT NULL DEFAULT 0,            -- row version number (optimistic lock)
  title      VARCHAR(5)  NOT NULL,              -- Melle, Mme, Mr
  last_name  VARCHAR(20) NOT NULL,
  first_name VARCHAR(20) NOT NULL,
  `user`     INT NOT NULL,                      -- foreign key -> users(id)
  CONSTRAINT fk_doctors_user FOREIGN KEY (`user`) REFERENCES users(id),
  -- one account per doctor, and one doctor per account
  CONSTRAINT unq_doctors_user UNIQUE (`user`),
  -- two doctors cannot have the same first and last names
  CONSTRAINT unq_doctors_first_name_last_name UNIQUE (first_name, last_name)
);

-- -------------------------------------------------------------------------
-- table [clients]: the doctors' patients
-- -------------------------------------------------------------------------
CREATE TABLE clients (
  id         INT AUTO_INCREMENT PRIMARY KEY,
  version    INT NOT NULL DEFAULT 0,
  title      VARCHAR(5)  NOT NULL,
  last_name  VARCHAR(20) NOT NULL,
  first_name VARCHAR(20) NOT NULL,
  `user`     INT NOT NULL,                      -- foreign key -> users(id)
  CONSTRAINT fk_clients_user FOREIGN KEY (`user`) REFERENCES users(id),
  -- one account per client, and one client per account
  CONSTRAINT unq_clients_user UNIQUE (`user`),
  -- two clients cannot have the same first and last names
  CONSTRAINT unq_clients_first_name_last_name UNIQUE (first_name, last_name)
);

-- -------------------------------------------------------------------------
-- table [slots]: the time slots of each doctor
-- -------------------------------------------------------------------------
CREATE TABLE slots (
  id           INT AUTO_INCREMENT PRIMARY KEY,
  version      INT NOT NULL DEFAULT 0,
  doctor_id    INT NOT NULL,                     -- foreign key -> doctors(id)
  start_hour   INT NOT NULL,                     -- start hour of the slot
  start_minute INT NOT NULL,                     -- start minute of the slot
  end_hour     INT NOT NULL,                     -- end hour of the slot
  end_minute   INT NOT NULL,                     -- end minute of the slot
  CONSTRAINT fk_slots_doctor FOREIGN KEY (doctor_id) REFERENCES doctors(id)
);

-- -------------------------------------------------------------------------
-- table [appointments]: the appointments booked by the clients
-- -------------------------------------------------------------------------
CREATE TABLE appointments (
  id         INT AUTO_INCREMENT PRIMARY KEY,
  version    INT NOT NULL DEFAULT 0,
  day        DATE NOT NULL,                      -- day of the appointment
  slot_id    INT NOT NULL,                       -- foreign key -> slots(id)
  client_id  INT NOT NULL,                       -- foreign key -> clients(id)
  CONSTRAINT fk_appointments_slot   FOREIGN KEY (slot_id)   REFERENCES slots(id),
  CONSTRAINT fk_appointments_client FOREIGN KEY (client_id) REFERENCES clients(id),
  -- the same slot, on the same day, can be booked only once
  CONSTRAINT unq1_appointments UNIQUE (day, slot_id)
);

-- =========================================================================
-- Demo data
-- =========================================================================

-- the users: passwords hashed with bcrypt (10 salt rounds)
--   admin/admin (ADMIN)
--   one DOCTOR account per doctor, one USER account per client
--   (the user/user demo account is gone: a USER account is
--   now ALWAYS a client's account, who can book and manage the account)
--   (login = password = last name in lower case)
INSERT INTO users (login, password, name, role) VALUES
  ('admin',     '$2b$10$YSNBJXXHX9mp13nPZb6YGu8M1jR6uvPwO1s2X73z0QAqBR7TOQjoq', 'Administrator',    'ADMIN'),
  -- doctors
  ('pelissier', '$2b$10$DnSDOK5ONrdMxTTr1NKCHOphR3WSp5QAyG9S9ueruw9HwlxI.16ba', 'Marie PELISSIER',  'DOCTOR'),
  ('tatou',     '$2b$10$PeovBO.gmJUKP4PglI/fwu4xtWOYSxoM5VTWjTj7uQ4wcd9P9.htS', 'Paul TATOU',       'DOCTOR'),
  ('dupont',    '$2b$10$az7wvPKC1h0EiAxXwu/PU.vYjzmB04rd5B7QnK0mvxp4niYIHVoc.', 'Sophie DUPONT',    'DOCTOR'),
  -- clients
  ('martin',    '$2b$10$GT6S5pp.WdGapFw/UmocXesbC6LEjJfZzgatCUyG9Sf4o1iBNlqZW', 'Jean MARTIN',      'USER'),
  ('durand',    '$2b$10$l2gl/zCder8E7Nh4VxGRX.0.SsO3FDiXt6YwqYq3v9nFzgC/jQCy6', 'Alice DURAND',     'USER'),
  ('bistrou',   '$2b$10$naeRp2YUU6c.QDl5itJMAum3Y5aHdUV6jwIcsbp0D.64jPG4F6E9m', 'Brigitte BISTROU', 'USER'),
  ('german',    '$2b$10$tTWxh3ROWF7NjhZOMnWDMOsZcnSWUiwwfy5C3G0.xhccTXALKyLeW', 'Luc GERMAN',       'USER');

-- the doctors: the [user] column is looked up from the login,
-- rather than assuming the values of the primary keys of [users]
INSERT INTO doctors (title, last_name, first_name, `user`) VALUES
  ('Mme',  'PELISSIER', 'Marie',  (SELECT id FROM users WHERE login = 'pelissier')),
  ('Mr',   'TATOU',     'Paul',   (SELECT id FROM users WHERE login = 'tatou')),
  ('Melle','DUPONT',    'Sophie', (SELECT id FROM users WHERE login = 'dupont'));

INSERT INTO clients (title, last_name, first_name, `user`) VALUES
  ('Mr',   'MARTIN',  'Jean',     (SELECT id FROM users WHERE login = 'martin')),
  ('Mme',  'DURAND',  'Alice',    (SELECT id FROM users WHERE login = 'durand')),
  ('Melle','BISTROU', 'Brigitte', (SELECT id FROM users WHERE login = 'bistrou')),
  ('Mr',   'GERMAN',  'Luc',      (SELECT id FROM users WHERE login = 'german'));

-- slots of doctor PELISSIER (doctor_id = 1): from 8:00 to 12:00 in 20-minute steps
INSERT INTO slots (doctor_id, start_hour, start_minute, end_hour, end_minute) VALUES
  (1, 8, 0, 8, 20), (1, 8, 20, 8, 40), (1, 8, 40, 9, 0),
  (1, 9, 0, 9, 20), (1, 9, 20, 9, 40), (1, 9, 40, 10, 0),
  (1, 10, 0, 10, 20), (1, 10, 20, 10, 40), (1, 10, 40, 11, 0),
  (1, 11, 0, 11, 20), (1, 11, 20, 11, 40), (1, 11, 40, 12, 0);

-- slots of doctor TATOU (doctor_id = 2): from 14:00 to 18:00 in 20-minute steps
INSERT INTO slots (doctor_id, start_hour, start_minute, end_hour, end_minute) VALUES
  (2, 14, 0, 14, 20), (2, 14, 20, 14, 40), (2, 14, 40, 15, 0),
  (2, 15, 0, 15, 20), (2, 15, 20, 15, 40), (2, 15, 40, 16, 0),
  (2, 16, 0, 16, 20), (2, 16, 20, 16, 40), (2, 16, 40, 17, 0),
  (2, 17, 0, 17, 20), (2, 17, 20, 17, 40), (2, 17, 40, 18, 0);

-- an appointment already booked, to see an occupied slot right away in the schedule
INSERT INTO appointments (day, slot_id, client_id) VALUES
  (CURDATE(), 2, 3);
  • line 33: the database is created if it does not exist, with the character set [utf8mb4] and the collation [utf8mb4_unicode_ci], which compares strings without regard to uppercase letters or accents (example 28);
  • lines 37–41: the tables are dropped and then recreated; the script restores the database to its initial state. The order matters: a table cannot be dropped as long as another table references it via a foreign key;
  • Lines 46–55: Users: a username and a password hashed with [bcrypt] (example 25), the display name after logging in, and the role ([ADMIN], [DOCTOR], or [USER]). The constraint [unq_users_login] ensures that a username belongs to only one account;
  • Lines 60–72: Each table has a primary key [id], generated by the database, and a column [version], which is the optimistic lock (Example 28). The column [user] is a foreign key referencing [users(id)]: the database rejects any value that is not the ID of an existing user. Since [user] is a word in the SQL language, it is surrounded by grave accents. Three constraints: [fk_doctors_user] (the foreign key), [unq_doctors_user] (one account per doctor, and one doctor per account), and [unq_doctors_first_name_last_name] (no homonyms). All constraints are named: the application recognizes a violated constraint by its name;
  • lines 77–89: the [clients] table has exactly the same columns;
  • lines 94–103: a time slot belongs to a doctor; its start and end times are stored in hours and minutes;
  • lines 108–118: an appointment consists of a day (type [DATE], without a time), a time slot, and a client. The constraint [unq1_appointments] prevents the same time slot from being booked twice on the same day: the database enforces this rule, even if two users attempt to book it at the same time;
  • lines 130–140: demo users, whose password is their login. The hashes begin with [$2b$10$]: the algorithm ([bcrypt], variant [2b]) and the cost (10). Next come the salt and the hash. These hashes were calculated by the [Flask] version of the application; [PHP] writes its own with the prefix [$2y$], but verifies both equally well;
  • lines 144–147: the [user] column for each doctor is retrieved via a subquery based on the login, rather than by assuming the values of the primary keys;
  • lines 170–171: an appointment already scheduled for today ([CURDATE()]) in Dr. PELISSIER’s second time slot for Ms. BISTROU: the calendar thus immediately shows a booked time slot.

Unlike in the case of [Symfony], the database is not created by [Doctrine] from the entities, nor through migrations (example 28): it is shared with the other versions of the application, and the script SQL is common to all of them. The command [php bin/console doctrine:schema:validate] verifies that the entity descriptions are consistent and that they correspond to the database tables.

11.6. The project configuration

11.6.1. composer.json

{
    "name": "rdvmedecins/rdvmedecins2-symfony",
    "description": "RdvMedecins: booking medical appointments, HTML pages built by the server (Symfony + Twig + Doctrine)",
    "type": "project",
    "license": "MIT",
    "require": {
        "php": ">=8.4",
        "ext-intl": "*",
        "ext-pdo_mysql": "*",
        "doctrine/doctrine-bundle": "^3.3",
        "doctrine/orm": "^3.7",
        "firebase/php-jwt": "^7.2",
        "symfony/asset": "^8.1",
        "symfony/console": "^8.1",
        "symfony/dotenv": "^8.1",
        "symfony/form": "^8.1",
        "symfony/framework-bundle": "^8.1",
        "symfony/lock": "^8.1",
        "symfony/rate-limiter": "^8.1",
        "symfony/runtime": "^8.1",
        "symfony/security-bundle": "^8.1",
        "symfony/translation": "^8.1",
        "symfony/twig-bundle": "^8.1",
        "symfony/validator": "^8.1",
        "symfony/yaml": "^8.1",
        "twig/twig": "^3.30"
    },
    "autoload": {
        "psr-4": {
            "App\\": "src/"
        }
    },
    "config": {
        "allow-plugins": {
            "symfony/runtime": true
        },
        "optimize-autoloader": true,
        "sort-packages": true
    },
    "minimum-stability": "stable",
    "prefer-stable": true
}
  • Lines 6–27: the libraries required by the application and the constraints they must meet. [^8.1] reads “version 8.1 or a newer version of the same major version 8”: [Composer] accepts versions that fix bugs or add features, but not those that could break the application (version 9). The lines [php], [ext-intl], and [ext-pdo_mysql] do not refer to libraries, but to environment requirements: [Composer] will refuse installation if the version of [PHP] or its extensions are incompatible, rather than allowing the application to fail later. [Symfony] is not a single library: each of its components (routing, forms, security, etc.) is a library, and the application requests only those it needs; [Composer] additionally installs the libraries on which they depend;
  • Lines 28–32: The application’s PSR-4 rule: classes in the [App] namespace are located in the [src] folder (Example 01: In the examples, the file [public/index.php] itself declared this rule to the class loader via [addPsr4]);
  • Lines 34–36: The component [Runtime] is a plugin (plugin) for [Composer]; it runs during installation to write the file [vendor/autoload_runtime.php]. [Composer] requests permission to execute a plugin’s code; this permission is granted here.

[composer install] installs the versions that meet these constraints and records the exact versions installed in a file named [composer.lock]. Subsequent installations (on another machine, for example) use this file: they install exactly the same versions. [composer update], on the other hand, searches for the most recent versions permitted by [composer.json] and updates [composer.lock]. The command [composer show] lists what is installed in the [vendor] folder. In a typical project, this file is supplemented by [Symfony Flex] for each [composer require] (example 01).

11.6.2. .env

# =========================================================================
# .env: the settings specific to THIS installation, read at the start of each
# request by the Dotenv component (see public/index.php). They become
# environment variables, which the configuration (config folder) reads through
# %env(NAME)%. A real system environment variable takes precedence over this file.
# =========================================================================

# dev: development; prod: production (see README.md)
APP_ENV=dev
# Symfony's secret key (anti-CSRF tokens, signatures...). In production:
# another value, long and random
APP_SECRET=34b2364cc4c93df6e4ee35251a048088

# ---- the MySQL database [dbrdvmedecins2] (script: database/dbrdvmedecins2.sql):
# the DBMS, the user and their password (those of Laragon: root, without
# password), the server, its port, the database, then the server version (for MariaDB:
# serverVersion=10.11.2-MariaDB, for example) and the character set
DATABASE_URL="mysql://root:@127.0.0.1:3306/dbrdvmedecins2?serverVersion=8.0.32&charset=utf8mb4"

# ---- authentication (see src/Security/AuthService.php)
# secret key of the JWT tokens: at least 32 characters (HMAC-SHA256)
JWT_SECRET=ef1cfc9709f10b5b5d183b4deb6d5dbbe69518629c3b12a8
# validity duration of a token, in minutes (120 min = 2 h)
JWT_DURATION_MINUTES=120
# 1 in production (site served over HTTPS): cookies never travel unencrypted
COOKIE_SECURE=0

# ---- protection against robots (see src/Security/AttemptLimiter.php)
# login failures allowed for the same login from the same IP address, then
# block for this duration (in minutes); an IP address is also blocked if it
# accumulates LOGIN_MAX_FAILURES_IP failures, all logins together (a robot trying
# thousands of different logins)
LOGIN_MAX_FAILURES=5
LOGIN_MAX_FAILURES_IP=25
LOGIN_BLOCK_MINUTES=15
# client accounts created at most per hour and per IP address. In a classroom, all
# the computers often go out through the same address: increase if needed
REGISTRATION_MAX_PER_HOUR=10

The file containing settings specific to this installation (example 25): these vary from one machine to another (the database, its credentials) or are confidential (the keys). In a real project, production secrets are not included here: they are provided by actual environment variables, by a [.env.local] file that is never published, or by the [Symfony] secrets vault (example 25).

  • line 9: the application mode: [dev] (development: debug mode, debug page, cache rebuilt with every change) or [prod];
  • line 12: the secret key for [Symfony], which is used in particular for anti-CSRF tokens (example 21);
  • line 18: the database connection (example 28);
  • lines 20–38: application-specific settings: the JWT tokens (example 25) and bot protection (example 27).

11.6.3. config/bundles.php and config/packages/framework.yaml

1
2
3
4
5
6
7
8
9
<?php
// config/bundles.php: the bundles of the application (see example 01): the core of
// Symfony, the Twig views, security, and Doctrine (database access)
return [
    Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
    Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
    Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
    Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
];

The application’s four bundles: the core [Symfony], [Twig] (Example 13), security (Example 25), and [Doctrine] (Example 28).

# =========================================================================
# config/packages/framework.yaml: the configuration of the FrameworkBundle
# =========================================================================
framework:
    secret: '%env(APP_SECRET)%'

    # ---- the languages (see example 22): English by default, French; the
    # translation service and its dictionaries (translations folder)
    default_locale: en
    enabled_locales: ['en', 'fr']
    translator:
        default_path: '%kernel.project_dir%/translations'
        fallbacks: ['en']

    # ---- form validation: its messages are KEYS of our
    # dictionaries (ERRORS.FIRSTNAME_REQUIRED...), translated in the domain
    # [messages], the same as that of the views (by default: the [validators] domain)
    validation:
        translation_domain: messages

    # ---- the SESSION (flash messages, anti-CSRF tokens): files of the folder
    # var/sessions. The session cookie is never readable by the JavaScript of
    # the page (httponly), nor sent with a POST request coming from another site
    # (samesite lax), nor, in production, sent unencrypted (secure)
    session:
        handler_id: null
        save_path: '%kernel.project_dir%/var/sessions/%kernel.environment%'
        name: rdvmedecins_session
        cookie_httponly: true
        cookie_samesite: lax
        cookie_secure: '%env(bool:COOKIE_SECURE)%'
    csrf_protection: true

    # ---- the locks (see example 20), for the attempt limiters
    lock: 'flock://%kernel.project_dir%/var/lock'

    # ---- the attempt LIMITERS (see example 27 and src/Security/AttemptLimiter.php).
    # fixed_window: at most [limit] attempts per period [interval], counted
    # from the first attempt
    rate_limiter:
        # the login failures of a login, from an IP address
        login:
            policy: fixed_window
            limit: '%env(int:LOGIN_MAX_FAILURES)%'
            interval: '%env(LOGIN_BLOCK_MINUTES)% minutes'
        # the login failures of an IP address, all logins together
        login_ip:
            policy: fixed_window
            limit: '%env(int:LOGIN_MAX_FAILURES_IP)%'
            interval: '%env(LOGIN_BLOCK_MINUTES)% minutes'
        # the accounts created from an IP address, per hour
        registration:
            policy: fixed_window
            limit: '%env(int:REGISTRATION_MAX_PER_HOUR)%'
            interval: '1 hour'
  • lines 9–13: languages and the translator (Example 22);
  • lines 18–19: the validation component translates its messages into the [validators] domain (example 09); Our constraints use keys from our dictionaries ([ERRORS.FIRSTNAME_REQUIRED]...) as message keys, which are in the [messages] domain: we specify this to the validation component. Thus, a single dictionary per language is sufficient for the entire application;
  • Lines 25–32: the session (example 20): files in the [var/sessions] folder. The application stores very little there: flash messages and anti-CSRF tokens. The session cookie is named [rdvmedecins_session]; it is never readable by the [JavaScript] on the page, nor is it sent with a POST request from another site, nor, in production, sent in plain text (the prefix [bool:] converts the variable to a Boolean, example 25);
  • line 35: the locks (example 20), used by the attempt limiters and the CAPTCHA service;
  • lines 40–55: the three anti-bot limiters (example 27), whose settings come from the file [.env]. A value may combine text and a variable: ['%env(LOGIN_BLOCK_MINUTES)% minutes'].

The application cache (the [cache.app] service, Example 20) does not need to be configured: it is stored in files within the [var/cache/dev/pools] (or [prod]) folder. The application stores lists of doctors and clients, CAPTCHAs, attempt counters, and passes there—all of which must be shared across all requests. With multiple servers, you would choose a shared cache, such as [Redis] ([framework.cache.app: cache.adapter.redis]).

11.6.4. config/packages/security.yaml

# =========================================================================
# config/packages/security.yaml: security (see examples 25 and 26)
# =========================================================================
security:
    # the hashing of the accounts' passwords: bcrypt, 10 rounds, like the
    # hashes of the SQL script. PHP also verifies the $2b$ (Python) and $2a$
    # (Java) hashes of the other versions of the application, which share the same database
    password_hashers:
        App\Entity\User:
            algorithm: bcrypt
            cost: 10

    # the user provider: irrelevant here, the logged-in user is
    # described by their token (see src/Security/TokenAuthenticator.php); Symfony
    # nevertheless requires one: an empty list
    providers:
        none:
            memory: ~

    firewalls:
        # ONE firewall for the whole application, STATELESS: the user is not
        # stored in the session, they are found again on each request from the
        # JWT token of their cookie
        main:
            stateless: true
            provider: none
            custom_authenticators:
                - App\Security\TokenAuthenticator

    # ---------------------------------------------------------------------
    # THE ACCESS RULE: the rules are examined in order, the first one whose
    # pattern (path) matches the path of the URL applies:
    #   - a few PUBLIC URLs (PUBLIC_ACCESS): the home page, the login, the
    #     choice of the language, the creation of an account;
    #   - URLs reserved to certain roles (a list of roles: ONE of them is enough);
    #   - all the OTHERS require an authenticated user (last rule).
    # It is the rule "everything is forbidden except what is allowed": a URL added
    # later, and that one would have forgotten to protect, is protected by default.
    # A refused anonymous visitor is sent to the login page (the entry
    # point of the firewall); an authenticated user without the requested role
    # receives a 403 error page.
    # ---------------------------------------------------------------------
    access_control:
        - { path: ^/$, roles: PUBLIC_ACCESS }
        - { path: ^/(login|logout)$, roles: PUBLIC_ACCESS }
        - { path: ^/language/, roles: PUBLIC_ACCESS }
        - { path: ^/register, roles: PUBLIC_ACCESS }
        # a patient cancels THEIR appointment from "My area", not from the schedule
        - { path: ^/schedule/cancel$, roles: [ROLE_ADMIN, ROLE_DOCTOR] }
        - { path: ^/(doctors|clients), roles: ROLE_ADMIN }
        - { path: ^/my-, roles: ROLE_USER }
        - { path: ^/, roles: IS_AUTHENTICATED }

Security configuration (examples 25 and 26):

  • Lines 8–11: Password hashing: [bcrypt], 10 rounds, like the hashes in the script SQL. To verify a password, [PHP] reads the algorithm and cost from the hash itself: the hashes in the database, written by [Python] ([$2b$]) or by [Java] ([$2a$]), are verified just as much as those of [PHP] ([$2y$]);
  • lines 16–18: the user provider. The application authenticator does not need one: the logged-in user is identified by their token, without reading the database (example 25). But the security component requires one: a provider [memory], with no users;
  • lines 24–28: a single, stateless firewall for the entire application, with our authenticator. No pattern ([pattern]): it covers all URL. The files in the [public] folder ([Bootstrap], the stylesheet, the script) do not pass through [Symfony]: the web server serves them directly;
  • Lines 43–52: the application’s access rule. The [access_control] rules are evaluated in order, and the first one whose pattern matches the path of URL is applied. [PUBLIC_ACCESS] grants access to URL to everyone, including anonymous visitors: the home page, login, language selection, and account creation. Next come the URL rules reserved for certain roles: a list of roles (line 49) means that any one of them is sufficient. The last rule, [^/], applies to all remaining URL rules and requires an authenticated user. This is the “everything is prohibited except what is permitted” rule: a URL added later—even if you forgot to protect it—is automatically protected. The opposite error—a public URL that was accidentally left unprotected—is immediately apparent; the error of a forgotten protected page is much less so.

Roles are verified here, in a single file, using URL patterns, rather than [#[IsGranted]] attributes on actions (example 26): both are possible and can be combined.

11.6.5. config/packages/doctrine.yaml, twig.yaml, config/routes.yaml

# config/packages/doctrine.yaml: Doctrine, database access (see example 28)
doctrine:
    # the connection: the DATABASE_URL of the .env file. In debug mode, each SQL
    # statement is written to the log, which the development server displays
    dbal:
        url: '%env(resolve:DATABASE_URL)%'
    # the ENTITIES: the classes of the src/Entity folder, described by PHP attributes
    orm:
        mappings:
            App:
                type: attribute
                dir: '%kernel.project_dir%/src/Entity'
                prefix: 'App\Entity'

The configuration for [Doctrine] is the one shown in Example 28.

1
2
3
4
5
# config/packages/twig.yaml: the Twig views (templates folder)
twig:
    default_path: '%kernel.project_dir%/templates'
    # the forms are drawn with the Bootstrap 5 classes (see example 18)
    form_themes: ['bootstrap_5_layout.html.twig']

The form fields are drawn using the classes from [Bootstrap] 5 (Example 18).

1
2
3
4
# config/routes.yaml: the routes are described by the #[Route] attributes of the
# controllers of the src/Controller folder (see example 01)
controllers:
    resource: routing.controllers

The routes are described by the [#[Route]] attributes of the controllers (Example 01). The [php bin/console debug:router] command lists them all (see below, the web layer).

11.7. To start the application

11.7.1. public/index.php and bin/console

<?php
// public/index.php: the entry point of ALL the HTTP requests. The folder
// [public] is the root of the site: only its files are accessible from the
// browser (Bootstrap, style sheet, script), never the code of the application.
//
// The file vendor/autoload_runtime.php is written by Symfony's Runtime component
// (the symfony/runtime library): it loads the classes, reads the file
// .env, enables the debug mode, then calls the function below, which returns the
// kernel of the application; it finally makes it handle the current request, sends the
// response, and ends the request (see the index.php file of the examples)

use App\Kernel;

require_once dirname(__DIR__).'/vendor/autoload_runtime.php';

return function (array $context) {
    return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};

The entry point for the examples (Example 01) loaded the classes, read the file [.env], enabled debug mode, created the core, had it process the request, sent the response, and terminated the request. All of this is handled here by the [Runtime] component of [Symfony] (line 14): this is the form of the [public/index.php] file in a standard [Symfony] application.

  • Line 14: The file [vendor/autoload_runtime.php], written by [Composer] (see [composer.json]), loads the class loader, then executes the script that included it, in this case, [public/index.php], to obtain its return value: an included [PHP] file can return a value ([return]), just like a function;
  • Lines 16–18: This value is an anonymous function that receives the application context (the environment variables, read from [.env] by the [Runtime] component) and returns the core. The [Runtime] component handles the rest: the [Request], [handle], [send], and [terminate] objects.

The advantage of this approach is that the same [index.php] works with servers other than [PHP-FPM]. With the worker mode of [FrankenPHP] (see the section on scopes), for example, a different “runtime” keeps the kernel in memory and has it process requests sequentially, without this file changing. The file [bin/console] has the same structure: its function handles the execution of commands, built around the kernel.

11.7.2. src/Kernel.php and config/services.yaml

<?php
// src/Kernel.php: the kernel of the application (see example 01)

namespace App;

use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;

class Kernel extends BaseKernel
{
    use MicroKernelTrait;

    public function boot(): void
    {
        parent::boot();
        // the time zone: that of the medical practice. It sets today's date (no
        // appointment in the past, see src/Business/Dates.php)
        date_default_timezone_set('Europe/Paris');
    }
}

The core of the examples (Example 01), with one additional method:

  • lines 13–19: [boot] is called when the kernel starts up, for each request. It sets the time zone for [PHP]. By default, [PHP] operates in the time zone specified in its configuration ([date.timezone], in the file [php.ini]), which is often Coordinated Universal Time (UTC); however, the current date is used for a business rule: no appointments in the past. In Paris, between midnight and 2:00 a.m. in the summer, the day UTC is still the previous day: the firm’s time zone takes precedence.
# =========================================================================
# config/services.yaml: the SERVICES of the application (see example 06). All the
# classes of the src/ folder are declared to the container, which creates them on demand and
# gives each one the objects it needs (dependency injection): a
# controller receives the ApplicationModel, which receives the business layer, which receives the
# DAOs, which receive Doctrine's entity manager...
# Only one object of each class during a request.
# =========================================================================
services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\:
        resource: '../src/'
        # DATA objects, created by the code (or by Doctrine), never by the
        # container: they are not services
        exclude:
            - '../src/Entity/'
            - '../src/Domain/'
            - '../src/Views/'
            - '../src/Kernel.php'
  • Lines 10–15: All classes in the [src] folder are services created by the container, which provides each one with the objects it requests in its constructor (example 06). The services are shared: a single [ApplicationModel], a single [BusinessService]... for the entire request (see the chapter on scopes);
  • lines 16–22: except for data classes: entities created by [Doctrine] or by the business layer, calculated calendar objects, and view models created by actions. These are excluded: they are not services. (Other data classes, such as [PersonData] or [Message], are declared, but since no one requests them from the container, the container discards them on its own.)

There is nothing else to declare: the listeners ([#[AsEventListener]]), the controllers ([#[Route]]), the extensions [Twig], the form types, and the authenticator are recognized by the container based on their attributes or their parent class ([autoconfigure]), and configured accordingly. The command [php bin/console debug:container App] lists the application’s services.

11.8. The entities

The [Doctrine] entities (Example 28): one class per table, in the [src/Entity] folder.

11.8.1. src/Entity/Role.php

<?php
// src/Entity/Role.php: the three roles of a user (column users.role):
//   - ADMIN : the administrator - appointments + management of doctors and clients;
//   - DOCTOR: a doctor - books and cancels appointments;
//   - USER  : a client (patient) - books for themselves, manages their account.
// A "backed" enumeration (backed enum): each case has a value, the string
// stored as is in the database; Role::from('ADMIN') goes the other way.

namespace App\Entity;

enum Role: string
{
    case ADMIN = 'ADMIN';
    case DOCTOR = 'DOCTOR';
    case USER = 'USER';

    // the role in Symfony's sense (see config/packages/security.yaml): ROLE_ADMIN...
    public function symfonyRole(): string
    {
        return 'ROLE_'.$this->value;
    }
}

The three roles, listed as follows ([enum], [PHP] 8.1): a type whose only possible values are its cases: [Role::ADMIN], [Role::DOCTOR], and [Role::USER]. A variable of type [Role] cannot contain ['ADMINN']: the typo is detected. [enum Role: string] declares a backed enumeration: each case has a value, in this case the base-sorted string. [Role::ADMIN->value] is equal to ['ADMIN']; [Role::from('ADMIN')] returns the corresponding case (and throws an exception for an unknown value); [Role::tryFrom(...)] returns [null] in this case. An enumeration can have methods: [symfonyRole()] (lines 18–21) returns the role as defined by [Symfony], [ROLE_ADMIN], [ROLE_DOCTOR], or [ROLE_USER] (example 25)—the one verified by the rules [access_control]. [$this] designates the case here.

11.8.2. src/Entity/User.php

<?php
// =========================================================================
// src/Entity/User.php: a user allowed to log in (table [users]).
// Each doctor and each client has THEIR account (column [user] of the tables
// [doctors] and [clients], see Person.php).
//
// SQL uniqueness constraint: unq_users_login (a login belongs to only one
// account). It is created by the script database/dbrdvmedecins2.sql; the business
// layer recognizes it by its NAME (see src/Business/BusinessService.php).
//
// PasswordAuthenticatedUserInterface: a user who has a (hashed) password;
// it is what Symfony's hashing service expects (see AuthService).
// =========================================================================

namespace App\Entity;

use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;

#[ORM\Entity]
#[ORM\Table(name: 'users')]
class User implements PasswordAuthenticatedUserInterface
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    // the version number of the row: the optimistic lock (see example 28)
    #[ORM\Version]
    #[ORM\Column(type: Types::INTEGER)]
    private int $version = 0;

    public function __construct(
        #[ORM\Column(length: 30)]
        private string $login,
        // the HASHED password (bcrypt), never the password itself
        #[ORM\Column(length: 100)]
        private string $password,
        // the name displayed once logged in: "Marie PELISSIER"
        #[ORM\Column(length: 40)]
        private string $name,
        // enumType: the column (a text) becomes a Role, and vice versa
        #[ORM\Column(length: 10, enumType: Role::class)]
        private Role $role,
    ) {
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getVersion(): int
    {
        return $this->version;
    }

    public function getLogin(): string
    {
        return $this->login;
    }

    public function getPassword(): string
    {
        return $this->password;
    }

    public function getName(): string
    {
        return $this->name;
    }

    public function getRole(): Role
    {
        return $this->role;
    }

    public function setLogin(string $login): void
    {
        $this->login = $login;
    }

    public function setPassword(string $hash): void
    {
        $this->password = $hash;
    }

    public function setName(string $name): void
    {
        $this->name = $name;
    }
}
  • lines 21–23: the entity in table [users]. It implements the [PasswordAuthenticatedUserInterface] interface of the security component (Example 25): this is what the password hashing service expects;
  • lines 25–33: the primary key and version, the optimistic lock (Example 28);
  • lines 35–48: the other columns are declared in the constructor using constructor promotion (Example 03): the attributes [#[ORM\Column]] can be applied to both a promoted parameter and a property. [length] specifies the length of the [VARCHAR] columns. Line 45: The option [enumType] converts the column [role] (text) to [Role] during read operations, and vice versa during write operations: [$user->getRole()] returns a [Role], never a string;
  • Lines 50–93: the getters and three setter methods. The primary key and the version do not have setter methods: [Doctrine] keeps them up to date.

11.8.3. src/Entity/Person.php

<?php
// =========================================================================
// src/Entity/Person.php: what the doctors (table [doctors]) and the clients
// (table [clients]) have in common: a title, a last name, a first name - and the
// associated user account (column [user]).
//
// The two tables have exactly the same columns: they are described ONCE,
// in this ABSTRACT class (a "Person" is never created), from which
// Doctor and Client inherit; each one only specifies the name of its table.
// #[ORM\MappedSuperclass]: a parent class whose columns are those of the
// entities that inherit from it, but which has no table of its own.
//
// Beware: [Client] here means a PATIENT of the medical practice, not an HTTP
// client! It is the vocabulary of the original business domain, kept as is.
// =========================================================================

namespace App\Entity;

use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\MappedSuperclass]
abstract class Person
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    protected ?int $id = null;

    // optimistic lock (see example 28)
    #[ORM\Version]
    #[ORM\Column(type: Types::INTEGER)]
    protected int $version = 0;

    public function __construct(
        // Mr, Mme, Melle
        #[ORM\Column(length: 5)]
        protected string $title,
        #[ORM\Column(name: 'last_name', length: 20)]
        protected string $lastName,
        #[ORM\Column(name: 'first_name', length: 20)]
        protected string $firstName,
        // -------------------------------------------------------------------
        // The RELATION to the user account: a person has ONE account,
        // designated by the foreign key [user]. #[ORM\OneToOne]: one account per
        // person, one person per account; #[ORM\JoinColumn]: the column of the
        // foreign key (surrounded by backticks: `user` is a word of the SQL
        // language of MySQL). $doctor->getAccount() returns a User object, read from the database on
        // first access - or right away by the query, if it asks for it (see src/Dao)
        // -------------------------------------------------------------------
        #[ORM\OneToOne]
        #[ORM\JoinColumn(name: '`user`', nullable: false)]
        protected User $account,
    ) {
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getVersion(): int
    {
        return $this->version;
    }

    public function getTitle(): string
    {
        return $this->title;
    }

    public function getLastName(): string
    {
        return $this->lastName;
    }

    public function getFirstName(): string
    {
        return $this->firstName;
    }

    public function getAccount(): User
    {
        return $this->account;
    }

    public function edit(string $title, string $lastName, string $firstName): void
    {
        $this->title = $title;
        $this->lastName = $lastName;
        $this->firstName = $firstName;
    }

    // "Mme Alice DURAND"
    public function label(): string
    {
        return "{$this->title} {$this->firstName} {$this->lastName}";
    }
}

The tables [doctors] and [clients] have the same columns. Their shared description is written once, in an abstract class ([abstract class]): a class for which no objects are ever created, serving only as a base class for inheritance. The classes [Doctor] and [Client] inherit from it.

  • Line 22: The attribute [#[ORM\MappedSuperclass]] makes the class a superclass: its columns are those of each of the entities that inherit from it, but it does not have its own table. ([Doctrine] also supports true entity hierarchies, organized in a single table or in one table per class: this is not the case here, where the two tables are independent.) The properties are [protected]: visible to child classes;
  • Lines 51–53: The relationship to the account. [#[ORM\OneToOne]]: A person has an account, and an account belongs to at most one person. [#[ORM\JoinColumn]] refers to the foreign key column, [user], and ensures that it is not empty ([nullable: false]). The column name is enclosed in grave accents: [user] is a word in the language SQL of [MySQL], and [Doctrine] will enclose it in this way in the SQL commands it writes (see the console output above). The property [account] is a [User] object, not a number: that is the purpose of a ORM. When [Doctrine] reads a person, it also reads their account if the request calls for it (lazy loading; see the DAO layer); otherwise, it places a lazy [User] object (lazy) in the property, which will only be read from the database upon the first access to one of its properties—except for its ID, which it already knows;
  • lines 87–92: modification of the three identity columns;
  • lines 95–98: the name of a person, “Ms. Alice DURAND,” used by all pages. In a string enclosed in quotation marks, [{$this->title}] inserts the value of a property.

11.8.4. src/Entity/Doctor.php and Client.php

<?php
// src/Entity/Doctor.php: a doctor (table [doctors]).
// SQL uniqueness constraint: unq_doctors_first_name_last_name (two doctors cannot
// have the same first and last names).

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\Table(name: 'doctors')]
class Doctor extends Person
{
}
<?php
// src/Entity/Client.php: a client, that is to say a PATIENT (table [clients]).
// SQL uniqueness constraint: unq_clients_first_name_last_name.

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\Table(name: 'clients')]
class Client extends Person
{
}

Two entities that specify only their table: everything else is inherited. A [Client] is a patient at the practice, not a client HTTP: this is the original business domain vocabulary, preserved in all versions of the application.

11.8.5. src/Entity/Slot.php

<?php
// src/Entity/Slot.php: a time slot of a doctor (table [slots]):
// 8:00 - 8:20, etc. The slots are created by the SQL script: the application
// only reads them.

namespace App\Entity;

use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\Table(name: 'slots')]
class Slot
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Version]
    #[ORM\Column(type: Types::INTEGER)]
    private int $version = 0;

    // several slots for the same doctor: #[ORM\ManyToOne], through the foreign
    // key [doctor_id]
    #[ORM\ManyToOne]
    #[ORM\JoinColumn(name: 'doctor_id', nullable: false)]
    private Doctor $doctor;

    #[ORM\Column(name: 'start_hour')]
    private int $startHour;

    #[ORM\Column(name: 'start_minute')]
    private int $startMinute;

    #[ORM\Column(name: 'end_hour')]
    private int $endHour;

    #[ORM\Column(name: 'end_minute')]
    private int $endMinute;

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getDoctor(): Doctor
    {
        return $this->doctor;
    }

    // "8:00 - 8:20"; sprintf('%02d'): "8:05" rather than "8:5"
    public function time(): string
    {
        return sprintf('%d:%02d - %d:%02d', $this->startHour, $this->startMinute, $this->endHour, $this->endMinute);
    }
}
  • lines 26–28: the relationship to the doctor for the time slot. [#[ORM\ManyToOne]]: multiple time slots for the same doctor, via the foreign key [doctor_id];
  • lines 30–40: the start and end hours and minutes. The time slots are created by the script SQL: the class has no constructor, and [Doctrine] populates its properties itself;
  • lines 53–56: the time range, “8:00 – 8:20.” The [%02d] format of [sprintf] writes a two-digit integer, padded with zeros: [8:05] rather than [8:5].

11.8.6. src/Entity/Appointment.php

<?php
// src/Entity/Appointment.php: an appointment (table [appointments]): a client, on a slot,
// on a given day.
// SQL uniqueness constraint: unq1_appointments (day, slot_id) - a slot is booked
// only once per day. Created by the SQL script, recognized by its name in the
// business layer.

namespace App\Entity;

use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\Table(name: 'appointments')]
class Appointment
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Version]
    #[ORM\Column(type: Types::INTEGER)]
    private int $version = 0;

    public function __construct(
        // the date_immutable type: the column [day] (SQL type DATE) becomes a date
        // DateTimeImmutable, at midnight; it is written to the database in the yyyy-mm-dd format
        #[ORM\Column(type: Types::DATE_IMMUTABLE)]
        private DateTimeImmutable $day,
        #[ORM\ManyToOne]
        #[ORM\JoinColumn(name: 'slot_id', nullable: false)]
        private Slot $slot,
        #[ORM\ManyToOne]
        #[ORM\JoinColumn(name: 'client_id', nullable: false)]
        private Client $client,
    ) {
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getDay(): DateTimeImmutable
    {
        return $this->day;
    }

    public function getSlot(): Slot
    {
        return $this->slot;
    }

    public function getClient(): Client
    {
        return $this->client;
    }
}
  • lines 30–31: the column [day] (type SQL [DATE]) is converted to [DateTimeImmutable] at midnight (type [Doctrine] [date_immutable]). Immutable: its methods never modify the object; they return a new one ([$day->modify('+1 day')] returns the next day, [$day] remains unchanged). The entire application works with immutable dates: a date shared among multiple objects cannot be modified by one of them without the others’ knowledge;
  • lines 32–37: two relationships, one to the time slot and one to the appointment’s client.

11.9. The domain: the calendar

11.9.1. src/Domain/DoctorDaySlot.php and DoctorDaySchedule.php

<?php
// src/Domain/DoctorDaySlot.php: a time slot, and the appointment possibly
// booked on that slot on a given day (null if the slot is free).
// A computed "business" object, which matches no table.
//
// readonly: the properties are set once and for all by the constructor -
// exactly what is needed for a computed object, which nobody modifies afterwards.

namespace App\Domain;

use App\Entity\Slot;
use App\Entity\Appointment;

readonly class DoctorDaySlot
{
    public function __construct(
        public Slot $slot,
        public ?Appointment $appointment,
    ) {
    }
}
<?php
// src/Domain/DoctorDaySchedule.php: the complete schedule of a doctor for a given
// day, the list of their slots, each one free or taken. Computed by
// BusinessService::getDoctorDaySchedule, turned into an HTML table by
// ScheduleController.

namespace App\Domain;

use App\Entity\Doctor;
use DateTimeImmutable;

readonly class DoctorDaySchedule
{
    /** @param list<DoctorDaySlot> $doctorDaySlots */
    public function __construct(
        public Doctor $doctor,
        public DateTimeImmutable $day,
        public array $doctorDaySlots,
    ) {
    }
}

Two calculated objects that do not correspond to any table: a time slot and its potential appointment on a given day; a doctor’s calendar for a single day, listing their available time slots. These are [readonly] classes (example 03), whose properties are declared in the constructor. The comment [/** @param list<DoctorDaySlot> ... */] specifies the contents of the array: [PHP] cannot determine the type of an array’s contents, but editors ([PHP Intelephense]) and analysis tools read these comments, known as [PHPDoc].

11.10. The DAO layer

The DAO layer (Example 28) groups together the queries: one class per table, with no business logic. Its methods do not start a transaction; the business layer calls them within its own transactions. They are written in DQL (Example 28), or using the query builder in [Doctrine], which generates a DQL query through method calls; the values are query parameters: SQL injection is not possible.

11.10.1. src/Dao/PersonDao.php

<?php
// =========================================================================
// src/Dao/PersonDao.php: the DAO layer common to the doctors and the clients.
//
// The DAOs of the doctors and of the clients would be identical: this ABSTRACT class
// writes them only once. $this->entityClass is the class of the entity concerned
// (Doctor::class or Client::class): the DQL queries are about ITS table.
//
// The queries are written in DQL, Doctrine's query language (see
// example 28), which Doctrine translates into SQL. The values (first name, last name...) are
// PARAMETERS of the query (:firstName): no string concatenation, therefore no
// SQL injection. These methods open no transaction: it is the business
// layer that calls them, inside its own.
// =========================================================================

namespace App\Dao;

use App\Entity\Person;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\QueryBuilder;

abstract class PersonDao
{
    /** @param class-string<Person> $entityClass */
    public function __construct(protected EntityManagerInterface $em, protected string $entityClass)
    {
    }

    // -----------------------------------------------------------------------
    // a query on the table of the class, user account included. Doctrine's
    // QUERY BUILDER (createQueryBuilder) builds the DQL query
    // through method calls:
    //   SELECT p, a FROM App\Entity\Doctor p JOIN p.account a
    // JOIN p.account a: the SQL join with the [users] table; SELECT p, a: the
    // accounts are loaded BY THE SAME query. Without it, each access to
    // $person->getAccount() would trigger its own query
    // -----------------------------------------------------------------------
    protected function query(): QueryBuilder
    {
        return $this->em->createQueryBuilder()
            ->select('p', 'a')
            ->from($this->entityClass, 'p')
            ->join('p.account', 'a');
    }

    // list sorted by last name then first name
    /** @return list<Person> */
    public function list(): array
    {
        return $this->query()->orderBy('p.lastName')->addOrderBy('p.firstName')->getQuery()->getResult();
    }

    // a person and their account; null if they do not exist
    public function read(int $id): ?Person
    {
        return $this->query()->where('p.id = :id')->setParameter('id', $id)
            ->getQuery()->getOneOrNullResult();
    }

    // the person whose user account is [$userId]
    public function readByUser(int $userId): ?Person
    {
        return $this->query()->where('a.id = :userId')->setParameter('userId', $userId)
            ->getQuery()->getOneOrNullResult();
    }

    // the person who has these first and last names, if there is one. The comparison is
    // made by MySQL, therefore with the collation of the database (utf8mb4_unicode_ci: regardless
    // of case and accents) - exactly the same rule as
    // that of the SQL uniqueness constraint
    public function namesake(string $firstName, string $lastName): ?Person
    {
        return $this->query()->where('p.firstName = :firstName')->andWhere('p.lastName = :lastName')
            ->setParameter('firstName', $firstName)->setParameter('lastName', $lastName)
            ->setMaxResults(1)->getQuery()->getOneOrNullResult();
    }

    // the person will be inserted by the next flush (that of the transaction of
    // the business layer); the database will generate their id, which Doctrine will store in the object
    public function add(Person $person): void
    {
        $this->em->persist($person);
    }

    // the person will be deleted by the next flush
    public function remove(Person $person): void
    {
        $this->em->remove($person);
    }
}

The DAO layer common to both doctors and clients, implemented once in an abstract class.

  • Line 25: The constructor receives the entity manager (Example 28) and the relevant class, [Doctor::class] or [Client::class] (a string representing the full class name). The comment [@param class-string<Person>] clarifies this for developers. The service container does not know which string to provide: it is the child classes that call this constructor;
  • lines 38–44: a query that also loads the accounts of the read users. [createQueryBuilder] creates a query builder; [select('p', 'a')], [from($this->entityClass, 'p')], and [join('p.account', 'a')] write the query DQL [SELECT p, a FROM App\Entity\Doctor p JOIN p.account a]. The join ([JOIN]) follows the relationship [account] of the entity: [Doctrine] derives the condition SQL ([ON d0_.user = u1_.id]). The [SELECT] for both aliases requires eager loading of the accounts: a single query retrieves the people and their accounts. Without it, the list of doctors displayed by the administrator would trigger one query per doctor upon the first access to [$doctor->getAccount()->getLogin()]: this is the so-called “N + 1 queries” problem. Another reason: the lists of doctors and clients are stored in the cache ([ApplicationModel], discussed later); the objects added to the cache must be complete;
  • lines 48–51: the list, sorted by last name then first name ([orderBy], then [addOrderBy]: a second criterion; a second [orderBy] would replace the first). [getQuery()] returns the query DQL; [getResult()] executes it and returns the array of entities;
  • lines 54–58: [where] adds a condition with a named parameter ([:id]), whose value is provided by [setParameter]. [getOneOrNullResult] returns the single entity found, or [null];
  • lines 61–65: the person whose account is [$userId], condition based on the linked account ID;
  • lines 71–76: the person with the same first and last names. [andWhere] adds a second condition (a second [where] would replace the first). The comparison is performed by [MySQL], using the database’s collation: case-insensitive and ignoring accents, exactly like the uniqueness constraint (Example 28);
  • lines 80–89: the insertion and deletion, which will be sent to the database by [flush] as part of the business layer transaction (Example 28).

There is no update method: the business layer modifies the read entity, and [Doctrine] will send [UPDATE] to [flush] (change tracking, Example 28), using an optimistic lock.

11.10.2. src/Dao/DoctorDao.php and ClientDao.php

<?php
// src/Dao/DoctorDao.php: the DAO of the doctors - everything is inherited from PersonDao.

namespace App\Dao;

use App\Entity\Doctor;
use Doctrine\ORM\EntityManagerInterface;

class DoctorDao extends PersonDao
{
    public function __construct(EntityManagerInterface $em)
    {
        parent::__construct($em, Doctor::class);
    }
}

Everything is inherited: the constructor receives the container’s entity manager and passes it to [PersonDao] along with the entity class ([parent::__construct] calls the parent class’s constructor).

<?php
// src/Dao/ClientDao.php: the DAO of the clients - that of the persons, plus a
// locking method.

namespace App\Dao;

use App\Entity\Client;
use Doctrine\DBAL\LockMode;
use Doctrine\ORM\EntityManagerInterface;

class ClientDao extends PersonDao
{
    public function __construct(EntityManagerInterface $em)
    {
        parent::__construct($em, Client::class);
    }

    // -----------------------------------------------------------------------
    // puts a LOCK on a client's row until the end of the current transaction
    // (SELECT ... FOR UPDATE), and returns its id (null if the client does not
    // exist). A second transaction asking for the same lock WAITS until the
    // first one is finished. LockMode::PESSIMISTIC_WRITE: Doctrine's "pessimistic"
    // lock, which requires an open transaction.
    // Used for booking (see BusinessService::addAppointment): two
    // simultaneous bookings for the same patient are thus processed one
    // AFTER the other, and the second one sees the appointment created by the first one.
    // -----------------------------------------------------------------------
    public function lock(int $id): ?int
    {
        $row = $this->em->createQuery('SELECT c.id FROM App\Entity\Client c WHERE c.id = :id')
            ->setParameter('id', $id)
            ->setLockMode(LockMode::PESSIMISTIC_WRITE)
            ->getOneOrNullResult();
        return $row['id'] ?? null;
    }
}
  • Lines 28–35: a pessimistic lock, the other type of lock. [setLockMode(LockMode::PESSIMISTIC_WRITE)] adds [FOR UPDATE] to the query: [SELECT c0_.id AS id_0 FROM clients c0_ WHERE c0_.id = ? FOR UPDATE]. The row being read is locked until the end of the current transaction: another transaction requesting the same lock waits for the first one to finish. [Doctrine] denies this lock outside of a transaction. The query reads only the ID ([SELECT c.id]): its result is not an entity, but an associative array ([['id' => 3]]), or [null]. We will see, in the business layer, what this lock is used for.

11.10.3. src/Dao/SlotDao.php

<?php
// src/Dao/SlotDao.php: DAO layer of the time slots.

namespace App\Dao;

use App\Entity\Slot;
use Doctrine\ORM\EntityManagerInterface;

class SlotDao
{
    public function __construct(private EntityManagerInterface $em)
    {
    }

    // a slot and its doctor
    public function read(int $id): ?Slot
    {
        return $this->em->createQuery(
            'SELECT s, d FROM App\Entity\Slot s JOIN s.doctor d WHERE s.id = :id')
            ->setParameter('id', $id)->getOneOrNullResult();
    }

    // the slots of a doctor, in chronological order. s.doctor = :doctorId:
    // an association compared to an id, that of the foreign key
    /** @return list<Slot> */
    public function listByDoctor(int $doctorId): array
    {
        return $this->em->createQuery(
            'SELECT s FROM App\Entity\Slot s WHERE s.doctor = :doctorId ORDER BY s.startHour, s.startMinute')
            ->setParameter('doctorId', $doctorId)->getResult();
    }

    // deletes all the slots of a doctor (deletion of a doctor): a
    // DQL DELETE query, executed right away:
    //   DELETE FROM slots WHERE doctor_id = ?
    public function deleteByDoctor(int $doctorId): void
    {
        $this->em->createQuery('DELETE FROM App\Entity\Slot s WHERE s.doctor = :doctorId')
            ->setParameter('doctorId', $doctorId)->execute();
    }
}

An appointment slot and its doctor (preloaded); a doctor’s appointment slots, in chronological order: [s.doctor = :doctorId] compares a relationship to an ID—that of the foreign key; and a [DELETE] written as DQL, executed immediately ([execute()]), without reading the rows first.

11.10.4. src/Dao/AppointmentDao.php

<?php
// =========================================================================
// src/Dao/AppointmentDao.php: DAO layer of the appointments.
//
// The [appointments] table does not reference the doctor directly: we go through the slot
// (JOIN a.slot s ... WHERE s.doctor = ...).
// The dates are passed with their type (Types::DATE_IMMUTABLE): Doctrine
// writes them in the format of the database, yyyy-mm-dd.
// =========================================================================

namespace App\Dao;

use App\Entity\Appointment;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityManagerInterface;

class AppointmentDao
{
    public function __construct(private EntityManagerInterface $em)
    {
    }

    // an appointment, its client and its slot
    public function read(int $id): ?Appointment
    {
        return $this->em->createQuery(
            'SELECT a, c, s FROM App\Entity\Appointment a JOIN a.client c JOIN a.slot s WHERE a.id = :id')
            ->setParameter('id', $id)->getOneOrNullResult();
    }

    // the appointments of a doctor on a given day, with their client
    /** @return list<Appointment> */
    public function doctorDayAppointments(int $doctorId, DateTimeImmutable $day): array
    {
        return $this->em->createQuery(
            'SELECT a, c FROM App\Entity\Appointment a JOIN a.client c JOIN a.slot s
             WHERE a.day = :day AND s.doctor = :doctorId')
            ->setParameter('day', $day, Types::DATE_IMMUTABLE)
            ->setParameter('doctorId', $doctorId)
            ->getResult();
    }

    // -----------------------------------------------------------------------
    // the appointments of a client from the day [$since] (included), in chronological
    // order, with the slot AND the doctor of that slot (two joins:
    // JOIN a.slot s JOIN s.doctor d, and SELECT a, s, d)
    // -----------------------------------------------------------------------
    /** @return list<Appointment> */
    public function clientUpcomingAppointments(int $clientId, DateTimeImmutable $since): array
    {
        return $this->em->createQuery(
            'SELECT a, s, d FROM App\Entity\Appointment a JOIN a.slot s JOIN s.doctor d
             WHERE a.client = :clientId AND a.day >= :since
             ORDER BY a.day, s.startHour, s.startMinute')
            ->setParameter('clientId', $clientId)
            ->setParameter('since', $since, Types::DATE_IMMUTABLE)
            ->getResult();
    }

    // number of appointments of a client from the day [$since] (included);
    // getSingleScalarResult: the only value of the only row of the result
    public function countClientUpcoming(int $clientId, DateTimeImmutable $since): int
    {
        return (int) $this->em->createQuery(
            'SELECT COUNT(a.id) FROM App\Entity\Appointment a WHERE a.client = :clientId AND a.day >= :since')
            ->setParameter('clientId', $clientId)
            ->setParameter('since', $since, Types::DATE_IMMUTABLE)
            ->getSingleScalarResult();
    }

    // number of appointments (past or future) of a doctor
    public function countByDoctor(int $doctorId): int
    {
        return (int) $this->em->createQuery(
            'SELECT COUNT(a.id) FROM App\Entity\Appointment a JOIN a.slot s WHERE s.doctor = :doctorId')
            ->setParameter('doctorId', $doctorId)
            ->getSingleScalarResult();
    }

    // the appointment will be inserted by the next flush
    public function add(Appointment $appointment): void
    {
        $this->em->persist($appointment);
    }

    public function remove(Appointment $appointment): void
    {
        $this->em->remove($appointment);
    }

    // deletes ALL the appointments (past and future) of a client, right away
    public function deleteByClient(int $clientId): void
    {
        $this->em->createQuery('DELETE FROM App\Entity\Appointment a WHERE a.client = :clientId')
            ->setParameter('clientId', $clientId)->execute();
    }
}

The table [appointments] does not directly reference the doctor: it goes through the time slot.

  • Lines 34–42: a doctor’s appointments on a given day, and their clients. Two joins: one ([a.client c]) to load the clients ([SELECT a, c]), the other ([a.slot s]) for the filter on the doctor. The third parameter of [setParameter] specifies the value type [Doctrine]: a [DateTimeImmutable] is then written in the column format [DATE], [yyyy-mm-dd];
  • lines 50–59: a patient’s upcoming appointments, with their time slot and the doctor assigned to that slot: [JOIN s.doctor d] follows a relationship from the relation. Sorting is also performed based on the time slot, a column from another table: this is made possible by the join;
  • lines 63–79: counts. [COUNT(a.id)] counts the appointments; [getSingleScalarResult] returns the single value from the single row in the result (a string, converted to an integer by [(int)]);
  • lines 93–97: Deleting all appointments for a client, using DQL.

11.10.5. src/Dao/UserDao.php

<?php
// src/Dao/UserDao.php: DAO layer of the user accounts. Creating (or deleting)
// a doctor or a client also creates (or deletes) their account - see src/Business.
// A modified account is modified through its methods (setLogin...): Doctrine will send
// the UPDATE at the next flush (change tracking, see example 28).

namespace App\Dao;

use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;

class UserDao
{
    public function __construct(private EntityManagerInterface $em)
    {
    }

    // null if no user has this login; findOneBy: the repository of the entity
    // looks for the row whose columns have these values
    public function readByLogin(string $login): ?User
    {
        return $this->em->getRepository(User::class)->findOneBy(['login' => $login]);
    }

    public function add(User $user): void
    {
        $this->em->persist($user);
    }

    public function remove(User $user): void
    {
        $this->em->remove($user);
    }
}

Accounts: retrieval by login, with entity storage (example 28): [findOneBy(criteria)] returns the single entity that matches the criteria, or [null]; addition and deletion.

11.11. The Business Layer

The business layer brings together business rules and transactions (Example 28). It is described by an interface, implemented by two classes: [BusinessService], the business layer itself, and [ApplicationModel], the web layer cache (discussed later).

11.11.1. src/Business/Dates.php

<?php
// src/Business/Dates.php: today's date. It is used by BUSINESS RULES (no
// booking in the past, only one UPCOMING appointment per patient): it therefore
// lives in the business layer. The web layer reuses it.

namespace App\Business;

use DateTimeImmutable;

class Dates
{
    // today at midnight, in the application's time zone (see src/Kernel.php)
    public static function today(): DateTimeImmutable
    {
        return new DateTimeImmutable('today');
    }
}

The current date at midnight (['today']), in the application’s time zone (see [src/Kernel.php]). It is used for business rules (no past reservations, only one upcoming appointment): it resides in the business layer. A static method ([static]) is called without creating an object: [Dates::today()].

11.11.2. src/Business/BusinessException.php

<?php
// =========================================================================
// src/Business/BusinessException.php: the exception thrown by the business layer when a
// BUSINESS RULE is violated (login already taken, doctor who still has
// appointments, slot already booked...). It does not carry a text, but a
// translation KEY (e.g. "ERRORS.LOGIN_TAKEN"): it is the web layer that will
// translate it into French or English when displaying the page (see translations).
//
// A BusinessException is not an anomaly: it is a normal answer ("this login
// is already taken"), displayed to the user. Any other exception is a real
// anomaly, which ends up on the 500 error page.
// =========================================================================

namespace App\Business;

use RuntimeException;
use Throwable;

class BusinessException extends RuntimeException
{
    public function __construct(
        // translation key of the message
        public readonly string $key,
        // possible parameters of the message ({minutes}...)
        public readonly array $params = [],
        ?Throwable $previous = null,
    ) {
        parent::__construct($key, 0, $previous);
    }
}

The exception thrown when a business rule is violated. It does not carry text, but rather a translation key ([ERRORS.LOGIN_TAKEN]) and its parameters: the web layer will translate it into the page’s language (Example 22). The business layer knows nothing about the interface languages. [RuntimeException] is a subclass of [PHP]. The constructor properties [public readonly] are declared and initialized by the constructor promotion (Example 03); [parent::__construct($key, 0, $previous)] calls the constructor of [RuntimeException], passing the message, the code, and the previous exception (the one that caused this one—a database error, for example: it appears in the log along with the new one).

11.11.3. src/Business/PersonData.php

<?php
// src/Business/PersonData.php: the data entered in a "doctor" or "client" form,
// as the web layer passes them to the business layer.

namespace App\Business;

readonly class PersonData
{
    public function __construct(
        public string $title,        // Mr, Mme, Melle
        public string $lastName,
        public string $firstName,
        // creation: optional (null), defaults to the last name in lower case
        // modification: optional, the login is unchanged if it is absent
        public ?string $login = null,
        // creation: optional, defaults to the login
        // modification: optional, the password is unchanged if it is absent
        public ?string $password = null,
        // modification: the version of the record when the form was
        // displayed (optimistic lock)
        public ?int $version = null,
    ) {
    }
}

Data from a “person” form, as transmitted by the web layer to the business layer: a class named [readonly]. The login, password, and version are optional ([?string], [?int], default value [null]); the comments explain what their absence means.

11.11.4. src/Business/Business.php

<?php
// =========================================================================
// src/Business/Business.php: the CONTRACT of the business layer: reads, schedule,
// appointments, management of doctors and clients.
//
// An INTERFACE describes the methods a class must have, without saying how
// they are written. BusinessService (the business layer) and ApplicationModel (the cache
// of the web layer) both implement it; PHP checks, when loading
// the class, that no method is missing.
//
// ?Type: a result that may be absent (null: a doctor who does not exist...).
// list<Doctor> (in a /** */ comment): an array of doctors, indexed 0, 1, 2...
// =========================================================================

namespace App\Business;

use App\Domain\DoctorDaySchedule;
use App\Entity\Client;
use App\Entity\Slot;
use App\Entity\Doctor;
use App\Entity\Appointment;
use DateTimeImmutable;

interface Business
{
    // ------------------------------------------------ reads
    /** @return list<Client> */
    public function getAllClients(): array;

    /** @return list<Doctor> */
    public function getAllDoctors(): array;

    /** @return list<Slot> */
    public function getAllSlots(int $doctorId): array;

    /** @return list<Appointment> */
    public function getDoctorDayAppointments(int $doctorId, DateTimeImmutable $day): array;

    public function getClientById(int $id): ?Client;

    public function getDoctorById(int $id): ?Doctor;

    public function getAppointmentById(int $id): ?Appointment;

    public function getSlotById(int $id): ?Slot;

    public function getDoctorDaySchedule(int $doctorId, DateTimeImmutable $day): ?DoctorDaySchedule;

    // the client (patient) whose user account is [$userId]
    public function getClientByUserId(int $userId): ?Client;

    // the appointments of a client, today's or future ones, with slot and doctor
    /** @return list<Appointment> */
    public function getClientUpcomingAppointments(int $clientId): array;

    // ------------------------------------------------ appointments (ADMIN, DOCTOR, USER)
    // throws a BusinessException if:
    //  - the day is past ("ERRORS.PAST_DATE");
    //  - the patient already has an upcoming appointment ("ERRORS.PATIENT_HAS_UPCOMING_APPOINTMENT");
    //  - the slot is already taken on that day ("ERRORS.SLOT_ALREADY_BOOKED").
    public function addAppointment(DateTimeImmutable $day, Slot $slot, Client $client): Appointment;

    public function deleteAppointment(Appointment $appointment): void;

    // ------------------------------------------------ doctors (ADMIN)
    // creates the doctor AND their user account (role DOCTOR)
    public function addDoctor(PersonData $data): Doctor;

    // modifies the doctor AND their user account
    public function editDoctor(int $id, PersonData $data): Doctor;

    // deletes the doctor, their slots AND their account - refused if they have appointments
    public function deleteDoctor(int $id): void;

    // ------------------------------------------------ clients
    // (the ADMIN; addClient is also used for the registration of a patient,
    // editClient and deleteClient by the patient for their own account)
    // creates the client AND their user account (role USER)
    public function addClient(PersonData $data): Client;

    public function editClient(int $id, PersonData $data): Client;

    // deletes the client, their account AND all their appointments (past and future)
    public function deleteClient(int $id): void;
}

The business layer contract: an interface (example 06), the list of methods, without their code. Read operations return entities or arrays of entities (the comment [@return list<Client>] specifies their content); [?Client]: a client, or [null] if it does not exist. The comments specify the rules for each write operation and the keys of the [BusinessException] entities it may raise.

11.11.5. src/Business/Descriptor.php

<?php
// src/Business/Descriptor.php: everything that distinguishes a doctor from a client for the
// business rules (see BusinessService). Doctors and clients follow the
// same rules: the common code is written ONCE, and parameterized by a descriptor.

namespace App\Business;

use App\Dao\PersonDao;
use App\Entity\Role;
use Closure;

readonly class Descriptor
{
    public function __construct(
        public PersonDao $dao,
        public Role $role,              // DOCTOR or USER
        // the class of the entity, to create a person: Doctor::class or Client::class
        public string $entityClass,
        public string $notFoundKey,     // message if the person does not exist
        public string $alreadyExistsKey, // message if the same first and last names already exist
        // optional (null): if present, the deletion is REFUSED as long as the
        // person has appointments (countAppointments(id) > 0), with the message hasAppointmentsKey
        public ?Closure $countAppointments,
        public ?string $hasAppointmentsKey,
        // optional: what must be deleted BEFORE the person (the rows that
        // reference them through a foreign key); receives their id
        public ?Closure $beforeDelete,
    ) {
    }
}

Doctors and clients follow the same management rules, with a few minor differences: the DAO, the account role, the class of the entity to be created, the error messages, and the deletion rule. This descriptor consolidates these details; the common code reads them. Two properties are functions ([?Closure]: a function, or [null]): the appointment count, which prevents deletion (doctors only), and the deletion of references to the person.

11.11.6. src/Business/BusinessService.php

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
<?php
// =========================================================================
// src/Business/BusinessService.php: the implementation of the business layer.
//
// The management of doctors and clients obeys three business rules:
//
//   1. a person (doctor or client) and their user account ALWAYS go
//      together: they are created, modified and deleted together, in the same
//      TRANSACTION - either everything succeeds, or nothing is written;
//   2. the login is unique in the whole application, and two doctors (or two
//      clients) cannot have the same first and last names;
//   3. deletion:
//      - a DOCTOR who has appointments cannot be deleted (their patients
//        would lose their appointment without being notified): they must first
//        be cancelled. Their time slots disappear with them;
//      - a CLIENT, on the other hand, disappears with ALL their appointments (past and future) -
//        whether the deletion comes from the ADMIN or from the patient themselves.
//
// And two rules for booking, whatever the role of the person who
// books (ADMIN, DOCTOR or the patient themselves):
//   4. no appointment in the past;
//   5. a patient has only ONE upcoming appointment at a time.
//
// Doctors and clients follow exactly the same rules: the common code is
// written ONCE (private methods xxxPerson), and parameterized by a descriptor
// that says which type of person we are dealing with.
//
// The TRANSACTIONS are opened by the transaction method (at the end of the file),
// which also translates into BusinessException the errors reported by the database.
// =========================================================================

namespace App\Business;

use App\Dao\ClientDao;
use App\Dao\SlotDao;
use App\Dao\DoctorDao;
use App\Dao\AppointmentDao;
use App\Dao\UserDao;
use App\Domain\DoctorDaySchedule;
use App\Domain\DoctorDaySlot;
use App\Entity\Client;
use App\Entity\Slot;
use App\Entity\Doctor;
use App\Entity\Person;
use App\Entity\Role;
use App\Entity\Appointment;
use App\Entity\User;
use Closure;
use DateTimeImmutable;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\OptimisticLockException;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
use Symfony\Component\PasswordHasher\PasswordHasherInterface;

class BusinessService implements Business
{
    // -----------------------------------------------------------------------
    // Database errors -> BusinessException
    //
    // When a UNIQUE constraint refuses a write, Doctrine throws a
    // UniqueConstraintViolationException, whose message is that of MySQL. It
    // quotes the NAME of the constraint:
    //   Duplicate entry 'Marie-PELISSIER' for key 'doctors.unq_doctors_first_name_last_name'
    // (MySQL 8 prefixes the name with that of the table, MariaDB does not). That is why
    // the SQL script NAMES all its constraints.
    // -----------------------------------------------------------------------
    private const CONSTRAINT_MESSAGES = [
        'unq_users_login' => 'ERRORS.LOGIN_TAKEN',
        'unq_doctors_first_name_last_name' => 'ERRORS.DOCTOR_ALREADY_EXISTS',
        'unq_clients_first_name_last_name' => 'ERRORS.PATIENT_ALREADY_EXISTS',
        'unq1_appointments' => 'ERRORS.SLOT_ALREADY_BOOKED',
    ];

    private const CONSTRAINT = "/for key '(?:[^'.]*\\.)?([^']+)'/";

    private Descriptor $doctor;

    private Descriptor $client;

    // the hashing of passwords (bcrypt, see config/packages/security.yaml)
    private PasswordHasherInterface $hasher;

    // all the dependencies are provided by Symfony's service container
    // (dependency injection, see example 06)
    public function __construct(
        private EntityManagerInterface $em,
        private DoctorDao $doctors,
        private ClientDao $clients,
        private SlotDao $slots,
        private AppointmentDao $appointments,
        private UserDao $users,
        PasswordHasherFactoryInterface $hashers,
    ) {
        $this->hasher = $hashers->getPasswordHasher(User::class);

        $this->doctor = new Descriptor(
            dao: $doctors,
            role: Role::DOCTOR,
            entityClass: Doctor::class,
            notFoundKey: 'ERRORS.DOCTOR_NOT_FOUND',
            alreadyExistsKey: 'ERRORS.DOCTOR_ALREADY_EXISTS',
            // $appointments->countByDoctor(...): the method itself, as a Closure
            // (so-called "first-class callable" syntax)
            countAppointments: $appointments->countByDoctor(...),
            hasAppointmentsKey: 'ERRORS.DOCTOR_HAS_APPOINTMENTS',
            // a doctor without appointments may still have time slots:
            // they make no sense without them, they are deleted with them (otherwise the
            // foreign key slots.doctor_id would forbid the deletion)
            beforeDelete: $slots->deleteByDoctor(...));

        $this->client = new Descriptor(
            dao: $clients,
            role: Role::USER,
            entityClass: Client::class,
            notFoundKey: 'ERRORS.PATIENT_NOT_FOUND',
            alreadyExistsKey: 'ERRORS.PATIENT_ALREADY_EXISTS',
            // no countAppointments: the deletion of a client is never refused;
            // their appointments (past and future) are deleted with them (otherwise
            // the foreign key appointments.client_id would forbid the deletion)
            countAppointments: null,
            hasAppointmentsKey: null,
            beforeDelete: $appointments->deleteByClient(...));
    }

    // =======================================================================
    // reads
    // =======================================================================

    public function getAllClients(): array
    {
        return $this->clients->list();
    }

    public function getAllDoctors(): array
    {
        return $this->doctors->list();
    }

    public function getAllSlots(int $doctorId): array
    {
        return $this->slots->listByDoctor($doctorId);
    }

    public function getDoctorDayAppointments(int $doctorId, DateTimeImmutable $day): array
    {
        return $this->appointments->doctorDayAppointments($doctorId, $day);
    }

    public function getClientById(int $id): ?Client
    {
        return $this->clients->read($id);
    }

    public function getDoctorById(int $id): ?Doctor
    {
        return $this->doctors->read($id);
    }

    public function getAppointmentById(int $id): ?Appointment
    {
        return $this->appointments->read($id);
    }

    public function getSlotById(int $id): ?Slot
    {
        return $this->slots->read($id);
    }

    public function getClientByUserId(int $userId): ?Client
    {
        return $this->clients->readByUser($userId);
    }

    public function getClientUpcomingAppointments(int $clientId): array
    {
        return $this->appointments->clientUpcomingAppointments($clientId, Dates::today());
    }

    // the schedule of a doctor on a given day: their slots, and for each one the
    // appointment booked on that day (null if it is free)
    public function getDoctorDaySchedule(int $doctorId, DateTimeImmutable $day): ?DoctorDaySchedule
    {
        $doctor = $this->getDoctorById($doctorId);
        if ($doctor === null) {
            return null;
        }
        // slot number -> appointment booked on that slot
        $bookings = [];
        foreach ($this->getDoctorDayAppointments($doctorId, $day) as $appointment) {
            $bookings[$appointment->getSlot()->getId()] = $appointment;
        }
        // array_map: applies the function to each slot, and returns the array of the results
        $doctorDaySlots = array_map(
            fn (Slot $slot) => new DoctorDaySlot($slot, $bookings[$slot->getId()] ?? null),
            $this->getAllSlots($doctorId));
        return new DoctorDaySchedule($doctor, $day, $doctorDaySlots);
    }

    // =======================================================================
    // appointments
    // =======================================================================

    // -----------------------------------------------------------------------
    // booking, in a transaction:
    //   1. no appointment in the past;
    //   2. we LOCK the patient's row (SELECT ... FOR UPDATE): if the same
    //      patient books two slots at the same moment (two tabs, double
    //      click...), the second transaction waits for the end of the first one - and then sees
    //      the appointment it has just created;
    //   3. only one upcoming appointment per patient;
    //   4. finally the insertion. If the slot has just been taken by ANOTHER patient,
    //      it is the SQL constraint unq1_appointments (day, slot_id) that refuses
    //      the insertion - translated into BusinessException by transaction().
    // -----------------------------------------------------------------------
    public function addAppointment(DateTimeImmutable $day, Slot $slot, Client $client): Appointment
    {
        return $this->transaction(function () use ($day, $slot, $client) {
            $today = Dates::today();
            if ($day < $today) {
                throw new BusinessException('ERRORS.PAST_DATE');
            }
            $patientId = $this->clients->lock($client->getId())
                ?? throw new BusinessException('ERRORS.PATIENT_NOT_FOUND');
            if ($this->appointments->countClientUpcoming($patientId, $today) > 0) {
                throw new BusinessException('ERRORS.PATIENT_HAS_UPCOMING_APPOINTMENT');
            }
            $appointment = new Appointment($day, $slot, $client);
            $this->appointments->add($appointment);
            return $appointment;
        });
    }

    public function deleteAppointment(Appointment $appointment): void
    {
        $this->transaction(fn () => $this->appointments->remove($appointment));
    }

    // =======================================================================
    // doctors and clients - the public methods delegate to the common private
    // methods, with the right descriptor
    // =======================================================================

    public function addDoctor(PersonData $data): Doctor
    {
        return $this->addPerson($this->doctor, $data);
    }

    public function editDoctor(int $id, PersonData $data): Doctor
    {
        return $this->editPerson($this->doctor, $id, $data);
    }

    public function deleteDoctor(int $id): void
    {
        $this->deletePerson($this->doctor, $id);
    }

    public function addClient(PersonData $data): Client
    {
        return $this->addPerson($this->client, $data);
    }

    public function editClient(int $id, PersonData $data): Client
    {
        return $this->editPerson($this->client, $id, $data);
    }

    // the client, their account AND all their appointments (ADMIN, or the patient themselves
    // from "Manage my account": same method, same rule)
    public function deleteClient(int $id): void
    {
        $this->deletePerson($this->client, $id);
    }

    // -----------------------------------------------------------------------
    // creation: the user account AND the person (who references it), in ONE
    // transaction. Doctrine first inserts the account, then the person, whose foreign
    // key [user] receives the id the database has just generated for the account.
    // -----------------------------------------------------------------------
    private function addPerson(Descriptor $d, PersonData $data): Person
    {
        $lastName = mb_strtoupper(trim($data->lastName));
        $firstName = trim($data->firstName);
        // the rules of the dbrdvmedecins2 database: login = last name in lower case,
        // password = login - unless the ADMIN (or the patient) entered other ones
        $login = mb_strtolower($data->login ?: $lastName);
        $password = $data->password ?: $login;

        return $this->transaction(function () use ($d, $data, $lastName, $firstName, $login, $password) {
            $this->checkPersonUnique($d, $firstName, $lastName, null);
            $this->checkLoginFree($login);
            // hash: the bcrypt hash of the password
            $user = new User($login, $this->hasher->hash($password), "$firstName $lastName", $d->role);
            $this->users->add($user);
            // new ($d->entityClass)(...): an instance of the class whose name is in the
            // property [entityClass] of the descriptor (Doctor or Client)
            $person = new ($d->entityClass)($data->title, $lastName, $firstName, $user);
            $d->dao->add($person);
            return $person;
        });
    }

    // -----------------------------------------------------------------------
    // modification: the person AND their account (the displayed name "firstName LASTNAME" must
    // follow; the login and the password only change if they have been entered).
    // -----------------------------------------------------------------------
    private function editPerson(Descriptor $d, int $id, PersonData $data): Person
    {
        return $this->transaction(function () use ($d, $id, $data) {
            $person = $d->dao->read($id) ?? throw new BusinessException($d->notFoundKey);

            // optimistic lock: if the version in the database is no longer that of the
            // form, someone has modified this record in the meantime - we refuse
            // rather than silently overwrite their changes
            if ($data->version !== null && $data->version !== $person->getVersion()) {
                throw new BusinessException('ERRORS.CONCURRENT_UPDATE');
            }

            $lastName = mb_strtoupper(trim($data->lastName));
            $firstName = trim($data->firstName);
            $this->checkPersonUnique($d, $firstName, $lastName, $id);
            $person->edit($data->title, $lastName, $firstName);

            // their account: the displayed name, and, if they have been entered, the login and the
            // password
            $account = $person->getAccount();
            $account->setName("$firstName $lastName");
            $login = $data->login ? mb_strtolower($data->login) : null;
            if ($login !== null && $login !== $account->getLogin()) {
                $this->checkLoginFree($login);
                $account->setLogin($login);
            }
            if ($data->password) {
                $account->setPassword($this->hasher->hash($data->password));
            }
            // nothing else to do: at the end of the transaction, Doctrine sends the
            // UPDATEs of the modified rows, with their optimistic lock
            //   UPDATE ... SET ..., version = version + 1 WHERE id = ? AND version = ?
            // If the record has been modified AFTER the read above, no row
            // is modified, and Doctrine throws an OptimisticLockException
            return $person;
        });
    }

    // -----------------------------------------------------------------------
    // deletion, in ONE transaction, in the order imposed by the foreign
    // keys:
    //   1. possible refusal if appointments remain (doctor);
    //   2. what references the person (slots of a doctor, appointments of a
    //      client), right away;
    //   3. the person (they reference their account), then their account: Doctrine
    //      deletes them in that order at the end of the transaction.
    // -----------------------------------------------------------------------
    private function deletePerson(Descriptor $d, int $id): void
    {
        $this->transaction(function () use ($d, $id) {
            $person = $d->dao->read($id) ?? throw new BusinessException($d->notFoundKey);
            if ($d->countAppointments !== null && ($d->countAppointments)($id) > 0) {
                throw new BusinessException($d->hasAppointmentsKey);
            }
            if ($d->beforeDelete !== null) {
                ($d->beforeDelete)($id);
            }
            $d->dao->remove($person);
            $this->users->remove($person->getAccount());
        });
    }

    // refuses first and last names already used by ANOTHER person of the same type
    // ($currentId: the person being modified, who of course has the right
    // to keep their own first and last names; null for a creation)
    private function checkPersonUnique(Descriptor $d, string $firstName, string $lastName, ?int $currentId): void
    {
        $namesake = $d->dao->namesake($firstName, $lastName);
        if ($namesake !== null && $namesake->getId() !== $currentId) {
            throw new BusinessException($d->alreadyExistsKey);
        }
    }

    private function checkLoginFree(string $login): void
    {
        if ($this->users->readByLogin($login) !== null) {
            throw new BusinessException('ERRORS.LOGIN_TAKEN');
        }
    }

    // -----------------------------------------------------------------------
    // executes [$work] in a transaction (wrapInTransaction, see example 28):
    // if the function ends normally, Doctrine sends the pending writes
    // to the database (flush), then commits the transaction (COMMIT); if an
    // exception comes out of it (BusinessException included), ROLLBACK: nothing is written.
    //
    // The "login free" and "unique first + last names" checks are made BEFORE
    // the write, to give a clear message. But two ADMINs could
    // save the same login (or the same doctor) at the same moment: in this
    // rare case, it is an SQL uniqueness constraint that refuses the second write.
    // Likewise, two simultaneous modifications of the same record are decided by the
    // optimistic lock. We therefore catch these errors AROUND the transaction,
    // to translate them into BusinessException. Any other error is a real
    // anomaly: we let it go up.
    // After an exception, Doctrine CLOSES the entity manager: the current request
    // must no longer write anything (the controllers redirect, or
    // redisplay a form).
    // -----------------------------------------------------------------------
    private function transaction(Closure $work): mixed
    {
        try {
            return $this->em->wrapInTransaction($work);
        } catch (UniqueConstraintViolationException $e) {
            if (preg_match(self::CONSTRAINT, $e->getMessage(), $m)
                && isset(self::CONSTRAINT_MESSAGES[$m[1]])) {
                throw new BusinessException(self::CONSTRAINT_MESSAGES[$m[1]], previous: $e);
            }
            throw $e;
        } catch (OptimisticLockException $e) {
            throw new BusinessException('ERRORS.CONCURRENT_UPDATE', previous: $e);
        }
    }
}

The most important file in the application. We’ll go through it by topic.

11.11.6.1. Dependencies and descriptors

Lines 86–124: The constructor receives from the container the entity manager (for transactions), the five DAO instances, and the hash service factory ([PasswordHasherFactoryInterface]), which is asked to provide the one for accounts: [getPasswordHasher(User::class)] provides the service that applies the algorithm chosen for class [User] in [security.yaml] ([bcrypt], 10 iterations). Next come the two descriptors. Named arguments ([dao: $doctors], example 01) make the call readable. [$appointments->countByDoctor(...)] (with three dots in parentheses) has no significance: it is the syntax of [PHP] 8.1 that creates, from a method, a [Closure]—a function that will be called later ([($d->countAppointments)($id)], line 359). A doctor is removed along with their time slots ([deleteByDoctor]), but not if they have appointments; a client is removed along with all their appointments ([deleteByClient]).

11.11.6.2. Read operations and the calendar

The read operations (lines 130–178) delegate to the DAO layer. A doctor’s schedule (lines 182–198) is calculated: the doctor, their time slots, and the appointments for that day. Appointments are stored in an array indexed by slot number: [$bookings[$slot->getId()] ?? null] returns the appointment for a slot, or [null] if the slot is free. [array_map] converts each time slot to [DoctorDaySlot].

11.11.6.3. Scheduling an Appointment

Lines 216–232: four steps, in a single transaction.

  • No past appointments: The [DateTimeImmutable] entries match the usual operators ([<], [>=]...);
  • the patient’s record is locked ([lock], [SELECT ... FOR UPDATE]). Why? The rule “only one upcoming appointment per patient” is verified by a count (third step), then the insertion takes place (fourth step). If the same patient books two time slots at the same time (two tabs, a double-click), both transactions could count zero appointments and then each insert its own: the rule would be violated. With the lock, the second transaction waits for the first one to finish, then counts... one appointment. [?? throw new BusinessException(...)]: if the client no longer exists, [lock] returns [null], and the operator [??] evaluates its second operand, which raises the exception (since [PHP] 8, [throw] is an expression);
  • counting upcoming appointments;
  • insertion, at the end of the transaction. If the time slot has just been taken by another patient, the database constraint [unq1_appointments] rejects the insertion: the method [transaction] translates this error into [BusinessException].

11.11.6.4. Managing Doctors and Patients

The public methods (lines 244–274) call the shared private methods, using the correct descriptor.

  • Lines 281–302: creation. The last name is capitalized ([mb_strtoupper]: the version of [strtoupper] that supports accented letters), and the first name and last name are stripped of unnecessary spaces ([trim]). By default, the login is the last name in lowercase, and the password is the login (as per the rules for demo accounts): [$a ?: $b] is equivalent to [$a] if it is “true” (neither [null] nor an empty string), and [$b] otherwise. In the transaction: the validations (first and last names can be any value, login can be any value), then the account, whose password is hashed ([hash]), then the person, who references the account. [new ($d->entityClass)(...)] creates an object of the class whose name is in the [entityClass] property of the descriptor (parentheses are required around an expression). [Doctrine] will insert the account before the person: it knows the relationships between the entities and deduces the order of the [INSERT] objects. [use (...)]: An ordinary anonymous function ([function () use ($d, ...)]) cannot see the variables of the method that contains it; [use] passes them to it (an arrow function, [fn () =>], sees them on its own, but can only contain an expression);
  • Lines 308–344: the update, with two optimistic locks. The first is a comparison (lines 316–318): the form’s version (hidden field, Example 28) must match that of the record in the database; otherwise, someone modified the record between the time the form was displayed and the time it was submitted. The second is that of [Doctrine]: the [UPDATE] records sent at the end of the transaction carry the condition [WHERE version = ?], which detects a change that occurred between reading and writing ([OptimisticLockException]). The account follows: its displayed name (“firstName LASTNAME”) is always updated; its login and password are updated only if they were entered; its own optimistic lock also protects the row in the [users] table. There is nothing to “save”: the entity being read is tracked by the entity manager, which will write its changes. At the end of the transaction, [Doctrine] stores the new versions of the entities: the caller receives the updated person;
  • lines 355–368: deletion, in the order dictated by the foreign keys: first, the records referencing the person (time slots or appointments), immediately followed by [DELETE] from DAO; then the person, then their account, which [Doctrine] deletes in that order at the end of the transaction. The reverse order would be rejected by the database: a row that is still referenced by a foreign key cannot be deleted;
  • lines 373–387: the person being modified is allowed to retain their own first and last names; [$currentId] is equivalent to [null] for a creation.

11.11.6.5. Database Transactions and Errors

Lines 406–419: All postings use this method. [wrapInTransaction] executes the function within a transaction (Example 28): [flush], then [COMMIT] if it completes normally, and [ROLLBACK] if an exception occurs—including a [BusinessException]: a rule violation in the middle of an operation leaves no trace. The “login available” and “first and last names available” checks are performed before writing, to provide a clear message. However, two administrators could attempt to register the same login at the same time: each finds it available, and the second [INSERT] is rejected by the constraint [unq_users_login]. [Doctrine] then raises a [UniqueConstraintViolationException] (example 28), whose message is that of [MySQL]; it contains the name of the violated constraint:

SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry 'dupont' for key 'users.unq_users_login'

The regular expression on line 75 extracts this name ([MySQL] 8 prefixes it with the table name, [users.unq_users_login], but not [MariaDB]: the part [(?:[^'.]*\.)?] accepts both forms), and the table in lines 68–73 provides the message key. Another constraint violation is a true anomaly: it is retried ([throw $e]) and will result in a 500 error page. Finally, the conflict detected by the optimistic lock on [Doctrine] ([OptimisticLockException]) becomes the same [BusinessException] as the one detected by the version comparison.

Recall a consequence of [wrapInTransaction] (Example 28): after an exception, the entity manager is closed and can no longer write during the current request. The controllers take this into account: after a [BusinessException], they redirect or re-display a form without writing anything else.

11.12. Authentication

The [src/Security] folder includes examples 25 through 27, featuring database users and a few additional security measures. Let’s review the principle (Example 25): the [main] firewall for [config/packages/security.yaml] is stateless ([stateless]); for each request, an authenticator of our own design reads the token JWT from the cookie [rdvmedecins_jwt] and identifies the user; the rules [access_control] then determine access to the pages.

11.12.1. src/Security/ConnectedUser.php

<?php
// =========================================================================
// src/Security/ConnectedUser.php: the logged-in user, as described by
// their JWT token (see example 25). UserInterface: the interface Symfony expects from
// authenticated users ($this->getUser() in a controller, #[CurrentUser],
// app.user and is_granted() in a view...).
//
// The claims of our tokens (see AuthService::tokenCookie):
//   sub   : the id of the user account (JWT convention: "subject"), as text
//   login : the login
//   name  : the displayed name ("Marie PELISSIER"), for the navigation bar
//   role  : ADMIN, DOCTOR or USER
//   iat, exp : creation and expiry dates of the token
//
// This object does NOT come from the database: it is rebuilt, on each request, from
// the token, whose signature guarantees the content.
// =========================================================================

namespace App\Security;

use App\Entity\Role;
use Symfony\Component\Security\Core\User\UserInterface;

readonly class ConnectedUser implements UserInterface
{
    public function __construct(
        public int $userId,
        public string $login,
        public string $name,
        public Role $role,
    ) {
    }

    // the user described by the claims of an already verified token; null if a claim
    // is missing or does not have the expected form. Role::tryFrom: null for an unknown role
    public static function of(array $claims): ?self
    {
        $role = Role::tryFrom((string) ($claims['role'] ?? ''));
        if (! isset($claims['sub'], $claims['login'], $claims['name']) || $role === null
            || ! ctype_digit((string) $claims['sub'])) {
            return null;
        }
        return new self((int) $claims['sub'], (string) $claims['login'], (string) $claims['name'], $role);
    }

    public function isAdmin(): bool
    {
        return $this->role === Role::ADMIN;
    }

    public function isPatient(): bool
    {
        return $this->role === Role::USER;
    }

    // ---- the UserInterface interface: the identifier of the user (their login),
    // and their roles in Symfony's sense (ROLE_ADMIN, ROLE_DOCTOR or ROLE_USER, see
    // the Role enumeration), checked by the access_control rules of
    // config/packages/security.yaml
    public function getUserIdentifier(): string
    {
        return $this->login;
    }

    public function getRoles(): array
    {
        return [$this->role->symfonyRole()];
    }
}

The logged-in user, as described by their token: their account ID, username, name, and role. Like [User] in Example 25, it implements the [UserInterface] interface, which [Symfony] expects from authenticated users. This object does not come from the database: it is reconstructed with each request, based on the token, whose signature guarantees its content. The application therefore does not read the account from the database with each request. The class is [readonly] (Example 08): its public properties cannot be modified after construction.

  • Lines 36–44: the user is described by the claims (claims) of a token that has already been verified, or [null] if one of the claims is missing or does not have the expected format. A properly signed token should never be malformed; however, the secret key could be shared with another application, which might generate different tokens: it costs nothing to be cautious. [Role::tryFrom] returns the role, or [null] for an unknown value; [ctype_digit] verifies that a string consists only of digits;
  • lines 46–54: two questions that views and controllers often ask;
  • Lines 60–68: the [UserInterface] interface. The user’s identifier is their login; their roles, as defined by [Symfony], are limited to a single role, [ROLE_ADMIN], [ROLE_DOCTOR] or [ROLE_USER], calculated by the enumeration [Role]. These are the ones checked by the rules [access_control], the attribute [#[IsGranted]], and the function [is_granted] of the views. (The [eraseCredentials] method, which cleared the plaintext password after login, has been removed from the interface with [Symfony] 8.)

11.12.2. src/Security/AuthService.php

<?php
// =========================================================================
// src/Security/AuthService.php: the authentication logic:
//   - validate()       : checks a login / password pair;
//   - tokenCookie()    : builds the JWT token of a validated user, and the
//                        cookie that carries it;
//   - currentUser()    : the user described by a token.
//
// Where does the JWT token travel? In a COOKIE (see example 25): a browser
// that displays ordinary HTML pages thus presents it on its own, on each
// request. The options of this cookie matter for security:
//   - HttpOnly: the JavaScript of the page cannot read the cookie (an injected
//     script could therefore not steal the token);
//   - SameSite=Lax: the browser does NOT send the cookie with a POST coming from another
//     site (first protection against the so-called CSRF attack; the second one is
//     the anti-CSRF token of the forms);
//   - Secure: in production (HTTPS), the cookie never travels unencrypted;
//   - lifetime: the cookie disappears at the same time as the token expires.
// =========================================================================

namespace App\Security;

use App\Dao\UserDao;
use App\Entity\User;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use RuntimeException;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
use Throwable;

class AuthService
{
    // the name of the cookie that carries the token
    public const COOKIE = 'rdvmedecins_jwt';

    // the settings of the .env file: #[Autowire(env: ...)] (see example 25)
    public function __construct(
        private UserDao $users,
        private PasswordHasherFactoryInterface $hashers,
        #[Autowire(env: 'JWT_SECRET')] private string $secret,
        #[Autowire(env: 'int:JWT_DURATION_MINUTES')] private int $duration,
        #[Autowire(env: 'bool:COOKIE_SECURE')] private bool $cookieSecure,
    ) {
        // HMAC-SHA256 requires a key of at least 256 bits: we say it clearly,
        // rather than letting the library fail further on
        if (strlen($this->secret) < 32) {
            throw new RuntimeException('JWT_SECRET must be at least 32 characters long (see .env).');
        }
    }

    // null if the login does not exist or if the password is wrong. verify hashes the
    // entered password with the "salt" contained in the stored hash, then
    // compares the two results
    public function validate(string $login, string $password): ?User
    {
        $user = $this->users->readByLogin($login);
        if ($user === null
            || ! $this->hashers->getPasswordHasher(User::class)->verify($user->getPassword(), $password)) {
            return null;
        }
        return $user;
    }

    // -----------------------------------------------------------------------
    // the JWT token of [$user], in the cookie that carries it. The token is SIGNED
    // (HMAC-SHA256) with the secret key: the browser can read it (it is not
    // encrypted), but not modify it - any modification would make the signature
    // invalid.
    // -----------------------------------------------------------------------
    public function tokenCookie(User $user): Cookie
    {
        $now = time();
        $claims = [
            'sub' => (string) $user->getId(),
            'login' => $user->getLogin(),
            'name' => $user->getName(),
            'role' => $user->getRole()->value,
            'iat' => $now,
            'exp' => $now + $this->duration * 60,
        ];
        $token = JWT::encode($claims, $this->secret, 'HS256');
        return Cookie::create(self::COOKIE, $token)
            ->withExpires($now + $this->duration * 60)
            ->withSecure($this->cookieSecure)
            ->withHttpOnly(true)
            ->withSameSite(Cookie::SAMESITE_LAX);
    }

    // clears the token cookie: the response carries a cookie of the same name, empty, already expired
    public static function clearCookie(Response $response): Response
    {
        $response->headers->clearCookie(self::COOKIE);
        return $response;
    }

    // -----------------------------------------------------------------------
    // the user described by a token; null if the token is expired, forged or
    // malformed. JWT::decode checks the signature AND the expiry date (claim exp).
    // -----------------------------------------------------------------------
    public function currentUser(string $token): ?ConnectedUser
    {
        try {
            // decode returns an object; (array) converts it into an associative array
            $claims = (array) JWT::decode($token, new Key($this->secret, 'HS256'));
        } catch (Throwable) {
            return null;
        }
        return ConnectedUser::of($claims);
    }
}
  • line 37: the name of the token cookie, a class constant, read by [AuthService::COOKIE];
  • lines 40–52: the service receives the DAO layer for accounts; the hash service factory (the one corresponding to the [User] class, bcrypt, is declared in [security.yaml]), and three settings from the [.env] file, via the [#[Autowire(env: ...)]] attribute (Example 25). The prefixes [int:] and [bool:] convert the text of the environment variable. If a key is too short, this is reported as soon as the service is started, with a clear message (see “Common Problems”);
  • Lines 57–65: Verification of a username and password (Example 25), with the account retrieved from the database by the DAO layer. Note that this service uses the DAO layer directly, without going through the business layer: password verification is not a business rule;
  • Lines 73–90: the user’s token and the cookie that carries it. The statements are the same as in Example 25, with the account ID ([sub], a string, as required by the standard) and the username. [Cookie::create] creates a cookie, which the [with...] methods supplement (each returns a new cookie: the object is immutable): its expiration date (that of the token), [Secure] (in production, on HTTPS), [HttpOnly], and [SameSite=Lax] (see the comment on lines 9–18);
  • lines 93–97: Deleting the cookie. [clearCookie(name)] adds a cookie with the same name to the response—empty and already expired—so the browser deletes it. The method is static: it requires no configuration, and the classes that call it do not need to receive the service;
  • lines 103–112: token verification (example 25).

11.12.3. src/Security/TokenAuthenticator.php

<?php
// =========================================================================
// src/Security/TokenAuthenticator.php: the AUTHENTICATOR of the firewall (see
// config/packages/security.yaml and example 25): on each request, it establishes who
// the user is, from the JWT token of the cookie [rdvmedecins_jwt].
//
// It refuses nothing itself: a missing or invalid token leaves the visitor
// ANONYMOUS. It is the access_control rules that then refuse access to the protected
// pages; a refused anonymous visitor is then handed over to the ENTRY POINT (the
// start method), which sends them back to the login page.
// =========================================================================

namespace App\Security;

use App\Web\Flash;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;

class TokenAuthenticator extends AbstractAuthenticator implements AuthenticationEntryPointInterface
{
    // the request attribute that signals an invalid token, to be cleared
    private const INVALID_TOKEN = '_invalid_token';

    public function __construct(
        private AuthService $auth,
        private Flash $flash,
        private UrlGeneratorInterface $urls,
    ) {
    }

    // the authenticator only acts if the request carries the token cookie
    public function supports(Request $request): ?bool
    {
        return $request->cookies->getString(AuthService::COOKIE) !== '';
    }

    // the token, checked by AuthService, describes the user: the passport
    // validates itself, and the badge returns this user directly (without
    // looking for them in the database, see example 25)
    public function authenticate(Request $request): Passport
    {
        $user = $this->auth->currentUser($request->cookies->getString(AuthService::COOKIE))
            ?? throw new CustomUserMessageAuthenticationException('invalid token');
        return new SelfValidatingPassport(new UserBadge($user->login, fn () => $user));
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        return null;
    }

    // an invalid token (expired, forged): the visitor stays anonymous (null), and the
    // cookie will be cleared from the response, whatever it is (see clearInvalidToken)
    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        $request->attributes->set(self::INVALID_TOKEN, true);
        return null;
    }

    // -----------------------------------------------------------------------
    // the ENTRY POINT: an anonymous visitor asks for a protected page. Off to
    // the login page. If they had a token cookie, it means their token is no
    // longer valid (expired, forged): their session has expired, we tell them so
    // -----------------------------------------------------------------------
    public function start(Request $request, ?AuthenticationException $authException = null): Response
    {
        if ($request->cookies->has(AuthService::COOKIE)) {
            $this->flash->error('ERRORS.SESSION_EXPIRED');
        }
        return new RedirectResponse($this->urls->generate('login'));
    }

    // -----------------------------------------------------------------------
    // a kernel.response listener (see example 24), written in the same class:
    // it clears the cookie of an invalid token, on all the responses
    // -----------------------------------------------------------------------
    #[AsEventListener(event: KernelEvents::RESPONSE)]
    public function clearInvalidToken(ResponseEvent $event): void
    {
        if ($event->getRequest()->attributes->get(self::INVALID_TOKEN)) {
            AuthService::clearCookie($event->getResponse());
        }
    }
}

The authenticator from Example 25, declared in [security.yaml] ([custom_authenticators]). It also serves as the firewall’s entry point: it implements the [AuthenticationEntryPointInterface] interface, and [Symfony] selects it automatically, since it is the firewall’s only authenticator.

  • lines 45–48: [supports] determines whether the authenticator should intervene: only if the request contains the token cookie. Without a cookie, the visitor is anonymous, and no further action is taken;
  • lines 53–58: [authenticate] verifies the token. An invalid token triggers a [AuthenticationException] (its subclass, [CustomUserMessageAuthenticationException], carries a free message). Otherwise, a self-validating passport ([SelfValidatingPassport]: there is no password to verify; the token’s signature serves as the password), whose user is constructed from the token without looking it up in the database;
  • lines 60–63: after a successful authentication, [null]: the request continues on to the requested action;
  • lines 67–71: after a failure (expired or forged token), [null] as well: the visitor remains anonymous. The rules [access_control] will redirect the visitor to the login page if they request a protected page, but they can view public pages (including the login page itself!). The only indication is in a request attribute stating that the cookie should be deleted;
  • lines 78–84: the entry point, called by the firewall when an anonymous visitor requests a protected page: a redirect to the login page. If the request contained a token cookie, it means that token is no longer valid: the message “Your session has expired: please log in again.” is displayed (a pop-up message, discussed later);
  • Lines 90–96: A listener for event [kernel.response] (Example 24), defined within the same class: the [#[AsEventListener]] attribute is applied to a method, and the authenticator—a service like any other—also becomes a listener. It deletes the cookie for an invalid token, regardless of the response: the login page, a public page, or a redirect from the entry point. An invalid cookie serves no purpose and would corrupt the message of the next request.

11.12.4. src/Security/InvalidSession.php

<?php
// src/Security/InvalidSession.php: the token is valid, but the account it
// designates no longer exists: it has been deleted since the login (by the patient
// themselves in another tab, or by the ADMIN). We treat this case as an
// expired session: the ErrorPage listener clears the cookie and sends back to the login
// page.

namespace App\Security;

use RuntimeException;

class InvalidSession extends RuntimeException
{
}

A scenario not covered in Example 25: the token is valid, but the account it refers to no longer exists. A patient has deleted their account in one tab, but the token is still present in another tab; or the administrator has deleted the account of a logged-in patient. The token’s signature provides no indication of the account’s existence; it is only when the application searches for the patient associated with the account ([Utils::connectedPatient], discussed later) that it discovers the account no longer exists. It then throws this exception, which the listener [ErrorPage] (discussed later) treats as an expired session. The class is empty: only its type matters, which the listener recognizes ([instanceof]).

11.12.5. src/Security/CaptchaService.php, Captcha.php, and CaptchaDrawing.php

[CaptchaDrawing.php] and [Captcha.php] are from Example 27. The service is almost the same:

<?php
// =========================================================================
// src/Security/CaptchaService.php: a CAPTCHA ("test to tell a human from a
// robot") built by the server itself, without any external service (see example 27).
//
// How it works:
//   1. generate() draws a random text, makes an image of it, and stores the right
//      answer IN THE CACHE of the server, under a random identifier;
//   2. the page displays the image and puts the identifier in a hidden field of the
//      form (the answer itself never leaves the server);
//   3. verify(id, typed) compares the input with the expected answer, then FORGETS
//      this captcha: each image is used only once, a robot cannot replay
//      an answer already found.
//
// Why the cache? A PHP application keeps NOTHING in memory from one request to
// the next: each request starts from scratch. What must last beyond a request
// therefore goes into the cache (here files, in var/cache), shared by all the
// requests, and even by several servers with a cache like Redis.
// =========================================================================

namespace App\Security;

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

class CaptchaService
{
    // validity duration of an image, in seconds: 10 minutes to fill in the form
    private const LIFETIME = 600;

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

    public function generate(): Captcha
    {
        $text = CaptchaDrawing::randomText(5);        // 5 characters
        $id = bin2hex(random_bytes(12));              // an identifier impossible to guess
        // the cache will forget the answer by itself after 10 minutes
        $item = $this->cache->getItem("captcha.$id");
        $this->cache->save($item->set($text)->expiresAfter(self::LIFETIME));
        return new Captcha($id, CaptchaDrawing::svg($text));
    }

    // true if [$typed] is the right answer to the captcha [$id] (regardless of
    // case). The captcha is consumed in all cases.
    public function verify(?string $id, ?string $typed): bool
    {
        // the identifier comes from the browser: hexadecimal digits, or nothing
        if ($id === null || ! ctype_xdigit($id) || $typed === null) {
            return false;
        }
        // if two requests present the same captcha at the same moment, only one
        // gets the lock (acquire(false): without waiting) - the other one fails
        $lock = $this->locks->createLock("captcha.$id");
        if (! $lock->acquire(false)) {
            return false;
        }
        try {
            $item = $this->cache->getItem("captcha.$id");
            $this->cache->deleteItem("captcha.$id");
        } finally {
            $lock->release();
        }
        return $item->isHit() && strcasecmp($item->get(), trim($typed)) === 0;
    }
}
  • lines 35–43: a 5-character string generated at random, a random identifier ([random_bytes], a cryptographic generator, whose bytes are written in hexadecimal as [bin2hex]), and the correct answer stored in the cache (example 20) for 10 minutes ([expiresAfter], in seconds);
  • lines 47–66: a difference from Example 27. The answer is read, then removed from the cache. Between the reading and the removal, a second request presenting the same CAPTCHA could also read it: two attempts for a single solved image. A lock (Example 20), specific to this CAPTCHA, therefore protects both the reading and the removal. [acquire(false)] acquires it immediately: it returns [false] if another request already holds it, and that request, which loses the race, fails. The block [finally] releases the lock in all cases. The comparison ignores uppercase letters ([strcasecmp] returns 0 for two strings that are case-insensitive matches) and spaces around the input ([trim]).

11.12.6. src/Security/AttemptLimiter.php

<?php
// =========================================================================
// src/Security/AttemptLimiter.php: second rampart against robots (the first one
// is the captcha): we COUNT the attempts, and beyond a threshold we BLOCK
// for a while (see example 27).
//
//   - login: after LOGIN_MAX_FAILURES failures (wrong captcha or wrong
//     password) for the same login from the same IP address, this pair is
//     blocked until the end of a window of LOGIN_BLOCK_MINUTES minutes, counted
//     from the first failure. The same IP address is also blocked if it accumulates
//     LOGIN_MAX_FAILURES_IP failures, all logins together (a robot trying
//     thousands of different logins);
//   - account creation: at most REGISTRATION_MAX_PER_HOUR accounts created per
//     hour from the same IP address.
//
// Beware in a classroom: all the computers often go out to the Internet through the
// SAME IP address. That is why the login block first applies to the
// pair (IP, login) - a student who mistypes their password does not block their
// neighbours - and why the thresholds can be adjusted (.env file).
//
// The counters are those of the three limiters of the RateLimiter component, declared
// in config/packages/framework.yaml, and stored in the cache.
// =========================================================================

namespace App\Security;

use Symfony\Component\RateLimiter\LimiterInterface;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;

class AttemptLimiter
{
    // the factories of the limiters [login], [login_ip] and [registration]: the name
    // of the parameter designates the limiter (see example 27)
    public function __construct(
        private RateLimiterFactoryInterface $loginLimiter,
        private RateLimiterFactoryInterface $loginIpLimiter,
        private RateLimiterFactoryInterface $registrationLimiter,
    ) {
    }

    // ---------------------------------------------------------------- login

    // number of remaining block minutes (0 = the attempt is allowed)
    public function loginBlockMinutes(string $ip, string $login): int
    {
        return max(self::remainingMinutes($this->ipLoginLimiter($ip, $login)),
            self::remainingMinutes($this->loginIpLimiter->create($ip)));
    }

    // consume(): one more failure; the first one opens a window of
    // LOGIN_BLOCK_MINUTES minutes, at the end of which the counter starts again from zero
    public function loginFailure(string $ip, string $login): void
    {
        $this->ipLoginLimiter($ip, $login)->consume();
        $this->loginIpLimiter->create($ip)->consume();
    }

    // a successful login resets the counter of THIS login
    public function loginSuccess(string $ip, string $login): void
    {
        $this->ipLoginLimiter($ip, $login)->reset();
    }

    // ---------------------------------------------------------------- registration

    public function registrationBlockMinutes(string $ip): int
    {
        return self::remainingMinutes($this->registrationLimiter->create($ip));
    }

    public function registrationSuccess(string $ip): void
    {
        $this->registrationLimiter->create($ip)->consume();
    }

    // ---------------------------------------------------------------- common mechanics

    // the limiter of the pair (IP, login): create(key) returns a limiter that has its
    // own counter, under this key
    private function ipLoginLimiter(string $ip, string $login): LimiterInterface
    {
        return $this->loginLimiter->create("$ip|".mb_strtolower(trim($login)));
    }

    // consume(0) queries the limiter without consuming anything: no "token" left?
    // getRetryAfter: the date of the end of the current window; the seconds that
    // remain until then are rounded UP to the minute: 30 s -> "1 minute"
    private static function remainingMinutes(LimiterInterface $limiter): int
    {
        $state = $limiter->consume(0);
        if ($state->getRemainingTokens() > 0) {
            return 0;
        }
        return max(1, (int) ceil(($state->getRetryAfter()->getTimestamp() - time()) / 60));
    }
}

The second line of defense against bots, after the CAPTCHA: we count the attempts, and once a threshold is exceeded, we block access. Example 27 presented the [RateLimiter] component of [Symfony]. The three limiters are declared in [config/packages/framework.yaml] (policy [fixed_window]: a fixed-duration window, opened by the first token consumed); the service receives their factories, identified by the parameter names (lines 34–38): [$loginLimiter] for the limiter [login], [$loginIpLimiter] for [login_ip], [$registrationLimiter] for [registration]. A factory uses its method [create(key)] to create a limiter that has its own counter under this key. Two rules:

  • the connection (lines 44–62): each failure (incorrect CAPTCHA or password) is counted twice: for the pair (address IP, login), and for the address IP alone. The pair is blocked after 5 failed attempts (setting [LOGIN_MAX_FAILURES]), and the address IP after 25 (setting [LOGIN_MAX_FAILURES_IP]), across all logins: It’s the bot trying thousands of different logins. Why not block the address IP first? In a classroom (or at a company), all machines often access the Internet through the same address, IP: a student who enters the wrong password shouldn’t block their neighbors. [consume()] consumes a token (counts as a failed attempt); the first attempt opens a 15-minute window (setting [LOGIN_BLOCK_MINUTES]), at the end of which the counter resets to zero. A successful connection resets the counter for that user ([reset]);
  • account creation (lines 66–74): no more than 10 accounts per hour per address IP (setting [REGISTRATION_MAX_PER_HOUR]);
  • lines 80–83: the key for a torque limiter. The login is in lowercase: [Admin] and [admin] are considered the same (as with the database; see its collation);
  • Lines 88–95: [consume(0)] queries the limiter without consuming any tokens (example 27): there are tokens remaining, so the attempt is allowed; otherwise, [getRetryAfter] returns the end date of the current window, and the number of seconds remaining until then is rounded up to the next minute ([ceil]): there are 30 seconds left, so “1 minute” is displayed. The message displayed depends on this: “Too many failed attempts. Try again in 15 minutes.”

The limiters’ counters are stored in the cache: they are shared by all requests and survive a server restart. The command [php bin/console cache:pool:clear cache.rate_limiter] resets them all to zero (useful during development, when you’ve gotten yourself stuck); [php bin/console cache:clear], which clears the entire [var/cache] directory, does the same.

11.12.7. src/Security/RegistrationPass.php and RegistrationPassRequired.php

<?php
// =========================================================================
// src/Security/RegistrationPass.php: the "pass" of the account creation.
//
// To open the page "Create a patient account", one must first have copied the
// captcha of the login page (see RegistrationController, POST
// /register/access). The server then issues a pass:
//   - a random identifier, stored in an HttpOnly cookie;
//   - valid for 15 minutes (the time to fill in the form, even when trying
//     several times after input errors);
//   - consumed as soon as an account is created: ONE solved captcha = ONE account at most.
//     A robot cannot create accounts in series.
//
// Without a valid pass, GET and POST /register send back to the login
// page (see the #[RegistrationPassRequired] attribute and the listener
// CheckRegistrationPass): typing the URL /register directly does not allow
// getting around the captcha.
//
// Like the captchas, the passes live in the cache.
// =========================================================================

namespace App\Security;

use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Response;

class RegistrationPass
{
    public const COOKIE = 'rdvmedecins_registration';

    // the cookie is sent by the browser only to the /register... addresses
    private const PATH = '/register';

    // lifetime of a pass, in minutes
    private const LIFETIME = 15;

    public function __construct(
        private CacheItemPoolInterface $cache,
        #[Autowire(env: 'bool:COOKIE_SECURE')] private bool $cookieSecure,
    ) {
    }

    // issues a new pass; returns the cookie that carries it
    public function issue(): Cookie
    {
        $id = bin2hex(random_bytes(12));
        $item = $this->cache->getItem("pass.$id");
        $this->cache->save($item->set(true)->expiresAfter(self::LIFETIME * 60));
        return Cookie::create(self::COOKIE, $id)
            ->withExpires(time() + self::LIFETIME * 60)
            ->withPath(self::PATH)
            ->withSecure($this->cookieSecure)
            ->withHttpOnly(true)
            ->withSameSite(Cookie::SAMESITE_LAX);
    }

    // true if [$id] is an existing, non-expired pass; hasItem: does the
    // cache have this item?
    public function isValid(?string $id): bool
    {
        return $id !== null && ctype_xdigit($id) && $this->cache->hasItem("pass.$id");
    }

    // the pass has been used: we forget it...
    public function consume(?string $id): void
    {
        if ($id !== null && ctype_xdigit($id)) {
            $this->cache->deleteItem("pass.$id");
        }
    }

    // ... and its cookie is cleared by a cookie of the same name and the same path, already expired
    public static function clearCookie(Response $response): Response
    {
        $response->headers->clearCookie(self::COOKIE, self::PATH);
        return $response;
    }
}

Creating a patient account is protected by the CAPTCHA on the login page. But the account creation form is on a different page ([/register]): how can the system know, when the form is requested, that the CAPTCHA was solved just before? Through a token: when the CAPTCHA is solved, the server generates a random identifier, stores it in the cache for 15 minutes, and sends it to the browser in a cookie. The form is only displayed—and the account is only created—with a valid token; the token is consumed when the account is created. A solved CAPTCHA thus allows you to create an account, and typing the address [/register] directly leads nowhere.

  • Lines 46–57: The cookie has the path [/register] ([withPath]): the browser sends it only to addresses that begin with this path. It contains only a random identifier—impossible to guess—and only the server’s cache knows whether it is valid: the cookie does not need to be signed;
  • lines 61–64: [hasItem] indicates whether the cache contains an (unexpired) entry under this key;
  • lines 75–79: To delete a cookie, you must specify the same name and the same path: to the browser, two cookies with the same name but different paths are two distinct cookies.
<?php
// src/Security/RegistrationPassRequired.php: a PHP ATTRIBUTE of our own (see
// example 24), placed on the actions that require an account creation pass
// (see RegistrationPass and the listener CheckRegistrationPass).

namespace App\Security;

use Attribute;

#[Attribute(Attribute::TARGET_METHOD)]
class RegistrationPassRequired
{
}

An attribute [PHP] of our own invention, like the [#[Timed]] in Example 24, is applied to actions that require a pass. It is read by the listener [CheckRegistrationPass].

11.13. The listeners: src/EventListener

Three kernel event listeners (Example 24), stored in the [src/EventListener] folder, as is customary. The [#[AsEventListener]] attribute, set on the class, specifies the event and priority; the method called is then [__invoke].

11.13.1. src/EventListener/SetLanguage.php

<?php
// =========================================================================
// src/EventListener/SetLanguage.php: the language of the request (see example 22):
// that of the cookie [rdvmedecins_language] if it is a supported language, English otherwise.
//
// Priority 40: the listener runs BEFORE Symfony's routing (priority 32). It
// thus runs for ALL the requests, even those that lead to no
// route (404, 405 page...): their error page is in the right language. For the
// same reason, it also sets the language of the translator itself, which would otherwise be done by
// a Symfony listener (priority 15) that a routing error would prevent
// from running.
// =========================================================================

namespace App\EventListener;

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

#[AsEventListener(event: KernelEvents::REQUEST, priority: 40)]
class SetLanguage
{
    // the cookie that remembers the chosen language (see LanguageController)
    public const COOKIE = 'rdvmedecins_language';

    // the supported languages (translations folder); the first one is the default language
    public const LANGUAGES = ['en', 'fr'];

    // LocaleAwareInterface: a service that has a language, here the translator
    public function __construct(private LocaleAwareInterface $translator)
    {
    }

    public function __invoke(RequestEvent $event): void
    {
        $request = $event->getRequest();
        $language = $request->cookies->get(self::COOKIE);
        $language = in_array($language, self::LANGUAGES, true) ? $language : self::LANGUAGES[0];
        $request->setLocale($language);
        $this->translator->setLocale($language);
    }
}

The request language (Example 22): the language of the cookie, if it is a supported language; otherwise, the default language (English). A difference from Example 22: priority 40 (line 21), which is higher than the routing priority (32). The listener in Example 22 (priority 20) ran after the routing; for a URL that does not match any route, the routing throws an exception before the listener, and the 404 page would have been in English even for a French-speaking user. For the same reason, the listener itself sets the translator’s language (line 41), which is typically done by a listener for [Symfony] ([LocaleAwareListener]), which is also placed after the routing. [LocaleAwareInterface] is the interface for services that have a language; the translator is one of them, and it is this translator that [Symfony] injects here.

11.13.2. src/EventListener/CheckRegistrationPass.php

<?php
// =========================================================================
// src/EventListener/CheckRegistrationPass.php: the actions marked with
// the #[RegistrationPassRequired] attribute (GET and POST /register) require a
// valid pass (see src/Security/RegistrationPass.php); otherwise, back to
// the login page, with a message. Typing the URL
// /register directly therefore does not allow getting around the captcha.
//
// A kernel.controller listener (see example 24): the action is chosen, but
// not called yet. To refuse, it REPLACES it (setController) with a
// function that returns a redirection.
// =========================================================================

namespace App\EventListener;

use App\Security\RegistrationPass;
use App\Security\RegistrationPassRequired;
use App\Web\Flash;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;

#[AsEventListener(event: KernelEvents::CONTROLLER)]
class CheckRegistrationPass
{
    public function __construct(
        private RegistrationPass $passes,
        private Flash $flash,
        private UrlGeneratorInterface $urls,
    ) {
    }

    public function __invoke(ControllerEvent $event): void
    {
        if ($event->getAttributes(RegistrationPassRequired::class) === []) {
            return;
        }
        if (! $this->passes->isValid($event->getRequest()->cookies->get(RegistrationPass::COOKIE))) {
            $this->flash->error('ERRORS.CAPTCHA_BEFORE_ACCOUNT');
            $url = $this->urls->generate('login');
            $event->setController(fn () => new RedirectResponse($url));
        }
    }
}

A listener for [kernel.controller] (example 24): the action is selected but not yet called.

  • lines 37–39: [getAttributes(class)] returns the attributes of this class carried by the action: an empty list; the action does not require a token; the listener does nothing;
  • lines 40–44: no valid token: a message is displayed, and the action is replaced ([setController]) by a function that redirects to the login page. The kernel calls this function instead of the action. URL is calculated outside the arrow function, which therefore requires only one variable.

The listener receives its services through its constructor: a listener is a service, created by the container, just like controllers.

11.13.3. src/EventListener/ErrorPage.php

<?php
// =========================================================================
// src/EventListener/ErrorPage.php: how errors become HTML pages
// (see example 24). Three cases:
//   1. an InvalidSession (account deleted since the login) -> back to the
//      login page, as for an expired session;
//   2. an HTTP error: invalid request (400), insufficient role (403), unknown
//      page (404), unsupported method (405), missing or wrong anti-CSRF
//      token (419)... -> the error page, with the original code;
//   3. any other exception (a real anomaly): Symfony has already written it to
//      the log, with its call stack; in production (APP_ENV=prod),
//      the user sees the 500 error page - never the call stack; in
//      development, Symfony's debug page.
//
// Priority -10: after Symfony's listener that writes the exception to the log
// (0) and after that of security (1), which turns an access refusal into a
// redirection to the login page (anonymous visitor) or into a 403 error
// (authenticated user); before the one that builds Symfony's error page
// (-128).
// =========================================================================

namespace App\EventListener;

use App\Security\AuthService;
use App\Security\InvalidSession;
use App\Web\Flash;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Twig\Environment;

#[AsEventListener(event: KernelEvents::EXCEPTION, priority: -10)]
class ErrorPage
{
    public function __construct(
        private Environment $twig,
        private Flash $flash,
        private UrlGeneratorInterface $urls,
        // the debug mode (APP_ENV=dev): the kernel.debug parameter of the container
        #[Autowire('%kernel.debug%')] private bool $debug,
    ) {
    }

    public function __invoke(ExceptionEvent $event): void
    {
        $e = $event->getThrowable();

        if ($e instanceof InvalidSession) {
            $this->flash->error('ERRORS.SESSION_EXPIRED');
            $event->setResponse(AuthService::clearCookie(new RedirectResponse($this->urls->generate('login'))));
            return;
        }

        if ($e instanceof HttpExceptionInterface) {
            $event->setResponse($this->page($e->getStatusCode()));
            return;
        }

        // a real anomaly: in development, Symfony's debug page
        if (! $this->debug) {
            $event->setResponse($this->page(500));
        }
    }

    // the error page, with the HTTP code of the error and its message
    private function page(int $code): Response
    {
        $message = match ($code) {
            400 => 'ERRORS.BAD_REQUEST',
            403 => 'ERRORS.FORBIDDEN',
            404, 405 => 'ERRORS.NOT_FOUND',
            419 => 'ERRORS.PAGE_EXPIRED',
            default => 'ERRORS.SERVER_ERROR',
        };
        return new Response($this->twig->render('error.html.twig', ['pageTitle' => 'ERROR_PAGE.TITLE',
            'code' => $code, 'message' => $message]), $code);
    }
}

How Errors Become Pages HTML: This is the listener [ErrorPage] from Example 24, expanded. It listens for the event [kernel.exception], with priority -10 (line 37): after the [Symfony] listener, which logs the exception (priority 0), and after the security component’s listener (priority 1), which converts an access denial either into a call to the entry point (anonymous visitor) or into a 403 error (authenticated user with an inappropriate role); before the handler that generates the error page, [Symfony] (-128). Three cases:

  • lines 53–57: a [InvalidSession] (the account has been deleted): the “session expired” message, deletion of the token cookie, and the login page;
  • lines 59–62: a HTTP error: [HttpExceptionInterface] is the exception handler for exceptions with the code HTTP ([NotFoundHttpException], [AccessDeniedHttpException], [HttpException(419)]...), which is thrown by [getStatusCode]. The error page is displayed, showing this code;
  • lines 65–67: another exception is a true error. In development ([kernel.debug], example 24), the listener does nothing: the debug page for [Symfony], showing the call stack, will be displayed. In production, the 500 error page: the user never sees the call stack, which the log has already captured;
  • lines 71–82: the error page, view [error.html.twig], showing the message code and key. [match] ([PHP] 8) selects a value based on another, using strict equality; [404, 405 =>] covers two cases; [default] handles all others. Code 405 (Method Not Allowed: a GET on an address that only accepts POST) is displayed as a "page not found" error.

11.14. The connection controller

11.14.1. src/Controller/AuthController.php

<?php
// =========================================================================
// src/Controller/AuthController.php: the PUBLIC routes of the login:
//   GET  /        redirects to the home page of the role (or to /login)
//   GET  /login   displays the login form (with its captcha)
//   POST /login   checks attempt limit, captcha, login / password
//   POST /logout  the logout
//
// Protection against robots, in this order:
//   1. too many recent failures for this login from this IP address?
//      -> immediate refusal, without even looking at the password;
//   2. wrong answer to the captcha? -> refusal (counted as a failure);
//   3. only then, check of the password.
// We NEVER check the password of an attempt refused at steps 1 or 2:
// a robot can therefore learn nothing by insisting.
// =========================================================================

namespace App\Controller;

use App\Entity\Role;
use App\Security\AuthService;
use App\Security\CaptchaService;
use App\Security\AttemptLimiter;
use App\Security\ConnectedUser;
use App\Web\BaseController;
use App\Web\Message;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class AuthController extends BaseController
{
    public function __construct(
        private AuthService $auth,
        private CaptchaService $captchas,
        private AttemptLimiter $limiter,
    ) {
    }

    // the home page of each role, right after the login: the name of its route
    public static function homePage(Role $role): string
    {
        return $role === Role::USER ? 'area' : 'schedule';
    }

    // -----------------------------------------------------------------------
    // GET /
    // The route is public: the user, if there is one, is known
    // ($this->getUser()), but nothing is required.
    // -----------------------------------------------------------------------
    #[Route('/', name: 'home', methods: ['GET'])]
    public function home(): Response
    {
        $user = $this->getUser();
        return $this->redirectToRoute($user instanceof ConnectedUser
            ? self::homePage($user->role) : 'login');
    }

    // -----------------------------------------------------------------------
    // GET /login[?login=xxx]: the optional parameter pre-fills the login field
    // (used right after the creation of an account, see RegistrationController)
    // -----------------------------------------------------------------------
    #[Route('/login', name: 'login', methods: ['GET'])]
    public function form(Request $request): Response
    {
        // already logged in (token still valid): straight to the home page
        $user = $this->getUser();
        if ($user instanceof ConnectedUser) {
            return $this->redirectToRoute(self::homePage($user->role));
        }
        // mb_substr: 30 characters at most
        return $this->page(mb_substr($request->query->getString('login'), 0, 30), null);
    }

    // -----------------------------------------------------------------------
    // POST /login
    // -----------------------------------------------------------------------
    #[Route('/login', name: 'login_submit', methods: ['POST'])]
    public function logIn(Request $request): Response
    {
        $this->checkCsrf($request);
        $login = $request->request->getString('login');
        $password = $request->request->getString('password');
        $ip = (string) $request->getClientIp();

        // 1. too many recent failures?
        $minutes = $this->limiter->loginBlockMinutes($ip, $login);
        if ($minutes > 0) {
            return $this->page($login, new Message('ERRORS.TOO_MANY_ATTEMPTS', ['minutes' => $minutes]));
        }

        // 2. the captcha
        if (! $this->captchas->verify($request->request->getString('captchaId'),
            $request->request->getString('captcha'))) {
            $this->limiter->loginFailure($ip, $login);
            return $this->page($login, new Message('ERRORS.WRONG_CAPTCHA'));
        }

        // 3. the password
        $user = $login !== '' && $password !== '' ? $this->auth->validate($login, $password) : null;
        if ($user === null) {
            $this->limiter->loginFailure($ip, $login);
            return $this->page($login, new Message('LOGIN.ERROR'));
        }

        $this->limiter->loginSuccess($ip, $login);
        $response = $this->redirectToRoute(self::homePage($user->getRole()));
        $response->headers->setCookie($this->auth->tokenCookie($user));
        return $response;
    }

    // -----------------------------------------------------------------------
    // POST /logout: clear the token cookie, then go to the login page.
    // POST only: a simple link (GET), placed on another site, cannot
    // log the user out without their knowledge.
    // -----------------------------------------------------------------------
    #[Route('/logout', name: 'logout', methods: ['POST'])]
    public function logOut(Request $request): Response
    {
        $this->checkCsrf($request);
        return AuthService::clearCookie($this->redirectToRoute('login'));
    }

    // the form, always with a NEW captcha (each image is used only
    // once); the entered login is displayed again, never the password. An error:
    // the code 422, as for an invalid form (see example 19)
    private function page(string $login, ?Message $error): Response
    {
        return $this->render('login.html.twig', ['pageTitle' => 'LOGIN.TITLE', 'error' => $error,
            'login' => $login, 'captcha' => $this->captchas->generate()],
            new Response(status: $error ? 422 : 200));
    }
}

The login controller, which is based on Example 27. It inherits from the application’s [BaseController] class (discussed later), which itself is a subclass of [AbstractController].

  • lines 41–44: the home page for each role, along with its route name: “My area” for a patient, and the calendar for others;
  • lines 51–57: The address [/] links to the role’s home page or to the login page. The route is public (rule [access_control] of [security.yaml]): [getUser()] returns the user established by the authenticator, if there is one, without requiring anything. [redirectToRoute] (example 04) creates a redirect to a named route;
  • lines 63–73: a user who is already logged in goes directly to their home page. The optional parameter [login] of URL pre-fills the field (after account creation); [mb_substr] truncates it to 30 characters;
  • Lines 78–110: the login process, in the order shown in Example 27: the anti-CSRF token (discussed later), any blocking measures, the CAPTCHA, and the password. A password is never verified for an attempt rejected by the limiter or the CAPTCHA: a bot learns nothing by persisting. [getClientIp] returns the client’s address IP. If the attempt fails, the page is rendered directly (no redirection): the re-displayed form always has a new CAPTCHA and the entered login. The error is an object [Message] (see below), consisting of a key and its parameters, displayed at the bottom of the page. If successful, the login counter is reset to zero, and the browser is redirected to the role’s home page, with the token cookie ([headers->setCookie]);
  • lines 117–122: Logout deletes the token cookie (example 25). It only accepts POST, along with the anti-CSRF token: a simple link placed on another site cannot log the user out without their knowledge;
  • lines 127–132: the login page. The third parameter of [render] is the response to be populated: an error is returned with code 422 (Unprocessable Content), as with an invalid form (example 19). [new Response(status: ...)] uses a named argument ([PHP] 8): the first two parameters of the constructor retain their default values.

The login page receives the variable [pageTitle], the translation key for the page title: all actions pass it to their view for the template (see below).

11.15. The Web Layer: Shared Services

The [src/Web] folder contains the elements shared by the controllers.

11.15.1. src/Web/ApplicationModel.php

<?php
// =========================================================================
// src/Web/ApplicationModel.php: the cache / facade of the business layer (the equivalent
// of the [ApplicationModel] class of the other versions of the application): the
// lists of doctors and clients are read from the database once, then served from
// the cache.
//
// Why Symfony's CACHE, and not simply a property of this object? Because
// a PHP application keeps nothing in memory from one request to the next: this
// object is a shared service, but a service only lives for ONE request.
// The cache, on the other hand, is shared by all the requests (see examples 20 and 23).
//
// These lists may CHANGE (the ADMIN adds, modifies, deletes doctors and
// clients): each write therefore goes through this facade, which FORGETS the list
// concerned right afterwards; the next request that needs it reads it again from the database. That is
// the whole point of a SINGLE entry point to the business layer: the rule
// "write => cache update" is written only here.
// =========================================================================

namespace App\Web;

use App\Domain\DoctorDaySchedule;
use App\Entity\Client;
use App\Entity\Slot;
use App\Entity\Doctor;
use App\Entity\Appointment;
use App\Business\PersonData;
use App\Business\Business;
use App\Business\BusinessService;
use DateTimeImmutable;
use Symfony\Contracts\Cache\CacheInterface;

// same contract as the business layer
class ApplicationModel implements Business
{
    private const DOCTORS = 'rdvmedecins.doctors';

    private const CLIENTS = 'rdvmedecins.clients';

    // CacheInterface: the cache of the application (the cache.app service), seen through
    // the interface of Symfony's "contracts", simpler than that of PSR-6
    public function __construct(private BusinessService $business, private CacheInterface $cache)
    {
    }

    // ------------------------------------------------ reads from the cache

    // get(key, function): the value of the cache if it exists; otherwise, the function
    // is called, and its result stored in the cache (without time limit) before
    // being returned. The entities are stored there serialized (see serialize): each
    // request that reads them gets copies of them, which Doctrine does not track
    public function getAllDoctors(): array
    {
        return $this->cache->get(self::DOCTORS, fn () => $this->business->getAllDoctors());
    }

    public function getAllClients(): array
    {
        return $this->cache->get(self::CLIENTS, fn () => $this->business->getAllClients());
    }

    // the doctor whose user account is [$userId] - used to preselect,
    // for a logged-in doctor, their own schedule. array_find: the first element
    // of the array that satisfies the condition (null if there is none)
    public function getDoctorByUserId(int $userId): ?Doctor
    {
        return array_find($this->getAllDoctors(), fn (Doctor $d) => $d->getAccount()->getId() === $userId);
    }

    // ------------------------------------------------ simple delegations

    public function getAllSlots(int $doctorId): array
    {
        return $this->business->getAllSlots($doctorId);
    }

    public function getDoctorDayAppointments(int $doctorId, DateTimeImmutable $day): array
    {
        return $this->business->getDoctorDayAppointments($doctorId, $day);
    }

    public function getClientById(int $id): ?Client
    {
        return $this->business->getClientById($id);
    }

    public function getDoctorById(int $id): ?Doctor
    {
        return $this->business->getDoctorById($id);
    }

    public function getAppointmentById(int $id): ?Appointment
    {
        return $this->business->getAppointmentById($id);
    }

    public function getSlotById(int $id): ?Slot
    {
        return $this->business->getSlotById($id);
    }

    public function getDoctorDaySchedule(int $doctorId, DateTimeImmutable $day): ?DoctorDaySchedule
    {
        return $this->business->getDoctorDaySchedule($doctorId, $day);
    }

    // no cache here: it is the most recent data that matters (a patient who
    // has just modified or deleted their account)
    public function getClientByUserId(int $userId): ?Client
    {
        return $this->business->getClientByUserId($userId);
    }

    public function getClientUpcomingAppointments(int $clientId): array
    {
        return $this->business->getClientUpcomingAppointments($clientId);
    }

    public function addAppointment(DateTimeImmutable $day, Slot $slot, Client $client): Appointment
    {
        return $this->business->addAppointment($day, $slot, $client);
    }

    public function deleteAppointment(Appointment $appointment): void
    {
        $this->business->deleteAppointment($appointment);
    }

    // ------------------------------------------------ writes => cache forgotten
    // (delete: the item is removed from the cache)

    public function addDoctor(PersonData $data): Doctor
    {
        $doctor = $this->business->addDoctor($data);
        $this->cache->delete(self::DOCTORS);
        return $doctor;
    }

    public function editDoctor(int $id, PersonData $data): Doctor
    {
        $doctor = $this->business->editDoctor($id, $data);
        $this->cache->delete(self::DOCTORS);
        return $doctor;
    }

    public function deleteDoctor(int $id): void
    {
        $this->business->deleteDoctor($id);
        $this->cache->delete(self::DOCTORS);
    }

    public function addClient(PersonData $data): Client
    {
        $client = $this->business->addClient($data);
        $this->cache->delete(self::CLIENTS);
        return $client;
    }

    public function editClient(int $id, PersonData $data): Client
    {
        $client = $this->business->editClient($id, $data);
        $this->cache->delete(self::CLIENTS);
        return $client;
    }

    public function deleteClient(int $id): void
    {
        $this->business->deleteClient($id);
        $this->cache->delete(self::CLIENTS);
    }
}

All versions of the application have a class named [ApplicationModel]: a single entry point from the web layer to the business layer, which keeps the lists of doctors and clients in memory. These lists are accessed by almost all pages (the drop-down list of doctors in the calendar, the list of patients in the booking window, and the administration pages) and rarely change.

What changes in [PHP] is the meaning of “in memory.” An application such as [Java], [C#], or [Python] remains in memory between requests; the [Flask] version stored these lists in the properties of a single object created at startup. An application [PHP] is recreated with each request (Example 01): the object [ApplicationModel] is indeed a shared service, but it exists only for the duration of a single request (Example 23). The lists are therefore stored in the cache of [Symfony] (example 20), which is shared by all requests.

  • Line 34: The class implements the [Business] interface, just like [BusinessService]: the controllers call the same methods, regardless of which class serves them;
  • Line 42: This time, the cache is received via the [CacheInterface] interface of the “contracts” for [Symfony] (the same [cache.app] service), which is simpler than the PSR-6 interface in the examples;
  • Lines 52–60: [get(key, function)] returns the value stored under this key; if it does not exist, it calls the function (which reads the list from the database), stores its result in the cache indefinitely, and returns it. The first query that needs the list reads it directly from the database; subsequent queries find it in the cache. The cache stores the array of entities in a serialized form (example 20) and reconstructs it upon reading, along with the loaded relationships (each person’s account). The reconstructed entities are copies that the [Doctrine] entity manager does not track: they are used for display, never for writing (the business layer always re-reads from the database what it modifies);
  • lines 65–68: the physician associated with an account, retrieved from the cache list by [array_find] (example 06);
  • Lines 72–127: The other methods of the [Business] contract simply delegate to the business layer. An account’s client ([getClientByUserId]) is always retrieved from the database, not from the cache: the most recent data takes precedence (a patient who has just modified or deleted their account);
  • Lines 132–170: the updates. Each update made by a doctor or a client does not save the relevant list ([delete]): the next query that needs it will retrieve it from the database. This is the whole point of a single entry point: the rule “a write updates the cache” is implemented only here. If the write fails (e.g., a [BusinessException]), the list is not overwritten—it hasn’t changed. An application that writes to the database via a different path (another program, another version of the application using the same database) would leave the cache out of date: [php bin/console cache:pool:clear cache.app] would clear it.

11.15.2. src/Web/Message.php and Flash.php

<?php
// src/Web/Message.php: a message to translate: its key, and its parameters.
// The views display it with {{ message.key|trans(message.params) }}.

namespace App\Web;

readonly class Message
{
    public function __construct(
        public string $key,
        public array $params = [],
    ) {
    }
}

A message to be translated: a key and its parameters. Errors in the business layer ([BusinessException]) and those in the controllers become [Message], which the views translate into the page’s language.

<?php
// =========================================================================
// src/Web/Flash.php: the "flash" messages: a message to display ONCE, on the
// NEXT page (see example 20). It is the natural complement of the
// "Post / Redirect / Get" pattern used by all the forms of the application: a
// successful POST builds no page, it redirects (302) to a GET page; the
// message, on the other hand, must survive the redirection.
//
// Symfony stores them in the SESSION (files of the var/sessions folder, see
// config/packages/framework.yaml).
//
// A message does not contain a text, but a translation KEY and its parameters
// (a Message object): it is translated when displayed, in the language of
// the page (see templates/fragments). Two lists: the successes (displayed at the top of
// the page) and the errors (displayed at the bottom).
// =========================================================================

namespace App\Web;

use Symfony\Component\HttpFoundation\RequestStack;

class Flash
{
    // the stack of requests, to reach the session of the current request
    public function __construct(private RequestStack $requests)
    {
    }

    public function success(string $key, array $params = []): void
    {
        $this->add('success', $key, $params);
    }

    public function error(string $key, array $params = []): void
    {
        $this->add('errors', $key, $params);
    }

    // getFlashBag: the "bag" of the flash messages of the session; add(type, message)
    // adds a message to the list of that type (see example 20)
    private function add(string $type, string $key, array $params): void
    {
        $this->requests->getSession()->getFlashBag()->add($type, new Message($key, $params));
    }
}

Flash messages (example 20): a message placed in the session by an action, displayed by the next page, then discarded. This complements the Post/Redirect/Get pattern, which all forms in the application follow. There are two types: success messages are displayed at the top of the page, and error messages at the bottom.

  • Line 25: the request stack ([RequestStack]), a service of [Symfony] that knows the current request and, therefore, its session. A shared service cannot receive the request itself via its constructor: it is created once and may be used for multiple requests (a main request and its sub-requests);
  • lines 41–44: the “bag” of session flash messages ([getFlashBag]), for which [add(type, message)] completes the list of this type. This is what the [addFlash] method of [AbstractController] does (Example 20); making it a service allows a message to be placed outside a controller—such as the authenticator or listeners.

The [Message] objects are stored as-is in the session: [PHP] serializes the session data (Example 20) and reconstructs it on the next request. In the views, [app.flashes('errors')] renders the list of messages of this type and removes them from the session.

11.15.3. src/Web/BaseController.php

<?php
// =========================================================================
// src/Web/BaseController.php: the parent class of the controllers of the application. It
// inherits from AbstractController (see example 03), and adds the check of the
// anti-CSRF token of the hand-written forms (see example 21).
// =========================================================================

namespace App\Web;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\HttpException;

abstract class BaseController extends AbstractController
{
    // the identifier of the anti-CSRF token of the hand-written forms: only one
    // for the whole application (in the views: csrf_token('rdvmedecins')). The
    // forms of the Form component each have their own
    public const CSRF = 'rdvmedecins';

    // -----------------------------------------------------------------------
    // a missing or wrong anti-CSRF token: the page was probably displayed before
    // the expiry of the session, which contained the token. Error 419, the (unofficial,
    // but widespread) code of an expired page: the ErrorPage listener displays
    // "The page has expired: reload it, then try again."
    // -----------------------------------------------------------------------
    protected function checkCsrf(Request $request): void
    {
        if (! $this->isCsrfTokenValid(self::CSRF, $request->request->getString('_token'))) {
            throw new HttpException(419, 'invalid anti-CSRF token');
        }
    }
}

The parent class for controllers. Forms written manually in the views (login, logout, deletions, reservations, etc.) include an anti-CSRF token (Example 21) with the identifier [rdvmedecins]: a single identifier is sufficient here, since all these actions already require a logged-in user or a CAPTCHA. Each form in the Form component has its own token, which is validated by the component.

A missing or invalid token stops the action with a 419 error (lines 27–32), the (unofficial but widely used) code for a page that has expired: the token is stored in the session, and a page displayed before the end of the session contains a token that the server no longer recognizes. The [ErrorPage] listener then displays “The page has expired: reload it, then try again.” [HttpException] is the generic exception HTTP: its constructor receives the code and a message (intended for the log, not for the user).

11.15.4. src/Web/Utils.php

<?php
// =========================================================================
// src/Web/Utils.php: small utility functions of the web layer:
//   - dates: "yyyy-mm-dd", days "Wednesday 23 September 2026";
//   - input: reading of a positive integer, of a date, sent by the browser;
//   - user: the logged-in user, and their patient (Client entity).
// The STATIC methods are called without an object (Utils::iso($day)); the others
// need services (the current request, security...): the class is a
// service, which the controllers receive through their constructor.
// =========================================================================

namespace App\Web;

use App\Entity\Client;
use App\Security\InvalidSession;
use App\Security\ConnectedUser;
use DateTimeImmutable;
use IntlDateFormatter;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;

class Utils
{
    // Security: Symfony's security service, which knows the user of
    // the current request (the one established by the authenticator)
    public function __construct(
        private RequestStack $requests,
        private Security $security,
        private ApplicationModel $application,
    ) {
    }

    // ----------------------------------------------------------------- dates

    // the date, if [$text] is a date that exists in the yyyy-mm-dd format; null otherwise.
    // createFromFormat accepts "2026-02-30" (which becomes March 2): we therefore
    // check that the date read back is written exactly like the received text
    public static function validDate(mixed $text): ?DateTimeImmutable
    {
        if (! is_string($text) || ! preg_match('/^\d{4}-\d{2}-\d{2}$/', $text)) {
            return null;
        }
        // "!Y-m-d": the exclamation mark sets the time to midnight
        $date = DateTimeImmutable::createFromFormat('!Y-m-d', $text);
        return $date !== false && $date->format('Y-m-d') === $text ? $date : null;
    }

    public static function iso(DateTimeImmutable $day): string
    {
        return $day->format('Y-m-d');
    }

    // "mercredi 23 septembre 2026" / "Wednesday 23 September 2026", depending on the language
    // of the request (see example 16). The ICU pattern: EEEE the day of the week in
    // full, d the day, MMMM the month in full, y the year
    public function formatDay(DateTimeImmutable $day): string
    {
        $language = $this->requests->getCurrentRequest()?->getLocale() ?? 'en';
        return (new IntlDateFormatter($language, IntlDateFormatter::NONE, IntlDateFormatter::NONE,
            pattern: 'EEEE d MMMM y'))->format($day);
    }

    // ----------------------------------------------------------------- input

    // "3" -> 3; "", "abc", "-2", "+2", null -> null
    public static function positiveInteger(mixed $value): ?int
    {
        if (! is_string($value) || ! preg_match('/^\d{1,9}$/', $value)) {
            return null;
        }
        $n = (int) $value;
        return $n > 0 ? $n : null;
    }

    // ----------------------------------------------------------------- user

    // the logged-in user. The protected actions are only called if they exist;
    // as a precaution, their absence is treated as an expired session
    public function user(): ConnectedUser
    {
        $user = $this->security->getUser();
        return $user instanceof ConnectedUser ? $user : throw new InvalidSession();
    }

    // -----------------------------------------------------------------------
    // The patient (Client entity) that matches the logged-in user.
    //
    // The JWT token only contains the id of the ACCOUNT (users.id). For a USER role, we
    // find the matching client thanks to the clients.user column.
    //
    // If there is none, it means the account has been deleted since the login
    // while its token is still valid: we treat it as an expired
    // session (see InvalidSession).
    // -----------------------------------------------------------------------
    public function connectedPatient(): Client
    {
        return $this->application->getClientByUserId($this->user()->userId)
            ?? throw new InvalidSession();
    }
}

Some utility functions. The static methods are called without an object ([Utils::iso($day)]); the others require services: the class is therefore also a service, which the controllers receive via their constructor.

  • Lines 38–46: the date of a parameter [yyyy-mm-dd] or [null]. Two checks: the format (a regular expression), then the existence of the date. [createFromFormat] is tolerant: February 30 becomes March 2. We then re-read the resulting date and verify that it is written exactly as the received text. The [!] in the format sets the time to midnight (without it, the time would be the current time). [mixed]: any type;
  • lines 56–61: “Saturday 26 September 2026” or “samedi 26 septembre 2026,” depending on the language of the request. [IntlDateFormatter] (example 16), with a pattern rather than a style: the codes are those from the ICU library, [EEEE] for the day of the week spelled out, [d] for the day, [MMMM] for the month spelled out, and [y] for the year. The parameter [pattern] is passed by name: the parameters that precede it in the signature—and for which no value is provided—retain their default values;
  • lines 66–73: A request parameter can be a string, such as [null], or even an array ([?doctorId[]=1], example 10). Only digits are accepted (up to nine: to avoid exceeding capacity), and the number must be strictly positive;
  • lines 79–83: the logged-in user, determined by the authenticator, which is returned by the [Security] service of [Symfony]. Protected actions are called only for an authenticated user, but the user’s absence is still handled as an expired session. [throw] is an expression ([PHP] 8): it can appear in the operator [? :];
  • lines 95–99: the patient (the [Client]) of the logged-in user. The token contains only the account ID; the client is identified by the [clients.user] column. If it does not exist, the account has been deleted since the user logged in: [InvalidSession].

11.15.5. src/Twig/ViewsExtension.php

<?php
// =========================================================================
// src/Twig/ViewsExtension.php: a Twig extension (see example 16), which adds
// to the views the functions that ALL the pages need:
//   - user()   : the logged-in user (or null), for the navigation bar;
//   - titles() : the possible titles of a person (Mr, Mme, Melle).
// =========================================================================

namespace App\Twig;

use App\Form\PersonFieldsType;
use App\Security\AuthService;
use App\Security\ConnectedUser;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;

class ViewsExtension extends AbstractExtension
{
    public function __construct(
        private Security $security,
        private RequestStack $requests,
        private AuthService $auth,
    ) {
    }

    public function getFunctions(): array
    {
        return [
            new TwigFunction('user', $this->user(...)),
            new TwigFunction('titles', fn () => PersonFieldsType::TITLES),
        ];
    }

    // -----------------------------------------------------------------------
    // the user established by the firewall. A 404 or 405 error page is
    // built BEFORE the firewall (routing, which fails, comes before it, see
    // example 24): the user is then read directly from the token, so that
    // the navigation bar of the error page is complete
    // -----------------------------------------------------------------------
    public function user(): ?ConnectedUser
    {
        $user = $this->security->getUser();
        if ($user instanceof ConnectedUser) {
            return $user;
        }
        $token = $this->requests->getMainRequest()?->cookies->getString(AuthService::COOKIE);
        return $token ? $this->auth->currentUser($token) : null;
    }
}

An extension of [Twig] (Example 16), which adds two functions to the views that all pages need:

  • [user()]: the logged-in user, or [null], for the navigation bar. Usually, this is the one from the [Security] service (line 44). However, a 404 or 405 error page is generated before the firewall has examined the request: the routing, which fails, takes precedence over it (Example 24). The user is then retrieved directly from the token (lines 48–49) so that the navigation bar on the error page is complete. [getMainRequest] makes the main request; the operator [?->] returns [null] without calling the method if the object is [null];
  • [titles()]: the possible titles for a person ([Mr], [Mme], [Melle]), a constant in the person form class (see below).

[$this->user(...)] (line 31) is the syntax for first-class functions ([PHP] 8.1): it creates a function that calls the [user] method of that object. The extension is automatically declared as [Twig], just like in Example 16: the container recognizes a class that inherits from [AbstractExtension].

11.16. Dictionaries

The interface texts are the same as those in other versions of the application, which stored them in nested JSON files:

1
2
3
4
5
6
{
  "LOGIN": {
    "TITLE": "Login",
    "USERNAME": "Login"
  }
}

The [Flask] version “flattened” them at startup: [LOGIN.TITLE] referred to the text [Login]. The YAML dictionaries from [Symfony] do the same (example 22): nested keys are separated by periods. We therefore simply converted the JSON files to YAML, and replaced the [{{name}}] parameters in the other versions with those of the ICU syntax, [{name}] (the [+intl-icu] suffix in the filenames, example 22):

1
2
3
4
5
6
7
APP:
    TITLE: 'RdvMedecins'
    LOGOUT: 'Log out'
DOCTORS:
    ADDED: 'Doctor {name} has been added (login: {login}).'
ERRORS:
    TOO_MANY_ATTEMPTS: 'Too many failed attempts. Try again in {minutes, plural, one {# minute} other {# minutes}}.'

The files [translations/messages+intl-icu.fr.yaml] and [messages+intl-icu.en.yaml] are not reproduced here. A key is represented by [{{ 'APP.LOGOUT'|trans }}] in a view, and a parameterized text by [{{ 'DOCTORS.ADDED'|trans({name: ..., login: ...}) }}]. A key missing from the dictionary is displayed as-is, which immediately flags an omission (and the command [php bin/console debug:translation fr] lists the keys used by the views that are missing from the dictionary). ICU enables something that previous versions could not do: the plural (“1 minute,” “15 minutes”), using the format [plural] (example 22). A text entry has been added: [ERRORS.PAGE_EXPIRED], the message on page 419, which is not present in other versions.

Form error messages are also keys in these dictionaries ([ERRORS.FIRSTNAME_REQUIRED]...): the validation component translates them into the [messages] domain, using the line [translation_domain: messages] from [framework.yaml]. Their parameters correspond to the constraints: [{limit}], for the minimum password length.

11.17. Forms

"Person" forms are forms from the Form component (examples 18 and 19): a type class describes the fields and their constraints; the action creates the form ([createForm]), passes the request to it ([handleRequest]), and then checks whether it has been submitted and is valid ([isSubmitted], [isValid]). An invalid form is redisplayed, showing the user’s input and error messages below the fields (excluding passwords: a [PasswordType] field is never redisplayed). The anti-CSRF token for each form is verified by the component itself.

11.17.1. src/Form/PersonFieldsType.php

<?php
// =========================================================================
// src/Form/PersonFieldsType.php: the fields of the "person" forms and their
// constraints (see examples 18 and 19). Three forms share the same identity
// fields (title, first name, last name):
//
//   PersonFieldsType            title, first name, last name
//    ├─ PersonType              + OPTIONAL login / password, version
//    │                            (the ADMIN adds or modifies a doctor / client)
//    ├─ RegistrationType        + REQUIRED login / password / confirmation
//    │                            (a patient creates their account)
//    └─ MyAccountType           + login, optional new password, REQUIRED
//                                 CURRENT password, version
//                                 (a patient modifies their account)
//
// The child classes INHERIT from this one (in the PHP sense): their buildForm first
// calls that of the parent, then adds its fields.
//
// The forms have no data class (data_class, see example 19): the
// validated input is an associative array, which the data() method turns into
// PersonData for the business layer.
//
// The labels, the help texts and the error messages are KEYS of the
// dictionaries: the Form component translates the labels and the help texts, the
// Validator component the messages (see config/packages/framework.yaml).
// Sequentially: the constraints are checked in order, and the check
// stops at the first one that is not satisfied - only one message per field.
// The maximum lengths are those of the SQL columns.
// =========================================================================

namespace App\Form;

use App\Business\PersonData;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;

abstract class PersonFieldsType extends AbstractType
{
    public const TITLES = ['Mr', 'Mme', 'Melle'];

    // login: lower-case letters, digits, dot, hyphen, underscore - 30 characters at most
    public const LOGIN_FORMAT = '/^[a-z0-9._-]{1,30}$/';

    // minimum length of a password CHOSEN by a patient
    public const PASSWORD_MIN = 6;

    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            // array_combine: ['Mr' => 'Mr', ...], the label and the value of each
            // option; choice_translation_domain false: the titles are not translated
            ->add('title', ChoiceType::class, [
                'label' => 'PERSONS.TITLE_LABEL',
                'choices' => array_combine(self::TITLES, self::TITLES),
                'choice_translation_domain' => false,
                'invalid_message' => 'ERRORS.INVALID_TITLE',
                'constraints' => [new Assert\NotBlank(message: 'ERRORS.INVALID_TITLE')],
            ])
            ->add('first_name', TextType::class, [
                'label' => 'PERSONS.FIRSTNAME_LABEL',
                'attr' => ['maxlength' => 20],
                'constraints' => [new Assert\Sequentially([
                    new Assert\NotBlank(message: 'ERRORS.FIRSTNAME_REQUIRED'),
                    new Assert\Length(max: 20, maxMessage: 'ERRORS.FIRSTNAME_TOO_LONG'),
                ])],
            ])
            ->add('last_name', TextType::class, [
                'label' => 'PERSONS.LASTNAME_LABEL',
                'attr' => ['maxlength' => 20],
                'constraints' => [new Assert\Sequentially([
                    new Assert\NotBlank(message: 'ERRORS.LASTNAME_REQUIRED'),
                    new Assert\Length(max: 20, maxMessage: 'ERRORS.LASTNAME_TOO_LONG'),
                ])],
            ]);
    }

    // the common options: an invalid form sent back as is to the browser,
    // without validation by the browser itself (see example 19: it is the SERVER
    // that is authoritative); the message of a wrong anti-CSRF token
    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'attr' => ['novalidate' => 'novalidate'],
            'csrf_message' => 'ERRORS.PAGE_EXPIRED',
        ]);
    }

    // -----------------------------------------------------------------------
    // the login field, common to the child forms. A login is always in
    // lower case: a TRANSFORMER (CallbackTransformer) converts the input BEFORE
    // its validation. Its first function turns the value of the form into
    // displayed text (here, nothing to do), the second one the input into the value of the form
    // -----------------------------------------------------------------------
    protected static function addLogin(FormBuilderInterface $builder, array $options): void
    {
        $builder->add('login', TextType::class, ['label' => 'PERSONS.LOGIN_LABEL', 'required' => false,
            'attr' => ['maxlength' => 30, 'autocomplete' => 'off'], ...$options]);
        $builder->get('login')->addModelTransformer(new CallbackTransformer(
            fn (?string $login) => $login,
            fn (?string $login) => $login === null ? null : mb_strtolower($login)));
    }

    // the validated input -> the data expected by the business layer. The empty
    // fields are null (the Form component converts them so)
    public static function data(array $input): PersonData
    {
        $password = $input['password'] ?? null;
        return new PersonData($input['title'], $input['last_name'], $input['first_name'],
            $input['login'] ?? null, $password === '' ? null : $password,
            isset($input['version']) && ctype_digit((string) $input['version']) ? (int) $input['version'] : null);
    }
}

Three forms share the same identity fields (title, first name, last name): the administrator form (for a doctor or a client), the patient account creation form, and “Manage My Account.” Their common parent class is abstract; each of its child classes extends these fields. None of these forms has a data class (the [data_class] option from Example 19): their data consists of an associative array, with one element per field.

  • lines 44–50: the input rules, in the form of constants, which are also read by the views (the list of titles, by function [titles()] from extension [Twig]);
  • lines 57–63: the title, a list ([ChoiceType], Example 18). [array_combine] generates the array [['Mr' => 'Mr', ...]]: the label for each option and its value. [choice_translation_domain: false]: The titles are not translation keys. [invalid_message]: The message for a value that is not part of the list (a manually constructed query);
  • Lines 64–79: first name and last name. The constraint [Sequentially] (example 09) checks its constraints in order and stops at the first one that is not met: only one message per field. [Length(max: 20)]: 20 characters maximum; the column length is SQL; the attribute HTML [maxlength] prevents more characters from being entered. Spaces at the beginning and end of an entry are removed by the Form component itself (the [trim] option for a text field is set to [true]), and an empty entry becomes [null]; a field with the option [PasswordType], on the other hand, is not cleaned: spaces can be part of a password;
  • Lines 85–91: common options. [novalidate] disables browser validations ([required], [maxlength]...) when the form is submitted: the server performs the validation, and its result is final (example 19). [csrf_message]: the message for a invalid anti-CSRF token, the one from the 419 page;
  • lines 99–106: the login, common to all three girls, to which each adds her own options (the operator [...] adds elements from one array to another; a key present in both takes on the last value). A login is always in lowercase: a transformer converts the input before it is validated. A model transformer ([addModelTransformer]) has two functions: the first transforms the form data into a displayed value (in this case, no action is required), and the second transforms the entered value into form data. [CallbackTransformer] creates a transformer based on these two functions;
  • Lines 110–116: The validated input is converted to [PersonData] for the business layer: the business layer is not aware of the forms. An empty password is [null] (unchanged, or the default value); the version (the hidden field of the optimistic lock, e.g., 28) is an integer, or [null].

11.17.2. src/Form/PersonType.php, RegistrationType.php, MyAccountType.php

<?php
// src/Form/PersonType.php: the form of a doctor or of a client (ADMIN):
// OPTIONAL login and password (see src/Business/PersonData.php).

namespace App\Form;

use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;

class PersonType extends PersonFieldsType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        parent::buildForm($builder, $options);
        $creation = $options['creation'];
        // an empty field is accepted: the constraints only apply to an entered value
        self::addLogin($builder, [
            'help' => $creation ? 'PERSONS.LOGIN_HELP_CREATE' : 'PERSONS.LOGIN_HELP_EDIT',
            'constraints' => [new Assert\Regex(self::LOGIN_FORMAT, message: 'ERRORS.INVALID_LOGIN')],
        ]);
        $builder->add('password', PasswordType::class, [
            'label' => 'PERSONS.PASSWORD_LABEL',
            'required' => false,
            'help' => $creation ? 'PERSONS.PASSWORD_HELP_CREATE' : 'PERSONS.PASSWORD_HELP_EDIT',
            'attr' => ['maxlength' => 72, 'autocomplete' => 'new-password'],
            // 72 characters at most: beyond that, bcrypt ignores the rest
            'constraints' => [new Assert\Length(max: 72, maxMessage: 'ERRORS.PASSWORD_TOO_LONG')],
        ]);
        // modification: the version of the displayed record, in a hidden field (the
        // optimistic lock)
        if (! $creation) {
            $builder->add('version', HiddenType::class);
        }
    }

    // an option of our own (see example 28): creation form, or modification form
    public function configureOptions(OptionsResolver $resolver): void
    {
        parent::configureOptions($resolver);
        $resolver->setDefaults(['creation' => true]);
    }
}

The administrator form. [parent::buildForm] (line 17) adds the fields from the parent class; next come the username and password, which are optional: a constraint applies only to a value that is entered (except for [NotBlank]), so an empty field is accepted. [Regex] validates the login format; [Length(max: 72)] limits the password length: [bcrypt] considers only the first 72 bytes (Example 25). The option [creation] (lines 40–44), an option we devised, declared with its default value (example 28), distinguishes between creation and modification: the tooltips differ (“Optional: defaults to the last name in lower case,” “Leave empty to keep it unchanged”), and only the modification has the hidden version field.

<?php
// src/Form/RegistrationType.php: "Create a patient account": REQUIRED login, password and
// confirmation.

namespace App\Form;

use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints as Assert;

class RegistrationType extends PersonFieldsType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        parent::buildForm($builder, $options);
        self::addLogin($builder, [
            'required' => true,
            'help' => 'REGISTRATION.LOGIN_HELP',
            'attr' => ['maxlength' => 30, 'autocomplete' => 'username'],
            'constraints' => [new Assert\Sequentially([
                new Assert\NotBlank(message: 'ERRORS.LOGIN_REQUIRED'),
                new Assert\Regex(self::LOGIN_FORMAT, message: 'ERRORS.INVALID_LOGIN'),
            ])],
        ]);
        $builder->add('password', RepeatedType::class, self::repeatedPassword(true));
    }

    // -----------------------------------------------------------------------
    // the password and its confirmation: a REPEATED field (see example 19), whose
    // two inputs must be identical (otherwise: invalid_message). Common to
    // the registration (required) and to the patient's account (optional).
    // {limit}: in the PASSWORD_TOO_SHORT message, the minimum length;
    // help_translation_parameters: the parameters of the help text
    // -----------------------------------------------------------------------
    public static function repeatedPassword(bool $required): array
    {
        $length = new Assert\Length(min: self::PASSWORD_MIN, max: 72,
            minMessage: 'ERRORS.PASSWORD_TOO_SHORT', maxMessage: 'ERRORS.PASSWORD_TOO_LONG');
        return [
            'type' => PasswordType::class,
            'required' => $required,
            'invalid_message' => 'ERRORS.PASSWORDS_DIFFER',
            'first_options' => [
                'label' => $required ? 'PERSONS.PASSWORD_LABEL' : 'MY_ACCOUNT.NEW_PASSWORD_LABEL',
                'help' => $required ? 'REGISTRATION.PASSWORD_HELP' : 'PERSONS.PASSWORD_HELP_EDIT',
                'help_translation_parameters' => ['min' => self::PASSWORD_MIN],
            ],
            'second_options' => ['label' => 'ACCOUNT.CONFIRMATION_LABEL'],
            'options' => ['attr' => ['maxlength' => 72, 'autocomplete' => 'new-password']],
            'constraints' => $required
                ? [new Assert\Sequentially([new Assert\NotBlank(message: 'ERRORS.PASSWORD_REQUIRED'), $length])]
                : [$length],
        ];
    }
}

Creating a patient account: login and password are required, and the password must be confirmed. The password is a repeated field ([RepeatedType], example 19): two [PasswordType] fields, whose entries must be identical (otherwise, the message [invalid_message] is displayed below the first one). Its options are generated by a static method, shared with “Manage My Account” (lines 36–55):

  • [first_options] and [second_options]: the options for each of the two fields; [options]: the options they have in common;
  • [help_translation_parameters]: the parameters for the help text, “At least {min} characters.”;
  • the minimum length is required only for the password chosen by a patient (6 characters). The message [ERRORS.PASSWORD_TOO_SHORT] contains [{limit}], the parameter that the constraint [Length] provides to its message;
  • [autocomplete="new-password"]: The browser offers to generate a password instead of filling in the field with the saved password.
<?php
// src/Form/MyAccountType.php: "Manage my account" (a patient): required login,
// optional new password, REQUIRED current password.

namespace App\Form;

use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints as Assert;

class MyAccountType extends PersonFieldsType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        parent::buildForm($builder, $options);
        self::addLogin($builder, [
            'required' => true,
            'attr' => ['maxlength' => 30, 'autocomplete' => 'username'],
            'constraints' => [new Assert\Sequentially([
                new Assert\NotBlank(message: 'ERRORS.LOGIN_REQUIRED'),
                new Assert\Regex(self::LOGIN_FORMAT, message: 'ERRORS.INVALID_LOGIN'),
            ])],
        ]);
        // optional: empty = password unchanged
        $builder->add('password', RepeatedType::class, RegistrationType::repeatedPassword(false));
        // the CURRENT password, required for any modification of the account; mapped
        // false: it is not part of the data of the form, it is read separately
        $builder->add('current_password', PasswordType::class, [
            'label' => 'MY_ACCOUNT.CURRENT_PASSWORD_LABEL',
            'help' => 'MY_ACCOUNT.CURRENT_PASSWORD_HELP',
            'mapped' => false,
            'attr' => ['maxlength' => 72, 'autocomplete' => 'current-password'],
            'constraints' => [new Assert\NotBlank(message: 'ERRORS.CURRENT_PASSWORD_REQUIRED')],
        ]);
        $builder->add('version', HiddenType::class);
    }
}

“Manage My Account”: the new password is optional (empty: unchanged), the current password is required. [mapped: false] (line 33): the current password is not part of the form data ([getData()] does not contain it); the action reads it separately, via [$form->get('current_password')->getData()]. It is not validated here, but by the action: an incorrect password must be counted by the attempt limiter as a failed login. ([Symfony] has a constraint, [UserPassword], which compares the entered password to the logged-in user’s password; this constraint is not applicable here: our logged-in user, [ConnectedUser], does not contain the hash of their password.)

11.18. View Templates

The [src/Views] folder contains the view templates: objects created by an action, which the view simply reads. The rule followed by all pages: no logic in the views. Actions prepare data that is already formatted (“Ms. Marie PELISSIER,” “8:00 – 8:20”) and already filtered according to the user’s role; the views place this data into the HTML. All these classes are of type [readonly] and have only properties, which are initialized by their constructor. The folder is excluded from the services ([config/services.yaml]): these are data, not services.

<?php
// src/Views/OptionView.php: an option of a drop-down list (doctor, patient).
//
// The src/Views folder contains the "view models": objects
// built in one go by an action, which the view merely reads. Rule
// followed by all the pages: no logic in the views. The actions
// prepare data ALREADY formatted ("Mme Marie PELISSIER",
// "8:00 - 8:20", selected = true...) and already filtered according to the role of
// the user; the views merely place them in the HTML.

namespace App\Views;

readonly class OptionView
{
    public function __construct(
        public int $id,
        public string $label,
        public bool $selected,
    ) {
    }
}

An option in a drop-down list: the ID, the label, and whether the option is selected?

<?php
// src/Views/ScheduleRow.php: a row of the displayed schedule (a slot).

namespace App\Views;

readonly class ScheduleRow
{
    public function __construct(
        public int $slotId,
        public string $time,         // "8:00 - 8:20"
        public bool $free,
        // displayed name for a taken slot; null = "Booked"
        public ?string $patient,
        public bool $mine,           // the appointment of the logged-in patient
        public bool $bookable,       // display the "Book" button?
        public string $bookUrl,
        // set only if the "Cancel" button is displayed
        public ?int $appointmentId,
    ) {
    }
}

A calendar entry: a time slot, and what the logged-in user is authorized to view and do with it. [patient] is populated only if the user has permission to see the patient’s name; [bookable] indicates whether to display the “Book” button; [appointmentId] indicates whether to display the “Cancel” button.

<?php
// src/Views/ScheduleView.php: the displayed schedule: the doctor, the day, the rows.

namespace App\Views;

readonly class ScheduleView
{
    /** @param list<ScheduleRow> $rows */
    public function __construct(
        public string $doctor,       // "Mme Marie PELISSIER"
        public string $day,          // "Wednesday 23 September 2026"
        public array $rows,
    ) {
    }
}
<?php
// src/Views/BookingView.php: the booking dialog of a slot.

namespace App\Views;

readonly class BookingView
{
    /** @param list<OptionView> $clients */
    public function __construct(
        public int $slotId,
        public string $time,
        // ADMIN / DOCTOR: the patients to choose from
        public array $clients,
        // USER: the patient is themselves
        public ?string $patient,
    ) {
    }
}

The displayed calendar and the booking window: for the administrator and doctors, the list of patients to choose from; for a patient, their own name. The comment [/** @param list<ScheduleRow> $rows */] specifies, for editors and analysis tools, the contents of the table: [PHP] can only declare the type [array].

<?php
// src/Views/PersonKeys.php: the translation keys specific to each type of
// person (doctors or clients), for the common views persons/list and
// persons/form.

namespace App\Views;

readonly class PersonKeys
{
    public function __construct(
        public string $title,                 // "Doctors"
        public string $add,                   // "Add a doctor"
        public string $edit,                  // "Edit a doctor"
        public string $empty,                 // "No doctors."
        public string $confirmDelete,         // "Delete this doctor... ?"
        public string $added,                 // "Doctor {name} has been added (login: {login})."
        public string $edited,
        public string $deleted,
    ) {
    }

    // the eight keys of a section of the dictionary ("DOCTORS", "CLIENTS")
    public static function of(string $section): self
    {
        return new self("$section.TITLE", "$section.ADD", "$section.EDIT", "$section.EMPTY",
            "$section.DELETE_CONFIRM", "$section.ADDED", "$section.UPDATED", "$section.DELETED");
    }
}

Translation keys specific to doctors or clients, for the two views they have in common. The static method [of] (lines 23–27) generates the eight keys for a section of the dictionaries: [PersonKeys::of('DOCTORS')] yields [DOCTORS.TITLE], [DOCTORS.ADD]... [new self(...)] creates an object of the class itself.

<?php
// src/Views/AppointmentView.php: an upcoming appointment, in the patient's area.

namespace App\Views;

readonly class AppointmentView
{
    public function __construct(
        public int $id,
        public string $day,          // "Wednesday 23 September 2026"
        public string $time,         // "8:00 - 8:20"
        public string $doctor,       // "Mme Marie PELISSIER"
        public string $scheduleUrl,  // the doctor's schedule on that day
    ) {
    }
}

An upcoming appointment in the patient area.

11.19. The web layer: routes and controllers

11.19.1. Routes

Routes are described by the [#[Route]] attributes of controllers (Example 02), which are loaded by the [config/routes.yaml] file. The [debug:router] command lists them all, along with their names, methods, and paths:

$ php bin/console debug:router
 --------------------- ---------- ----------------------
  Name                  Method     Path
 --------------------- ---------- ----------------------
  home                  GET        /
  login                 GET        /login
  login_submit          POST       /login
  logout                POST       /logout
  clients_list          GET        /clients
  clients_add           GET|POST   /clients/add
  clients_edit          GET|POST   /clients/{id}/edit
  clients_delete        POST       /clients/{id}/delete
  doctors_list          GET        /doctors
  doctors_add           GET|POST   /doctors/add
  doctors_edit          GET|POST   /doctors/{id}/edit
  doctors_delete        POST       /doctors/{id}/delete
  language              GET        /language/{code}
  area                  GET        /my-area
  area_cancel           POST       /my-area/cancel
  account               GET|POST   /my-account
  account_delete        POST       /my-account/delete
  registration_access   POST       /register/access
  registration          GET|POST   /register
  schedule              GET        /schedule
  schedule_book         POST       /schedule/book
  schedule_cancel       POST       /schedule/cancel
 --------------------- ---------- ----------------------

The URL files correspond to other versions of the application: modifying and deleting a doctor are POST requests ([/doctors/{id}/edit], [/doctors/{id}/delete]), not PUT and DELETE as in Example 28. The code always generates the URL based on the route names ([redirectToRoute('login')], [path('schedule')] in the views, Example 04): changing a URL requires modifying only its [#[Route]] attribute.

Access rules are not in the controllers, but in the [access_control] rules of [config/packages/security.yaml], as shown above: one rule per URL prefix ([^/(doctors|clients)] for the administrator, [^/my-] for patients...), and a final rule that requires an authenticated user for all other URL. The application’s URL routes were chosen for this purpose: all pages within the same role begin the same way. A URL route that does not match any route returns a 404 page, even to an anonymous visitor; a request for GET directed at a URL that exists only as POST ([/logout]) results in a 405 page, presented as a "page not found" (handler [ErrorPage]).

11.19.2. src/Controller/LanguageController.php

<?php
// =========================================================================
// src/Controller/LanguageController.php: GET /language/fr, GET /language/en: the two
// FR / EN buttons of the navigation bar. Public route: one must be able to
// change the language before logging in.
//
// The choice is remembered in a cookie: on each following request, the listener
// SetLanguage reads it again and sets the language of the request. Then we go back to the
// page we came from (see example 22).
// =========================================================================

namespace App\Controller;

use App\EventListener\SetLanguage;
use App\Web\BaseController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class LanguageController extends BaseController
{
    public function __construct(#[Autowire(env: 'bool:COOKIE_SECURE')] private bool $cookieSecure)
    {
    }

    // {code} is limited to the supported languages (requirements): /language/de gives a 404 page
    #[Route('/language/{code}', name: 'language', requirements: ['code' => 'fr|en'], methods: ['GET'])]
    public function change(Request $request, string $code): Response
    {
        $response = $this->redirect($this->originPage($request));
        $response->headers->setCookie(Cookie::create(SetLanguage::COOKIE, $code)
            ->withExpires(new \DateTimeImmutable('+1 year'))
            ->withSecure($this->cookieSecure)
            ->withHttpOnly(true)
            ->withSameSite(Cookie::SAMESITE_LAX));
        return $response;
    }

    // -----------------------------------------------------------------------
    // the previous page, according to the HTTP "Referer" header sent by the browser.
    // Only its path is kept (/schedule?...): redirecting to a complete
    // address provided by the request would allow sending the user to
    // another site (so-called "open redirect" flaw).
    // -----------------------------------------------------------------------
    private function originPage(Request $request): string
    {
        $referer = $request->headers->get('Referer');
        $url = $referer ? parse_url($referer) : false;
        $path = $url['path'] ?? '';
        // "//site.com/..." would be understood by the browser as an address on
        // ANOTHER site: refused too
        if (! isset($url['scheme']) || ! str_starts_with($path, '/') || str_starts_with($path, '//')
            || str_starts_with($path, '/language/')) {
            return '/';
        }
        return isset($url['query']) ? "$path?{$url['query']}" : $path;
    }
}

The FR / EN buttons on the navigation bar: the language selection is stored in a cookie for one year (example 22), then the browser returns to the page it came from. The cookie is [HttpOnly]: the [JavaScript] on the page does not need to read it. It is unsigned: the listener [SetLanguage] verifies its value, and an unknown value results in English (see the chapter on ranges).

  • Line 29: [requirements: ['code' => 'fr|en']] accepts only these two values for the parameter [{code}]: [/language/de] does not correspond to any route (404 page);
  • Lines 47–59: The previous page is specified by the header HTTP ***[Referer], which the browser sends with the request. This data is provided by the client and is therefore suspect: redirecting to a full URL from the request would allow an attacker to send the user to their own site via a link to [/language/fr] that includes a fake ***[Referer] (the so-called “open redirect” vulnerability). Therefore, we retain only the path and parameters ([/schedule?doctorId=1&...]), extracted by the function [parse_url] from [PHP], which splits a URL into its parts ([scheme], [host], [path], [query]...). A path starting with [//] is also rejected: a browser interprets [//site.com/page] as an address on another site. Finally, we cannot return to [/language/...]: a loop.

11.19.3. src/Controller/ScheduleController.php

<?php
// =========================================================================
// src/Controller/ScheduleController.php: the schedule page: choice of a doctor and
// of a day, schedule of that doctor on that day, booking and cancellation of
// appointments.
//
//   GET  /schedule?doctorId=1&day=2026-09-23               the page
//   GET  /schedule?doctorId=1&day=2026-09-23&book=7        the same page, with the
//                                                          booking dialog
//                                                          of slot 7 open
//   POST /schedule/book                                    creates an appointment
//   POST /schedule/cancel                                  cancels an appointment
//
// The whole page is described by its URL: it can be reloaded, bookmarked,
// the browser's "Back" button can be used - without a single line of
// JavaScript.
//
// What each one sees and can do:
//
//   | role          | taken slot displayed              | Book               | Cancel  |
//   |---------------|-----------------------------------|--------------------|---------|
//   | ADMIN, DOCTOR | name of the patient               | for a patient      | yes     |
//   |               |                                   | chosen in a list   |         |
//   | USER (patient)| "Booked", or THEIR name if it is  | for themselves, if | no (*)  |
//   |               | their own appointment             | they have no       |         |
//   |               |                                   | upcoming one       |         |
//
//   (*) the patient cancels their appointment from their "My area" page.
//
// CONFIDENTIALITY: for a patient, the name of the OTHER patients is not
// simply hidden by the view - it is never passed to the view. The HTML sent
// to the browser therefore does not contain it, not even hidden (a simple "View page
// source" would otherwise reveal it).
//
// No booking in the past: the "Book" button only appears for
// today and the following days (and the business layer would refuse it
// anyway).
//
// Security: all the /schedule URLs require an authenticated user, and
// /schedule/cancel the ADMIN or DOCTOR role (see config/packages/security.yaml).
// =========================================================================

namespace App\Controller;

use App\Domain\DoctorDaySchedule;
use App\Entity\Client;
use App\Entity\Person;
use App\Entity\Role;
use App\Business\Dates;
use App\Business\BusinessException;
use App\Views\ScheduleView;
use App\Views\ScheduleRow;
use App\Views\OptionView;
use App\Views\BookingView;
use App\Web\ApplicationModel;
use App\Web\BaseController;
use App\Web\Flash;
use App\Web\Message;
use App\Web\Utils;
use DateTimeImmutable;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/schedule')]
class ScheduleController extends BaseController
{
    public function __construct(
        private ApplicationModel $application,
        private Utils $utils,
        private Flash $flash,
    ) {
    }

    // -----------------------------------------------------------------------
    // GET /schedule
    // The parameters of the URL are read raw ($request->query), to distinguish an
    // ABSENT parameter (null) from an EMPTY parameter (?doctorId=).
    // -----------------------------------------------------------------------
    #[Route('', name: 'schedule', methods: ['GET'])]
    public function show(Request $request): Response
    {
        $error = null;
        $user = $this->utils->user();
        $rights = $this->rights();

        // the day: that of the URL if it is valid, otherwise today
        $day = Dates::today();
        $rawDay = $request->query->get('day');
        if ($rawDay !== null) {
            $requestedDay = Utils::validDate($rawDay);
            if ($requestedDay !== null) {
                $day = $requestedDay;
            } else {
                $error = 'ERRORS.INVALID_DATE';
            }
        }
        $bookableDay = $day >= Dates::today();

        // the doctor: that of the URL; failing that, for a logged-in doctor, themselves
        $rawDoctorId = $request->query->get('doctorId');
        $doctorId = Utils::positiveInteger($rawDoctorId);
        if ($rawDoctorId === null && $user->role === Role::DOCTOR) {
            $doctorId = $this->application->getDoctorByUserId($user->userId)?->getId();
        }

        // the drop-down list of the doctors
        $doctors = array_map(fn (Person $d) => self::option($d, $d->getId() === $doctorId),
            $this->application->getAllDoctors());

        // the schedule, if a doctor is chosen (and the date valid)
        $schedule = null;
        if ($doctorId !== null && $error === null) {
            $doctorSchedule = $this->application->getDoctorDaySchedule($doctorId, $day);
            if ($doctorSchedule === null) {
                $error = 'ERRORS.DOCTOR_NOT_FOUND';
            } else {
                $schedule = $this->scheduleView($doctorSchedule, $rights, $bookableDay);
            }
        }

        // the booking dialog: only if the requested slot can be booked
        // by this user. array_find: the first element that satisfies the
        // condition (null if there is none)
        $booking = null;
        $slotId = Utils::positiveInteger($request->query->get('book'));
        if ($schedule !== null && $slotId !== null) {
            $row = array_find($schedule->rows,
                fn (ScheduleRow $r) => $r->slotId === $slotId && $r->bookable);
            if ($row !== null) {
                $booking = new BookingView(
                    $row->slotId,
                    $row->time,
                    $rights['manager']
                        ? array_map(fn (Client $c) => self::option($c, false), $this->application->getAllClients())
                        : [],
                    $rights['patient']?->label());
            }
        }

        return $this->render('schedule.html.twig', [
            'pageTitle' => 'SCHEDULE.PAGE_TITLE',
            'error' => $error ? new Message($error) : null,
            'doctors' => $doctors,
            'doctorId' => $doctorId,
            'day' => Utils::iso($day),
            'schedule' => $schedule,
            'booking' => $booking,
            // the displayed schedule, without booking dialog
            'backUrl' => $doctorId !== null ? $this->scheduleUrl($doctorId, $day) : $this->generateUrl('schedule'),
            'info' => $this->patientInfo($rights, $schedule),
        ]);
    }

    // -----------------------------------------------------------------------
    // POST /schedule/book   fields: doctorId, day, slotId, clientId
    //
    // For a patient (USER), the clientId field is IGNORED: they always book
    // for themselves. Trusting a field sent by the browser
    // would allow a cunning patient to book in the name of another one.
    //
    // "Post / Redirect / Get" pattern: whatever the result, no page is built
    // here - a flash message is left and we redirect to the schedule
    // (see example 20).
    // -----------------------------------------------------------------------
    #[Route('/book', name: 'schedule_book', methods: ['POST'])]
    public function book(Request $request): Response
    {
        $this->checkCsrf($request);
        [$doctorId, $day] = $this->requestedSchedule($request);
        $slotId = Utils::positiveInteger($request->request->get('slotId'));
        if ($doctorId === null || $slotId === null) {
            // -> "invalid request" page (see src/EventListener/ErrorPage.php)
            throw new BadRequestHttpException('invalid schedule or slot');
        }
        $user = $this->utils->user();

        if ($user->isPatient()) {
            $client = $this->utils->connectedPatient();
        } else {
            $clientId = Utils::positiveInteger($request->request->get('clientId'));
            if ($clientId === null) {
                // no patient chosen: the booking dialog is opened again
                $this->flash->error('ERRORS.CHOOSE_PATIENT');
                return $this->redirect($this->scheduleUrl($doctorId, $day, $slotId));
            }
            $client = $this->application->getClientById($clientId);
        }

        $slot = $this->application->getSlotById($slotId);
        if ($client === null) {
            $this->flash->error('ERRORS.PATIENT_NOT_FOUND');
        } elseif ($slot === null || $slot->getDoctor()->getId() !== $doctorId) {
            $this->flash->error('ERRORS.SLOT_NOT_FOUND');
        } else {
            try {
                // the rules (not in the past, only one upcoming appointment per
                // patient, free slot) are checked by the business layer
                $this->application->addAppointment($day, $slot, $client);
                $this->flash->success('SCHEDULE.BOOKED');
            } catch (BusinessException $e) {
                // same rule, but a message in the 2nd person when it is the
                // patient themselves who books ("You already have..." rather than
                // "This patient..."). The other exceptions (real anomalies) are not
                // caught: 500 page.
                if ($e->key === 'ERRORS.PATIENT_HAS_UPCOMING_APPOINTMENT' && $user->isPatient()) {
                    $this->flash->error('SCHEDULE.ALREADY_BOOKED_INFO');
                } else {
                    $this->flash->error($e->key, $e->params);
                }
            }
        }
        return $this->redirect($this->scheduleUrl($doctorId, $day));
    }

    // -----------------------------------------------------------------------
    // POST /schedule/cancel   fields: appointmentId, doctorId, day
    // (a patient cancels THEIR appointment from "My area", not from here)
    // -----------------------------------------------------------------------
    #[Route('/cancel', name: 'schedule_cancel', methods: ['POST'])]
    public function cancel(Request $request): Response
    {
        $this->checkCsrf($request);
        [$doctorId, $day] = $this->requestedSchedule($request);
        $appointmentId = Utils::positiveInteger($request->request->get('appointmentId'));
        if ($doctorId === null || $appointmentId === null) {
            throw new BadRequestHttpException('invalid schedule or appointment');
        }
        $appointment = $this->application->getAppointmentById($appointmentId);
        if ($appointment === null) {
            // already cancelled (by someone else, or double click)
            $this->flash->error('ERRORS.APPOINTMENT_NOT_FOUND');
        } else {
            $this->application->deleteAppointment($appointment);
            $this->flash->success('SCHEDULE.CANCELLED');
        }
        return $this->redirect($this->scheduleUrl($doctorId, $day));
    }

    // -----------------------------------------------------------------------
    // who is logged in, and what are they allowed to do on this page?
    //   manager: ADMIN or DOCTOR;
    //   patient: the logged-in client (USER role), otherwise null;
    //   patientCanBook: a USER without an upcoming appointment.
    // -----------------------------------------------------------------------
    private function rights(): array
    {
        if (! $this->utils->user()->isPatient()) {
            return ['manager' => true, 'patient' => null, 'patientCanBook' => false];
        }
        $patient = $this->utils->connectedPatient();
        $upcoming = $this->application->getClientUpcomingAppointments($patient->getId());
        return ['manager' => false, 'patient' => $patient, 'patientCanBook' => $upcoming === []];
    }

    // -----------------------------------------------------------------------
    // the doctor's schedule, formatted for the view - and filtered according to the rights
    // -----------------------------------------------------------------------
    private function scheduleView(DoctorDaySchedule $doctorSchedule, array $rights, bool $bookableDay): ScheduleView
    {
        $doctorId = $doctorSchedule->doctor->getId();
        $day = $doctorSchedule->day;
        $rows = [];
        foreach ($doctorSchedule->doctorDaySlots as $dds) {
            $slot = $dds->slot;
            $appointment = $dds->appointment;
            $free = $appointment === null;
            // the appointment of the logged-in patient?
            $mine = $appointment !== null && $rights['patient'] !== null
                && $appointment->getClient()->getId() === $rights['patient']->getId();
            $rows[] = new ScheduleRow(
                slotId: $slot->getId(),
                time: $slot->time(),
                free: $free,
                // THE confidentiality point: a patient only receives the name of
                // THEIR appointment; for the others, null (the view displays "Booked")
                patient: $appointment !== null && ($rights['manager'] || $mine) ? $appointment->getClient()->label() : null,
                mine: $mine,
                bookable: $free && $bookableDay && ($rights['manager'] || $rights['patientCanBook']),
                bookUrl: $this->scheduleUrl($doctorId, $day, $slot->getId()),
                appointmentId: $appointment !== null && $rights['manager'] ? $appointment->getId() : null);
        }
        return new ScheduleView($doctorSchedule->doctor->label(), $this->utils->formatDay($day), $rows);
    }

    // ----------------------------------------------------------------- utility functions

    // the hidden fields doctorId / day of the POST forms; [null, null] if they
    // are invalid
    private function requestedSchedule(Request $request): array
    {
        $doctorId = Utils::positiveInteger($request->request->get('doctorId'));
        $day = Utils::validDate($request->request->get('day'));
        return $doctorId === null || $day === null ? [null, null] : [$doctorId, $day];
    }

    // /schedule?doctorId=1&day=2026-09-23[&book=7]: generateUrl appends to the end
    // of the URL the parameters that are not parameters of the path (see example 16)
    private function scheduleUrl(int $doctorId, DateTimeImmutable $day, ?int $book = null): string
    {
        $params = ['doctorId' => $doctorId, 'day' => Utils::iso($day)];
        if ($book !== null) {
            $params['book'] = $book;
        }
        return $this->generateUrl('schedule', $params);
    }

    private static function option(Person $p, bool $selected): OptionView
    {
        return new OptionView($p->getId(), $p->label(), $selected);
    }

    // -----------------------------------------------------------------------
    // the information message of a patient who already has an upcoming appointment
    // (why there is no "Book" button), or null. Useless if THEIR
    // appointment appears in the displayed schedule (in blue): that is the case right
    // after they have just booked it, when the message would be out of place.
    // array_any: does at least one element satisfy the condition?
    // -----------------------------------------------------------------------
    private function patientInfo(array $rights, ?ScheduleView $schedule): ?string
    {
        if ($rights['patient'] === null || $rights['patientCanBook']) {
            return null;
        }
        $appointmentShown = $schedule !== null && array_any($schedule->rows, fn (ScheduleRow $r) => $r->mine);
        return $appointmentShown ? null : 'SCHEDULE.ALREADY_BOOKED_INFO';
    }
}

The application’s main page. The header comment summarizes what each user sees and can do. The [#[Route('/schedule')]] attribute of the class (line 66) specifies the start of the path for all its routes (example 02): the route [''] is [/schedule] itself.

11.19.3.1. The URL on the page

The entire page is described by its URL: [/schedule?doctorId=1&day=2026-09-26] displays Doctor 1’s schedule for that day; [&book=4] also opens the booking window for slot 4. You can reload the page, bookmark it, or use the browser’s Back button without a single line of [JavaScript]. The form at the top of the page (for selecting the doctor and the day) is a GET form (example 17): it reloads the page with new parameters. The appointment booking window is opened by a simple link and closed by a link to the same page without the [book] parameter.

11.19.3.2. GET /schedule

Lines 81–154:

  • the parameters of URL are read as-is ([$request->query->get('day')]), without validation by [Symfony]: a missing parameter ([null]) is not an error (the system defaults to today or “no doctor”); an invalid date is an error, displayed at the bottom of the page (the doctor and day selections remain visible, but the calendar is not displayed); a doctor or time slot number that is not a positive integer is simply ignored;
  • a logged-in user who has not selected a doctor sees their schedule: their doctor is retrieved via their account ([getDoctorByUserId]). [?->getId()]: the nullsafe operator returns [null] if the object is [null], instead of raising an error;
  • the drop-down list of doctors: [array_map] converts each doctor to [OptionView];
  • the calendar, formatted and filtered according to the user’s permissions ([scheduleView], below);
  • the booking window, only if the requested time slot is bookable by that user: a patient who attempts to generate the URL [&book=2] for a booked time slot would get nothing. [array_find] (example 06) returns the first element of an array that satisfies a condition, or [null]. The list of patients is provided only to the administrator and doctors; a patient receives only their own name;
  • the view receives its variables ([render], example 13), including [pageTitle] and [error], which are read by the template.

11.19.3.3. POST /schedule/book

Lines 167–215:

  • the anti-CSRF token, followed by the hidden fields from the booking form (doctor, day, time slot). Since these are invalid, this must be a manually crafted request: [BadRequestHttpException], the “invalid request” page (400);
  • For a patient, the field [clientId] is ignored: the patient always makes an appointment for themselves ([Utils::connectedPatient]). Relying on a field sent by the browser would allow a malicious patient to make a reservation on behalf of someone else. For the administrator and doctors, a patient must have been selected; otherwise, the reservation window reopens with a message;
  • the time slot must belong to the doctor listed on the page;
  • the business rules (not in the past, only one upcoming appointment, available time slot) are verified by the business logic layer. A [BusinessException] becomes a flash error message; its public properties [key] and [params] provide the message. The same rejection—“one upcoming appointment”—is phrased in the second person for the patient themselves (“You already have…”). Any other exception is not intercepted: this is a true error (500 page);
  • in all cases, the Post/Redirect/Get pattern (example 20): a pop-up message and a redirect to the calendar.

11.19.3.4. POST /schedule/cancel

Lines 221–239: Cancellation of an appointment by the administrator or a doctor (rule [^/schedule/cancel$] derived from [security.yaml]). An appointment that no longer exists (already canceled by someone else, or due to a double-click) is not an error: it’s just a message.

11.19.3.5. Permissions, the formatted calendar, and utility functions

  • lines 247–255: what the user is authorized to do on the page, in an associative array: is the user an administrator (ADMIN or DOCTOR)? a patient (and which one)? can this patient make a reservation (no upcoming appointments)?
  • Lines 260–285: The doctor’s schedule, converted into rows for the view. The privacy consideration is on line 278: a patient sees only the name associated with their own appointment. For the others, the property [patient] is set to [null], and the view displays “Booked.” The name is not hidden by the view: it is never passed to the view, and therefore does not appear in the page’s source code, where it would be easy to read it. The “Book” button is displayed for an available time slot, on a day that has not yet passed, to an administrator or a patient who can make a reservation; the “Cancel” button is displayed only to administrators. The named arguments ([slotId: $slot->getId()]) make the construction of an object with eight properties readable;
  • lines 291–296: the hidden fields of the POST forms. [[$doctorId, $day] = ...] (lines 171 and 225) breaks down the rendered array into two variables;
  • lines 300–307: the URL for a doctor’s schedule on a given day. [generateUrl('schedule', [...])] adds the values that are not path parameters to the end of URL as query parameters: [/schedule?doctorId=1&day=2026-09-26];
  • lines 321–328: the message explaining to a patient why there is no “Book” button (they already have an upcoming appointment), unless that appointment is in the displayed calendar (immediately after booking it, the message would be moved). [array_any] ([PHP] 8.4): Does at least one element satisfy the condition?

11.19.4. src/Controller/PersonsController.php

<?php
// =========================================================================
// src/Controller/PersonsController.php: the management of the doctors and that of the
// clients (reserved to the ADMIN) are IDENTICAL: same list, same form,
// same rules. Rather than writing the same actions twice, we write them
// once in this ABSTRACT class; DoctorsController and ClientsController
// inherit from it, and only specify what distinguishes them: the name of the section
// (doctors or clients), the translation keys, and the methods of
// the ApplicationModel to call.
//
// The #[Route] attributes of the actions are inherited too: the #[Route] attribute
// of EACH child class gives them the beginning of their path (/doctors or
// /clients) and of their name (doctors_ or clients_). We thus get, for each one:
//
//   GET  /doctors                    list                      doctors_list
//   GET  /doctors/add                empty form                doctors_add
//   POST /doctors/add                creation                  doctors_add
//   GET  /doctors/{id}/edit          pre-filled form           doctors_edit
//   POST /doctors/{id}/edit          modification              doctors_edit
//   POST /doctors/{id}/delete        deletion                  doctors_delete
//
// (and the same URLs under /clients). The views are common too:
// templates/persons/list.html.twig and persons/form.html.twig.
//
// SECURITY: the access_control rule ^/(doctors|clients) of
// config/packages/security.yaml reserves all these URLs to the ADMIN - including those
// that would be added later.
// =========================================================================

namespace App\Controller;

use App\Entity\Person;
use App\Form\PersonFieldsType;
use App\Form\PersonType;
use App\Business\PersonData;
use App\Business\BusinessException;
use App\Views\PersonKeys;
use App\Web\ApplicationModel;
use App\Web\BaseController;
use App\Web\Flash;
use App\Web\Message;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

abstract class PersonsController extends BaseController
{
    // the form fields concerned by each "input" business error (a
    // duplicate): they are framed in red, the message being at the bottom of the page
    public const BUSINESS_ERROR_FIELDS = [
        'ERRORS.LOGIN_TAKEN' => ['login'],
        'ERRORS.DOCTOR_ALREADY_EXISTS' => ['first_name', 'last_name'],
        'ERRORS.PATIENT_ALREADY_EXISTS' => ['first_name', 'last_name'],
    ];

    public function __construct(protected ApplicationModel $application, protected Flash $flash)
    {
    }

    // ---- what each type of person must provide

    // "doctors" or "clients": the beginning of the names of the routes
    abstract protected function section(): string;

    abstract protected function keys(): PersonKeys;

    /** @return list<Person> */
    abstract protected function all(): array;

    abstract protected function find(int $id): ?Person;

    abstract protected function create(PersonData $data): Person;

    abstract protected function update(int $id, PersonData $data): Person;

    abstract protected function erase(int $id): void;

    // -------------------------------------------------------------------
    // GET /doctors (or /clients)
    // -------------------------------------------------------------------
    #[Route('', name: 'list', methods: ['GET'])]
    public function list(): Response
    {
        return $this->render('persons/list.html.twig', ['pageTitle' => $this->keys()->title,
            'keys' => $this->keys(), 'section' => $this->section(), 'persons' => $this->all()]);
    }

    // -------------------------------------------------------------------
    // GET /doctors/add: the empty form; POST: the creation
    // -------------------------------------------------------------------
    #[Route('/add', name: 'add', methods: ['GET', 'POST'])]
    public function add(Request $request): Response
    {
        $form = $this->createForm(PersonType::class, ['title' => 'Mr']);
        $form->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {
            try {
                $person = $this->create(PersonFieldsType::data($form->getData()));
            } catch (BusinessException $e) {
                return $this->businessError($e, $form, null);
            }
            $this->flash->success($this->keys()->added, self::params($person));
            return $this->redirectToRoute($this->section().'_list');
        }
        return $this->page($form, null);
    }

    // -------------------------------------------------------------------
    // GET /doctors/{id}/edit: the pre-filled form; POST: the modification.
    // {id<\d+>}: a parameter made of digits (see example 02):
    // /doctors/abc/edit matches no route (404 page)
    // -------------------------------------------------------------------
    #[Route('/{id<\d+>}/edit', name: 'edit', methods: ['GET', 'POST'])]
    public function edit(Request $request, int $id): Response
    {
        $person = $this->find($id) ?? throw $this->createNotFoundException();
        // the form, pre-filled with the values of the person (the
        // password, on the other hand, is never displayed)
        $form = $this->createForm(PersonType::class, [
            'title' => $person->getTitle(), 'first_name' => $person->getFirstName(),
            'last_name' => $person->getLastName(), 'login' => $person->getAccount()->getLogin(),
            'version' => $person->getVersion(),
        ], ['creation' => false]);
        $form->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {
            try {
                $person = $this->update($id, PersonFieldsType::data($form->getData()));
            } catch (BusinessException $e) {
                return $this->businessError($e, $form, $person);
            }
            $this->flash->success($this->keys()->edited, self::params($person));
            return $this->redirectToRoute($this->section().'_list');
        }
        return $this->page($form, $person);
    }

    // -------------------------------------------------------------------
    // POST /doctors/{id}/delete
    // (the confirmation "Are you sure?" is asked before the form is sent,
    // see public/js/app.js)
    // -------------------------------------------------------------------
    #[Route('/{id<\d+>}/delete', name: 'delete', methods: ['POST'])]
    public function delete(Request $request, int $id): Response
    {
        $this->checkCsrf($request);
        $person = $this->find($id);
        try {
            $this->erase($id);
            $this->flash->success($this->keys()->deleted, self::params($person));
        } catch (BusinessException $e) {
            $this->flash->error($e->key, $e->params);
        }
        return $this->redirectToRoute($this->section().'_list');
    }

    // ----------------------------------------------------------------- internal functions

    // $person null: creation form; otherwise: modification form.
    // A refused form (invalid input, or business rule): the code 422
    private function page(FormInterface $form, ?Person $person, ?Message $error = null,
        array $invalidFields = []): Response
    {
        $keys = $this->keys();
        $refused = $error !== null || ($form->isSubmitted() && ! $form->isValid());
        return $this->render('persons/form.html.twig', [
            'pageTitle' => $person === null ? $keys->add : $keys->edit,
            'form' => $form,
            'error' => $error,
            'invalidFields' => $invalidFields,
            'listUrl' => $this->generateUrl($this->section().'_list'),
        ], new Response(status: $refused ? 422 : 200));
    }

    // a business rule refused the write: the form is displayed again, with
    // the user's input (without the password), the message of the business
    // layer (at the bottom of the page), and the fields concerned, if any, framed in
    // red so that the user immediately sees what to correct
    private function businessError(BusinessException $e, FormInterface $form, ?Person $person): Response
    {
        if (in_array($e->key, ['ERRORS.DOCTOR_NOT_FOUND', 'ERRORS.PATIENT_NOT_FOUND'], true)) {
            // deleted in the meantime by another ADMIN: back to the list
            $this->flash->error($e->key, $e->params);
            return $this->redirectToRoute($this->section().'_list');
        }
        return $this->page($form, $person, new Message($e->key, $e->params),
            self::BUSINESS_ERROR_FIELDS[$e->key] ?? []);
    }

    // {name} and {login} of the messages "added / updated / deleted"
    private static function params(?Person $person): array
    {
        return $person === null ? ['name' => '', 'login' => '']
            : ['name' => "{$person->getFirstName()} {$person->getLastName()}",
                'login' => $person->getAccount()->getLogin()];
    }
}

The management of doctors and clients is identical: same list, same form, same rules. The actions are written once, in an abstract class; [DoctorsController] and [ClientsController] inherit from it. Each must provide what sets it apart: this is the role of the abstract methods in lines 64–77, declared without code; a child class that does not implement them would be rejected by [PHP]. This is the design pattern known as the “template method”: the parent class defines the algorithm, and the child classes provide the details.

The [#[Route]] attributes of the actions are also inherited. [Symfony] ignores the abstract class but examines the methods of each of the child classes, including inherited ones; the [#[Route]] attribute of the child class specifies the start of their path and name: the action [list] becomes the routes [doctors_list] ([/doctors]) and [clients_list] ([/clients]). To add an action to the doctor management or client management modules, simply write it here.

  • Lines 51–55: the fields affected by each business-logic input error: a duplicate login affects the “login” field; a name match affects the “first name” and “last name” fields. These fields will be highlighted in red;
  • line 57: the constructor, inherited by the child classes. Its properties are [protected]: visible to the child classes;
  • Lines 82–87: the list. This view is common to both sections: it receives the translation keys for the section ([keys]) and its name ([section]), which is used to generate the route names ([section ~ '_add'] in the view);
  • Lines 92–107: Adding. A single action for GET (the empty form, with the title [Mr] selected) and POST (creation), as in Example 19. An invalid entry reloads the form. A valid entry proceeds to the business layer, which may reject the write operation (e.g., a namesake, a login already in use); otherwise, a success message appears and the user returns to the list (schema PRG);
  • lines 114–136: the update, for an existing person: [?? throw $this->createNotFoundException()] (the 404 page; [throw] is an expression that can be used to the right of [??]). [{id<\d+>}] (line 114) accepts only digits for the parameter [{id}] (example: 02). The form is pre-filled with the person’s values and their version; [handleRequest] replaces them with the entered data, resulting in POST;
  • lines 143–155: deletion. The form requested confirmation in the browser (example 21). The person’s information is retrieved before deletion to generate the success message (“Doctor Paul TATOU has been deleted.”);
  • lines 161–173: the form, either for creation ([$person] equals [null]) or modification. A rejected form (invalid input or business rule violation) is returned with status code 422 (example 19). The view receives the form itself ([render] converts it into a “form view,” example 18);
  • lines 179–188: the business layer rejected the write operation. If a person has been deleted in the meantime (by another administrator), the user is returned to the list. Otherwise, the form is redisplayed directly (without redirection), as in the case of an input error: it already contains the user’s input. Added to this are the message from the business layer (displayed at the bottom of the page, in the error area) and the fields to be highlighted in red. Note that the entity manager is closed after the exception (the business layer, as mentioned above): the action now only displays;
  • lines 191–196: the parameters [{name}] and [{login}] for the success messages.

11.19.5. src/Controller/DoctorsController.php and ClientsController.php

<?php
// src/Controller/DoctorsController.php: the management of the doctors (ADMIN role
// only). All the actions are written by PersonsController: all that
// remains here is to say WHAT to call and WHICH labels to display.

namespace App\Controller;

use App\Entity\Person;
use App\Business\PersonData;
use App\Views\PersonKeys;
use Symfony\Component\Routing\Attribute\Route;

// the beginning of the path and of the name of all the routes inherited from PersonsController
#[Route('/doctors', name: 'doctors_')]
class DoctorsController extends PersonsController
{
    protected function section(): string
    {
        return 'doctors';
    }

    // DOCTORS.TITLE, DOCTORS.ADD... (see translations)
    protected function keys(): PersonKeys
    {
        return PersonKeys::of('DOCTORS');
    }

    protected function all(): array
    {
        return $this->application->getAllDoctors();
    }

    protected function find(int $id): ?Person
    {
        return $this->application->getDoctorById($id);
    }

    protected function create(PersonData $data): Person
    {
        return $this->application->addDoctor($data);
    }

    protected function update(int $id, PersonData $data): Person
    {
        return $this->application->editDoctor($id, $data);
    }

    protected function erase(int $id): void
    {
        $this->application->deleteDoctor($id);
    }
}

All that remains here is to specify which methods to call (the methods of [ApplicationModel]) and which labels to display (the [DOCTORS.xxx] keys in the dictionaries), and to provide the starting paths and route names (line 14). [ApplicationModel] is located in the [$this->application] property, declared by the parent class’s constructor. [ClientsController] is identical, with the client methods and the [CLIENTS.xxx] keys.

11.19.6. src/Controller/RegistrationController.php

<?php
// =========================================================================
// src/Controller/RegistrationController.php: "Create a patient account": a patient
// who does not have an account yet creates it themselves. PUBLIC routes (one does not
// have an account yet, by definition):
//
//   POST /register/access     "Create a patient account" button of the login
//                             page: checks the captcha of THAT
//                             page, then opens the form
//   GET  /register            the form
//   POST /register            the creation, then back to the login page
//
// The path is protected against robots by ONE captcha, that of the login
// page:
//   1. the "Create a patient account" button sends the login form to
//      /register/access; if the captcha is copied correctly, the server issues a
//      pass (see src/Security/RegistrationPass.php);
//   2. without a valid pass, /register sends back to the login
//      page (#[RegistrationPassRequired] attribute, see
//      src/EventListener/CheckRegistrationPass.php);
//   3. the pass is consumed at the creation: one solved captcha allows creating
//      ONE account;
//   4. in addition, the number of accounts created per hour and per IP address is limited
//      (see src/Security/AttemptLimiter.php).
//
// The account created is exactly the one the ADMIN would create from the "Patients" page
// (same method of the business layer: addClient), except that the patient
// CHOOSES their login and their password (required, with confirmation).
// =========================================================================

namespace App\Controller;

use App\Form\PersonFieldsType;
use App\Form\RegistrationType;
use App\Business\BusinessException;
use App\Security\CaptchaService;
use App\Security\AttemptLimiter;
use App\Security\RegistrationPass;
use App\Security\RegistrationPassRequired;
use App\Web\ApplicationModel;
use App\Web\BaseController;
use App\Web\Flash;
use App\Web\Message;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/register')]
class RegistrationController extends BaseController
{
    public function __construct(
        private ApplicationModel $application,
        private CaptchaService $captchas,
        private AttemptLimiter $limiter,
        private RegistrationPass $passes,
        private Flash $flash,
    ) {
    }

    // -----------------------------------------------------------------------
    // POST /register/access   fields: captchaId, captcha (those of the login
    // page; login and password, also sent, are ignored)
    // -----------------------------------------------------------------------
    #[Route('/access', name: 'registration_access', methods: ['POST'])]
    public function access(Request $request): Response
    {
        $this->checkCsrf($request);
        $ip = (string) $request->getClientIp();

        // too many accounts created recently from this address: no point going further
        $minutes = $this->limiter->registrationBlockMinutes($ip);
        if ($minutes > 0) {
            $this->flash->error('ERRORS.TOO_MANY_ACCOUNTS', ['minutes' => $minutes]);
            return $this->redirectToRoute('login');
        }

        // missing or wrong captcha: back to the login page, with a
        // new image (a failure counts as a login failure)
        if (! $this->captchas->verify($request->request->getString('captchaId'),
            $request->request->getString('captcha'))) {
            $this->limiter->loginFailure($ip, '');
            $this->flash->error('ERRORS.CAPTCHA_BEFORE_ACCOUNT');
            return $this->redirectToRoute('login');
        }

        // correct captcha: the pass is issued and the form is opened
        $response = $this->redirectToRoute('registration');
        $response->headers->setCookie($this->passes->issue());
        return $response;
    }

    // -----------------------------------------------------------------------
    // GET /register: the form; POST /register: the creation. The
    // pass has been checked BEFORE the call of the action (#[RegistrationPassRequired]).
    // The form validates ALL the input (fields and password confirmation):
    // the user corrects all their errors in one go
    // -----------------------------------------------------------------------
    #[Route('', name: 'registration', methods: ['GET', 'POST'])]
    #[RegistrationPassRequired]
    public function register(Request $request): Response
    {
        $form = $this->createForm(RegistrationType::class, ['title' => 'Mr']);
        $form->handleRequest($request);
        if (! $form->isSubmitted() || ! $form->isValid()) {
            return $this->page($form);
        }
        $ip = (string) $request->getClientIp();

        // 1. too many accounts created recently from this address?
        $minutes = $this->limiter->registrationBlockMinutes($ip);
        if ($minutes > 0) {
            return $this->page($form, new Message('ERRORS.TOO_MANY_ACCOUNTS', ['minutes' => $minutes]));
        }

        // 2. the creation (client + USER account, in a transaction)
        $data = PersonFieldsType::data($form->getData());
        try {
            $this->application->addClient($data);
        } catch (BusinessException $e) {
            // login or first + last names already taken: the pass remains valid
            return $this->page($form, new Message($e->key, $e->params),
                PersonsController::BUSINESS_ERROR_FIELDS[$e->key] ?? []);
        }

        // 3. the pass has been used: it will not allow creating a 2nd account
        $this->passes->consume($request->cookies->get(RegistrationPass::COOKIE));
        $this->limiter->registrationSuccess($ip);

        // 4. back to the login page, login pre-filled
        $this->flash->success('REGISTRATION.DONE', ['login' => $data->login]);
        return RegistrationPass::clearCookie($this->redirectToRoute('login', ['login' => $data->login]));
    }

    // the form; a refused form: the code 422
    private function page(FormInterface $form, ?Message $error = null, array $invalidFields = []): Response
    {
        $refused = $error !== null || ($form->isSubmitted() && ! $form->isValid());
        return $this->render('registration.html.twig', ['pageTitle' => 'REGISTRATION.TITLE',
            'form' => $form, 'error' => $error, 'invalidFields' => $invalidFields],
            new Response(status: $refused ? 422 : 200));
    }
}

The patient creates a patient account themselves. The flow is protected by a CAPTCHA, the same one used on the login page.

  • Lines 65–91: The “Create a patient account” button on the login page submits the login form to this address (later, the view [login]). If too many accounts have been created recently from this address (IP), there’s no need to proceed further. If the CAPTCHA is missing or incorrect: return to the login page with a new image and a message; the failure counts as a failed login attempt (with an empty username). If the CAPTCHA is correct: a token is stored in a cookie, and the user is redirected to the form;
  • lines 99–107: the form. The [#[RegistrationPassRequired]] attribute (line 100) causes the token to be verified by the listener [CheckRegistrationPass] before the action is called. The form validates all input, including the password confirmation: the user sees all errors at once;
  • Lines 108–132: creation. The client and their account are created using the same business-layer method as for the administrator ([addClient]); only the username and password are chosen by the patient. If a username or a homonym is already taken: return to the form without consuming the pass. Creating an account consumes the token (one solved CAPTCHA equals one account), counts toward the hourly limit, and redirects to the login page with the username pre-filled ([redirectToRoute('login', ['login' => ...])] adds the parameter [?login=...]), along with a success message. The pass cookie is cleared.

11.19.7. src/Controller/PatientAreaController.php

<?php
// =========================================================================
// src/Controller/PatientAreaController.php: the PERSONAL pages of the patient (role
// USER), the one they see right after logging in:
//
//   GET  /my-area                  home page: [Manage my account], [View a
//                                  schedule], and their upcoming appointment
//   POST /my-area/cancel           cancels THEIR appointment
//   GET  /my-account               modification form of their account
//   POST /my-account               modification
//   POST /my-account/delete        deletion of the account (and of its appointments)
//
// Golden rule of this controller: the patient concerned is ALWAYS that of the token
// ($this->utils->connectedPatient()), never an identifier sent by the
// browser. A patient can therefore neither see, nor modify, nor cancel what
// belongs to another patient, even by building their requests themselves.
//
// Any modification or deletion of the account requires the CURRENT password:
// someone who found a session left open could neither change the
// password, nor delete the account. The current password errors are
// counted as login failures (same limit).
//
// Security: all these URLs are reserved to the USER role (rule ^/my- of
// config/packages/security.yaml).
// =========================================================================

namespace App\Controller;

use App\Entity\Client;
use App\Entity\Appointment;
use App\Form\PersonFieldsType;
use App\Form\MyAccountType;
use App\Business\BusinessException;
use App\Security\AuthService;
use App\Security\AttemptLimiter;
use App\Views\AppointmentView;
use App\Web\ApplicationModel;
use App\Web\BaseController;
use App\Web\Flash;
use App\Web\Message;
use App\Web\Utils;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Translation\TranslatorInterface;

class PatientAreaController extends BaseController
{
    public function __construct(
        private ApplicationModel $application,
        private AuthService $auth,
        private AttemptLimiter $limiter,
        private Utils $utils,
        private Flash $flash,
        private TranslatorInterface $translator,
    ) {
    }

    // -----------------------------------------------------------------------
    // GET /my-area
    // -----------------------------------------------------------------------
    #[Route('/my-area', name: 'area', methods: ['GET'])]
    public function area(): Response
    {
        $patient = $this->utils->connectedPatient();
        $appointments = array_map(fn (Appointment $a) => new AppointmentView(
            $a->getId(),
            $this->utils->formatDay($a->getDay()),
            $a->getSlot()->time(),
            $a->getSlot()->getDoctor()->label(),
            // link to the doctor's schedule on that day: the patient sees their name there
            $this->generateUrl('schedule', ['doctorId' => $a->getSlot()->getDoctor()->getId(),
                'day' => Utils::iso($a->getDay())])),
            $this->application->getClientUpcomingAppointments($patient->getId()));
        return $this->render('area.html.twig', ['pageTitle' => 'AREA.TITLE',
            'patient' => $patient->label(), 'appointments' => $appointments]);
    }

    // -----------------------------------------------------------------------
    // POST /my-area/cancel   field: appointmentId
    // -----------------------------------------------------------------------
    #[Route('/my-area/cancel', name: 'area_cancel', methods: ['POST'])]
    public function cancelAppointment(Request $request): Response
    {
        $this->checkCsrf($request);
        $patient = $this->utils->connectedPatient();
        $appointmentId = Utils::positiveInteger($request->request->get('appointmentId'));
        $appointment = $appointmentId !== null ? $this->application->getAppointmentById($appointmentId) : null;
        // an appointment that does not exist, and that of ANOTHER patient, receive the
        // same answer: nothing is revealed about the appointments of the others
        if ($appointment === null || $appointment->getClient()->getId() !== $patient->getId()) {
            $this->flash->error('ERRORS.APPOINTMENT_NOT_FOUND');
        } else {
            $this->application->deleteAppointment($appointment);
            $this->flash->success('SCHEDULE.CANCELLED');
        }
        return $this->redirectToRoute('area');
    }

    // -----------------------------------------------------------------------
    // GET /my-account: the form, pre-filled with the values of the patient;
    // POST /my-account: the modification
    // -----------------------------------------------------------------------
    #[Route('/my-account', name: 'account', methods: ['GET', 'POST'])]
    public function account(Request $request): Response
    {
        $patient = $this->utils->connectedPatient();
        $form = $this->createForm(MyAccountType::class, [
            'title' => $patient->getTitle(), 'first_name' => $patient->getFirstName(), 'last_name' => $patient->getLastName(),
            'login' => $patient->getAccount()->getLogin(), 'version' => $patient->getVersion(),
        ]);
        $form->handleRequest($request);
        if (! $form->isSubmitted() || ! $form->isValid()) {
            return $this->page($form);
        }

        // the current password; wrong: the message goes UNDER the field (an error
        // added to the field, FormError), and at the bottom of the page
        $error = $this->checkCurrentPassword($request, $patient,
            $form->get('current_password')->getData());
        if ($error !== null) {
            $form->get('current_password')->addError(
                new FormError($this->translator->trans($error->key, $error->params)));
            return $this->page($form, $error);
        }

        try {
            $client = $this->application->editClient($patient->getId(),
                PersonFieldsType::data($form->getData()));
        } catch (BusinessException $e) {
            return $this->page($form, new Message($e->key, $e->params),
                PersonsController::BUSINESS_ERROR_FIELDS[$e->key] ?? []);
        }
        // the login and the displayed name are IN the token: a new one is issued,
        // otherwise the navigation bar would keep the old name
        $this->flash->success('MY_ACCOUNT.UPDATED');
        $response = $this->redirectToRoute('area');
        $response->headers->setCookie($this->auth->tokenCookie($client->getAccount()));
        return $response;
    }

    // -----------------------------------------------------------------------
    // POST /my-account/delete   field: current_password
    // The account, the client and ALL their appointments disappear together, then the
    // patient is logged out.
    // -----------------------------------------------------------------------
    #[Route('/my-account/delete', name: 'account_delete', methods: ['POST'])]
    public function deleteAccount(Request $request): Response
    {
        $this->checkCsrf($request);
        $patient = $this->utils->connectedPatient();
        $error = $this->checkCurrentPassword($request, $patient,
            $request->request->getString('current_password'));
        if ($error !== null) {
            $this->flash->error($error->key, $error->params);
            return $this->redirectToRoute('account');
        }
        $this->application->deleteClient($patient->getId());
        $this->flash->success('MY_ACCOUNT.DELETED');
        return AuthService::clearCookie($this->redirectToRoute('login'));
    }

    // ----------------------------------------------------------------- internal functions

    // the account page; a refused form: the code 422
    private function page(FormInterface $form, ?Message $error = null, array $invalidFields = []): Response
    {
        $refused = $error !== null || ($form->isSubmitted() && ! $form->isValid());
        return $this->render('account.html.twig', ['pageTitle' => 'MY_ACCOUNT.TITLE',
            'form' => $form, 'error' => $error, 'invalidFields' => $invalidFields],
            new Response(status: $refused ? 422 : 200));
    }

    // null if the current password is correct; otherwise the error message
    private function checkCurrentPassword(Request $request, Client $patient, ?string $password): ?Message
    {
        $ip = (string) $request->getClientIp();
        $login = $patient->getAccount()->getLogin();
        $minutes = $this->limiter->loginBlockMinutes($ip, $login);
        if ($minutes > 0) {
            return new Message('ERRORS.TOO_MANY_ATTEMPTS', ['minutes' => $minutes]);
        }
        if (! $password || $this->auth->validate($login, $password) === null) {
            $this->limiter->loginFailure($ip, $login);
            return new Message('ERRORS.WRONG_CURRENT_PASSWORD');
        }
        return null;
    }
}

The patient’s personal pages. The golden rule for this controller: the patient in question is always the one associated with the token ([Utils::connectedPatient]), never an identifier sent by the browser. A patient cannot therefore view, modify, or cancel information belonging to another patient, even by crafting their own requests.

  • lines 64–79: My area: the patient’s name and their upcoming appointments, formatted ([AppointmentView]), each with a link to the doctor’s schedule for that day;
  • lines 84–100: the cancellation of his appointment. Both a nonexistent appointment and another patient’s appointment receive the same response: no information is disclosed about other patients’ appointments (not even their existence);
  • lines 106–117: the “Manage my account” form, pre-filled with the patient’s information, and its submission;
  • lines 119–142: the modification. Any modification requires the current password: someone who found a session left open would be unable to change the password or do anything else. If the current password is incorrect, the form is redisplayed, with the message below the field and at the bottom of the page. [addError(new FormError(...))] adds an error to a field, as if the validation had detected it (example 18); its message is text that has already been translated, hence the translation received by the builder ([TranslatorInterface], example 22). After the modification, a new token is generated: the login and the displayed name are included in the token, and the navigation bar would otherwise retain the old name until the next login;
  • lines 149–163: deletion of the account, the client, and all their appointments, using the same business-logic method as for the administrator. This second form on the page is hand-coded (a single field, the current password), with the application’s anti-CSRF token. The patient is then logged out (the token cookie is cleared);
  • lines 177–190: Verification of the current password. This is subject to the attempt limiter, just like a login: without this, someone with an open session could try thousands of passwords via the “Manage my account” form and discover the correct one.

11.20. The Views

The [Twig] views in the [templates] folder: a template, fragments, macros, and pages.

11.20.1. The template and fragments

11.20.1.1. templates/layout.html.twig

<!doctype html>
{# =========================================================================
   templates/layout.html.twig
   -------------------------------------------------------------------------
   The layout COMMON to all the pages (see example 15): each page
   (schedule.html.twig, login.html.twig...) EXTENDS it ({% extends %}) and fills its
   [content] block.

   Twig ESCAPES everything it displays with {{ ... }} (the characters < > & " '
   become &lt; &gt;...): protection against HTML injection through an entered
   name, for example. To insert HTML as is, it must be requested
   explicitly (raw filter, see fragments/captcha.html.twig).

   Messages: a success is displayed at the TOP of the page, an error at the BOTTOM, on a light
   pink background (errors fragment).

   The layout shares the variables of the page: [pageTitle] (the key of the title of
   the page, passed by the action). The language of the page is that of the request
   (app.request.locale, see src/EventListener/SetLanguage.php).
   ========================================================================= #}
<html lang="{{ app.request.locale }}">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  {# pageTitle|trans: the translation of a key; here, the key is a VARIABLE #}
  <title>{{ pageTitle|trans }} - {{ 'APP.TITLE'|trans }}</title>
  {# asset(): the URL of a file of the public folder #}
  <link rel="stylesheet" href="{{ asset('lib/bootstrap/css/bootstrap.min.css') }}">
  <link rel="stylesheet" href="{{ asset('css/styles.css') }}">
</head>
<body>
  <div class="container py-4">
    {% include 'fragments/navbar.html.twig' %}
    {% include 'fragments/success.html.twig' %}
    <main>
      {% block content %}{% endblock %}
    </main>
    {% include 'fragments/errors.html.twig' %}
  </div>
  {# the "Are you sure?" dialog, hidden as long as it is not needed #}
  {% include 'fragments/confirmation.html.twig' %}
  <script src="{{ asset('js/app.js') }}"></script>
</body>
</html>

The template common to all pages (Example 15): each page extends it ([{% extends 'layout.html.twig' %}]) and fills in its [content] block (line 36).

  • Line 21: the page’s language, [fr] or [en]: the language of the request, set by the listener [SetLanguage]. [app] is the global variable that [Symfony] provides to all views (Example 15);
  • line 26: the page title, translated: [pageTitle|trans] translates the key contained in the variable [pageTitle], which each action passes to its view;
  • lines 28–29: the function [asset] returns the URL from a file in the [public] folder (Example 15);
  • lines 33–38: the navigation bar, the success messages at the top of the page, the page content, and the error area at the bottom of the page. These fragments are inserted by [{% include %}] (Example 15): they access the page’s variables;
  • lines 41–42: the “Are you sure?” window, hidden until needed, and the script that displays it.

[Twig] escapes everything it displays using [{{ ... }}] (Example 13): an entered name containing HTML ([<script>]) is displayed as-is, without being interpreted. This is protection against the effortless injection of HTML (XSS) into all pages.

11.20.1.2. templates/fragments/navbar.html.twig

{# =========================================================================
   templates/fragments/navbar.html.twig - the navigation bar.

   Title, FR/EN, role, name, logout - and a menu whose entries depend
   on the role: "Doctors" and "Patients" only appear for the ADMIN. It is
   only a display convenience: the real protection is the access rules
   (a USER who types /doctors by hand gets a 403 page). A patient (USER)
   also has their "My area" menu.

   user(): the logged-in user, or null (see src/Twig/ViewsExtension.php).
   The menu of the current page is highlighted ("active" class): it is if
   the path of the URL (app.request.pathInfo) begins with ([starts with]) one of the
   prefixes of the entry. The entries of the menu: a list of arrays [name of the
   route, key of the label, prefixes of the paths for which the entry is active].
   ========================================================================= #}
{% set user = user() %}
{% set menu = [] %}
{% if user %}
  {% if user.isPatient %}
    {# active for /my-area AND /my-account #}
    {% set menu = menu|merge([['area', 'APP.MENU_MY_AREA', ['/my-']]]) %}
  {% endif %}
  {% set menu = menu|merge([['schedule', 'APP.MENU_SCHEDULE', ['/schedule']]]) %}
  {% if user.isAdmin %}
    {% set menu = menu|merge([['doctors_list', 'APP.MENU_DOCTORS', ['/doctors']],
                              ['clients_list', 'APP.MENU_PATIENTS', ['/clients']]]) %}
  {% endif %}
{% endif %}
<nav class="navbar navbar-expand-sm navbar-dark bg-primary rounded mb-4 px-3">
  <span class="navbar-brand mb-0">{{ 'APP.TITLE'|trans }}</span>

  <ul class="navbar-nav me-auto">
    {# entry[0]: the name of the route; filter keeps the matching prefixes #}
    {% for entry in menu %}
    {% set active = entry[2]|filter(p => app.request.pathInfo starts with p) is not empty %}
    <li class="nav-item">
      <a class="nav-link{{ active ? ' active' }}" href="{{ path(entry[0]) }}">{{ entry[1]|trans }}</a>
    </li>
    {% endfor %}
  </ul>

  <div class="d-flex align-items-center gap-2">
    {# FR / EN: simple links (GET /language/fr), the active language highlighted #}
    <div class="btn-group btn-group-sm" role="group" aria-label="FR / EN">
      {% for code in ['fr', 'en'] %}
      <a class="btn {{ app.request.locale == code ? 'btn-light' : 'btn-outline-light' }}"
         href="{{ path('language', {code: code}) }}">{{ code|upper }}</a>
      {% endfor %}
    </div>

    {% if user %}
    <span class="badge text-bg-light text-primary">{{ user.role.value }}</span>
    <span class="text-white">{{ user.name }}</span>
    {# logout is a POST: a form reduced to its button (and to the anti-CSRF token) #}
    <form method="post" action="{{ path('logout') }}" class="m-0">
      <input type="hidden" name="_token" value="{{ csrf_token('rdvmedecins') }}">
      <button type="submit" class="btn btn-sm btn-outline-light">{{ 'APP.LOGOUT'|trans }}</button>
    </form>
    {% endif %}
  </div>
</nav>

The navigation bar. The menu items depend on the role: “My area” for a patient, “Doctors” and “Patients” for the administrator. This is merely a convenience feature: the real protection comes from the rules [access_control] (a patient who types [/doctors] manually will receive a 403 page).

  • Line 16: the logged-in user, or [null], returned by the [user()] function in our extension;
  • Lines 17–28: The menu is generated by the [{% set %}] instructions, which produce a list of entries: the route name, the label key, and the path prefixes for which the entry is active. The filter [merge] returns a new list, consisting of the elements from the first list followed by those from the second (for two associative arrays, it combines their keys). [user.isPatient]: [Twig] calls the method [isPatient()]. The entry “My area” is active for [/my-area] and [/my-account] (prefix [/my-]). This is borderline with the “no logic in views” rule: however, this logic only affects the display and is used only here;
  • Lines 34–39: For each entry, [active] is true if the path of the requested URL ([app.request.pathInfo]) begins with ([starts with]) one of the entry’s prefixes. The filter [filter] retains only those elements of a list that satisfy a condition, written as an arrow function ([p => ...]);
  • lines 44–49: the buttons FR / EN, which are simple links ([/language/fr]), with the active language highlighted;
  • lines 51–59: for a logged-in user, their role ([user.role.value]: the enumeration case string), their name, and the logout button. Logout is a request POST: a form reduced to its button, with its anti-CSRF token ([csrf_token('rdvmedecins')], example 21).

11.20.1.3. templates/fragments/success.html.twig

{# =========================================================================
   templates/fragments/success.html.twig - the SUCCESS messages left by
   the previous action (appointment booked, doctor added...), displayed only
   once, at the top of the page (see src/Web/Flash.php). An ERROR flash
   message, on the other hand, is displayed at the bottom of the page, by the errors fragment.

   app.flashes('success'): the flash messages of that type (see example 20). Each
   message is a Message object: message.key|trans(message.params) is the text
   of the key, where {name}, {login}... are replaced by their values.
   ========================================================================= #}
{% for message in app.flashes('success') %}
<div class="alert alert-success" role="status">{{ message.key|trans(message.params) }}</div>
{% endfor %}

The success flash messages, set by the previous action ([Flash::success]), are read and removed from the session by [app.flashes('success')] (Example 20). Each is a [Message] object, translated here into the page’s language: [message.key|trans(message.params)]. [role="status"]: A screen reader announces the message.

11.20.1.4. templates/fragments/errors.html.twig

{# =========================================================================
   templates/fragments/errors.html.twig - the error zone, at the BOTTOM of each
   page, on a light pink background (class .error-zone, see public/css/styles.css).

   It groups the three possible sources of error:
     - [error]: the error of the displayed page (invalid date, captcha badly
       copied, login already taken...), passed by the action;
     - the error flash messages, left by the previous action before a
       redirection (slot already booked, deletion refused...);
     - the input errors of an invalid form (see example 19): their
       messages are displayed UNDER the fields; here, if there is no other
       message, a simple sentence "The form contains errors.".

   error ?? null: the variable does not exist in all the pages.
   role="alert": a screen reader announces the message as soon as the page is displayed,
   even if it is at the bottom.
   ========================================================================= #}
{% set messages = app.flashes('errors') %}
{% if error ?? null %}
  {% set messages = [error]|merge(messages) %}
{% endif %}
{% if messages is empty and form is defined and form.vars.submitted and not form.vars.valid %}
  {% set messages = [{key: 'ERRORS.INVALID_FORM', params: []}] %}
{% endif %}
{% if messages is not empty %}
<div class="error-zone" role="alert">
  {% for message in messages %}
  <div>{{ message.key|trans(message.params) }}</div>
  {% endfor %}
</div>
{% endif %}

The error area, at the bottom of each page, on a light pink background. It combines the three possible sources of error:

  • flash error messages, generated by the previous action (a time slot already reserved, an expired session, etc.);
  • the error on the displayed page ([error]), transmitted by the action: an invalid date, a misspelled CAPTCHA, a username already in use, etc.; placed at the top of the list. [error ?? null]: this variable does not exist on all pages; the operator [??] does not flag a nonexistent variable—it replaces it with [null];
  • Input errors in an invalid form (example 19): their messages are displayed below the fields; here, if there are no other messages, a simple phrase: “The form contains errors.” On a long form, this lets the user know they need to scroll back up. [form.vars]: the properties of the form view, including [submitted] (submitted?) and [valid] (valid?). The message is written as an associative array with the same keys as the properties of a [Message]: for [Twig], [message.key] refers to both the key of an array and the property of an object.

[role="alert"]: A screen reader announces the message as soon as the page is displayed, even if it is at the bottom. The area remains fixed at the bottom of the window when the page is long (see the stylesheet below).

11.20.1.5. templates/fragments/confirmation.html.twig

{# =========================================================================
     templates/fragments/confirmation.html.twig - the modal dialog
     "Are you sure?" (see example 21).

     It replaces the browser's window.confirm() box, which can neither be styled
     nor translated (its buttons stay in the language of the browser, not
     in that of the page).

     ONE SINGLE dialog for the whole page, hidden (d-none) at first. Its labels
     are translated here, by the server, like the rest of the page; the small script
     public/js/app.js merely displays it, puts in it the message specific to the
     form (data-confirmation attribute), and sends the form if
     the user confirms.
     ========================================================================= #}
<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"
       aria-labelledby="confirmation-modal-title" aria-describedby="confirmation-modal-message">
    <div class="modal-dialog modal-dialog-centered">
      <div class="modal-content">
        <div class="modal-header">
          <h5 class="modal-title" id="confirmation-modal-title">{{ 'CONFIRMATION.TITLE'|trans }}</h5>
          <button type="button" class="btn-close" data-action="no" aria-label="{{ 'BOOKING_DIALOG.CLOSE_ARIA'|trans }}"></button>
        </div>
        <div class="modal-body">
          <p class="mb-0" id="confirmation-modal-message"></p>
        </div>
        <div class="modal-footer">
          {# "No, go back" rather than "Cancel": the question is sometimes
               "Cancel this appointment?", and a "Cancel" button would be ambiguous there #}
          <button type="button" class="btn btn-secondary" data-action="no">{{ 'CONFIRMATION.NO'|trans }}</button>
          <button type="button" class="btn btn-danger" data-action="yes">{{ 'CONFIRMATION.YES'|trans }}</button>
        </div>
      </div>
    </div>
  </div>
</div>

The “Are you sure?” modal window from Example 21, which replaces the browser’s [window.confirm()] dialog box: this dialog box does not format correctly, and its buttons remain in the browser’s language, not the page’s language. A single window for the entire page, initially hidden (class [d-none] derived from [Bootstrap]); its labels are translated by the server, just like the rest of the page. The [public/js/app.js] script displays it, places the message from the form to be confirmed inside it (its [data-confirmation] attribute), and submits the form if the user confirms. The "Decline" button is labeled “No, go back” rather than “Cancel”: the question is sometimes “Cancel this appointment?”, and a “Cancel” button would be ambiguous in that context.

11.20.1.6. templates/fragments/booking.html.twig

{# =========================================================================
   templates/fragments/booking.html.twig - the booking dialog of
   the schedule.

   The REAL Bootstrap classes of a modal dialog (.modal, .modal-backdrop...),
   made visible "hard-coded" (show d-block) rather than by Bootstrap's
   JavaScript. It is the SERVER that decides to display it (parameter &book=...
   of the URL), and closing it amounts to following a link to the same page without this
   parameter.

   The fragment directly reads the variables of the page that includes it
   (booking, doctorId, day, backUrl).
   ========================================================================= #}
<div class="modal-backdrop fade show"></div>
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" aria-labelledby="booking-title">
  <div class="modal-dialog modal-dialog-centered">
    <form method="post" action="{{ path('schedule_book') }}" class="modal-content">
      <div class="modal-header">
        <h5 class="modal-title" id="booking-title">{{ 'BOOKING_DIALOG.TITLE'|trans }}</h5>
        <a class="btn-close" href="{{ backUrl }}" aria-label="{{ 'BOOKING_DIALOG.CLOSE_ARIA'|trans }}"></a>
      </div>

      <div class="modal-body">
        <p>{{ 'BOOKING_DIALOG.SLOT'|trans({time: booking.time}) }}</p>

        {# what the server must know in addition to the chosen patient #}
        <input type="hidden" name="_token" value="{{ csrf_token('rdvmedecins') }}">
        <input type="hidden" name="doctorId" value="{{ doctorId }}">
        <input type="hidden" name="day" value="{{ day }}">
        <input type="hidden" name="slotId" value="{{ booking.slotId }}">

        {# a patient books for themselves: no list to choose from (and the
           server ignores a possible clientId anyway) #}
        {% if booking.patient %}
        <p class="mb-0">
          <span>{{ 'BOOKING_DIALOG.PATIENT_LABEL'|trans }}</span>:
          <strong>{{ booking.patient }}</strong>
        </p>
        {% else %}
        <label class="form-label" for="clientId">{{ 'BOOKING_DIALOG.PATIENT_LABEL'|trans }}</label>
        <select id="clientId" name="clientId" class="form-select" required autofocus>
          <option value="">{{ 'BOOKING_DIALOG.CHOOSE_PATIENT'|trans }}</option>
          {% for client in booking.clients %}
          <option value="{{ client.id }}">{{ client.label }}</option>
          {% endfor %}
        </select>
        {% endif %}
      </div>

      <div class="modal-footer">
        <a class="btn btn-secondary" href="{{ backUrl }}">{{ 'BOOKING_DIALOG.CANCEL'|trans }}</a>
        <button type="submit" class="btn btn-primary">{{ 'BOOKING_DIALOG.CONFIRM'|trans }}</button>
      </div>
    </form>
  </div>
</div>

The calendar booking window. It uses the actual classes of a modal window from [Bootstrap] ([modal], [modal-backdrop]...), but made visible "hard-coded" ([show d-block]), rather than through the [JavaScript] of [Bootstrap]: it is the server that decides to display it when URL contains [&book=...]. Closing it (using the X or the Cancel button) is equivalent to following a link to the same page without that parameter ([backUrl]).

  • Lines 27–30: the anti-CSRF token, and what the server needs to know: the doctor, the day, and the time slot;
  • Lines 34–47: A patient enters their own name without selecting from a list (and the server ignores any [clientId] field anyway). The administrator and doctors select the patient from a list; [required] and [autofocus]: the browser requires a selection and places the cursor in the list.

11.20.1.7. templates/fragments/captcha.html.twig

{# =========================================================================
   templates/fragments/captcha.html.twig - the captcha of the login
   page (see src/Security/CaptchaService.php). It protects both
   the login and the access to account creation ("Create a patient
   account" button). It reads the [captcha] variable of the page.

   captcha.svg|raw: the SVG image is inserted AS IS in the page, without
   escaping. It is harmless here because this SVG is built by the server
   itself and contains nothing that comes from the user - we would NEVER do
   that with user input.

   The hidden field captchaId tells the server WHICH captcha to check; the right
   answer itself is never sent to the browser. "Another image" simply reloads
   the page (path('login'): the URL of the page, without its parameters).
   ========================================================================= #}
<div class="mb-3">
  <label class="form-label" for="captcha">{{ 'CAPTCHA.LABEL'|trans }}</label>
  <div class="d-flex align-items-center gap-3 mb-2">
    <div class="captcha-image" role="img" aria-label="{{ 'CAPTCHA.IMAGE_ALT'|trans }}">{{ captcha.svg|raw }}</div>
    <a class="small" href="{{ path('login') }}">{{ 'CAPTCHA.NEW_IMAGE'|trans }}</a>
  </div>
  <input type="hidden" name="captchaId" value="{{ captcha.id }}">
  <input id="captcha" name="captcha" type="text" maxlength="10" autocomplete="off" autocapitalize="off"
         spellcheck="false" required class="form-control">
  <div class="form-text">{{ 'CAPTCHA.HELP'|trans }}</div>
</div>

The CAPTCHA (example 27). [captcha.svg|raw] inserts the image SVG as-is, without escaping it: otherwise, the browser would display its code XML. This is safe here because this SVG is generated by the server and contains nothing provided by the user: we would never do this with user input. The hidden field [captchaId] tells the server which CAPTCHA to verify; the response itself is never sent to the browser. The “Another image” link reloads the login page—and thus the CAPTCHA.

11.20.2. Macros: templates/macros.html.twig

{# =========================================================================
   templates/macros.html.twig - the macros of the forms (see example 15).

   field(field, invalidFields): a field of a form of the Form component,
   with its label, its error message or its help (form_row, drawn by the
   Bootstrap 5 theme, see example 18). The field is framed in red (class
   is-invalid) if it has an input error (the theme handles it), or if a BUSINESS
   error designates it: its name is in the list [invalidFields] passed by
   the action (a login already taken...), the message being at the bottom of the page.
   The attributes of the field (maxlength...) are completed, not replaced: merge.
   ========================================================================= #}
{% macro field(field, invalidFields = []) %}
  {% set attr = field.vars.attr %}
  {% if field.vars.name in invalidFields %}
    {% set attr = attr|merge({class: (attr.class|default('') ~ ' is-invalid')|trim}) %}
  {% endif %}
  {{ form_row(field, {attr: attr}) }}
{% endmacro %}

{# the identity fields (title, first name, last name), common to three forms:
   ADMIN's doctor/client, "Create a patient account" and "Manage my account" #}
{% macro identity(form, invalidFields = []) %}
  {# import _self: the macros of THIS file, to call the field macro #}
  {% import _self as m %}
  {{ m.field(form.title, invalidFields) }}
  <div class="row g-3">
    <div class="col-sm-6">{{ m.field(form.first_name, invalidFields) }}</div>
    <div class="col-sm-6">{{ m.field(form.last_name, invalidFields) }}</div>
  </div>
{% endmacro %}

Two macros (Example 15), used by the three pages that display a “person” form.

  • Lines 12–18: A field in a Form component form, generated by [form_row] (Example 18): its label, input field, error message, or tooltip, along with the classes from the [Bootstrap] 5 theme declared in [config/packages/twig.yaml]. The theme highlights a field with an input error in red (class [is-invalid]). The macro also adds it to a field identified by an error from the business layer: the list [invalidFields] passed by the action ([PersonsController::BUSINESS_ERROR_FIELDS]). A username that is already taken is not an input error (it has the correct format), but the user needs to see which field to correct. [field.vars]: the field view properties, including its name ([name]) and its attributes HTML ([attr]); these are appended ([merge]), not replaced: the field retains its [maxlength]. The second parameter of [form_row] replaces the field view properties;
  • Lines 22–30: the identity fields, common to three forms: the title, followed by the first name and last name side by side (the grid from [Bootstrap]: [row], [col-sm-6]). A macro cannot see the other macros in its file: [{% import _self as m %}] (line 24) imports them.

11.20.3. The pages

11.20.3.1. templates/login.html.twig

{# =========================================================================
   templates/login.html.twig - the login form (see AuthController).
   An ordinary hand-written HTML <form>: the browser itself sends login,
   password and captcha answer in POST /login, without JavaScript. In case
   of failure, the server sends back THIS page, with the login already entered, a NEW
   captcha and the error, displayed at the bottom of the page by the layout.
   ========================================================================= #}
{% extends 'layout.html.twig' %}

{% block content %}
<div class="row justify-content-center">
  <div class="col-sm-8 col-md-6 col-lg-5">
    <div class="card p-4">
      <h5 class="card-title mb-3">{{ 'LOGIN.TITLE'|trans }}</h5>

      <form method="post" action="{{ path('login_submit') }}">
        <input type="hidden" name="_token" value="{{ csrf_token('rdvmedecins') }}">
        <div class="mb-3">
          <label class="form-label" for="login">{{ 'LOGIN.LOGIN_LABEL'|trans }}</label>
          {# autofocus: a boolean attribute, written if the condition is true, omitted
             otherwise. The cursor goes to the first field to fill in #}
          <input id="login" name="login" type="text" class="form-control" autocomplete="username"
                 value="{{ login }}" required{{ login == '' ? ' autofocus' }}>
        </div>
        <div class="mb-3">
          <label class="form-label" for="password">{{ 'LOGIN.PASSWORD_LABEL'|trans }}</label>
          <input id="password" name="password" type="password" class="form-control"
                 autocomplete="current-password" required{{ login != '' ? ' autofocus' }}>
        </div>

        {% include 'fragments/captcha.html.twig' %}

        <button type="submit" class="btn btn-primary w-100">{{ 'LOGIN.SUBMIT'|trans }}</button>

        {# no account yet: a patient can create one themselves. It is NOT
           a simple link: it is a second submit button of THIS form,
           which goes to another address (formaction). The server checks the
           captcha above there before opening the account creation page (see
           RegistrationController). formnovalidate: the browser does not require
           login and password (useless to create an account). Enter in a
           field always triggers the FIRST button of the form, "Log in". #}
        <button type="submit" class="btn btn-outline-primary w-100 mt-3"
                formaction="{{ path('registration_access') }}" formnovalidate>{{ 'LOGIN.CREATE_ACCOUNT'|trans }}</button>
        <div class="form-text text-center">{{ 'LOGIN.CREATE_ACCOUNT_HELP'|trans }}</div>
      </form>

      <div class="text-muted small mt-3">
        <span>{{ 'LOGIN.DEMO_ACCOUNTS'|trans }}</span>
        <ul class="mb-0 mt-1">
          <li><code>admin</code> - <span>{{ 'LOGIN.DEMO_ADMIN'|trans }}</span></li>
          <li><code>pelissier</code>, <code>tatou</code>, <code>dupont</code> - <span>{{ 'LOGIN.DEMO_DOCTOR'|trans }}</span></li>
          <li><code>martin</code>, <code>durand</code>, <code>bistrou</code>, <code>german</code> - <span>{{ 'LOGIN.DEMO_USER'|trans }}</span></li>
        </ul>
      </div>
    </div>
  </div>
</div>
{% endblock %}

The login form, a hand-coded [<form>], is sent via POST to [/login] (the path [login_submit]).

  • Lines 22–28: The entered username is redisplayed after a failed login attempt ([login]). The attribute [autofocus] places the cursor in the first field to be filled in: the username if it is empty, or the password otherwise (after a failed attempt or after creating an account). [{{ login == '' ? ' autofocus' }}]: The ternary operator without “else” returns an empty string if the condition is false;
  • line 31: the CAPTCHA;
  • lines 42–43: the “Create a patient account” button is not a link; it is a second button that submits the same form to a different address (button attribute [formaction]). The server verifies the CAPTCHA there before opening the account creation page ([RegistrationController::access]). [formnovalidate]: The browser does not require a username and password (attributes [required]), as they are not needed to create an account. Pressing the Enter key in a field always triggers the first form submission button: Log in.

11.20.3.2. templates/schedule.html.twig

{# =========================================================================
   templates/schedule.html.twig - the main page (see ScheduleController).
   Three blocks:
     1. the choice of the doctor and of the day;
     2. the schedule;
     3. the booking dialog (fragment booking).
   A possible error (invalid date, unknown doctor) is displayed at the bottom of
   the page by the layout (fragment errors).
   ========================================================================= #}
{% extends 'layout.html.twig' %}

{% block content %}
  {# 1. choice of the doctor and of the day: a GET form, which simply reloads
        this page with ?doctorId=...&day=... in the URL #}
  <form method="get" action="{{ path('schedule') }}" class="card p-3 mb-3">
    <div class="row g-2 align-items-end">
      <div class="col-sm-5">
        <label class="form-label" for="doctorId">{{ 'DOCTOR_DAY_PICKER.DOCTOR_LABEL'|trans }}</label>
        <select id="doctorId" name="doctorId" class="form-select" required>
          <option value="">{{ 'DOCTOR_DAY_PICKER.CHOOSE_DOCTOR'|trans }}</option>
          {% for doctor in doctors %}
          <option value="{{ doctor.id }}"{{ doctor.selected ? ' selected' }}>{{ doctor.label }}</option>
          {% endfor %}
        </select>
      </div>
      <div class="col-sm-4">
        <label class="form-label" for="day">{{ 'DOCTOR_DAY_PICKER.DAY_LABEL'|trans }}</label>
        <input id="day" name="day" type="date" class="form-control" value="{{ day }}" required>
      </div>
      <div class="col-sm-3">
        <button type="submit" class="btn btn-primary w-100">{{ 'DOCTOR_DAY_PICKER.VIEW_SCHEDULE'|trans }}</button>
      </div>
    </div>
  </form>

  {# patient who already has an upcoming appointment: why there is no "Book" button #}
  {% if info %}
  <div class="alert alert-info">
    <span>{{ info|trans }}</span>
    <a href="{{ path('area') }}" class="alert-link">{{ 'APP.MENU_MY_AREA'|trans }}</a>
  </div>
  {% endif %}

  {# 2. the schedule #}
  {% if schedule %}
  <div class="card p-3">
    <h5>{{ 'SCHEDULE.TITLE_PREFIX'|trans }} {{ schedule.day }} - {{ schedule.doctor }}</h5>

    {% if schedule.rows is not empty %}
    <table class="table table-hover align-middle">
      <thead>
        <tr>
          <th>{{ 'SCHEDULE.COLUMN_SLOT'|trans }}</th>
          <th>{{ 'SCHEDULE.COLUMN_STATUS'|trans }}</th>
          <th></th>
        </tr>
      </thead>
      <tbody>
        {% for row in schedule.rows %}
        <tr{{ row.bookable ? ' class="free-slot"' }}>
          <td>{{ row.time }}</td>
          <td>
            {# [patient] is set only if the user has the right to see this
               name (ADMIN, DOCTOR, or the patient for THEIR appointment) - otherwise,
               simply "Booked" #}
            {% if row.free %}
            <span class="badge text-bg-success">{{ 'SCHEDULE.FREE'|trans }}</span>
            {% elseif row.patient %}
            <span class="badge {{ row.mine ? 'text-bg-primary' : 'text-bg-secondary' }}">{{ row.patient }}</span>
            {% else %}
            <span class="badge text-bg-secondary">{{ 'SCHEDULE.BOOKED_BY_OTHER'|trans }}</span>
            {% endif %}
          </td>
          <td>
            {# the displayed buttons have been decided by the controller (bookable,
               appointmentId) according to the role of the user. "Book" is a simple
               link: it reopens this page with the booking dialog of this slot #}
            {% if row.bookable %}
            <a class="btn btn-sm btn-outline-success" href="{{ row.bookUrl }}">{{ 'SCHEDULE.BOOK'|trans }}</a>
            {% endif %}
            {# "Cancel" MODIFIES data: it is therefore a POST #}
            {% if row.appointmentId %}
            <form method="post" action="{{ path('schedule_cancel') }}" class="m-0"
                  data-confirmation="{{ 'SCHEDULE.CANCEL_CONFIRM'|trans }}">
              <input type="hidden" name="_token" value="{{ csrf_token('rdvmedecins') }}">
              <input type="hidden" name="appointmentId" value="{{ row.appointmentId }}">
              <input type="hidden" name="doctorId" value="{{ doctorId }}">
              <input type="hidden" name="day" value="{{ day }}">
              <button type="submit" class="btn btn-sm btn-outline-danger">{{ 'SCHEDULE.CANCEL_APPOINTMENT'|trans }}</button>
            </form>
            {% endif %}
          </td>
        </tr>
        {% endfor %}
      </tbody>
    </table>
    {% else %}
    <p class="text-muted mb-0">{{ 'SCHEDULE.NO_SLOTS'|trans }}</p>
    {% endif %}
  </div>
  {% else %}
  <p class="text-muted">{{ 'SCHEDULE.EMPTY_STATE'|trans }}</p>
  {% endif %}

  {# 3. the booking dialog, only if the URL contains &book=... #}
  {% if booking %}
  {% include 'fragments/booking.html.twig' %}
  {% endif %}
{% endblock %}

The main page. Three sections: selecting a doctor and a day, the calendar, and the booking window.

  • Lines 15–34: Selecting a doctor and a day: a GET form (Example 17), which reloads the page with [?doctorId=...&day=...] in the URL. The day is a [type="date"] field: the browser displays a calendar and sends the date in [yyyy-mm-dd] format, regardless of how it is displayed ([26/09/2026] in French);
  • lines 37–42: for a patient who already has an upcoming appointment, why there is no “Book” button;
  • lines 66–72: the slot status: Free (in green), the patient’s name (in blue for the appointment of the logged-in patient, in gray otherwise), or “Booked.” The view does not make any decisions: [row.patient] is only populated if the user has permission to view that name;
  • lines 78–80: “Book” is a simple link: it reopens the page with the booking window for that time slot ([row.bookUrl]). It does not modify anything: a GET is sufficient;
  • lines 82–91: “Cancel” modifies data: it is a POST form (example 21), with its anti-CSRF token, the appointment to be canceled, and information to return to the same page (the doctor, the day). The [data-confirmation] attribute causes the page’s script to request confirmation;
  • lines 106–108: the booking window, if requested by URL (and if the time slot is available for booking).

11.20.3.3. templates/persons/list.html.twig

{# =========================================================================
   templates/persons/list.html.twig - the list of the doctors OR of the clients
   (ADMIN). A single view for both: the keys of the labels specific to each one
   arrive in [keys], and [section] ("doctors" or "clients") gives the beginning of the
   names of the routes (see PersonsController): section ~ '_add' -> "doctors_add".
   ========================================================================= #}
{% extends 'layout.html.twig' %}

{% block content %}
  <div class="d-flex justify-content-between align-items-center mb-3">
    <h4 class="mb-0">{{ keys.title|trans }}</h4>
    <a class="btn btn-primary" href="{{ path(section ~ '_add') }}">{{ keys.add|trans }}</a>
  </div>

  <div class="card p-3">
    {% if persons is not empty %}
    <table class="table table-hover align-middle mb-0">
      <thead>
        <tr>
          <th>{{ 'PERSONS.COLUMN_TITLE'|trans }}</th>
          <th>{{ 'PERSONS.COLUMN_FIRSTNAME'|trans }}</th>
          <th>{{ 'PERSONS.COLUMN_LASTNAME'|trans }}</th>
          <th>{{ 'PERSONS.COLUMN_LOGIN'|trans }}</th>
          <th></th>
        </tr>
      </thead>
      <tbody>
        {# person.title: Twig calls the getter getTitle() (see example 13) #}
        {% for person in persons %}
        <tr>
          <td>{{ person.title }}</td>
          <td>{{ person.firstName }}</td>
          <td>{{ person.lastName }}</td>
          <td><code>{{ person.account.login }}</code></td>
          <td class="text-end">
            <div class="d-inline-flex gap-2">
              <a class="btn btn-sm btn-outline-primary"
                 href="{{ path(section ~ '_edit', {id: person.id}) }}">{{ 'PERSONS.EDIT'|trans }}</a>
              {# data-confirmation: a confirmation dialog will open before
                 the form is sent (see public/js/app.js) #}
              <form method="post" action="{{ path(section ~ '_delete', {id: person.id}) }}" class="m-0"
                    data-confirmation="{{ keys.confirmDelete|trans }}">
                <input type="hidden" name="_token" value="{{ csrf_token('rdvmedecins') }}">
                <button type="submit" class="btn btn-sm btn-outline-danger">{{ 'PERSONS.DELETE'|trans }}</button>
              </form>
            </div>
          </td>
        </tr>
        {% endfor %}
      </tbody>
    </table>
    {% else %}
    <p class="text-muted mb-0">{{ keys.empty|trans }}</p>
    {% endif %}
  </div>
{% endblock %}

The list of doctors or clients: the labels specific to each one are translation keys ([keys]), and the route names are generated from the section name ([path(section ~ '_edit', {id: person.id})]: the operator [~] concatenates, resulting in [doctors_edit] or [clients_edit]). [person.account.login]: the account login, populated with the list (layer DAO). The Delete button is a POST form, with confirmation.

11.20.3.4. templates/persons/form.html.twig

{# =========================================================================
   templates/persons/form.html.twig - creation OR modification of a
   doctor or of a client (ADMIN), with the PersonType form.

   When the input is refused (empty field, login already taken...), the action
   displays THIS page again, and the view shows:
     - the values already entered (except the password);
     - the faulty fields framed in red (Bootstrap class "is-invalid");
     - under a field refused by validation, its message;
     - the error message of the page, AT THE BOTTOM of the page (errors fragment).
   form_start writes the <form> tag (POST method, URL of the page); form_end, the
   end of the form, with the fields that have not been written yet: the
   anti-CSRF token, and, in modification, the hidden version field (the optimistic lock).
   ========================================================================= #}
{% extends 'layout.html.twig' %}
{% import 'macros.html.twig' as m %}

{% block content %}
<div class="row justify-content-center">
  <div class="col-md-8 col-lg-6">
    <div class="card p-4">
      <h5 class="card-title mb-3">{{ pageTitle|trans }}</h5>

      {{ form_start(form) }}
        {# form_errors: the errors of the form itself (wrong anti-CSRF token) #}
        {{ form_errors(form) }}
        {# title, first name, last name: a macro common to three forms #}
        {{ m.identity(form, invalidFields) }}

        <h6 class="mt-4 text-muted">{{ 'PERSONS.ACCOUNT_SECTION'|trans }}</h6>
        {{ m.field(form.login, invalidFields) }}
        {# never pre-filled: the password never goes back to the browser #}
        {{ m.field(form.password) }}

        <div class="d-flex gap-2 mt-4">
          <button type="submit" class="btn btn-primary">{{ 'PERSONS.SAVE'|trans }}</button>
          <a class="btn btn-secondary" href="{{ listUrl }}">{{ 'PERSONS.BACK'|trans }}</a>
        </div>
      {{ form_end(form) }}
    </div>
  </div>
</div>
{% endblock %}

Creating or editing a doctor or a client using the [PersonType] form.

  • Line 16: Importing macros under the name [m];
  • Line 24: [form_start] writes the tag [<form>]: the method POST, the page’s URL, and the attribute [novalidate] (type option [attr]);
  • line 26: [form_errors(form)] displays errors in the form itself that do not relate to any specific field: an invalid anti-CSRF token (“The page has expired...”);
  • line 28: title, first name, and last name, via the [identity] macro;
  • lines 31–33: the username and password, which are never pre-filled. Their tooltips differ between creation and modification (type options);
  • line 39: [form_end] closes the form after writing the fields that have not yet been written: the anti-CSRF token, and, when editing, the hidden version field (the optimistic lock).

11.20.3.5. templates/registration.html.twig

{# =========================================================================
   templates/registration.html.twig - "Create a patient account" (public page, see
   RegistrationController), with the RegistrationType form. Same identity
   fields as the ADMIN's form (identity macro), but the login and the
   password are required, with confirmation. No captcha HERE: one
   only reaches this page after having copied that of the login
   page ("Create a patient account" button).
   ========================================================================= #}
{% extends 'layout.html.twig' %}
{% import 'macros.html.twig' as m %}

{% block content %}
<div class="row justify-content-center">
  <div class="col-md-8 col-lg-6">
    <div class="card p-4">
      <h5 class="card-title mb-1">{{ 'REGISTRATION.TITLE'|trans }}</h5>
      <p class="text-muted small mb-3">{{ 'REGISTRATION.INTRO'|trans }}</p>

      {{ form_start(form) }}
        {{ form_errors(form) }}
        {{ m.identity(form, invalidFields) }}

        <h6 class="mt-4 text-muted">{{ 'PERSONS.ACCOUNT_SECTION'|trans }}</h6>
        {{ m.field(form.login, invalidFields) }}
        {# the repeated field: its two inputs, side by side (first, second) #}
        <div class="row g-3">
          <div class="col-sm-6">{{ m.field(form.password.first) }}</div>
          <div class="col-sm-6">{{ m.field(form.password.second) }}</div>
        </div>

        <div class="d-flex gap-2 mt-3">
          <button type="submit" class="btn btn-primary">{{ 'REGISTRATION.SUBMIT'|trans }}</button>
          <a class="btn btn-secondary" href="{{ path('login') }}">{{ 'REGISTRATION.BACK_TO_LOGIN'|trans }}</a>
        </div>
      {{ form_end(form) }}
    </div>
  </div>
</div>
{% endblock %}

Creating a patient account: the identity fields, followed by the username, password, and password confirmation, which are required. The two fields in the repeated field are [form.password.first] and [form.password.second] (lines 27–28), placed side by side. The password help text specifies the minimum length: “At least 6 characters.” There is no CAPTCHA here: you can only reach this page after entering the CAPTCHA from the login page.

11.20.3.6. templates/area.html.twig

{# =========================================================================
   templates/area.html.twig - "My area": the home page of the patient (USER
   role), displayed right after they log in.
     - two options: [Manage my account] and [View a schedule];
     - their upcoming appointment, if they have one, with the means to cancel it.
   ========================================================================= #}
{% extends 'layout.html.twig' %}

{% block content %}
  <h4 class="mb-3">{{ 'AREA.HELLO'|trans({name: patient}) }}</h4>

  <div class="row g-3 mb-4">
    <div class="col-sm-6">
      <a class="card p-4 h-100 text-decoration-none option-card" href="{{ path('account') }}">
        <h5 class="mb-1">{{ 'AREA.MANAGE_ACCOUNT'|trans }}</h5>
        <span class="text-muted">{{ 'AREA.MANAGE_ACCOUNT_HELP'|trans }}</span>
      </a>
    </div>
    <div class="col-sm-6">
      <a class="card p-4 h-100 text-decoration-none option-card" href="{{ path('schedule') }}">
        <h5 class="mb-1">{{ 'AREA.VIEW_SCHEDULE'|trans }}</h5>
        <span class="text-muted">{{ 'AREA.VIEW_SCHEDULE_HELP'|trans }}</span>
      </a>
    </div>
  </div>

  <div class="card p-3">
    <h5>{{ 'AREA.MY_APPOINTMENT'|trans }}</h5>
    {# normally only one (rule "one upcoming appointment at a time"), but the
       page can display several. {% for %} ... {% else %}: a loop, and
       what is displayed if the list is empty; loop.last: last element? #}
    {% for appointment in appointments %}
    <div class="d-flex flex-wrap justify-content-between align-items-center gap-2 py-2{{ not loop.last ? ' border-bottom' }}">
      <div>
        <div class="fw-semibold">{{ appointment.day }}, {{ appointment.time }}</div>
        <div class="text-muted">
          <span>{{ appointment.doctor }}</span> -
          <a href="{{ appointment.scheduleUrl }}">{{ 'AREA.SEE_IN_SCHEDULE'|trans }}</a>
        </div>
      </div>
      <form method="post" action="{{ path('area_cancel') }}" class="m-0"
            data-confirmation="{{ 'SCHEDULE.CANCEL_CONFIRM'|trans }}">
        <input type="hidden" name="_token" value="{{ csrf_token('rdvmedecins') }}">
        <input type="hidden" name="appointmentId" value="{{ appointment.id }}">
        <button type="submit" class="btn btn-outline-danger">{{ 'AREA.CANCEL_APPOINTMENT'|trans }}</button>
      </form>
    </div>
    {% else %}
    <p class="text-muted mb-0">{{ 'AREA.NO_APPOINTMENT'|trans }}</p>
    {% endfor %}
  </div>
{% endblock %}

“My area,” the patient’s home page: two large cards (“Manage my account,” “View a schedule”), and their upcoming appointment.

  • Lines 32–50: [{% for %}] ... [{% else %}] (Example 14) is a loop, followed by what to display if the list is empty. Normally, a patient has at most one upcoming appointment; however, the page is capable of displaying multiple appointments. [loop.last] (Example 14): the last item has no separator;
  • each appointment has a cancellation form (POST, with confirmation), which sends only its ID: the server will verify that it belongs to the logged-in patient.

11.20.3.7. templates/account.html.twig

{# =========================================================================
   templates/account.html.twig - "Manage my account" (USER role).
   Two independent forms on the same page:
     1. the modification (identity, login, optional new password), with
        the MyAccountType form;
     2. the deletion of the account - which also cancels its appointments: a
        hand-written form.
   Both require the CURRENT password (current_password field).
   ========================================================================= #}
{% extends 'layout.html.twig' %}
{% import 'macros.html.twig' as m %}

{% block content %}
<div class="row justify-content-center">
  <div class="col-md-8 col-lg-6">

    {# 1. modification #}
    <div class="card p-4 mb-4">
      <h5 class="card-title mb-3">{{ 'MY_ACCOUNT.TITLE'|trans }}</h5>

      {{ form_start(form) }}
        {{ form_errors(form) }}
        {{ m.identity(form, invalidFields) }}

        <h6 class="mt-4 text-muted">{{ 'PERSONS.ACCOUNT_SECTION'|trans }}</h6>
        {{ m.field(form.login, invalidFields) }}
        <div class="row g-3">
          <div class="col-sm-6">{{ m.field(form.password.first) }}</div>
          <div class="col-sm-6">{{ m.field(form.password.second) }}</div>
        </div>
        {{ m.field(form.current_password) }}

        <div class="d-flex gap-2 mt-4">
          <button type="submit" class="btn btn-primary">{{ 'PERSONS.SAVE'|trans }}</button>
          <a class="btn btn-secondary" href="{{ path('area') }}">{{ 'MY_ACCOUNT.BACK'|trans }}</a>
        </div>
      {{ form_end(form) }}
    </div>

    {# 2. deletion. The password field has the same NAME as that of the first
       form (current_password), but another id: two elements of the same
       page cannot have the same id #}
    <div class="card p-4 border-danger">
      <h5 class="card-title text-danger mb-1">{{ 'MY_ACCOUNT.DELETE_TITLE'|trans }}</h5>
      <p class="text-muted small">{{ 'MY_ACCOUNT.DELETE_HELP'|trans }}</p>
      <form method="post" action="{{ path('account_delete') }}" class="d-flex flex-wrap gap-2 align-items-end"
            data-confirmation="{{ 'MY_ACCOUNT.DELETE_CONFIRM'|trans }}">
        <input type="hidden" name="_token" value="{{ csrf_token('rdvmedecins') }}">
        <div class="flex-grow-1">
          <label class="form-label" for="delete_password">{{ 'MY_ACCOUNT.CURRENT_PASSWORD_LABEL'|trans }}</label>
          <input id="delete_password" name="current_password" type="password" maxlength="72"
                 autocomplete="current-password" required class="form-control">
        </div>
        <button type="submit" class="btn btn-danger">{{ 'MY_ACCOUNT.DELETE_SUBMIT'|trans }}</button>
      </form>
    </div>

  </div>
</div>
{% endblock %}

“Manage my account”: two independent forms on the same page.

  • Lines 18–38: the update, using the [MyAccountType] form: the identity fields, the login, the new password and its confirmation (optional), and the current password (required). The version (optimistic lock) is set by [form_end];
  • Lines 43–56: Account deletion, a handwritten form with its own current password field and a confirmation field. The field is named [current_password] (the field in the first form is named [my_account[current_password]]: the Form component prefixes its field names with the form name), and its own unique ID: two elements on a page cannot have the same ID, as indicated by the [for] attribute of a label.

11.20.3.8. templates/error.html.twig

{# =========================================================================
   templates/error.html.twig - the error page (400, 403, 404, 405, 419,
   500...), built by src/EventListener/ErrorPage.php.
   ========================================================================= #}
{% extends 'layout.html.twig' %}

{% block content %}
<div class="row justify-content-center">
  <div class="col-md-8 col-lg-6">
    <div class="card p-4 text-center">
      <h1 class="display-4 text-danger">{{ code }}</h1>
      <p class="lead">{{ message|trans }}</p>
      <div><a class="btn btn-primary" href="{{ path('home') }}">{{ 'ERROR_PAGE.BACK'|trans }}</a></div>
    </div>
  </div>
</div>
{% endblock %}

The error page, generated by the [ErrorPage] listener: the code HTTP, the message, and a link to the home page ([/], which redirects to the role’s home page or to the login page).

11.21. Static files

The [public] folder is the site’s root directory: the server sends the files located there as-is (example 15). In addition to [index.php], it contains [Bootstrap] ([lib/bootstrap]), a stylesheet, and a script.

11.21.1. public/css/styles.css

/* =========================================================================
   public/css/styles.css - a few settings on top of Bootstrap
   (the same as in the other versions of this application).
   ========================================================================= */

body {
  background-color: #f5f7fa;
}

.free-slot:hover {
  background-color: #e9f7ef;
}

/* -------------------------------------------------------------------------
   .error-zone: the error messages, at the bottom of the page, on a light pink background
   (see templates/fragments/errors.html.twig).
   position: sticky + bottom: if the page is taller than the window (a
   schedule of 12 slots, for example), the zone stays stuck to the bottom of the
   window instead of disappearing below the fold - without ever
   covering the content when the page is short.
   z-index: above the grey background of the booking dialog (.modal-backdrop).
   ------------------------------------------------------------------------- */
.error-zone {
  position: sticky;
  bottom: 1rem;
  z-index: 1060;
  margin-top: 1.5rem;
  padding: 0.75rem 1rem;
  background-color: #fdecef; /* light pink */
  border: 1px solid #f5c2c7;
  border-radius: 0.375rem;
  color: #842029;
}

/* the two big options of the patient's "My area" page */
.option-card {
  transition: border-color 0.15s, box-shadow 0.15s;
}

.option-card:hover {
  border-color: #0d6efd;
  box-shadow: 0 0.25rem 0.75rem rgba(13, 110, 253, 0.15);
}

/* the captcha image (SVG of 150 x 50 pixels) */
.captcha-image svg {
  display: block;
  border: 1px solid #dee2e6;
  border-radius: 0.375rem;
}

A few additional settings for [Bootstrap], the same as in the other versions of the application.

  • Lines 10–12: A bookable row in the calendar turns green when the mouse hovers over it;
  • lines 23–33: the error area at the bottom of the page has a light pink background. [position: sticky] with [bottom: 1rem]: if the page is taller than the window (a calendar with 12 time slots), the area remains anchored to the bottom of the window instead of disappearing below the scroll bar; if the page is short, it stays in place without covering anything. [z-index]: It appears over the gray background of the booking window;
  • lines 36–43: the two large “My area” cards, which light up when the mouse hovers over them;
  • lines 46–50: the CAPTCHA image, with a border.

11.21.2. public/js/app.js

// =========================================================================
// public/js/app.js - the ONLY JavaScript of the application.
// -------------------------------------------------------------------------
// The whole application works without JavaScript. This script adds a
// convenience: before sending a form that carries the attribute
// data-confirmation="...", a modal dialog asks "Are you sure?"
// (the dialog itself is in the page: templates/fragments/confirmation.html.twig).
// If JavaScript is disabled, the form is simply sent without
// confirmation - nothing breaks ("progressive enhancement" principle).
//
// Why not window.confirm()? Its box cannot be styled, and its
// buttons stay in the language of the browser, not in that of the page.
// =========================================================================

(() => {
  const modal = document.getElementById('confirmation-modal');
  if (!modal) {
    return;
  }
  const message = document.getElementById('confirmation-modal-message');
  const noButton = modal.querySelector('.modal-footer [data-action="no"]');

  let pendingForm = null; // the form to send if the user confirms
  let previousFocus = null; // the element to give the focus back to on closing

  function open(form) {
    pendingForm = form;
    previousFocus = document.activeElement;
    // textContent (and not innerHTML): the message is displayed as TEXT,
    // never interpreted as HTML
    message.textContent = form.dataset.confirmation;
    modal.classList.remove('d-none');
    // the focus goes on "No": an absent-minded press on Enter deletes nothing
    noButton.focus();
  }

  function close() {
    modal.classList.add('d-none');
    pendingForm = null;
    if (previousFocus) {
      previousFocus.focus();
    }
  }

  // 1. we intercept the sending of the forms to confirm
  document.querySelectorAll('form[data-confirmation]').forEach((form) => {
    form.addEventListener('submit', (event) => {
      event.preventDefault();
      open(form);
    });
  });

  // 2. the buttons of the dialog (Yes / No / close cross)
  modal.addEventListener('click', (event) => {
    const action = event.target.closest('[data-action]')?.dataset.action;
    if (action === 'yes') {
      const form = pendingForm;
      close();
      // form.submit() sends the form WITHOUT triggering the
      // 'submit' event again: no new confirmation request
      form.submit();
    } else if (action === 'no') {
      close();
    } else if (event.target.classList.contains('modal')) {
      close(); // click on the grey background, outside the dialog
    }
  });

  // 3. keyboard: Escape closes the dialog; Tab stays INSIDE the dialog
  //    (otherwise the focus would go to the greyed-out page, behind)
  modal.addEventListener('keydown', (event) => {
    if (event.key === 'Escape') {
      close();
    } else if (event.key === 'Tab') {
      const buttons = [...modal.querySelectorAll('button')];
      const first = buttons[0];
      const last = buttons[buttons.length - 1];
      if (event.shiftKey && document.activeElement === first) {
        event.preventDefault();
        last.focus();
      } else if (!event.shiftKey && document.activeElement === last) {
        event.preventDefault();
        first.focus();
      }
    }
  });
})();

The only [JavaScript] in the application—the one from Example 21—with a few additions. The entire application works without [JavaScript]: this script adds convenience by providing a confirmation before submitting a form that carries the [data-confirmation] attribute. If [JavaScript] is disabled, the form submits without confirmation: nothing breaks (principle of progressive enhancement).

  • Lines 15–19: The script is a function called as soon as it is defined ([(() => { ... })()]); its variables are not visible to the rest of the page. Without a confirmation window on the page, it does nothing;
  • Lines 26–35: Opening the window: the form to be submitted if the user confirms is stored, and the form’s message is displayed in the window. [textContent], not [innerHTML]: the message is displayed as text and is never interpreted as HTML. Focus moves to the “No” button: accidentally pressing Enter does not delete anything;
  • lines 37–43: closing the window; focus returns to the element that had it previously;
  • lines 46–51: the submission of each form to be confirmed is intercepted ([preventDefault] prevents it), and the window opens;
  • lines 54–67: the window’s buttons. A single handler for the entire window: [closest('[data-action]')] finds the clicked button or the element containing it. “Yes” submits the form via [form.submit()], which does not trigger the [submit] event: no new confirmation request is issued. Clicking on the grayed-out background outside the window closes it;
  • lines 71–86: the keyboard. Esc closes the window; the Tab key moves focus from one button to another without leaving the window (otherwise, it would move to the grayed-out page behind it): this is what users who navigate with the keyboard and screen readers expect.