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, in [Laravel], of the [Flask] application from the previous lesson (which was itself ported from versions [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 [Blade] views; database access is handled by [Eloquent].
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 appointments in the past;
- A time slot is reserved only once a day;
- a patient has 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

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:

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

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:


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:

After the booking, a message confirms the appointment:

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

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:

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:

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


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

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:

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

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

11.2.4. Managing Doctors and Patients
The administrator has two additional menus, Doctors and Patients:


The form for adding a doctor, submitted with errors:

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):

Deleting a patient requires confirmation:

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:

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

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:

11.3. Installation and Execution
The application is located in the [rdvmedecins2-laravel] 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 [pdo_mysql] extension (the application uses features introduced in version 8.4, such as [array_find]). Verify the installation in a terminal:
The second command lists the extensions: [pdo_mysql] 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 application has been tested with it.
The recommended editor is [Visual Studio Code], with the extensions [PHP Intelephense] and [Laravel] (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-laravel] folder (you must be connected to the Internet):
[Composer] reads the file [composer.json] (discussed later), downloads [Laravel], [firebase/php-jwt], and the libraries they depend on, and generates the class loader [vendor/autoload.php].
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. From 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.
All installation configuration is contained in the file [.env] (discussed further below). By default, the application connects to [MySQL] on [127.0.0.1], port 3306, using the user [root] with no password: these are the default settings for [Laragon]. If your [root] account has a password, enter it in this file on the [DB_PASSWORD=] line. A system environment variable takes precedence over the file (example 25): this allows you to specify the password without writing it to a project file.
11.3.5. Run the application
In a terminal window open to the [rdvmedecins2-laravel] folder:
| php artisan serve --port=8081
|
| INFO Server running on [http://127.0.0.1:8081].
Press Ctrl+C to stop the server
|
Next, open the URL [http://localhost:8081] in a browser, and stop the application using [Ctrl-C]. Port 8081 allows you to run a course example (port 8000) at the same time.
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 [.env] file configures the application for development:
- [DB_LOG=true]: The server console displays every SQL command sent to the database, as well as each step of the transactions (example 28). For example, to display a calendar:
| select * from `doctors` order by `last_name` asc, `first_name` asc []
select * from `users` where `users`.`id` in (2, 3, 4) []
select * from `doctors` where `doctors`.`id` = ? limit 1 [1]
select * from `users` where `users`.`id` in (2) []
select * from `appointments` where `day` = ? and exists (select * from `slots` where `appointments`.`slot_id` = `slots`.`id` and `doctor_id` = ?) ["2026-09-26",1]
select * from `clients` where `clients`.`id` in (3) []
select * from `slots` where `doctor_id` = ? order by `start_hour` asc, `start_minute` asc [1]
2026-09-26 18:50:14 /schedule .................................... ~ 0.10ms
|
- [APP_DEBUG=true]: An unexpected exception displays the [Laravel] debug page (Example 03), showing 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 a modified view is recompiled on the next request.
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).
11.3.8. A production server
The [php artisan serve] server is not intended for production use. 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 [storage] and [bootstrap/cache] folders must be writable by the server. We set up the application with a few commands:
| composer install --no-dev --optimize-autoloader
php artisan key:generate
php artisan config:cache
php artisan route:cache
php artisan view:cache
|
[key:generate] writes a new encryption key to the file [.env]; the following three commands combine the configuration, routes, and compiled views into a few files, which load faster than the originals (this process must be repeated after each modification). In the [.env] file, you must also specify production mode, change the secret token key, and declare that the site is served via HTTPS:
| APP_ENV=production
APP_DEBUG=false
JWT_SECRET=<a long random string>
COOKIE_SECURE=true
|
A suitable random string can be obtained using [PHP] itself: [php -r "echo bin2hex(random_bytes(32));"].
11.3.9. Common Issues
A [PHP] application fails to “start”: each request rebuilds the application (example 01). An inaccessible database therefore does not prevent the server from launching: the error appears on the first page that queries the database (the login page does not query the database; the login process itself does). With [APP_DEBUG=true], the error is displayed on the [Laravel] debug page; in all cases, it is logged in the [storage/logs/laravel.log] log. For example, with a database that does not exist:
| [2026-09-26 18:55:12] local.ERROR: SQLSTATE[HY000] [1049] Unknown database 'dbunknown' (Connection: mysql, Host: 127.0.0.1, Port: 3306, Database: dbunknown, SQL: select * from `users` where `login` = admin limit 1) {"exception":"[object] (Illuminate\\Database\\QueryException(code: 1049): ...
|
The most common messages:
[Failed opening required '.../vendor/autoload.php'] | The command [composer install] was not executed |
[Your Composer dependencies require a PHP version ">= 8.4.0"] | The version of [PHP] is earlier than 8.4 |
[could not find driver] | The [pdo_mysql] extension of [PHP] is not enabled (file [php.ini], or menu PHP / Extensions of [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 [.env] (see “Configuring Database Access”) |
[SQLSTATE[HY000] [1049] Unknown database 'dbrdvmedecins2'] | The script [database/dbrdvmedecins2.sql] was not executed |
[No application encryption key has been specified.] | The variable [APP_KEY] in the file [.env] is empty: [php artisan key:generate] |
[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 127.0.0.1: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

- The browser sends its requests to the server ([php artisan serve], or [nginx] + [PHP-FPM]), which forwards them to [Laravel] via the file [public/index.php]. Each request first passes through the middleware layers (see the chapter on the lifecycle): the request language ([SetLanguage]), cookies, the session, anti-CSRF protection, and then authentication: the user is identified based on the JWT token in their cookie, and protected pages are denied to visitors who do not have access ([Authenticate], [RequireRoles]);
- [Http/Controllers]: 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; [Http/Requests]: form validation;
- [Auth]: tokens, JWT, CAPTCHA, attempt limits, and the account creation pass;
- [Web]: what the controllers have in common: the cache and the [ApplicationModel] facade, flash messages, and a few utilities; [Views]: view templates;
- [Business]: business rules and transactions;
- [Dao]: the DAO layer, one class per table;
- [Models]: the [Eloquent] models, which the DAO layer reads from and writes to the database; [Domain]: the calendar objects, calculated by the business layer;
- [resources/views]: the [Blade] views, which generate the HTML pages; [lang]: the dictionaries; [public]: the stylesheet, the page script, and [Bootstrap];
- [bootstrap/app.php] and [app/Providers/AppServiceProvider.php] assemble everything; [.env] and the [config] folder: the configuration.
The application folder (excluding [vendor] and the contents of [storage]):
| rdvmedecins2-laravel/
├── app/
│ ├── Auth/
│ │ ├── AttemptLimiter.php
│ │ ├── AuthService.php
│ │ ├── Captcha.php
│ │ ├── CaptchaDrawing.php
│ │ ├── CaptchaService.php
│ │ ├── ConnectedUser.php
│ │ ├── InvalidSession.php
│ │ ├── README.md
│ │ └── RegistrationPass.php
│ ├── Business/
│ │ ├── Business.php
│ │ ├── BusinessException.php
│ │ ├── BusinessService.php
│ │ ├── Dates.php
│ │ ├── Descriptor.php
│ │ ├── PersonData.php
│ │ └── README.md
│ ├── Dao/
│ │ ├── AppointmentDao.php
│ │ ├── ClientDao.php
│ │ ├── DoctorDao.php
│ │ ├── PersonDao.php
│ │ ├── README.md
│ │ ├── SlotDao.php
│ │ └── UserDao.php
│ ├── Domain/
│ │ ├── DoctorDaySchedule.php
│ │ ├── DoctorDaySlot.php
│ │ └── README.md
│ ├── Http/
│ │ ├── Controllers/
│ │ │ ├── AuthController.php
│ │ │ ├── ClientsController.php
│ │ │ ├── DoctorsController.php
│ │ │ ├── LanguageController.php
│ │ │ ├── PatientAreaController.php
│ │ │ ├── PersonsController.php
│ │ │ ├── RegistrationController.php
│ │ │ └── ScheduleController.php
│ │ ├── Middleware/
│ │ │ ├── CheckRegistrationPass.php
│ │ │ ├── RequireRoles.php
│ │ │ └── SetLanguage.php
│ │ ├── README.md
│ │ └── Requests/
│ │ ├── MyAccountRequest.php
│ │ ├── PersonFieldsRequest.php
│ │ ├── PersonRequest.php
│ │ └── RegistrationRequest.php
│ ├── Models/
│ │ ├── Appointment.php
│ │ ├── Client.php
│ │ ├── Doctor.php
│ │ ├── Person.php
│ │ ├── README.md
│ │ ├── Role.php
│ │ ├── Slot.php
│ │ └── User.php
│ ├── Providers/
│ │ └── AppServiceProvider.php
│ ├── Views/
│ │ ├── AppointmentView.php
│ │ ├── BookingView.php
│ │ ├── OptionView.php
│ │ ├── PersonKeys.php
│ │ ├── README.md
│ │ ├── ScheduleRow.php
│ │ └── ScheduleView.php
│ └── Web/
│ ├── ApplicationModel.php
│ ├── Flash.php
│ ├── Message.php
│ ├── README.md
│ └── Utils.php
├── artisan
├── bootstrap/
│ ├── app.php
│ ├── cache/
│ └── providers.php
├── composer.json
├── config/
│ ├── app.php
│ ├── auth.php
│ ├── hashing.php
│ ├── rdvmedecins.php
│ └── session.php
├── database/
│ ├── dbrdvmedecins2.sql
│ └── README.md
├── .env
├── .env.example
├── lang/
│ ├── en.json
│ └── fr.json
├── public/
│ ├── css/
│ │ └── styles.css
│ ├── index.php
│ ├── js/
│ │ └── app.js
│ └── lib/
│ └── bootstrap/
├── README.md
├── resources/
│ └── views/
│ ├── account.blade.php
│ ├── area.blade.php
│ ├── components/
│ │ ├── captcha.blade.php
│ │ ├── field.blade.php
│ │ └── person-fields.blade.php
│ ├── error.blade.php
│ ├── fragments/
│ │ ├── booking.blade.php
│ │ ├── confirmation.blade.php
│ │ ├── errors.blade.php
│ │ ├── navbar.blade.php
│ │ └── success.blade.php
│ ├── layout.blade.php
│ ├── login.blade.php
│ ├── persons/
│ │ ├── form.blade.php
│ │ └── list.blade.php
│ ├── README.md
│ ├── registration.blade.php
│ └── schedule.blade.php
├── routes/
│ └── web.php
└── storage/
|
This is the structure of a standard [Laravel] application—the one generated by the [composer create-project laravel/laravel] command (Example 01)—stripped of elements not needed here (tests, migrations, [JavaScript] build tools, etc.). There are two differences from the examples: the application has its own [vendor] folder (the examples shared the one in the [examples] folder), and a service provider ([app/Providers/AppServiceProvider.php]), which is discussed further below. The application’s classes are organized in the [app] folder, according to the PSR-4 rule (Example 01): the [App\Business\BusinessService] class is in the [app/Business/BusinessService.php] file. [Laravel] does not enforce any of these folders (Example 06): [Http], [Models], and [Providers] are the ones in use; the others ([Auth], [Dao], [Business]...) 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 login, a password hashed by [bcrypt] (example 25), the display name once logged 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 reserved word in SQL, 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 the 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 checks both equally (see [config/hashing.php], below);
- 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 with [Laravel], the database is not created through migrations ([PHP] classes that describe the creation and evolution of tables, executed by [php artisan migrate]): it is shared with the other versions of the application, and the SQL script is common to all of them.
11.6. The project configuration
11.6.1. composer.json
| {
"name": "rdvmedecins/rdvmedecins2-laravel",
"description": "RdvMedecins: medical appointment booking, HTML pages built by the server (Laravel + Blade)",
"type": "project",
"license": "MIT",
"require": {
"php": "^8.4",
"ext-pdo_mysql": "*",
"laravel/framework": "^13.33",
"firebase/php-jwt": "^7.0"
},
"autoload": {
"psr-4": {
"App\\": "app/"
}
},
"scripts": {
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
]
},
"config": {
"optimize-autoloader": true,
"sort-packages": true
},
"minimum-stability": "stable",
"prefer-stable": true
}
|
- Lines 6–11: the libraries required by the application and the constraints they must meet. [^13.33] reads “version 13.33 or a newer version of the same major version 13”: [Composer] accepts versions that fix bugs or add features, but not those that could break the application (version 14). The lines [php] and [ext-pdo_mysql] do not refer to libraries, but to environment requirements: [Composer] prevents installation if the version of [PHP] or the extension is incompatible, rather than allowing the application to fail later;
- Lines 12–16: The application’s rule PSR-4: Classes in the [App] namespace are located in the [app] folder (Example 01: In the examples, [bootstrap/app.php] itself declared this rule to the class loader via [addPsr4]);
- lines 18–21: commands that [Composer] executes after creating the class loader: [Laravel] lists the service providers for the installed libraries ([package:discover], in [bootstrap/cache]).
[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.
11.6.2. .env
| # =========================================================================
# .env: the settings specific to THIS installation (read by env('NAME') in the
# files of the config folder). This file is never published: it contains
# secrets. A system environment variable takes precedence over it.
# =========================================================================
APP_NAME=RdvMedecins
# local: development; production: in production
APP_ENV=local
# Laravel's encryption key (cookies, session). In production:
# php artisan key:generate (writes a new key in this file)
APP_KEY=base64:+UmYSB3+TOKSr/f/fiqPCyo5IXFgXOLrvO1II52r+q4=
# true: an unexpected exception displays Laravel's debug page, with its
# call stack (NEVER in production); false: the application's 500 error page
APP_DEBUG=true
APP_URL=http://localhost:8081
# ---- the MySQL database [dbrdvmedecins2] (script: database/dbrdvmedecins2.sql):
# the server, its port, the database, the user and its password (those of
# Laragon: root, without password)
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=dbrdvmedecins2
DB_USERNAME=root
DB_PASSWORD=
# true: the SQL statements sent to the database are displayed in the console
DB_LOG=true
# ---- Laravel's session (flash messages, anti-CSRF token, input to
# redisplay): stored in an ENCRYPTED cookie, the server keeps nothing
SESSION_DRIVER=cookie
SESSION_LIFETIME=120
# ---- the cache (lists of doctors and clients, captchas, attempt
# counters, passes): files, in storage/framework/cache
CACHE_STORE=file
# the log: storage/logs/laravel.log
LOG_CHANNEL=single
# ---- authentication (see config/rdvmedecins.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
# true in production (site served over HTTPS): cookies never travel unencrypted
COOKIE_SECURE=false
# ---- protection against robots (see app/Auth/AttemptLimiter.php)
LOGIN_MAX_FAILURES=5
LOGIN_BLOCK_MINUTES=15
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 secret (the keys). It is never published; the file [.env.example], which contains no secrets, serves as its template.
- Line 11: the encryption key for [Laravel]: it encrypts cookies (example 20), including the session cookie. The command [php artisan key:generate] writes a new one;
- line 14: During development, an unexpected exception displays the [Laravel] debug page; in production ([false]), the application’s 500 error page;
- lines 20–25: the connection to the database (example 28);
- Line 31: The [Laravel] session (Example 23) is stored in an encrypted cookie rather than in files on the server. The application stores very little information there (flash messages, the anti-CSRF token, form data to be re-displayed): a single cookie is sufficient, and the server no longer needs to store anything between requests. [Laravel] uses two cookies: [rdvmedecins_session] contains the session ID, and a second cookie—named after that ID—contains the session data;
- Line 35: The cache (example 20) is stored in files within the [storage/framework/cache] folder. The application stores lists of doctors and clients, CAPTCHAs, attempt counters, and access tokens there—information that must be shared across all requests. With multiple servers, a shared cache, such as [Redis], would be used;
- lines 41–48: application-specific settings: the JWT tokens (example 25) and bot protection (example 27).
11.6.3. config/app.php, config/session.php, config/hashing.php, config/auth.php
The files in the [config] folder supplement the default configuration of [Laravel] (Example 25): only the keys they write are changed.
| <?php
// config/app.php: this file COMPLETES Laravel's default configuration
// (vendor/laravel/framework/config/app.php): only the keys written here change
return [
// the default language of the interface (see lang/fr.json, lang/en.json), and the
// fallback language (it is only used by the PHP files of the lang folder: a key
// missing from a JSON dictionary is displayed as is)
'locale' => 'en',
'fallback_locale' => 'en',
// the time zone: that of the medical practice. It sets the current date
// (no appointment in the past, see app/Business/Dates.php)
'timezone' => 'Europe/Paris',
];
|
- lines 8–9: the default interface language (Example 22) and the fallback language (which is used only for the [PHP] files in the [lang] folder; see Example 22);
- line 13: the time zone. By default, [Laravel] operates in 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.
| <?php
// config/session.php: Laravel's session (completes the default configuration).
// [cookie] driver (see .env, SESSION_DRIVER): the session data (flash
// messages, anti-CSRF token, input to redisplay) travel in an ENCRYPTED cookie
return [
'cookie' => 'rdvmedecins_session',
// the application's cookies only travel over HTTPS in production
'secure' => (bool) env('COOKIE_SECURE', false),
];
|
The name of the session cookie, and the [secure] option for session cookies, read from the [.env] file ([(bool)] converts the value to a Boolean; [env] already includes [false] and [true]).
| <?php
// config/hashing.php: password hashing (completes the default configuration)
return [
'driver' => 'bcrypt',
'bcrypt' => [
// 10 rounds, like the hashes of the SQL script
'rounds' => 10,
// By default Laravel checks that a stored hash is indeed a bcrypt
// hash... as PHP writes it ($2y$). But the dbrdvmedecins2 database is
// shared with the Python ($2b$) and Java ($2a$) versions of the application,
// whose hashes PHP verifies very well, without recognizing them as
// "bcrypt". So this check is disabled
'verify' => false,
],
];
|
Password hashing: [bcrypt], 10 rounds, like the hashes in the SQL script. Line 13 disables a check that [Laravel] performs by default: before comparing a password to a hash, it verifies that the hash was indeed generated by the configured algorithm, as written by [PHP] ([$2y$]). The hashes in the database, generated by [Python] ([$2b$]) or by [Java] ([$2a$]), would fail this check, even though [PHP] is perfectly capable of verifying them. This is the price to pay for sharing a database with other applications.
| <?php
// config/auth.php: authentication (completes the default configuration)
return [
// the "guard" used by default: the one that reads the JWT token from the cookie
'defaults' => [
'guard' => 'jwt',
],
'guards' => [
// a guard with the [jwt] driver, defined by AppServiceProvider (Auth::viaRequest)
'jwt' => [
'driver' => 'jwt',
],
],
];
|
The guard [jwt], the default guard for driver [jwt]: exactly like Example 25.
11.6.4. config/rdvmedecins.php
| <?php
// config/rdvmedecins.php: the settings specific to the application, read by
// config('rdvmedecins.xxx'). The values come from the .env file; the second
// parameter of env() is the default value
return [
'jwt' => [
// secret key of the JWT tokens: at least 32 characters (HMAC-SHA256)
'secret' => env('JWT_SECRET', ''),
// validity duration of a token, in minutes; the cookie that carries it
// has the same lifetime
'duration' => (int) env('JWT_DURATION_MINUTES', 120),
],
// true: the SQL statements sent to the database are displayed in the console
'db_log' => (bool) env('DB_LOG', false),
// true in production (HTTPS): the cookies are never sent unencrypted
'cookie_secure' => (bool) env('COOKIE_SECURE', false),
// protection against robots (see app/Auth/AttemptLimiter.php):
// login: failures allowed for the same login from the same IP address,
// then block for this duration (the whole IP address is also blocked
// if it accumulates 5 times more failures, all logins together)
'login_max_failures' => (int) env('LOGIN_MAX_FAILURES', 5),
'login_block_minutes' => (int) env('LOGIN_BLOCK_MINUTES', 15),
// client account creation: maximum accounts created 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' => (int) env('REGISTRATION_MAX_PER_HOUR', 10),
];
|
Application-specific settings, read by [config('rdvmedecins.jwt.secret')], [config('rdvmedecins.login_max_failures')]... Each value comes from the file [.env], with a default value. Recall the rule (Example 25): [env] is called only in files within the [config] folder; the rest of the code reads [config(...)]. Once the configuration is cached ([php artisan config:cache]), the file [.env] is no longer read at all.
11.7. When the application
11.7.1. public/index.php and bootstrap/providers.php
The file [public/index.php] is the one from the examples (Example 01), with the path to the application’s [vendor] folder:
| <?php
// public/index.php: the entry point of ALL HTTP requests. The [public] folder
// is the root of the site: only its files are reachable from the browser
// (Bootstrap, style sheet, script), never the application's code
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
// Composer's class loader
require __DIR__.'/../vendor/autoload.php';
// the application, built by bootstrap/app.php, handles the current request
/** @var Application $app */
$app = require_once __DIR__.'/../bootstrap/app.php';
$app->handleRequest(Request::capture());
|
The file [bootstrap/providers.php] is new:
| <?php
// bootstrap/providers.php: the service providers of the
// application, loaded at the start of each request (see app/Providers)
use App\Providers\AppServiceProvider;
return [
AppServiceProvider::class,
];
|
It returns the list of service providers for the application. A service provider is a class for which [Laravel] calls two methods at the start of each request: [register], which registers services with the container (example 06), and then, once all providers are registered, [boot], which can use any service. [Laravel] itself is composed of service providers (one for the session, one for views, one for the database, etc.), as are most of the libraries written for it. In the examples, we wrote these declarations in [bootstrap/app.php] ([withSingletons], [booted]): This is convenient for a few lines; a full-scale application groups them into a service provider, [AppServiceProvider] by convention.
11.7.2. app/Providers/AppServiceProvider.php
| <?php
// =========================================================================
// app/Providers/AppServiceProvider.php: the application's SERVICE PROVIDER
// (declared in bootstrap/providers.php). It is the normal place, in a
// Laravel application, for what the examples wrote in bootstrap/app.php:
// - $singletons: the shared services (see example 06);
// - boot(): what must be done at the start of each request, once all
// the services are registered: the JWT authentication driver, the variables
// common to all the views, the display of the SQL statements.
// =========================================================================
namespace App\Providers;
use App\Auth\AuthService;
use App\Auth\CaptchaService;
use App\Auth\AttemptLimiter;
use App\Auth\RegistrationPass;
use App\Dao\ClientDao;
use App\Dao\SlotDao;
use App\Dao\DoctorDao;
use App\Dao\AppointmentDao;
use App\Dao\UserDao;
use App\Business\BusinessService;
use App\Web\ApplicationModel;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Database\Events\TransactionBeginning;
use Illuminate\Database\Events\TransactionCommitted;
use Illuminate\Database\Events\TransactionRolledBack;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cookie;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
use Illuminate\View\View as TheView;
class AppServiceProvider extends ServiceProvider
{
// -----------------------------------------------------------------------
// the shared services: for each of these classes, Laravel's container
// creates only ONE object (per request), given to all those who ask for it - the
// controllers, through their constructor. The container builds the
// dependencies of each one itself (BusinessService thus receives its five DAOs): this is
// dependency injection.
// -----------------------------------------------------------------------
public array $singletons = [
DoctorDao::class => DoctorDao::class,
ClientDao::class => ClientDao::class,
SlotDao::class => SlotDao::class,
AppointmentDao::class => AppointmentDao::class,
UserDao::class => UserDao::class,
BusinessService::class => BusinessService::class,
ApplicationModel::class => ApplicationModel::class,
AuthService::class => AuthService::class,
CaptchaService::class => CaptchaService::class,
AttemptLimiter::class => AttemptLimiter::class,
RegistrationPass::class => RegistrationPass::class,
];
public function boot(): void
{
// ---- the [jwt] guard driver (see config/auth.php, example 25): a
// function that receives the request, and returns the authenticated user, or null
// for an anonymous visitor. Laravel calls it on the first Auth::user() of the
// request; if it found a user, it keeps it for the rest of the
// request (otherwise, it calls the function again on each Auth::user())
Auth::viaRequest('jwt', function (Request $request) {
$token = $request->cookie(AuthService::COOKIE);
if (! is_string($token) || $token === '') {
return null;
}
$user = app(AuthService::class)->currentUser($token);
if ($user === null) {
// a cookie present but invalid (expired, forged token): we clear it
Cookie::queue(AuthService::clearingCookie());
}
return $user;
});
// ---- the variables common to ALL the views (see example 15): a
// "view composer" is run before each view is built ('*')
// - user : the logged-in user (or null), for the navigation bar;
// - language : "fr" or "en".
View::composer('*', function (TheView $view) {
$view->with(['user' => Auth::user(), 'language' => App::getLocale()]);
});
// ---- DB_LOG=true (.env file): each SQL statement, and each step of a
// transaction, is displayed in the server console (see example 28)
if (config('rdvmedecins.db_log')) {
DB::listen(fn (QueryExecuted $q) => error_log($q->sql.' '.json_encode($q->bindings, JSON_UNESCAPED_UNICODE)));
Event::listen(TransactionBeginning::class, fn () => error_log('BEGIN'));
Event::listen(TransactionCommitted::class, fn () => error_log('COMMIT'));
Event::listen(TransactionRolledBack::class, fn () => error_log('ROLLBACK'));
}
}
}
|
- Lines 48–60: shared services. The [$singletons] property of a service provider is read by [Laravel], which declares each of its [interface => class] pairs as a singleton (Example 06): This is equivalent to [withSingletons]. Here, each class is declared on its own. Remember that a singleton exists only for the duration of a single request (Example 06): the benefit here is that all controllers, middleware, and services within the same request share the same [ApplicationModel], the same [BusinessService]... The DAO instances might not even be declared: the container knows how to create a class that hasn’t been declared (Example 06), but it would create a new object for each request;
- lines 69–80: the guard driver [jwt] (example 25). The token is read from the [rdvmedecins_jwt] cookie (the name is a constant from [AuthService]) and verified by [AuthService::currentUser]. A cookie that exists but whose token is no longer valid is deleted. [Laravel] retains the user found for the entire request; as long as it has not found one (an anonymous visitor), it calls the function with each request, which has no impact: reading a cookie and, if necessary, verifying a signature, is very fast;
- Lines 86–88: variables common to all views (Example 15): the logged-in user (or [null]), for the navigation bar, and the request language. The pattern ['*'] matches all views: the composer is called for each one, including fragments and components;
- Lines 92–99: Display of SQL orders and transactions in the console (Example 28), if [DB_LOG=true]. [error_log] writes to the error log of [PHP]—that is, along with [php artisan serve], in the server console.
11.7.3. bootstrap/app.php
| <?php
// =========================================================================
// bootstrap/app.php: the construction of the application (see example 01):
// 1. the routes (routes/web.php);
// 2. the middlewares: language, cookies, authentication, roles;
// 3. the exceptions: how errors become HTML pages.
// The shared services are declared by app/Providers/AppServiceProvider.php.
// =========================================================================
use App\Auth\AuthService;
use App\Auth\InvalidSession;
use App\Http\Middleware\RequireRoles;
use App\Http\Middleware\SetLanguage;
use App\Http\Middleware\CheckRegistrationPass;
use App\Web\Flash;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Auth\Middleware\Authenticate;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\Request;
use Illuminate\Support\ViewErrorBag;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(web: __DIR__.'/../routes/web.php')
->withMiddleware(function (Middleware $middleware): void {
// the language of the request: a GLOBAL middleware, run for all
// requests, before those of the [web] group (see app/Http/Middleware/SetLanguage.php)
$middleware->append(SetLanguage::class);
// Laravel ENCRYPTS all the cookies it sets. Two exceptions: the JWT
// token, already signed; the language, read before the cookies are decrypted
$middleware->encryptCookies(except: [AuthService::COOKIE, SetLanguage::COOKIE]);
// -------------------------------------------------------------------
// THE ACCESS RULE: all the routes of the [web] group require an
// authenticated user (Authenticate middleware, alias [auth]) - except
// those that explicitly remove it (withoutMiddleware, see
// routes/web.php: login, account creation, language choice).
// This is the "everything is forbidden except what is allowed" rule: a route
// added later, that one would have forgotten to protect, is protected anyway.
// -------------------------------------------------------------------
$middleware->web(append: [Authenticate::class]);
// an unauthenticated visitor who requests a protected page: off to the
// login page. If they had a token cookie, their token is no longer
// valid (expired, forged): their session has expired, we tell them so
$middleware->redirectGuestsTo(function (Request $request) {
if ($request->cookie(AuthService::COOKIE)) {
Flash::error('ERRORS.SESSION_EXPIRED');
}
return route('login');
});
// short names (aliases) for the middlewares used in routes/web.php
$middleware->alias([
'role' => RequireRoles::class,
'registration.pass' => CheckRegistrationPass::class,
]);
})
// -----------------------------------------------------------------------
// 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: abort(400), abort(403) (insufficient role), 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): Laravel writes it in the
// log (storage/logs/laravel.log), with its call stack; in
// production (APP_DEBUG=false), the user sees the 500 error page -
// never the call stack; in development, Laravel's debug page.
// A render function that returns null lets Laravel handle the exception its
// own way.
// -----------------------------------------------------------------------
->withExceptions(function (Exceptions $exceptions): void {
// the error page. $errors: the input errors variable, which all
// the views can read, does not exist yet if the error occurred
// before the middleware that creates it (a 405 page, for example)
$errorPage = function (int $code) {
$message = match ($code) {
400 => 'ERRORS.BAD_REQUEST',
403 => 'ERRORS.FORBIDDEN',
404 => 'ERRORS.NOT_FOUND',
419 => 'ERRORS.PAGE_EXPIRED',
default => 'ERRORS.SERVER_ERROR',
};
return response()->view('error', ['pageTitle' => 'ERROR_PAGE.TITLE', 'code' => $code,
'message' => $message, 'errors' => new ViewErrorBag], $code);
};
$exceptions->render(function (InvalidSession $e) {
Flash::error('ERRORS.SESSION_EXPIRED');
return redirect()->route('login')->withCookie(AuthService::clearingCookie());
});
$exceptions->render(fn (HttpExceptionInterface $e) => $errorPage($e->getStatusCode()));
$exceptions->render(function (Throwable $e) use ($errorPage) {
// these exceptions have their own handling: redirection to the
// login page (AuthenticationException), back to the form with the
// input errors (ValidationException)...
if (config('app.debug') || $e instanceof AuthenticationException
|| $e instanceof ValidationException || $e instanceof HttpResponseException) {
return null;
}
return $errorPage(500);
});
})
->create();
|
The file that builds the application (Example 01). It contains everything related to request handling: routes, middleware, and exceptions.
- line 28: the routes ([routes/web.php], discussed later);
- line 33: the request language is set by a global middleware (example 24), which is executed for all requests, even before routing;
- line 37: [Laravel] encrypts all cookies (Example 20), except for those that are excluded: the JWT token, which is already signed (Example 25), and the language. The language cookie is read by a global middleware, which runs before the [EncryptCookies] middleware in the [web] group: it must therefore be readable without decryption. Why use a global middleware rather than a middleware from the [web] group? So that even requests that do not result in a route (a 405 page) display their error page in the correct language;
- Line 47: the application’s access rule. The [Authenticate] middleware (the [auth] alias from Example 25) is added to the [web] group, i.e., to all routes. A public route (the login page, account creation, language selection) must explicitly remove it using [withoutMiddleware] (within [routes/web.php]). This follows the rule “everything is prohibited unless explicitly permitted”: a route added later—even if you forgot to protect it—is automatically protected. The opposite error (a public route you forgot to open) is immediately apparent; the error of a forgotten protected page is much less so;
- Lines 52–57: An unauthenticated visitor who requests a protected page is redirected to the login page. If the visitor’s browser sent a token cookie, it means that token is no longer valid (expired or tampered with): the visitor is notified via a pop-up message ([Flash::error], discussed later). [redirectGuestsTo] accepts a URL, or a function that receives the request and returns the URL;
- Lines 60–63: aliases (short names) for route middleware: [role] and [registration.pass], used in [routes/web.php].
Lines 80–113 convert errors into HTML pages (Example 24):
- Lines 84–91: the function that generates the error page for code HTTP: the view [error], the code, the message key, and the response code HTTP. A subtle point: the application views read the variable [$errors] (input errors, Example 19), which the [ShareErrorsFromSession] middleware in the [web] group shares with all views. An error that occurs before this middleware (an unsupported HTTP method, 405, is detected by the router) would reach a view without [$errors]: an empty one ([new ViewErrorBag]) is provided to it;
- lines 96–99: the [InvalidSession] exception (the token account no longer exists; see below): return to the login page, token cookie cleared, with the message “session expired”;
- line 101: all errors HTTP—[abort(403)], [abort(404)], an unknown route, an unsupported method, a missing or invalid anti-CSRF token (419, example 18)... become the error page, along with their codes;
- Lines 103–112: Any other exception is a true anomaly (the database is inaccessible, a programming error...). [Laravel] always logs it in the [storage/logs/laravel.log] log, along with its call stack. In production ([APP_DEBUG=false]), the user sees the 500 error page—never the call stack, which would reveal the application’s code to a hacker; in development, the function returns [null]: [Laravel] displays its debug page. Note: [Throwable] handles all exceptions, including those that [Laravel] handles itself, and these should be left to it: [AuthenticationException] (an unauthenticated visitor, redirected to [/login]), [ValidationException] (an invalid input, returned to the form, example 19), [HttpResponseException] (an exception that carries its own response). The rendering functions are tried in the order in which they are declared: errors HTTP have already been handled by the previous one.
11.8. The templates
The [Eloquent] templates (example 28): one class per table.
11.8.1. app/Models/Role.php
| <?php
// app/Models/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') does the reverse path
namespace App\Models;
enum Role: string
{
case ADMIN = 'ADMIN';
case DOCTOR = 'DOCTOR';
case USER = 'USER';
}
|
The three roles, in the form of an enumeration ([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 string stored in the database. [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.
11.8.2. app/Models/User.php
| <?php
// app/Models/User.php: a user allowed to log in (table [users]).
// Each doctor and each client owns 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 app/Business/BusinessService.php).
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
protected $table = 'users';
// the table does not have the created_at / updated_at columns that Eloquent manages by default
public $timestamps = false;
// the columns that can be mass-filled (User::create([...]), new User([...]))
protected $fillable = ['login', 'password', 'name', 'role'];
// the HASHED password (bcrypt) never leaves the object if it is converted to an
// array or to JSON
protected $hidden = ['password'];
// the conversions: the [role] column (a text) becomes a Role, and vice versa
protected function casts(): array
{
return ['role' => Role::class, 'version' => 'integer'];
}
}
|
- line 16: the table. [Eloquent] would have guessed it (the class name, in lowercase, in the English plural: [users]); we write it out everywhere for clarity;
- Line 19: No table in the database has the columns [created_at] and [updated_at], which [Eloquent] populates by default (Example 28);
- line 22: the columns that can be filled in bulk using [User::create([...])] (example 28);
- line 26: the password hash will not appear if the object is converted to an array or to JSON ([$user->toArray()]): a precaution;
- Lines 29–32: the conversions (Example 28): the column [role] (text) becomes [Role] when read, and vice versa when written: [$user->role] is [Role], never a string. The column [version] is read as an integer: the [PDO] driver for [MySQL] can render numbers as strings, and the business layer’s strict comparison of [$version !== $person->version] would fail.
11.8.3. app/Models/Person.php
| <?php
// app/Models/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 (we never create a "Person"), from which
// Doctor and Client inherit; each one only specifies the name of its table.
//
// Beware: [Client] here designates a PATIENT of the medical practice, not an HTTP
// client! It is the vocabulary of the original business domain, kept as is.
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
abstract class Person extends Model
{
public $timestamps = false;
// [user] is the name of the foreign key column -> users(id): a simple
// number, the id of the account ($doctor->user)
protected $fillable = ['title', 'last_name', 'first_name', 'user'];
protected function casts(): array
{
return ['user' => 'integer', 'version' => 'integer'];
}
// -----------------------------------------------------------------------
// The RELATION to the user account: a person has ONE account, designated
// by the foreign key [user]. belongsTo: "belongs to" - the foreign key
// is in THIS table. $doctor->account is then a User object, read from the database
// on first access, or right with the query with with('account') (see app/Dao).
// The relation cannot be called [user]: this name is already that of the
// column, and $doctor->user designates the column.
// -----------------------------------------------------------------------
public function account(): BelongsTo
{
return $this->belongsTo(User::class, 'user');
}
// "Mme Alice DURAND"
public function label(): string
{
return "{$this->title} {$this->first_name} {$this->last_name}";
}
}
|
The tables [doctors] and [clients] have the same columns. Their common 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 and specify only the name of their table.
- Line 24: [user] is the foreign key column; [$doctor->user] is a simple number, the account ID;
- Lines 39–42: the relationship to the account. A relationship [Eloquent] is a method that describes the link between two tables; [belongsTo] (“belongs to”) describes a foreign key in this table: [belongsTo(User::class, 'user')] specifies that the column [user] contains the ID of a [User]. It is used as a property: [$doctor->account] is the object [User]. [Eloquent] reads it from the database on the first access (this is called *lazy loading*), or as soon as the query that retrieves the doctor requests it (if necessary; *eager loading*, see [with('account')] in the DAO layer). The relation cannot be named [user]: [$doctor->user] already refers to the column, and [Eloquent] gives priority to the columns;
- Lines 45–48: a person’s name, “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. app/Models/Doctor.php and Client.php
| <?php
// app/Models/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\Models;
class Doctor extends Person
{
protected $table = 'doctors';
}
|
| <?php
// app/Models/Client.php: a client, that is to say a PATIENT (table [clients]).
// SQL uniqueness constraint: unq_clients_first_name_last_name.
namespace App\Models;
class Client extends Person
{
protected $table = 'clients';
}
|
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. app/Models/Slot.php
| <?php
// app/Models/Slot.php: a time slot of a doctor (table [slots]):
// 8:00 - 8:20, etc.
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Slot extends Model
{
protected $table = 'slots';
public $timestamps = false;
protected function casts(): array
{
return ['doctor_id' => 'integer', 'start_hour' => 'integer', 'start_minute' => 'integer',
'end_hour' => 'integer', 'end_minute' => 'integer', 'version' => 'integer'];
}
// several slots for the same doctor: the foreign key [doctor_id]
public function doctor(): BelongsTo
{
return $this->belongsTo(Doctor::class, 'doctor_id');
}
// "8:00 - 8:20"; sprintf('%02d'): "8:05" rather than "8:5"
public function time(): string
{
return sprintf('%d:%02d - %d:%02d', $this->start_hour, $this->start_minute, $this->end_hour, $this->end_minute);
}
}
|
- lines 23–26: the relationship to the doctor for the appointment slot (foreign key [doctor_id]);
- lines 29–32: the time slot, “8:00 – 8:20.” The format [%02d] of [sprintf] writes a two-digit integer at a minimum, padded with zeros: [8:05] rather than [8:5].
11.8.6. app/Models/Appointment.php
| <?php
// app/Models/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\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Appointment extends Model
{
protected $table = 'appointments';
public $timestamps = false;
protected $fillable = ['day', 'slot_id', 'client_id'];
// immutable_date: the [day] column (SQL type DATE) becomes a
// CarbonImmutable date, at midnight; it is written to the database in yyyy-mm-dd format
protected function casts(): array
{
return ['day' => 'immutable_date', 'slot_id' => 'integer', 'client_id' => 'integer',
'version' => 'integer'];
}
public function client(): BelongsTo
{
return $this->belongsTo(Client::class, 'client_id');
}
public function slot(): BelongsTo
{
return $this->belongsTo(Slot::class, 'slot_id');
}
}
|
- Lines 23–27: The column [day] (type SQL [DATE]) is converted to [CarbonImmutable], the date class of [Laravel] (example 12), at midnight. Immutable: its methods never modify the object; they return a new one ([$day->addDay()] returns the next day, [$day] remains unchanged). The entire application handles immutable dates: a date shared among multiple objects cannot be modified by one of them without the others’ knowledge;
- lines 29–34: two relationships, one to the client and one to the appointment time slot.
11.9. The domain: the calendar
11.9.1. app/Domain/DoctorDaySlot.php and DoctorDaySchedule.php
| <?php
// app/Domain/DoctorDaySlot.php: a time slot, and the appointment possibly
// booked on this slot on a given day (null if the slot is free).
// A computed "business" object, which corresponds to 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\Models\Slot;
use App\Models\Appointment;
readonly class DoctorDaySlot
{
public function __construct(
public Slot $slot,
public ?Appointment $appointment,
) {
}
}
|
| <?php
// app/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\Models\Doctor;
use Carbon\CarbonImmutable;
readonly class DoctorDaySchedule
{
/** @param list<DoctorDaySlot> $doctorDaySlots */
public function __construct(
public Doctor $doctor,
public CarbonImmutable $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 classes of type [readonly] (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 the queries: one class per table, with no business logic. Its methods do not start transactions; the business layer calls them within its own transactions. They use the query builder from [Eloquent]: [where], [orderBy], [first], [get]... ; the values in these queries become parameters for the SQL query: no SQL injection is possible.
11.10.1. app/Dao/PersonDao.php
| <?php
// app/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->model is the Eloquent class concerned
// (Doctor::class or Client::class): $this->model::query() starts a query
// on ITS table.
//
// The queries are written with Eloquent's query builder (where,
// orderBy...); Eloquent translates them into SQL. The values (first name, last name...)
// become PARAMETERS of the query: 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\Models\Person;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
abstract class PersonDao
{
/** @param class-string<Person> $model */
public function __construct(protected string $model)
{
}
// a query on the class's table, user account included
protected function query(): Builder
{
// with('account'): ALSO loads the accounts, with a second query
// (SELECT * FROM users WHERE id IN (...)). Without it, each access to
// $person->account would trigger its own query
return $this->model::query()->with('account');
}
// list sorted by last name then first name
public function list(): Collection
{
return $this->query()->orderBy('last_name')->orderBy('first_name')->get();
}
// a person and their account; null if they do not exist
public function read(int $id): ?Person
{
return $this->query()->find($id);
}
// the person whose user account is [$userId]
public function readByUser(int $userId): ?Person
{
return $this->query()->where('user', $userId)->first();
}
// the person who has these first and last names, if there is one. The comparison is
// made by MySQL, hence with the database collation (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->model::query()->where('first_name', $firstName)->where('last_name', $lastName)->first();
}
// INSERT: the database generates the id, which Eloquent stores in the object
public function add(array $columns): Person
{
return $this->model::query()->create($columns);
}
// -----------------------------------------------------------------------
// the OPTIMISTIC LOCK (see example 28):
// UPDATE ... SET ..., version = version + 1 WHERE id = ? AND version = ?
// returns the number of modified rows: 0 if the row was modified (or
// deleted) since it was read
// -----------------------------------------------------------------------
public function edit(int $id, int $version, array $columns): int
{
return $this->model::query()->whereKey($id)->where('version', $version)
->update([...$columns, 'version' => DB::raw('version + 1')]);
}
// a DELETE sent directly to the database, without reading the person first
public function remove(int $id): void
{
$this->model::query()->whereKey($id)->delete();
}
}
|
The DAO layer common to both doctors and clients, defined once in an abstract class.
- Line 25: the relevant class [Eloquent], [Doctor::class], or [Client::class] (a string, the full name of the class). The comment [@param class-string<Person>] clarifies this for developers. [$this->model::query()] calls the static method [query] of this class: [PHP] accepts a variable instead of a class name. [query()] initiates a query on the model table; [Doctor::where(...)] is a shortcut for it;
- Lines 30–36: A query that also loads the accounts of the people retrieved: [with('account')] requests the preloading of the relation [account]. [Eloquent] then executes two queries: one for the individuals, followed by one for all their accounts at once ([select * from users where id in (2, 3, 4)]; see the console output above). Without this, the list of doctors displayed by the administrator would trigger one query per doctor upon the first access to [$doctor->account->login]: 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 retrieved from the cache must be complete;
- lines 39–42: the list, sorted by last name then first name; [get()] returns a [Collection] and a [Eloquent] (example 28);
- lines 45–48: [find] reads a row by its primary key; [null] if it does not exist;
- lines 51–54: the person whose account is [$userId]: [first()] returns the first row, or [null];
- lines 60–63: the person with the same first and last name. The comparison is performed using [MySQL], with the database’s collation: ignoring uppercase letters and accents, exactly as in the uniqueness constraint (example 28);
- lines 66–69: [create] inserts a row (columns from [$fillable]) and returns the object, along with the ID generated by the database;
- lines 77–81: the optimistic lock from Example 28: [UPDATE ... SET ..., version = version + 1 WHERE id = ? AND version = ?]. The method returns the number of modified rows: 0 if the row has been modified or deleted since it was read. [[...$columns, 'version' => ...]] creates an array containing the elements of [$columns], plus the version (the [...] operator unpacks an array). [whereKey($id)]: the condition on the primary key;
- lines 84–87: a [DELETE] sent directly to the database, without reading the row first.
11.10.2. app/Dao/DoctorDao.php and ClientDao.php
| <?php
// app/Dao/DoctorDao.php: the DAO of the doctors - everything is inherited from PersonDao.
namespace App\Dao;
use App\Models\Doctor;
class DoctorDao extends PersonDao
{
public function __construct()
{
parent::__construct(Doctor::class);
}
}
|
Everything is inherited: the constructor only specifies the class [Eloquent] to [PersonDao] ([parent::__construct] calls the parent class’s constructor).
| <?php
// app/Dao/ClientDao.php: the DAO of the clients - that of the persons, plus a
// locking method.
namespace App\Dao;
use App\Models\Client;
class ClientDao extends PersonDao
{
public function __construct()
{
parent::__construct(Client::class);
}
// -----------------------------------------------------------------------
// puts a LOCK on the row of a client until the end of the current
// transaction (SELECT ... FOR UPDATE), and returns its id (null if the client
// does not exist). A second transaction that requests the same lock WAITS for
// the first one to finish.
// 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.
// -----------------------------------------------------------------------
public function lock(int $id): ?int
{
return Client::query()->whereKey($id)->lockForUpdate()->value('id');
}
}
|
- Lines 25–28: a pessimistic lock, the other type of lock. [lockForUpdate()] adds [FOR UPDATE] to the query: [SELECT id FROM clients WHERE 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. [value('id')] returns the value of a column in the first row ([null] if there is none). We’ll see, in the business layer, what this lock is used for.
11.10.3. app/Dao/SlotDao.php
| <?php
// app/Dao/SlotDao.php: DAO layer of the time slots.
namespace App\Dao;
use App\Models\Slot;
use Illuminate\Database\Eloquent\Collection;
class SlotDao
{
// a slot and its doctor
public function read(int $id): ?Slot
{
return Slot::with('doctor')->find($id);
}
// the slots of a doctor, in chronological order
public function listByDoctor(int $doctorId): Collection
{
return Slot::where('doctor_id', $doctorId)->orderBy('start_hour')->orderBy('start_minute')->get();
}
// deletes all the slots of a doctor (deletion of a doctor):
// DELETE FROM slots WHERE doctor_id = ?
public function deleteByDoctor(int $doctorId): void
{
Slot::where('doctor_id', $doctorId)->delete();
}
}
|
[Slot::with('doctor')->find($id)]: a time slot and its doctor (preloaded); [Slot::where(...)->delete()]: a [DELETE] containing all rows that satisfy the condition.
11.10.4. app/Dao/AppointmentDao.php
| <?php
// app/Dao/AppointmentDao.php: DAO layer of the appointments.
//
// The [appointments] table does not reference the doctor directly: we go through the slot.
// whereHas('slot', ...): the appointments whose slot satisfies a condition
// (Eloquent writes it in SQL with an EXISTS subquery).
namespace App\Dao;
use App\Models\Appointment;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
class AppointmentDao
{
// an appointment, its client and its slot
public function read(int $id): ?Appointment
{
return Appointment::with(['client', 'slot'])->find($id);
}
// the appointments of a doctor on a given day, with their client
public function doctorDayAppointments(int $doctorId, CarbonImmutable $day): Collection
{
return Appointment::with('client')
->where('day', $day->toDateString())
->whereHas('slot', fn (Builder $q) => $q->where('doctor_id', $doctorId))
->get();
}
// -----------------------------------------------------------------------
// the appointments of a client from day [$since] (included), in chronological
// order, with the slot AND the doctor of this slot ('slot.doctor':
// a relation of the relation). The sort uses columns of the
// [slots] table: an SQL JOIN is needed; select('appointments.*'): we only keep the
// columns of [appointments] to fill the Appointment objects
// -----------------------------------------------------------------------
public function clientUpcomingAppointments(int $clientId, CarbonImmutable $since): Collection
{
return Appointment::with('slot.doctor')
->select('appointments.*')
->join('slots', 'slots.id', '=', 'appointments.slot_id')
->where('appointments.client_id', $clientId)
->where('appointments.day', '>=', $since->toDateString())
->orderBy('appointments.day')->orderBy('slots.start_hour')->orderBy('slots.start_minute')
->get();
}
// number of appointments of a client from day [$since] (included)
public function countClientUpcoming(int $clientId, CarbonImmutable $since): int
{
return Appointment::where('client_id', $clientId)->where('day', '>=', $since->toDateString())->count();
}
// number of appointments (past or future) of a doctor
public function countByDoctor(int $doctorId): int
{
return Appointment::whereHas('slot', fn (Builder $q) => $q->where('doctor_id', $doctorId))->count();
}
public function add(CarbonImmutable $day, int $slotId, int $clientId): Appointment
{
return Appointment::create(['day' => $day, 'slot_id' => $slotId, 'client_id' => $clientId]);
}
// DELETE FROM appointments WHERE id = ?
public function remove(int $id): void
{
Appointment::whereKey($id)->delete();
}
// deletes ALL the appointments (past and future) of a client
public function deleteByClient(int $clientId): void
{
Appointment::where('client_id', $clientId)->delete();
}
}
|
The [appointments] table does not directly reference the doctor: it goes through the time slot.
- Lines 24–30: a doctor’s appointments on a given day. [whereHas('slot', function)] retains the appointments for which the [slot] relationship satisfies the condition defined by the function: [Eloquent] translates this into a subquery [EXISTS] (see the console output above). [fn (Builder $q) => ...] is an arrow function (example 03); it receives the query on table [slots]. The date is compared in its [yyyy-mm-dd] format ([toDateString()]), which is the same as that of the columns in [DATE];
- Lines 39–48: A client’s upcoming appointments, along with their time slot and the doctor assigned to that slot: [with('slot.doctor')] loads a relation from the relation. The sort is based on the time of the time slot, a column in table [slots]: a join is required ([join]). [select('appointments.*')] retains only the columns from table [appointments] to populate the [Appointment] objects (otherwise, the slot ID would overwrite the appointment ID: both columns are named [id]);
- lines 51–54: [count()] executes a [SELECT COUNT(*)];
- lines 62–65: the object [CarbonImmutable] is written to the database in the column’s format, using the conversion declared by the model.
11.10.5. app/Dao/UserDao.php
| <?php
// app/Dao/UserDao.php: DAO layer of the user accounts. Creating (or deleting)
// a doctor or a client also creates (or deletes) their account - see app/Business.
namespace App\Dao;
use App\Models\Role;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class UserDao
{
// null if no user has this login
public function readByLogin(string $login): ?User
{
return User::where('login', $login)->first();
}
public function add(string $login, string $hashed, string $name, Role $role): User
{
return User::create(['login' => $login, 'password' => $hashed, 'name' => $name, 'role' => $role]);
}
// optimistic lock, like PersonDao::edit
public function edit(int $id, int $version, array $columns): int
{
return User::whereKey($id)->where('version', $version)
->update([...$columns, 'version' => DB::raw('version + 1')]);
}
public function remove(int $id): void
{
User::whereKey($id)->delete();
}
}
|
Accounts: viewing by login, adding, modifying with optimistic locking, deleting.
11.11. The business layer
The business layer brings together business rules and transactions (Example 28). It is described by an interface and implemented by two classes: [BusinessService], the business layer itself, and [ApplicationModel], the web layer cache (discussed later).
11.11.1. app/Business/Dates.php
| <?php
// app/Business/Dates.php: the current 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 Carbon\CarbonImmutable;
class Dates
{
// today at midnight, in the application's time zone (config/app.php)
public static function today(): CarbonImmutable
{
return CarbonImmutable::today();
}
}
|
The current date at midnight in the application’s time zone ([config/app.php]). It is used for business rules (no reservations in the past, 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. app/Business/BusinessException.php
| <?php
// app/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 lang/*.json).
//
// 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. app/Business/PersonData.php
| <?php
// app/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. app/Business/Business.php
| <?php
// app/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...).
namespace App\Business;
use App\Domain\DoctorDaySchedule;
use App\Models\Client;
use App\Models\Slot;
use App\Models\Doctor;
use App\Models\Appointment;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Collection;
interface Business
{
// ------------------------------------------------ reads
public function getAllClients(): Collection;
public function getAllDoctors(): Collection;
public function getAllSlots(int $doctorId): Collection;
public function getDoctorDayAppointments(int $doctorId, CarbonImmutable $day): Collection;
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, CarbonImmutable $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
public function getClientUpcomingAppointments(int $clientId): Collection;
// ------------------------------------------------ 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(CarbonImmutable $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 [Eloquent] or [Collection] objects; [?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] objects it may create.
11.11.5. app/Business/Descriptor.php
| <?php
// app/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\Models\Role;
use Closure;
readonly class Descriptor
{
public function __construct(
public PersonDao $dao,
public Role $role, // DOCTOR or USER
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, error messages, and the deletion rule. This descriptor brings these details together; 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 what references the person.
11.11.6. app/Business/BusinessService.php
| <?php
// =========================================================================
// app/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 warned): 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 one 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\Models\Client;
use App\Models\Slot;
use App\Models\Doctor;
use App\Models\Person;
use App\Models\Role;
use App\Models\Appointment;
use Carbon\CarbonImmutable;
use Closure;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class BusinessService implements Business
{
// -----------------------------------------------------------------------
// Database errors -> BusinessException
//
// When a UNIQUE constraint refuses a write, Laravel throws a
// QueryException, 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). This 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;
// all the dependencies are provided by Laravel's service container
// (dependency injection, see example 06)
public function __construct(
private DoctorDao $doctors,
private ClientDao $clients,
private SlotDao $slots,
private AppointmentDao $appointments,
private UserDao $users,
) {
$this->doctor = new Descriptor(
dao: $doctors,
role: Role::DOCTOR,
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,
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(): Collection
{
return $this->clients->list();
}
public function getAllDoctors(): Collection
{
return $this->doctors->list();
}
public function getAllSlots(int $doctorId): Collection
{
return $this->slots->listByDoctor($doctorId);
}
public function getDoctorDayAppointments(int $doctorId, CarbonImmutable $day): Collection
{
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): Collection
{
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, CarbonImmutable $day): ?DoctorDaySchedule
{
$doctor = $this->getDoctorById($doctorId);
if ($doctor === null) {
return null;
}
// slot no. -> appointment booked on this slot (keyBy: a collection
// indexed by the value of a column)
$bookings = $this->getDoctorDayAppointments($doctorId, $day)->keyBy('slot_id');
$doctorDaySlots = $this->getAllSlots($doctorId)
->map(fn (Slot $slot) => new DoctorDaySlot($slot, $bookings->get($slot->id)))
->all();
return new DoctorDaySchedule($doctor, $day, $doctorDaySlots);
}
// =======================================================================
// appointments
// =======================================================================
// -----------------------------------------------------------------------
// booking of an appointment, 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(CarbonImmutable $day, Slot $slot, Client $client): Appointment
{
return $this->transaction(function () use ($day, $slot, $client) {
$today = Dates::today();
if ($day->lt($today)) {
throw new BusinessException('ERRORS.PAST_DATE');
}
$patientId = $this->clients->lock($client->id)
?? throw new BusinessException('ERRORS.PATIENT_NOT_FOUND');
if ($this->appointments->countClientUpcoming($patientId, $today) > 0) {
throw new BusinessException('ERRORS.PATIENT_HAS_UPCOMING_APPOINTMENT');
}
return $this->appointments->add($day, $slot->id, $patientId);
});
}
public function deleteAppointment(Appointment $appointment): void
{
$this->transaction(fn () => $this->appointments->remove($appointment->id));
}
// =======================================================================
// 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, THEN the person (who references it), in
// ONE transaction.
// -----------------------------------------------------------------------
private function addPerson(Descriptor $d, PersonData $data): Person
{
$lastName = Str::upper(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) typed other ones
$login = Str::lower($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::make: the bcrypt hash of the password (see config/hashing.php)
$user = $this->users->add($login, Hash::make($password), "$firstName $lastName", $d->role);
$person = $d->dao->add(['title' => $data->title, 'last_name' => $lastName,
'first_name' => $firstName, 'user' => $user->id]);
// setRelation: the account, already in memory, is stored in the
// [account] relation of the person (no need to read it again from the database)
return $person->setRelation('account', $user);
});
}
// -----------------------------------------------------------------------
// modification: the person AND their account (the displayed name "first name LAST NAME" must
// follow; the login and the password only change if they were typed).
// -----------------------------------------------------------------------
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 overwriting their modifications
$version = $data->version ?? $person->version;
if ($version !== $person->version) {
throw new BusinessException('ERRORS.CONCURRENT_UPDATE');
}
$lastName = Str::upper(trim($data->lastName));
$firstName = trim($data->firstName);
$this->checkPersonUnique($d, $firstName, $lastName, $id);
// the person: UPDATE ... WHERE id = ? AND version = ? (if the record has
// been modified AFTER the read above, no row is modified)
if ($d->dao->edit($id, $version, ['title' => $data->title, 'last_name' => $lastName,
'first_name' => $firstName]) === 0) {
throw new BusinessException('ERRORS.CONCURRENT_UPDATE');
}
// their account: the displayed name, and, if they were typed, the login and the
// password
$account = $person->account;
$columns = ['name' => "$firstName $lastName"];
$login = $data->login ? Str::lower($data->login) : null;
if ($login !== null && $login !== $account->login) {
$this->checkLoginFree($login);
$columns['login'] = $login;
}
if ($data->password) {
$columns['password'] = Hash::make($data->password);
}
if ($this->users->edit($account->id, $account->version, $columns) === 0) {
throw new BusinessException('ERRORS.CONCURRENT_UPDATE');
}
// the person read again: their new values, their new versions
return $d->dao->read($id);
});
}
// -----------------------------------------------------------------------
// 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);
// 3. the person (who references their account);
// 4. their account.
// -----------------------------------------------------------------------
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($id);
$this->users->remove($person->user);
});
}
// refuses first and last names already used by ANOTHER person of the same type
// ($currentId: the person being modified, who obviously 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->id !== $currentId) {
throw new BusinessException($d->alreadyExistsKey);
}
}
private function checkLoginFree(string $login): void
{
if ($this->users->readByLogin($login) !== null) {
throw new BusinessException('ERRORS.LOGIN_TAKEN');
}
}
// -----------------------------------------------------------------------
// runs [$work] in a transaction (DB::transaction, see example 28):
// COMMIT if the function ends normally; if an exception comes out of it
// (BusinessException included), ROLLBACK: nothing is written.
//
// The "free login" 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. We
// therefore intercept the database errors AROUND the transaction, to
// translate them into the same BusinessException. Any other error is a real
// anomaly: we let it propagate.
// -----------------------------------------------------------------------
private function transaction(Closure $work): mixed
{
try {
return DB::transaction($work);
} catch (QueryException $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;
}
}
}
|
The most important file in the application. We’ll go through it by topic.
11.11.6.1. The descriptors
Lines 89–113: the two descriptors, created once by the constructor. The named arguments ([dao: $doctors], example 01) make the call readable. [$appointments->countByDoctor(...)] (with three dots in parentheses) does not count anything: it is the syntax of [PHP] 8.1 that generates, from a method, a [Closure]—a function that will be called later ([($d->countAppointments)($id)], line 349). 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. Readings and the calendar
The readings (lines 120–165) are delegated to the DAO layer. A doctor’s schedule (lines 172–185) is calculated: the doctor, their time slots, and the appointments for that day. [keyBy('slot_id')] transforms the collection of appointments into a collection indexed by slot number: [$bookings->get($slot->id)] returns the appointment for a given slot, or [null]. [map] transforms each time slot into [DoctorDaySlot]; [all()] returns the array [PHP] contained in the collection.
11.11.6.3. Scheduling an Appointment
Lines 203–217: four steps, in a single transaction.
- No past appointments: [$day->lt($today)] (*less than*) compares two dates;
- 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. 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 229–256) call the shared private methods, using the correct descriptor.
- Lines 265–285: creation. The last name is capitalized ([Str::upper]), and the first name and last name have unnecessary spaces removed ([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 “valid” (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), followed by the account, hashed as [Hash::make] (example 25), followed by the person, who references the account. [setRelation('account', $user)] stores the account—already in memory—in the person’s record: [$person->account] will not trigger a query. [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 291–334: the modification, with two optimistic locks. The first is a comparison: 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 the [WHERE version = ?] clause of the [UPDATE] ([PersonDao::edit]), which detects a change that occurred between reading and writing. The account is processed as follows: its displayed name (“first name LAST NAME”) 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. The person is finally read again: the caller receives their new values and new versions;
- lines 345–358: deletion, in the order dictated by the foreign keys: first what references the person (time slots or appointments), then the person, then their account. The reverse order would be rejected by the database: a row that is still referenced by a foreign key cannot be deleted;
- lines 363–369: the person being modified is allowed to retain their own first and last names; [$currentId] is equivalent to [null] for a new record.
11.11.6.5. Transactions and uniqueness constraints
Lines 391–402: All postings go through this method. [DB::transaction] executes the function in a transaction (Example 28): [COMMIT] if it completes normally, [ROLLBACK] if an exception occurs—including [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 the database, 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]. [Laravel] then raises a [QueryException], 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 'unq_users_login'
|
The regular expression on line 74 extracts this name ([MySQL] 8 prefixes it with the table name, [users.unq_users_login], but [MariaDB] does not: the "[(?:[^'.]*\.)?]" part accepts both forms), and the array in lines 67–72 provides the message key. Any other database error is a true anomaly: it is rethrown ([throw $e]) and will result in a 500 error page.
11.12. Authentication
The [app/Auth] file includes examples 25 through 27, featuring database users and a few additional security measures.
11.12.1. app/Auth/ConnectedUser.php
| <?php
// =========================================================================
// app/Auth/ConnectedUser.php: the logged-in user, as described by their
// JWT token (see example 25). Authenticatable: the interface Laravel expects from
// authenticated users (Auth::user(), auth()->user(), @auth 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 expiration dates of the token
//
// This object does NOT come from the database: it is rebuilt, with each request,
// from the token, whose signature guarantees the content.
// =========================================================================
namespace App\Auth;
use App\Models\Role;
use Illuminate\Contracts\Auth\Authenticatable;
readonly class ConnectedUser implements Authenticatable
{
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 Authenticatable interface: the user's identifier is the id
// of their account; the password and the "remember me" token of Laravel's
// sessions are irrelevant here
public function getAuthIdentifierName(): string
{
return 'userId';
}
public function getAuthIdentifier(): int
{
return $this->userId;
}
public function getAuthPasswordName(): string
{
return '';
}
public function getAuthPassword(): string
{
return '';
}
public function getRememberToken(): ?string
{
return null;
}
public function setRememberToken($value): void
{
}
public function getRememberTokenName(): string
{
return '';
}
}
|
The logged-in user, as described by their token: their account ID, username, name, and role. Like [User] in Example 25, it implements the [Authenticatable] interface, which [Laravel] expects from authenticated users. This object does not come from the database: it is reconstructed for each request based on the token, whose signature guarantees its content. The application therefore does not read the account from the database for each request.
- Lines 35–43: The user is described by the claims in an already verified token, 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 45–50: two questions that views and controllers often ask;
- lines 58–61 and following: the [Authenticatable] interface. The user’s identifier is their account ID; the password and the “remember me” token are irrelevant.
11.12.2. app/Auth/AuthService.php
| <?php
// =========================================================================
// app/Auth/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, with each
// request. The options of this cookie matter for security:
// - HttpOnly: the page's JavaScript 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 is
// the anti-CSRF token of the forms, see @csrf);
// - Secure: in production (HTTPS), the cookie never travels unencrypted;
// - lifetime: the cookie disappears at the same time as the token expires.
// =========================================================================
namespace App\Auth;
use App\Dao\UserDao;
use App\Models\User;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Illuminate\Support\Facades\Hash;
use RuntimeException;
use Symfony\Component\HttpFoundation\Cookie;
use Throwable;
class AuthService
{
// the name of the cookie that carries the token
public const COOKIE = 'rdvmedecins_jwt';
private string $secret;
private int $duration;
public function __construct(private UserDao $users)
{
$this->secret = config('rdvmedecins.jwt.secret');
$this->duration = config('rdvmedecins.jwt.duration');
// 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. Hash::check
// hashes the typed 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 || ! Hash::check($password, $user->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->id,
'login' => $user->login,
'name' => $user->name,
'role' => $user->role->value,
'iat' => $now,
'exp' => $now + $this->duration * 60,
];
$token = JWT::encode($claims, $this->secret, 'HS256');
// cookie(name, value, duration in minutes, path, domain, secure, httpOnly,
// raw, sameSite): Laravel's function that builds a cookie
return cookie(self::COOKIE, $token, $this->duration, '/', null,
config('rdvmedecins.cookie_secure'), true, false, 'lax');
}
// the cookie that CLEARS the token: the same name, empty, already expired
public static function clearingCookie(): Cookie
{
return cookie()->forget(self::COOKIE);
}
// -----------------------------------------------------------------------
// the user described by a token; null if the token is expired, forged or
// malformed. JWT::decode checks the signature AND the expiration date (exp claim).
// -----------------------------------------------------------------------
public function currentUser(string $token): ?ConnectedUser
{
try {
// decode returns an object; (array) converts it to an associative array
$claims = (array) JWT::decode($token, new Key($this->secret, 'HS256'));
} catch (Throwable) {
return null;
}
return ConnectedUser::of($claims);
}
}
|
- Line 35: the name of the token cookie, a class constant, read by [AuthService::COOKIE]. Why not configure [config/rdvmedecins.php], like the secret key? Because [bootstrap/app.php] needs it to exclude the cookie from encryption at a time when the configuration has not yet been loaded: the code for [withMiddleware] runs before [Laravel] has read the files in the [config] folder;
- lines 41–50: the token key and lifetime, read from the configuration. If the key is too short, this is flagged as soon as the service is created, with a clear message (see “Common Issues”);
- lines 55–62: 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 70–86: the user’s token and the cookie that carries it. The statements are the same as in Example 25, including the account ID ([sub], a string, as required by the standard) and the username. The function [cookie] of [Laravel] generates a cookie: name, value, lifetime in minutes (that of the token), path, domain ([null]: that of the site), [secure] (in production, on HTTPS), [httpOnly], [raw] ([false]: the value is encoded for a URL) and [sameSite];
- lines 89–92: The cookie that clears the token: [cookie()->forget(name)] creates an empty cookie with the same name that has already expired;
- lines 98–107: token verification (example 25).
11.12.3. app/Auth/InvalidSession.php
| <?php
// app/Auth/InvalidSession.php: the token is valid, but the account it designates
// no longer exists: it was deleted since the login (by the patient themselves
// in another tab, or by the ADMIN). We handle this case like an expired
// session: bootstrap/app.php clears the cookie and sends back to the login page.
namespace App\Auth;
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 [bootstrap/app.php] treats as an expired session. The class is empty: only its type matters, which allows it to be associated with its own rendering function.
11.12.4. app/Auth/CaptchaService.php, Captcha.php, and CaptchaDrawing.php
[CaptchaDrawing.php] and [Captcha.php] are from Example 27. The service is almost the same:
| <?php
// =========================================================================
// app/Auth/CaptchaService.php: a CAPTCHA ("test to tell a human from a
// robot") built by the server itself, without an 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 server's CACHE, 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, see CACHE_STORE in .env), shared by
// all the requests, and even by several servers with a cache such as Redis.
// =========================================================================
namespace App\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
class CaptchaService
{
// validity duration of an image, in seconds: 10 minutes to fill in the form
private const LIFETIME = 600;
public function generate(): Captcha
{
$text = CaptchaDrawing::randomText(5); // 5 characters
$id = Str::random(24); // an identifier impossible to guess
// the cache will forget the answer by itself after 10 minutes
Cache::put("captcha:$id", $text, self::LIFETIME);
return new Captcha($id, CaptchaDrawing::svg($text));
}
// true if [$typed] is the right answer to captcha [$id] (regardless of
// case). The captcha is consumed in all cases.
public function verify(?string $id, ?string $typed): bool
{
if (! $id || $typed === null) {
return false;
}
$expected = Cache::get("captcha:$id");
// forget returns true only for the request that ACTUALLY removed the entry:
// if two requests present the same captcha at the same moment, only one
// removes it - the other fails
if ($expected === null || ! Cache::forget("captcha:$id")) {
return false;
}
return strcasecmp($expected, trim($typed)) === 0;
}
}
|
- lines 31–38: a 5-character string generated at random, a random identifier ([Str::random], which uses a cryptographic generator), and the correct answer stored in the cache for 10 minutes (Example 27);
- lines 42–55: a difference from Example 27, which used [Cache::pull] (read, then remove). Between reading and removal, a second request displaying the same CAPTCHA could also read it: two attempts for a single solved image. Here, the answer is read, then removed by [Cache::forget], which returns [true] only if it has actually removed the entry: with file caching, this involves deleting a file, which the operating system allows only one of the two processes to do. The request that loses the race fails. This is equivalent to the lock in version [Flask], where the CAPTCHAs were kept in memory.
11.12.5. app/Auth/AttemptLimiter.php
| <?php
// =========================================================================
// app/Auth/AttemptLimiter.php: second line of defense against robots (the first 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
// 5 times more failures, all logins together (a robot that tries 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. This is why the login block applies first to the
// (IP, login) pair - a student who types a wrong password does not block their
// neighbors - and why the thresholds are adjustable (.env file).
//
// The counters are those of Laravel's limiter (RateLimiter), stored in the cache.
// =========================================================================
namespace App\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
class AttemptLimiter
{
private int $maxFailures;
private int $block; // in seconds
private int $maxRegistrations;
public function __construct()
{
$this->maxFailures = config('rdvmedecins.login_max_failures');
$this->block = config('rdvmedecins.login_block_minutes') * 60;
$this->maxRegistrations = config('rdvmedecins.registration_max_per_hour');
}
// ---------------------------------------------------------------- login
// number of block minutes remaining (0 = the attempt is allowed)
public function loginBlockMinutes(string $ip, string $login): int
{
return max($this->remainingMinutes(self::loginKey($ip, $login), $this->maxFailures),
$this->remainingMinutes(self::ipKey($ip), $this->maxFailures * 5));
}
// RateLimiter::hit: +1 to the counter; the first hit opens a window of
// [$this->block] seconds, at the end of which the counter restarts from zero
public function loginFailure(string $ip, string $login): void
{
RateLimiter::hit(self::loginKey($ip, $login), $this->block);
RateLimiter::hit(self::ipKey($ip), $this->block);
}
// a successful login resets the counter of THIS login
public function loginSuccess(string $ip, string $login): void
{
RateLimiter::clear(self::loginKey($ip, $login));
}
// ---------------------------------------------------------------- registration
public function registrationBlockMinutes(string $ip): int
{
return $this->remainingMinutes("registration|$ip", $this->maxRegistrations);
}
// one-hour window
public function registrationSuccess(string $ip): void
{
RateLimiter::hit("registration|$ip", 3600);
}
// ---------------------------------------------------------------- common mechanics
// tooManyAttempts: has the counter reached [$maximum] in the current
// window? availableIn: seconds remaining before the end of this window,
// rounded UP to the minute: 30 s remaining -> "1 minute"
private function remainingMinutes(string $key, int $maximum): int
{
if (! RateLimiter::tooManyAttempts($key, $maximum)) {
return 0;
}
return max(1, (int) ceil(RateLimiter::availableIn($key) / 60));
}
private static function loginKey(string $ip, string $login): string
{
return "login|$ip|".Str::lower(trim($login));
}
private static function ipKey(string $ip): string
{
return "ip|$ip";
}
}
|
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 limiter from [Laravel], the facade from [RateLimiter]. This class uses it for two rules:
- login (lines 47–62): each failed attempt (incorrect CAPTCHA or password) is counted twice—once for the pair (address IP, login), and once for the address IP alone. The pair is blocked after 5 failed attempts (setting [LOGIN_MAX_FAILURES]), and the address IP after 25, across all logins: this is 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. [RateLimiter::hit(key, duration)] increments the counter by 1; the first [hit] opens a 15-minute window (setting [LOGIN_BLOCK_MINUTES]), at the end of which the counter resets to zero. A successful login resets the counter for that user ([RateLimiter::clear]);
- account creation (lines 69–75): a maximum of 10 accounts per hour per address IP (setting [REGISTRATION_MAX_PER_HOUR]);
- lines 85–91: [tooManyAttempts(key, maximum)] indicates whether the counter has reached the maximum in the current window; [availableIn(key)] returns the number of seconds remaining in this window, rounded up to the nearest minute ([ceil]): There are 30 seconds remaining, but “1 minute” is announced. The displayed message depends on this: “Too many failed attempts. Try again in 15 minute(s).” ;
- lines 93–96: the counter keys. The login is converted to lowercase: [Admin] and [admin] are considered the same (as with the database; see its collation).
The counters for [RateLimiter] are stored in the cache: they are shared by all requests and survive a server restart. The command [php artisan cache:clear] clears the cache, thereby resetting all counters to zero (useful during development when you’ve gotten yourself stuck).
11.12.6. app/Auth/RegistrationPass.php
| <?php
// =========================================================================
// app/Auth/RegistrationPass.php: the "pass" of the account creation.
//
// To open the "Create a client account" page, you 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 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 (CheckRegistrationPass middleware): typing the URL /register
// directly does not bypass the captcha.
//
// Like the captchas, the passes live in the cache.
// =========================================================================
namespace App\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Cookie;
class RegistrationPass
{
public const COOKIE = 'rdvmedecins_registration';
// lifetime of a pass, in minutes
private const LIFETIME = 15;
// issues a new pass; returns its identifier
public function issue(): string
{
$id = Str::random(24);
Cache::put("pass:$id", true, self::LIFETIME * 60);
return $id;
}
// true if [$id] is an existing, unexpired pass
public function isValid(?string $id): bool
{
return $id !== null && $id !== '' && Cache::has("pass:$id");
}
// the pass has been used: we forget it
public function consume(?string $id): void
{
if ($id) {
Cache::forget("pass:$id");
}
}
// the browser only sends the cookie to the /register addresses...
public static function cookie(string $id): Cookie
{
return cookie(self::COOKIE, $id, self::LIFETIME, '/register', null,
config('rdvmedecins.cookie_secure'), true, false, 'lax');
}
// ... and it is cleared by a cookie with the same name and path, already expired
public static function clearingCookie(): Cookie
{
return cookie()->forget(self::COOKIE, '/register');
}
}
|
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 we tell, 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 will not work.
- Lines 57–61: The cookie has the path [/register]; the browser sends it only to addresses that begin with this path. It is encrypted by [Laravel], like all cookies in the application except for two (see [bootstrap/app.php]): without the application’s key, no one can create a valid cookie or even read the identifier;
- lines 64–67: 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 considered two separate cookies.
11.12.7. Middleware: app/Http/Middleware
Three middleware components, located in the [app/Http/Middleware] folder, as is customary.
| <?php
// app/Http/Middleware/SetLanguage.php: the language of the request (see example 22):
// that of the [rdvmedecins_language] cookie if it is a supported language, English otherwise.
//
// A GLOBAL middleware (see bootstrap/app.php): it runs for ALL the
// requests, even those that lead to no route (405 page...). It therefore runs
// BEFORE the EncryptCookies middleware of the [web] group, which decrypts the cookies:
// this is why the language cookie is excluded from encryption.
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Symfony\Component\HttpFoundation\Response;
class SetLanguage
{
// the cookie that remembers the chosen language (see LanguageController)
public const COOKIE = 'rdvmedecins_language';
// the supported languages (lang/fr.json, lang/en.json); by default, English
// (config/app.php, locale key)
public const LANGUAGES = ['fr', 'en'];
public function handle(Request $request, Closure $next): Response
{
$language = $request->cookie(self::COOKIE);
if (in_array($language, self::LANGUAGES, true)) {
App::setLocale($language);
}
return $next($request);
}
}
|
The request language (example 22): the language specified in the cookie, if it is a supported language; otherwise, the default language in the configuration (English). The languages and the cookie name are constants, for the same reason as the token cookie name. This middleware is global (see [bootstrap/app.php]).
| <?php
// app/Http/Middleware/RequireRoles.php: the role of the user must be one of
// those listed by the route; otherwise, 403 response (an error page, see
// bootstrap/app.php). A middleware WITH PARAMETERS: in routes/web.php,
// ->middleware('role:ADMIN,DOCTOR')
// calls handle($request, $next, 'ADMIN', 'DOCTOR'); ...$roles receives them all.
//
// It runs AFTER the authentication middleware (see bootstrap/app.php):
// the user is therefore known.
namespace App\Http\Middleware;
use App\Web\Utils;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class RequireRoles
{
public function handle(Request $request, Closure $next, string ...$roles): Response
{
if (! in_array(Utils::user()->role->value, $roles, true)) {
abort(403);
}
return $next($request);
}
}
|
A middleware with parameters. In [routes/web.php], [->middleware('role:ADMIN,DOCTOR')] refers to the alias middleware [role] (see [bootstrap/app.php]), with two parameters: [Laravel] calls [handle($request, $next, 'ADMIN', 'DOCTOR')]. The variadic parameter [string ...$roles] receives all remaining parameters in an array (line 20). One role is missing from the list: 403 response ([abort(403)], Example 24). This is equivalent to the [can:admin] middleware from Example 26, without defining [Gate]: the application has only role rules. This middleware is always placed on routes protected by [Authenticate], which executes before it: [Authenticate] is part of the [web] group, whose middleware runs before that of the route. The user is therefore known.
| <?php
// app/Http/Middleware/CheckRegistrationPass.php: GET and POST /register
// require a valid pass (see app/Auth/RegistrationPass.php); otherwise,
// back to the login page, with a message. Typing the URL
// /register directly therefore does not bypass the captcha.
namespace App\Http\Middleware;
use App\Auth\RegistrationPass;
use App\Web\Flash;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class CheckRegistrationPass
{
public function __construct(private RegistrationPass $passes)
{
}
public function handle(Request $request, Closure $next): Response
{
if (! $this->passes->isValid($request->cookie(RegistrationPass::COOKIE))) {
Flash::error('ERRORS.CAPTCHA_BEFORE_ACCOUNT');
return redirect()->route('login');
}
return $next($request);
}
}
|
No valid token: redirect to the login page with a message. The middleware receives the token service from its constructor: [Laravel] creates the middleware using the container, which provides them with their dependencies, just as it does for the controllers.
11.12.8. app/Http/Controllers/AuthController.php
| <?php
// =========================================================================
// app/Http/Controllers/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, verification 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\Http\Controllers;
use App\Auth\AuthService;
use App\Auth\CaptchaService;
use App\Auth\AttemptLimiter;
use App\Models\Role;
use App\Web\Message;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Illuminate\View\View;
class AuthController
{
public function __construct(
private AuthService $auth,
private CaptchaService $captchas,
private AttemptLimiter $limiter,
) {
}
// the home page of each role, right after the login
public static function homePage(Role $role): string
{
return $role === Role::USER ? route('area') : route('schedule');
}
// -----------------------------------------------------------------------
// GET /
// The route is public: the user, if there is one, is known (Auth::user()),
// but nothing is required.
// -----------------------------------------------------------------------
public function home(): RedirectResponse
{
$user = Auth::user();
return redirect($user ? self::homePage($user->role) : route('login'));
}
// -----------------------------------------------------------------------
// GET /login[?login=xxx]: the optional parameter pre-fills the login field
// (used right after the creation of an account, see RegistrationController)
// -----------------------------------------------------------------------
public function showLogin(Request $request): View|RedirectResponse
{
// already logged in (token still valid): straight to the home page
if ($user = Auth::user()) {
return redirect(self::homePage($user->role));
}
return $this->page(Str::limit((string) $request->query('login', ''), 30, ''), null);
}
// -----------------------------------------------------------------------
// POST /login
// -----------------------------------------------------------------------
public function logIn(Request $request): View|RedirectResponse
{
$login = (string) $request->input('login', '');
$password = (string) $request->input('password', '');
$ip = (string) $request->ip();
// 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->input('captchaId'), $request->input('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);
return redirect(self::homePage($user->role))->withCookie($this->auth->tokenCookie($user));
}
// -----------------------------------------------------------------------
// 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.
// -----------------------------------------------------------------------
public function logOut(): RedirectResponse
{
return redirect()->route('login')->withCookie(AuthService::clearingCookie());
}
// the form, always with a NEW captcha (each image is used only
// once); we redisplay the typed login, never the password
private function page(string $login, ?Message $error): View
{
return view('login', ['pageTitle' => 'LOGIN.TITLE', 'error' => $error, 'login' => $login,
'captcha' => $this->captchas->generate()]);
}
}
|
The connection controller, which is based on Example 27.
- Lines 41–44: the home page for each role: “My area” for a patient, the calendar for others. [route('area')] generates the URL for the route named [area] (Example 04);
- lines 51–55: The address [/] redirects to the role’s home page or to the login page. The route is public: [Auth::user()] returns the user, if there is one, without requiring any input;
- lines 61–68: A user who is already logged in goes directly to their home page. The optional parameter [login] of URL pre-fills the field (after an account is created); [Str::limit] truncates it to 30 characters without adding anything (the third parameter, [''], replaces the usual ellipsis);
- Lines 73–100: the login process, in the order shown in Example 27: potential blocking, the CAPTCHA, the password. A password is never verified for an attempt rejected by the limiter or the CAPTCHA: a bot learns nothing by persisting. In case of failure, 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), 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 ([withCookie]);
- lines 107–110: Logout deletes the token cookie (example 25).
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.13. The Web Layer: Shared Services
The [app/Web] folder contains the elements shared by the controllers.
11.13.1. app/Web/ApplicationModel.php
| <?php
// =========================================================================
// app/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 Laravel'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 singleton (see AppServiceProvider), but a singleton only lives for the
// duration of ONE request. The cache, for its part, is shared by all the requests (see
// examples 20 and 23).
//
// These lists can CHANGE (the ADMIN adds, modifies, deletes doctors and
// clients): each write therefore goes through this facade, which FORGETS the list
// concerned right after; the next request that needs it reads it again from the database. This 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\Business\PersonData;
use App\Business\Business;
use App\Business\BusinessService;
use App\Models\Client;
use App\Models\Slot;
use App\Models\Doctor;
use App\Models\Appointment;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Cache;
// same contract as the business layer
class ApplicationModel implements Business
{
private const DOCTORS = 'rdvmedecins.doctors';
private const CLIENTS = 'rdvmedecins.clients';
public function __construct(private BusinessService $business)
{
}
// ------------------------------------------------ reads from the cache
// rememberForever: 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
public function getAllDoctors(): Collection
{
return Cache::rememberForever(self::DOCTORS, fn () => $this->business->getAllDoctors());
}
public function getAllClients(): Collection
{
return Cache::rememberForever(self::CLIENTS, fn () => $this->business->getAllClients());
}
// the doctor whose user account is [$userId] - used to preselect,
// for a logged-in doctor, their own schedule. firstWhere: the first element
// of the collection whose [user] property equals $userId
public function getDoctorByUserId(int $userId): ?Doctor
{
return $this->getAllDoctors()->firstWhere('user', $userId);
}
// ------------------------------------------------ simple delegations
public function getAllSlots(int $doctorId): Collection
{
return $this->business->getAllSlots($doctorId);
}
public function getDoctorDayAppointments(int $doctorId, CarbonImmutable $day): Collection
{
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, CarbonImmutable $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): Collection
{
return $this->business->getClientUpcomingAppointments($clientId);
}
public function addAppointment(CarbonImmutable $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
public function addDoctor(PersonData $data): Doctor
{
return tap($this->business->addDoctor($data), fn () => Cache::forget(self::DOCTORS));
}
public function editDoctor(int $id, PersonData $data): Doctor
{
return tap($this->business->editDoctor($id, $data), fn () => Cache::forget(self::DOCTORS));
}
public function deleteDoctor(int $id): void
{
$this->business->deleteDoctor($id);
Cache::forget(self::DOCTORS);
}
public function addClient(PersonData $data): Client
{
return tap($this->business->addClient($data), fn () => Cache::forget(self::CLIENTS));
}
public function editClient(int $id, PersonData $data): Client
{
return tap($this->business->editClient($id, $data), fn () => Cache::forget(self::CLIENTS));
}
public function deleteClient(int $id): void
{
$this->business->deleteClient($id);
Cache::forget(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 singleton, but it exists only for the duration of a single request. The lists are therefore stored in the cache of [Laravel] (example 20), which is shared by all requests.
- Lines 51–54: [Cache::rememberForever(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 from the database; subsequent queries find it in the cache. The cache stores the collection of objects [Eloquent] in a serialized form (Example 20) and reconstructs it upon reading, along with the loaded relationships (each person’s account);
- lines 64–67: the doctor associated with an account, retrieved from the cache list: [firstWhere('user', $userId)] returns the first element of the collection whose property [user] is equal to [$userId];
- lines 71–123: 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 is what matters (a patient who has just modified or deleted their account);
- Lines 130–133 and following: the updates. Each write operation for a doctor or client clears the relevant list ([Cache::forget]): the next query that needs it will read it from the database again. [tap(value, function)] calls the function, then returns the value: in this case, the newly created doctor, after clearing the cache. This is the whole point of a single entry point: the rule “a write operation updates the cache” is defined only here. 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 artisan cache:clear] would clear it.
11.13.2. app/Web/Message.php and Flash.php
| <?php
// app/Web/Message.php: a message to translate: its key, and its parameters.
// The views display it with {{ __($message->key, $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
// =========================================================================
// app/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 does not build a page, it redirects (302) to a GET page; the
// message, for its part, must survive the redirection.
//
// Laravel stores them in the SESSION, here in an ENCRYPTED cookie ([cookie] driver,
// see .env): the server keeps nothing between two requests.
//
// A message does not contain a text, but a translation KEY and its parameters:
// it is translated at display time, in the language of the page (see
// resources/views/fragments). Two lists: the successes (displayed at the top of the
// page) and the errors (displayed at the bottom).
// =========================================================================
namespace App\Web;
class Flash
{
public static function success(string $key, array $params = []): void
{
self::add('flash_success', $key, $params);
}
public static function error(string $key, array $params = []): void
{
self::add('flash_errors', $key, $params);
}
// session()->flash(key, value): a value readable during the NEXT request
// only; we complete it if the current request has already put one
private static function add(string $list, string $key, array $params): void
{
$messages = session($list, []);
$messages[] = new Message($key, $params);
session()->flash($list, $messages);
}
}
|
Flash messages (example 20): a message stored 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. Two lists, stored in the session under the keys [flash_success] and [flash_errors]: success messages are displayed at the top of the page, and error messages at the bottom.
- Lines 34–39: An action can store multiple messages: we append to the list already stored by the current request ([session($list, [])] reads it, with an empty list by default), then store it again. [session()->flash(key, value)] stores a value that will only persist until the end of the next request (example 20).
[Message] objects are stored as-is in the session: [Laravel] serializes the session data (Example 20) and reconstructs it during the next request. With the [cookie] driver (file [.env]), they are transmitted in an encrypted cookie.
11.13.3. app/Web/Utils.php
| <?php
// =========================================================================
// app/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).
// STATIC methods: they are called without creating an object (Utils::iso($day)).
// =========================================================================
namespace App\Web;
use App\Auth\InvalidSession;
use App\Auth\ConnectedUser;
use App\Models\Client;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
class Utils
{
// ----------------------------------------------------------------- dates
// the date, if [$text] is a date that exists in 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(?string $text): ?CarbonImmutable
{
if ($text === null || ! preg_match('/^\d{4}-\d{2}-\d{2}$/', $text)) {
return null;
}
// "!Y-m-d": the exclamation mark sets the time to midnight
$date = CarbonImmutable::createFromFormat('!Y-m-d', $text);
return $date !== null && $date->format('Y-m-d') === $text ? $date : null;
}
public static function iso(CarbonImmutable $day): string
{
return $day->format('Y-m-d');
}
// "mercredi 23 septembre 2026" / "Wednesday 23 September 2026", depending on the language
// of the request. isoFormat: dddd the day of the week in full, D the
// day, MMMM the month in full, YYYY the year (see example 16)
public static function formatDay(CarbonImmutable $day): string
{
return $day->locale(App::getLocale())->isoFormat('dddd D MMMM YYYY');
}
// ----------------------------------------------------------------- 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 static function user(): ConnectedUser
{
return Auth::user() ?? throw new InvalidSession();
}
// -----------------------------------------------------------------------
// The patient (Client entity) that corresponds to the logged-in user.
//
// The JWT token only contains the id of the ACCOUNT (users.id). For a USER role, we
// find the corresponding client thanks to the clients.user column.
//
// If there is none, the account has been deleted since the login
// while its token is still valid: we treat it as an expired
// session (see InvalidSession).
// -----------------------------------------------------------------------
public static function connectedPatient(ApplicationModel $application): Client
{
return $application->getClientByUserId(self::user()->userId)
?? throw new InvalidSession();
}
}
|
Some utility functions, in the form of static methods.
- Lines 26–34: 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 lenient: February 30 becomes March 2. We therefore 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);
- Lines 44–47: “samedi 26 septembre 2026” or “Saturday 26 September 2026,” depending on the language of the query. [isoFormat] (example 16) uses the format codes from the [JavaScript] [Moment.js] library: [dddd], the day of the week spelled out; [D], the day; [MMMM], the month spelled out; [YYYY], the year. [locale(...)] returns a copy of the date set for a specific language;
- lines 52–59: a query parameter is a string, or [null], or even an array ([?doctorId[]=1], example 10). Only digits are accepted (up to nine: no overflow), and the number must be strictly positive. [mixed]: any type;
- lines 65–68: the logged-in user. [Auth::user()] returns [null] for an anonymous visitor; protected actions are only called for an authenticated user, but the absence of a user is still handled as an expired session;
- lines 80–84: the patient ([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.14. The dictionaries
The interface texts are the same as those in other versions of the application, which stored them in nested JSON files:
| {
"LOGIN": {
"TITLE": "Login",
...
}
}
|
The [Flask] version “flattened” them at startup: [LOGIN.TITLE] referred to the text in [Login]. [Laravel] reads flat JSON dictionaries (example 22), where the key is the text to be translated or an identifier: We have flattened the dictionaries once and for all, and replaced the [{name}] parameters from other versions with the notation used in [Laravel] and [:name]:
| {
"APP.TITLE": "RdvMedecins",
"APP.LOGOUT": "Log out",
...
"DOCTORS.ADDED": "Doctor :name has been added (login: :login).",
...
}
|
The files [lang/fr.json] and [lang/en.json] each contain 153 texts; they are not reproduced here. A key is represented by [__('APP.LOGOUT')], and a parameterized text by [__('DOCTORS.ADDED', ['name' => ..., 'login' => ...])]. [Laravel] first searches for the complete key in the language’s dictionary, JSON; if it does not find it, it searches for it in the [PHP] files in the [lang/en] folder (example 09); if that fails, it displays the key itself, which immediately signals an omission. A text entry has been added: [ERRORS.PAGE_EXPIRED], the message on page 419, which is not present in the other versions.
"Person" forms are validated by form requests ([FormRequest], example 19): classes that describe the validation rules for a request. Let’s review the principle: an action that receives a [FormRequest] as a parameter is called only if the input is valid; otherwise, [Laravel] redirects the browser back to the form page, along with error messages ([$errors] in the view) and the input to be redisplayed ([old('field')]), excluding passwords.
11.15.1. app/Http/Requests/PersonFieldsRequest.php
| <?php
// =========================================================================
// app/Http/Requests/PersonFieldsRequest.php: the fields of the "person"
// forms and their validation rules (see example 19). Three forms
// share the same identity fields (title, first name, last name):
//
// PersonFieldsRequest title, first name, last name
// ├─ PersonRequest + OPTIONAL login / password, version
// │ (the ADMIN adds or modifies a doctor / client)
// ├─ RegistrationRequest + REQUIRED login / password / confirmation
// │ (a patient creates their account)
// └─ MyAccountRequest + login, optional new password, REQUIRED
// CURRENT password, version
// (a patient modifies their account)
//
// An ABSTRACT class: it is never used alone; its children complete
// its rules (array_merge) and its messages.
//
// Reminders (example 19): an action that receives a FormRequest as a parameter is
// only called if the input is valid; otherwise, Laravel sends the browser back to
// the form page (redirection), with the error messages ($errors in the
// view) and the input to redisplay (old('field')) - NEVER the passwords.
// The messages are the texts of the lang/*.json dictionaries: __('ERRORS...').
// =========================================================================
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
abstract class PersonFieldsRequest extends FormRequest
{
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;
// before validation: a login is always in lower case. The superfluous
// spaces have already been removed from all the fields by Laravel's
// TrimStrings middleware - except from the passwords (password,
// password_confirmation, current_password), of which they may be part; an
// empty field has become null (ConvertEmptyStringsToNull middleware)
protected function prepareForValidation(): void
{
if (is_string($this->input('login'))) {
$this->merge(['login' => Str::lower($this->input('login'))]);
}
}
// bail: the validation of a field stops at its first broken rule -
// a single message per field. Maximum lengths = those of the SQL columns
public function rules(): array
{
return [
'title' => ['bail', 'required', Rule::in(self::TITLES)],
'first_name' => ['bail', 'required', 'string', 'max:20'],
'last_name' => ['bail', 'required', 'string', 'max:20'],
];
}
// field.rule -> message
public function messages(): array
{
return [
'title' => __('ERRORS.INVALID_TITLE'),
'first_name.required' => __('ERRORS.FIRSTNAME_REQUIRED'),
'first_name' => __('ERRORS.FIRSTNAME_TOO_LONG'),
'last_name.required' => __('ERRORS.LASTNAME_REQUIRED'),
'last_name' => __('ERRORS.LASTNAME_TOO_LONG'),
'login.required' => __('ERRORS.LOGIN_REQUIRED'),
'login' => __('ERRORS.INVALID_LOGIN'),
'password.required' => __('ERRORS.PASSWORD_REQUIRED'),
'password.min' => __('ERRORS.PASSWORD_TOO_SHORT', ['min' => self::PASSWORD_MIN]),
'password' => __('ERRORS.PASSWORD_TOO_LONG'),
'password_confirmation.required' => __('ERRORS.CONFIRMATION_REQUIRED'),
'password_confirmation' => __('ERRORS.PASSWORDS_DIFFER'),
'current_password' => __('ERRORS.CURRENT_PASSWORD_REQUIRED'),
'version' => __('ERRORS.INVALID_FORM'),
];
}
// the version (optimistic lock), if the form has one
public function version(): ?int
{
return $this->filled('version') ? (int) $this->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 its rules.
- lines 34–40: the input rules, in the form of constants, which are also read by the views (the list of titles);
- Lines 47–52: [prepareForValidation] is called before validation: the input can be transformed there. A login is always in lowercase: [merge] replaces the field value in the query. The other validations are already performed by two global middleware components of [Laravel] (see the chapter on the lifecycle): [TrimStrings] removes leading and trailing spaces from all values, except those in fields [password], [password_confirmation], and [current_password] (spaces may be part of a password); [ConvertEmptyStringsToNull] replaces an empty string with [null]. This is why the password fields in the application have these names, which correspond to the usage of [Laravel];
- Lines 56–63: the rules for the three fields. [bail] stops validating a field as soon as the first rule is violated: only one message per field. [Rule::in(...)]: the value must be one of those in the list. [max:20]: 20 characters maximum; the column length is defined by SQL;
- lines 66–84: error messages, taken from the dictionaries. The key ['first_name.required'] refers to the message for rule [required] in field [first_name]; the key ['first_name'] alone refers to the message for all other rules in this field. The message for a rule is first looked up under the key [field.rule], then under the key [rule] (the message for this rule for all fields), and then under the key [field]. The child rules (login, passwords) have their messages here: an unused message does not cause any issues. The method [messages] is called during validation, in the language of the request;
- lines 87–90: the version (the hidden field for the optimistic lock), as an integer, or [null] if the field is empty or absent. [filled]: the field is present and not empty.
11.15.2. app/Http/Requests/PersonRequest.php, RegistrationRequest.php, MyAccountRequest.php
| <?php
// app/Http/Requests/PersonRequest.php: the form of a doctor or of a client
// (ADMIN): login and password OPTIONAL (see app/Business/PersonData.php).
namespace App\Http\Requests;
use App\Business\PersonData;
class PersonRequest extends PersonFieldsRequest
{
// nullable: an empty field (null) is accepted, without checking the other rules
public function rules(): array
{
return array_merge(parent::rules(), [
'login' => ['bail', 'nullable', 'string', 'regex:'.self::LOGIN_FORMAT],
// 72 characters at most: beyond that, bcrypt ignores the rest
'password' => ['bail', 'nullable', 'string', 'max:72'],
// hidden field of the modification form (optimistic lock)
'version' => ['nullable', 'integer'],
]);
}
// the validated input -> the data expected by the business layer
public function personData(): PersonData
{
return new PersonData($this->input('title'), $this->input('last_name'), $this->input('first_name'),
$this->input('login'), $this->input('password'), $this->version());
}
}
|
The administrator form. [array_merge] adds three rules to those of the parent class: the username and password are optional ([nullable]: the value [null] is accepted, and the other rules are then not checked); [regex:...] validates the format of the username; [max:72] limits the length of a password: [bcrypt] considers only the first 72 bytes (example 25). The [personData] method (lines 24–28) converts the validated input into [PersonData] for the business layer: the business layer does not recognize HTTP requests.
| <?php
// app/Http/Requests/RegistrationRequest.php: "Create a client account": login, password
// and confirmation REQUIRED.
namespace App\Http\Requests;
use App\Business\PersonData;
class RegistrationRequest extends PersonFieldsRequest
{
public function rules(): array
{
return array_merge(parent::rules(), [
'login' => ['bail', 'required', 'string', 'regex:'.self::LOGIN_FORMAT],
'password' => ['bail', 'required', 'string', 'min:'.self::PASSWORD_MIN, 'max:72'],
// same:password: the confirmation must be identical to the password
'password_confirmation' => ['bail', 'required', 'same:password'],
]);
}
public function personData(): PersonData
{
return new PersonData($this->input('title'), $this->input('last_name'), $this->input('first_name'),
$this->input('login'), $this->input('password'));
}
}
|
Creating a patient account: login and password are required, and the password must be confirmed. The rule [same:password] requires that the field [password_confirmation] have the same value as the field [password]; its message (“The confirmation does not match the password. ”) appears below the confirmation field. [Laravel] also has a rule, [confirmed], which checks the same thing but based on the [password] field; its message would appear below the password field.
| <?php
// app/Http/Requests/MyAccountRequest.php: "Manage my account" (a patient): login
// required, new password optional, CURRENT password required.
namespace App\Http\Requests;
use App\Business\PersonData;
class MyAccountRequest extends PersonFieldsRequest
{
public function rules(): array
{
return array_merge(parent::rules(), [
'login' => ['bail', 'required', 'string', 'regex:'.self::LOGIN_FORMAT],
// optional: empty = password unchanged
'password' => ['bail', 'nullable', 'string', 'min:'.self::PASSWORD_MIN, 'max:72'],
'password_confirmation' => ['same:password'],
// the CURRENT password, required for any modification of the account
'current_password' => ['required'],
'version' => ['nullable', 'integer'],
]);
}
public function personData(): PersonData
{
return new PersonData($this->input('title'), $this->input('last_name'), $this->input('first_name'),
$this->input('login'), $this->input('password'), $this->version());
}
}
|
“Manage My Account”: The new password is optional (leave blank to keep the current password), and the current password is required. The rule [same:password] applies even to an empty confirmation field: if a new password is entered, the confirmation must also be entered. The current password is not verified here, but by the action: an incorrect password must be counted by the attempt limiter as a failed login. [Laravel] has a rule, [current_password], which compares the entered password to the logged-in user’s password; this rule is not suitable here: our logged-in user ([ConnectedUser]) does not contain the hash of their password.
11.16. View Templates
The [app/Views] folder contains the view templates: objects created by an action, which the view simply reads. The rule followed by all pages is: no logic in the views. The 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 them in the HTML. All these classes are [readonly] and have only properties, which are promoted by their constructor.
| <?php
// app/Views/OptionView.php: an option of a drop-down list (doctor, patient).
//
// The app/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
// app/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,
) {
}
}
|
An entry in the calendar: 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 view the patient’s name; [bookable] indicates whether to display the “Book” button; [appointmentId] indicates whether to display the “Cancel” button.
| <?php
// app/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
// app/Views/BookingView.php: the booking window 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.
| <?php
// app/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");
}
}
|
The 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
// app/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.17. The web layer: routes and controllers
11.17.1. routes/web.php
| <?php
// =========================================================================
// routes/web.php: all the URLs of the application.
//
// Reminder (see bootstrap/app.php): all these routes require an authenticated
// user, EXCEPT those of the first group, which remove the authentication
// middleware (withoutMiddleware). The [role:...] middleware also requires
// one of the listed roles; it applies to one route, or to all the routes of a
// group.
//
// ->name(...): the NAME of the route, with which the code builds its URLs
// (route('schedule', [...]), redirect()->route('login')...): changing a URL then
// only requires modifying this file.
// =========================================================================
use App\Http\Controllers\ScheduleController;
use App\Http\Controllers\AuthController;
use App\Http\Controllers\ClientsController;
use App\Http\Controllers\PatientAreaController;
use App\Http\Controllers\RegistrationController;
use App\Http\Controllers\LanguageController;
use App\Http\Controllers\DoctorsController;
use App\Http\Middleware\SetLanguage;
use Illuminate\Auth\Middleware\Authenticate;
use Illuminate\Support\Facades\Route;
// ---------------------------------------------------------------- the PUBLIC routes
Route::withoutMiddleware(Authenticate::class)->group(function () {
// the login (see AuthController)
Route::controller(AuthController::class)->group(function () {
Route::get('/', 'home')->name('home');
Route::get('login', 'showLogin')->name('login');
Route::post('login', 'logIn');
Route::post('logout', 'logOut')->name('logout');
});
// the language choice: /language/fr, /language/en; any other value: 404
Route::get('language/{code}', [LanguageController::class, 'change'])
->whereIn('code', SetLanguage::LANGUAGES)->name('language');
// the creation of a client account (see RegistrationController); the form
// requires a pass (registration.pass middleware)
Route::controller(RegistrationController::class)->prefix('register')->group(function () {
Route::post('access', 'access')->name('registration.access');
Route::middleware('registration.pass')->group(function () {
Route::get('', 'form')->name('registration');
Route::post('', 'create');
});
});
// no other route matches the requested URL: 404 page. A "fallback"
// route rather than Laravel's automatic 404: the request thus goes
// through the middlewares of the [web] group (session, user...), and the
// error page has its complete navigation bar
Route::fallback(fn () => abort(404));
});
// ---------------------------------------------------------------- the schedule (all roles)
Route::controller(ScheduleController::class)->prefix('schedule')->group(function () {
Route::get('', 'show')->name('schedule');
Route::post('book', 'book')->middleware('role:ADMIN,DOCTOR,USER')->name('schedule.book');
// a patient cancels THEIR appointment from "My area", not from here
Route::post('cancel', 'cancel')->middleware('role:ADMIN,DOCTOR')->name('schedule.cancel');
});
// ---------------------------------------------------------------- doctors and clients (ADMIN)
// The two sections have the SAME routes (see PersonsController): a loop
// declares them for each one. The route names are prefixed by the section:
// doctors.list, clients.list...
foreach (['doctors' => DoctorsController::class, 'clients' => ClientsController::class] as $section => $controller) {
Route::controller($controller)->prefix($section)->name("$section.")->middleware('role:ADMIN')
->group(function () {
Route::get('', 'list')->name('list');
Route::get('add', 'addForm')->name('addForm');
Route::post('add', 'add')->name('add');
// whereNumber: {id} must be made of digits
Route::get('{id}/edit', 'editForm')->whereNumber('id')->name('editForm');
Route::post('{id}/edit', 'edit')->whereNumber('id')->name('edit');
Route::post('{id}/delete', 'delete')->whereNumber('id')->name('delete');
});
}
// ---------------------------------------------------------------- the patient's area (USER)
Route::controller(PatientAreaController::class)->middleware('role:USER')->group(function () {
Route::get('my-area', 'patientArea')->name('area');
Route::post('my-area/cancel', 'cancelAppointment')->name('area.cancel');
Route::get('my-account', 'accountForm')->name('account');
Route::post('my-account', 'editAccount')->name('account.edit');
Route::post('my-account/delete', 'deleteAccount')->name('account.delete');
});
|
All URL routes in the application. Note that all these routes require an authenticated user (the [Authenticate] middleware, added to the [web] group by [bootstrap/app.php]), except for those that remove this requirement.
- Lines 28–56: the public routes. [withoutMiddleware] removes a middleware component from the routes in the group: in this case, authentication. These include login ([AuthController]), language selection, and patient account creation;
- Lines 30–35: [Route::controller] (Example 02) specifies the controller for the group’s routes: each route now names only its method. [->name('login')] assigns a name to the route (Example 04): the code generates its URL files using this name ([route('login')], [redirect()->route('login')]), and changing a URL file requires modifying only that file. The name [login] also has significance for [Laravel]: it is the page to which the [Authenticate] middleware redirects an anonymous visitor if no other page is specified;
- lines 38–39: [whereIn('code', [...])] accepts only the values in the list for the [{code}] parameter: [/language/de] does not correspond to any route (404 page);
- lines 43–49: account creation. [prefix('register')] adds [/register] before the URL entries in the group: [Route::get('', ...)] is the [/register] route itself. The form (GET and POST) also requires a pass: the alias middleware [registration.pass];
- line 55: the fallback route, selected when no other route matches a request for GET. Without it, [Laravel] itself generates a 404 error before the middleware in the [web] group is executed: no session, no decrypted cookies... The error page would have neither the user’s name in its navigation bar nor an anti-CSRF token for the logout button. With the fallback route, the request passes through the [web] group, and then the action raises a 404 error. The route is public: an unknown address returns a 404 page, even to an anonymous visitor. Side effect: a request from GET to URL, which exists only as POST ([/logout]) also returns a 404 page, not a 405;
- lines 59–64: the calendar. Its page is accessible to all authenticated users; booking is available to the three roles (i.e., any authenticated user; the list of roles documents this rule); cancellation is available to the administrator and doctors: [->middleware('role:ADMIN,DOCTOR')];
- lines 70–81: The routes for doctors and clients are the same: a loop declares them for each of the two sections. The routes file is a standard [PHP] file: you can write loops, conditions, and functions in it. For each section, there is a group: the controller, the URL prefix ([/doctors]), the name prefix ([->name("$section.")]: the route named [list] is called [doctors.list]), and the middleware [role:ADMIN], which protects all routes in the group—including those added later. [whereNumber('id')] accepts only numbers for the [{id}] parameter (e.g., 02);
- lines 84–90: the patient area, reserved for the USER role.
The URL codes correspond to other versions of the application: modifying and deleting a physician are POST requests ([/doctors/{id}/edit], [/doctors/{id}/delete]), not PUT and DELETE as in Example 28. The [php artisan route:list] command displays the list of routes, along with their names and actions.
11.17.2. app/Http/Controllers/LanguageController.php
| <?php
// =========================================================================
// app/Http/Controllers/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 middleware
// 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\Http\Controllers;
use App\Http\Middleware\SetLanguage;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class LanguageController
{
// {code} is limited to the supported languages by the route (whereIn): /language/de gives a 404 page
public function change(Request $request, string $code): RedirectResponse
{
// one year: 60 * 24 * 365 minutes
return redirect($this->originPage($request))->withCookie(cookie(
SetLanguage::COOKIE, $code, 60 * 24 * 365, '/', null,
config('rdvmedecins.cookie_secure'), true, false, 'lax'));
}
// -----------------------------------------------------------------------
// the previous page, according to the HTTP "Referer" header sent by the browser.
// We only keep its path (/schedule?...): redirecting to a complete
// address supplied 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), after which the browser returns to the page it came from. The cookie is not encrypted (see [bootstrap/app.php]); it is [httpOnly]: the [JavaScript] on the page does not need to read it.
- Lines 35–47: The previous page is specified by the header HTTP [Referer] that the browser sends with the request. This is data 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] with 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.
| <?php
// =========================================================================
// app/Http/Controllers/ScheduleController.php: the schedule page: choice of a
// doctor and of a day, schedule of this 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 window
// 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 appt. | |
//
// (*) 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, even hidden (a simple "View page
// source" would reveal it otherwise).
//
// 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 (general
// rule, see bootstrap/app.php); the [role] middleware of the POST routes also
// requires one of the listed roles (see routes/web.php).
// =========================================================================
namespace App\Http\Controllers;
use App\Domain\DoctorDaySchedule;
use App\Business\Dates;
use App\Business\BusinessException;
use App\Models\Client;
use App\Models\Person;
use App\Models\Role;
use App\Views\ScheduleView;
use App\Views\ScheduleRow;
use App\Views\OptionView;
use App\Views\BookingView;
use App\Web\ApplicationModel;
use App\Web\Flash;
use App\Web\Message;
use App\Web\Utils;
use Carbon\CarbonImmutable;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ScheduleController
{
public function __construct(private ApplicationModel $application)
{
}
// -----------------------------------------------------------------------
// GET /schedule
// The URL parameters are read raw ($request->query), to distinguish an
// ABSENT parameter (null) from an EMPTY parameter (?doctorId=).
// -----------------------------------------------------------------------
public function show(Request $request): View
{
$error = null;
$user = Utils::user();
$rights = $this->rights();
// the day: that of the URL if it is valid, otherwise today
$day = Dates::today();
$rawDay = $request->query('day');
if ($rawDay !== null) {
$requestedDay = Utils::validDate(is_string($rawDay) ? $rawDay : null);
if ($requestedDay !== null) {
$day = $requestedDay;
} else {
$error = 'ERRORS.INVALID_DATE';
}
}
$dayBookable = $day->gte(Dates::today());
// the doctor: that of the URL; failing that, for a logged-in doctor, themselves
$rawDoctorId = $request->query('doctorId');
$doctorId = Utils::positiveInteger($rawDoctorId);
if ($rawDoctorId === null && $user->role === Role::DOCTOR) {
$doctorId = $this->application->getDoctorByUserId($user->userId)?->id;
}
// the drop-down list of doctors
$doctors = $this->application->getAllDoctors()
->map(fn (Person $m) => self::option($m, $m->id === $doctorId))->all();
// 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, $dayBookable);
}
}
// the booking window: only if the requested slot is bookable
// by this user. array_find: the first element that satisfies the
// condition (null if there is none)
$booking = null;
$slotId = Utils::positiveInteger($request->query('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']
? $this->application->getAllClients()->map(fn (Client $c) => self::option($c, false))->all()
: [],
$rights['patient']?->label());
}
}
return view('schedule', [
'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 window
'backUrl' => $doctorId !== null ? self::scheduleUrl($doctorId, $day) : route('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 - we put a flash message and redirect to the schedule
// (see example 20).
// -----------------------------------------------------------------------
public function book(Request $request): RedirectResponse
{
[$doctorId, $day] = $this->readScheduleRequest($request);
$slotId = Utils::positiveInteger($request->input('slotId'));
if ($doctorId === null || $slotId === null) {
abort(400); // -> "bad request" page (see bootstrap/app.php)
}
$user = Utils::user();
if ($user->isPatient()) {
$client = Utils::connectedPatient($this->application);
} else {
$clientId = Utils::positiveInteger($request->input('clientId'));
if ($clientId === null) {
// no patient chosen: we reopen the booking window
Flash::error('ERRORS.CHOOSE_PATIENT');
return redirect(self::scheduleUrl($doctorId, $day, $slotId));
}
$client = $this->application->getClientById($clientId);
}
$slot = $this->application->getSlotById($slotId);
if ($client === null) {
Flash::error('ERRORS.PATIENT_NOT_FOUND');
} elseif ($slot === null || $slot->doctor_id !== $doctorId) {
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);
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()) {
Flash::error('SCHEDULE.ALREADY_BOOKED_INFO');
} else {
Flash::error($e->key, $e->params);
}
}
}
return redirect(self::scheduleUrl($doctorId, $day));
}
// -----------------------------------------------------------------------
// POST /schedule/cancel fields: appointmentId, doctorId, day
// (a patient cancels THEIR appointment from "My area", not from here)
// -----------------------------------------------------------------------
public function cancel(Request $request): RedirectResponse
{
[$doctorId, $day] = $this->readScheduleRequest($request);
$appointmentId = Utils::positiveInteger($request->input('appointmentId'));
if ($doctorId === null || $appointmentId === null) {
abort(400);
}
$appointment = $this->application->getAppointmentById($appointmentId);
if ($appointment === null) {
// already cancelled (by someone else, or double click)
Flash::error('ERRORS.APPOINTMENT_NOT_FOUND');
} else {
$this->application->deleteAppointment($appointment);
Flash::success('SCHEDULE.CANCELLED');
}
return redirect(self::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 (! Utils::user()->isPatient()) {
return ['manager' => true, 'patient' => null, 'patientCanBook' => false];
}
$patient = Utils::connectedPatient($this->application);
$upcoming = $this->application->getClientUpcomingAppointments($patient->id);
return ['manager' => false, 'patient' => $patient, 'patientCanBook' => $upcoming->isEmpty()];
}
// -----------------------------------------------------------------------
// the doctor's schedule, formatted for the view - and filtered according to the rights
// -----------------------------------------------------------------------
private function scheduleView(DoctorDaySchedule $doctorSchedule, array $rights, bool $dayBookable): ScheduleView
{
$doctorId = $doctorSchedule->doctor->id;
$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->client_id === $rights['patient']->id;
$rows[] = new ScheduleRow(
slotId: $slot->id,
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->client->label() : null,
mine: $mine,
bookable: $free && $dayBookable && ($rights['manager'] || $rights['patientCanBook']),
bookUrl: self::scheduleUrl($doctorId, $day, $slot->id),
appointmentId: $appointment !== null && $rights['manager'] ? $appointment->id : null);
}
return new ScheduleView($doctorSchedule->doctor->label(), Utils::formatDay($day), $rows);
}
// ----------------------------------------------------------------- utility functions
// the hidden fields doctorId / day of the POST forms; [null, null] if they
// are invalid
private function readScheduleRequest(Request $request): array
{
$doctorId = Utils::positiveInteger($request->input('doctorId'));
$day = Utils::validDate(is_string($request->input('day')) ? $request->input('day') : null);
return $doctorId === null || $day === null ? [null, null] : [$doctorId, $day];
}
// /schedule?doctorId=1&day=2026-09-23[&book=7]: route() appends to the end of
// the URL the parameters that are not path parameters
private static function scheduleUrl(int $doctorId, CarbonImmutable $day, ?int $book = null): string
{
$params = ['doctorId' => $doctorId, 'day' => Utils::iso($day)];
if ($book !== null) {
$params['book'] = $book;
}
return route('schedule', $params);
}
private static function option(Person $p, bool $selected): OptionView
{
return new OptionView($p->id, $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): this is the case right
// after they have just booked it, where the message would be out of place.
// -----------------------------------------------------------------------
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.
11.17.3.1. The URL elements 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 window for booking 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.17.3.2. GET /schedule
Lines 76–148:
- the parameters for URL are read as-is ([$request->query('day')]), without validation by [Laravel]: a missing parameter ([null]) is not an error (it defaults to “today” or “no doctor”); an invalid date is an error, displayed at the bottom of the page, without preventing the rest of the content from being displayed; a doctor or time slot number that is not a positive integer is simply ignored;
- A connected patient who has not selected a doctor can view their schedule: the system searches for their doctor using their account ([getDoctorByUserId]). [?->id]: The null-safe operator returns [null] if the object is [null], instead of raising an error;
- the drop-down list of doctors: each doctor becomes a [OptionView]; [map(...)->all()] returns an array;
- 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] ([PHP] 8.4) 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 ([view('schedule', [...])], example 13), including [pageTitle] and [error], which are read by the template.
11.17.3.3. POST /schedule/book
Lines 161–206:
- The hidden fields in the appointment form (doctor, day, time slot) are checked; if invalid, it must be a manually created request: [abort(400)], the “invalid request” page;
- For a patient, the field [clientId] is ignored: the patient always books for themselves ([Utils::connectedPatient]). Trusting a field sent by the browser would allow a malicious patient to make a reservation in someone else’s name. 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. The same rejection, “one upcoming appointment,” is displayed to the second person on behalf of the patient themselves (“You already have…”). Any other exception is not intercepted: this is a true error (Page 500);
- in all cases, the Post/Redirect/Get pattern (example 20) applies: a pop-up message and a redirect to the calendar.
11.17.3.4. POST /schedule/cancel
Lines 212–228: Cancellation of an appointment by the administrator or a doctor (the [role:ADMIN,DOCTOR] middleware in the route). An appointment that no longer exists (already canceled by someone else, or due to a double-click) is not an exception: it’s simply a message.
- lines 236–244: 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 249–273: the doctor’s schedule, converted into rows for the view. The key point regarding confidentiality is line 266: 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 booking; the “Cancel” button is displayed only to administrators. The named arguments ([slotId: $slot->id]) make the construction of an object with eight properties readable;
- lines 288–295: the URL for a doctor’s schedule on a given day. [route('schedule', [...])] adds, at the end of URL, as query parameters, the values that are not path parameters: [/schedule?doctorId=1&day=2026-09-26];
- lines 308–315: 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.17.4. app/Http/Controllers/PersonsController.php
| <?php
// =========================================================================
// app/Http/Controllers/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. We thus get, for each one (see routes/web.php):
//
// GET /doctors list
// GET /doctors/add empty form
// POST /doctors/add creation
// GET /doctors/{id}/edit pre-filled form
// POST /doctors/{id}/edit modification
// POST /doctors/{id}/delete deletion
//
// (and the same URLs under /clients). The views are common too:
// resources/views/persons/list.blade.php and persons/form.blade.php.
//
// SECURITY: the [role:ADMIN] middleware is put on the GROUP of the routes of each
// section (see routes/web.php): it protects all its actions - including those
// that would be added to it later.
// =========================================================================
namespace App\Http\Controllers;
use App\Http\Requests\PersonRequest;
use App\Business\PersonData;
use App\Business\BusinessException;
use App\Models\Person;
use App\Views\PersonKeys;
use App\Web\ApplicationModel;
use App\Web\Flash;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
abstract class PersonsController
{
// the form fields concerned by each "input" business error (a
// duplicate): they are outlined 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)
{
}
// ---- what each type of person must provide
// "doctors" or "clients": the start of the URLs and of the route names
abstract protected function section(): string;
abstract protected function keys(): PersonKeys;
abstract protected function listAll(): Collection;
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 remove(int $id): void;
// -------------------------------------------------------------------
// GET /doctors (or /clients)
// -------------------------------------------------------------------
public function list(): View
{
return view('persons.list', ['pageTitle' => $this->keys()->title, 'keys' => $this->keys(),
'section' => $this->section(), 'persons' => $this->listAll()]);
}
// -------------------------------------------------------------------
// GET /doctors/add
// -------------------------------------------------------------------
public function addForm(): View
{
return $this->form(null);
}
// -------------------------------------------------------------------
// POST /doctors/add
// PersonRequest has already validated the input: an invalid input does not get
// this far (Laravel has sent back to the form, with the errors)
// -------------------------------------------------------------------
public function add(PersonRequest $request): RedirectResponse
{
try {
$person = $this->create($request->personData());
} catch (BusinessException $e) {
return $this->businessError($e);
}
Flash::success($this->keys()->added, self::params($person));
return redirect()->route($this->section().'.list');
}
// -------------------------------------------------------------------
// GET /doctors/{id}/edit
// {id} must be made of digits (whereNumber, see routes/web.php):
// /doctors/abc/edit matches no route (404 page)
// -------------------------------------------------------------------
public function editForm(int $id): View
{
$person = $this->find($id) ?? abort(404);
return $this->form($person);
}
// -------------------------------------------------------------------
// POST /doctors/{id}/edit
// -------------------------------------------------------------------
public function edit(PersonRequest $request, int $id): RedirectResponse
{
try {
$person = $this->update($id, $request->personData());
} catch (BusinessException $e) {
return $this->businessError($e);
}
Flash::success($this->keys()->edited, self::params($person));
return redirect()->route($this->section().'.list');
}
// -------------------------------------------------------------------
// POST /doctors/{id}/delete
// (the confirmation "Are you sure?" is requested before the form is sent,
// see public/js/app.js)
// -------------------------------------------------------------------
public function delete(int $id): RedirectResponse
{
$person = $this->find($id);
try {
$this->remove($id);
Flash::success($this->keys()->deleted, self::params($person));
} catch (BusinessException $e) {
Flash::error($e->key, $e->params);
}
return redirect()->route($this->section().'.list');
}
// ----------------------------------------------------------------- internal functions
// $person null: creation form; otherwise: modification form,
// pre-filled with their values (unless the view has an input to redisplay: old())
private function form(?Person $person): View
{
$creating = $person === null;
$keys = $this->keys();
return view('persons.form', [
'pageTitle' => $creating ? $keys->add : $keys->edit,
'creating' => $creating,
'person' => $person,
'action' => $creating ? route($this->section().'.add')
: route($this->section().'.edit', ['id' => $person->id]),
'listUrl' => route($this->section().'.list'),
]);
}
// a business rule has refused the write: back to the form (back), with
// the user's input (withInput, without the passwords), the message of
// the business layer (at the bottom of the page), and the fields concerned, if any,
// outlined in red so that the user immediately sees what to correct
private function businessError(BusinessException $e): RedirectResponse
{
Flash::error($e->key, $e->params);
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
return redirect()->route($this->section().'.list');
}
return back()->withInput(request()->except('password'))
->with('invalidFields', self::BUSINESS_ERROR_FIELDS[$e->key] ?? []);
}
// :name and :login of the "added / edited / deleted" messages
private static function params(?Person $person): array
{
return $person === null ? ['name' => '', 'login' => '']
: ['name' => "{$person->first_name} {$person->last_name}", 'login' => $person->account?->login ?? ''];
}
}
|
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 56–68, 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, while the child classes provide the details.
- lines 43–47: the fields affected by each business-logic input error: a duplicate username affects the “login” field; a name conflict affects the “first name” and “last name” fields. These fields will be highlighted in red;
- Lines 73–77: the list. The view is common to both sections: it receives the translation keys for the section ([keys]) and its name ([section]), which are used to generate the URL ([route("$section.addForm")]);
- lines 92–101: the addition. The entry has already been validated by [PersonRequest]: an invalid entry does not reach this point ([Laravel] redirected the browser back to the form, along with the errors). That leaves the business logic layer, which may reject the save (due to a namesake or a login already in use); otherwise, a success message appears and the user is returned to the list (schema PRG);
- lines 108–112: the update form for an existing user: [?? abort(404)] ([abort] throws an exception: it is an expression, usable to the right of [??]);
- lines 117–126: the update; the ID comes from the path of URL ([{id}]), the form data;
- lines 133–143: the deletion. The form requested confirmation in the browser (example 21). The person is retrieved before deletion for the success message (“Doctor Paul TATOU has been deleted.”);
- lines 149–161: the form, for creation ([$person] equals [null]) or modification. The view receives the person and displays their values—except after a rejection, in which case it redisplays the entered data ([old], example 19);
- lines 167–176: The business layer rejected the write operation. If the person has been deleted in the meantime (by another administrator): return to the list. Otherwise, return to the form ([back()], the previous page, example 19), as in the case of an input error: the entry to be redisplayed ([withInput], without the password), the message from the business layer (a pop-up error message, displayed at the bottom of the page), and the fields to be highlighted in red ([with('invalidFields', ...)], a pop-up data value read by the view). [request()]: the current query, accessible everywhere;
- lines 179–183: the parameters [:name] and [:login] from the success messages.
11.17.5. app/Http/Controllers/DoctorsController.php and ClientsController.php
| <?php
// app/Http/Controllers/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\Http\Controllers;
use App\Business\PersonData;
use App\Models\Person;
use App\Views\PersonKeys;
use Illuminate\Database\Eloquent\Collection;
class DoctorsController extends PersonsController
{
protected function section(): string
{
return 'doctors';
}
// DOCTORS.TITLE, DOCTORS.ADD... (see lang/*.json)
protected function keys(): PersonKeys
{
return PersonKeys::of('DOCTORS');
}
protected function listAll(): Collection
{
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 remove(int $id): void
{
$this->application->deleteDoctor($id);
}
}
|
All that remains here is to specify what to call (the methods of [ApplicationModel]) and what labels to display (the [DOCTORS.xxx] keys in the dictionaries). The constructor is inherited from [PersonsController]: [ApplicationModel] is contained in the [$this->application] property, declared as [protected] (visible to child classes). [ClientsController] is identical, with the client methods and keys from [CLIENTS.xxx].
11.17.6. app/Http/Controllers/RegistrationController.php
| <?php
// =========================================================================
// app/Http/Controllers/RegistrationController.php: "Create a client 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 client account" button of the login
// page: checks the captcha of THIS
// 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 client account" button sends the login form to
// /register/access; if the captcha is correctly copied, the server issues a
// pass (see app/Auth/RegistrationPass.php);
// 2. without a valid pass, /register sends back to the login
// page (CheckRegistrationPass middleware);
// 3. the pass is consumed at 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 app/Auth/AttemptLimiter.php).
//
// The created account is exactly the one the ADMIN would create from the "Clients" page
// (same method of the business layer: addClient), except that the patient
// CHOOSES their login and their password (required, with confirmation).
// =========================================================================
namespace App\Http\Controllers;
use App\Auth\CaptchaService;
use App\Auth\AttemptLimiter;
use App\Auth\RegistrationPass;
use App\Http\Requests\RegistrationRequest;
use App\Business\BusinessException;
use App\Web\ApplicationModel;
use App\Web\Flash;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class RegistrationController
{
// the fields that are NEVER sent back to the browser
private const PASSWORDS = ['password', 'password_confirmation'];
public function __construct(
private ApplicationModel $application,
private CaptchaService $captchas,
private AttemptLimiter $limiter,
private RegistrationPass $passes,
) {
}
// -----------------------------------------------------------------------
// POST /register/access fields: captchaId, captcha (those of the login
// page; login and password, also sent, are ignored)
// -----------------------------------------------------------------------
public function access(Request $request): RedirectResponse
{
$ip = (string) $request->ip();
// too many accounts created recently from this address: no need to go further
$minutes = $this->limiter->registrationBlockMinutes($ip);
if ($minutes > 0) {
Flash::error('ERRORS.TOO_MANY_ACCOUNTS', ['minutes' => $minutes]);
return redirect()->route('login');
}
// captcha missing or wrong: back to the login page, with a
// new image (a failure counts as a login failure)
if (! $this->captchas->verify($request->input('captchaId'), $request->input('captcha'))) {
$this->limiter->loginFailure($ip, '');
Flash::error('ERRORS.CAPTCHA_BEFORE_ACCOUNT');
return redirect()->route('login');
}
// correct captcha: we issue the pass and open the form
return redirect()->route('registration')
->withCookie(RegistrationPass::cookie($this->passes->issue()));
}
// -----------------------------------------------------------------------
// GET /register (the pass has been checked by the middleware)
// -----------------------------------------------------------------------
public function form(): View
{
return view('registration', ['pageTitle' => 'REGISTRATION.TITLE']);
}
// -----------------------------------------------------------------------
// POST /register. RegistrationRequest has already validated ALL the input (fields
// and password confirmation): the user corrects all their errors
// in one go
// -----------------------------------------------------------------------
public function create(RegistrationRequest $request): RedirectResponse
{
$ip = (string) $request->ip();
// 1. too many accounts created recently from this address?
$minutes = $this->limiter->registrationBlockMinutes($ip);
if ($minutes > 0) {
Flash::error('ERRORS.TOO_MANY_ACCOUNTS', ['minutes' => $minutes]);
return back()->withInput($request->except(self::PASSWORDS));
}
// 2. the creation (client + USER account, in a transaction)
try {
$this->application->addClient($request->personData());
} catch (BusinessException $e) {
// login or first + last names already taken: the pass remains valid
Flash::error($e->key, $e->params);
return back()->withInput($request->except(self::PASSWORDS))
->with('invalidFields', PersonsController::BUSINESS_ERROR_FIELDS[$e->key] ?? []);
}
// 3. the pass has been used: it will not allow creating a 2nd account
$this->passes->consume($request->cookie(RegistrationPass::COOKIE));
$this->limiter->registrationSuccess($ip);
// 4. back to the login page, login pre-filled
$login = $request->input('login');
Flash::success('REGISTRATION.DONE', ['login' => $login]);
return redirect()->route('login', ['login' => $login])
->withCookie(RegistrationPass::clearingCookie());
}
}
|
The patient creates a patient account themselves. The flow is protected by a CAPTCHA, the same one used on the login page.
- Lines 60–82: 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 is 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 87–90: the form. The token has been verified by the route’s [registration.pass] middleware;
- Lines 97–127: Creation. [RegistrationRequest] has validated all the data entered, including the password confirmation: the user sees all their errors at once. 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 using up the token. 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 ([route('login', ['login' => $login])] adds the parameter [?login=...]), along with a success message. The pass cookie is cleared.
The constant [PASSWORDS] (line 46) lists the fields that should never be sent back to the browser: [back()->withInput()], without a parameter, would retain all input for the next form, including passwords; [$request->except([...])] submits the form without these fields. When [Laravel] returns to the form (due to a validation error), it automatically excludes the fields [password], [password_confirmation], and [current_password].
11.17.7. app/Http/Controllers/PatientAreaController.php
| <?php
// =========================================================================
// app/Http/Controllers/PatientAreaController.php: the PERSONAL pages of the patient (USER
// role), 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 the one of the token
// (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).
// =========================================================================
namespace App\Http\Controllers;
use App\Auth\AuthService;
use App\Auth\AttemptLimiter;
use App\Http\Requests\MyAccountRequest;
use App\Business\BusinessException;
use App\Models\Client;
use App\Models\Appointment;
use App\Views\AppointmentView;
use App\Web\ApplicationModel;
use App\Web\Flash;
use App\Web\Message;
use App\Web\Utils;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class PatientAreaController
{
public function __construct(
private ApplicationModel $application,
private AuthService $auth,
private AttemptLimiter $limiter,
) {
}
// -----------------------------------------------------------------------
// GET /my-area
// -----------------------------------------------------------------------
public function patientArea(): View
{
$patient = Utils::connectedPatient($this->application);
$appointments = $this->application->getClientUpcomingAppointments($patient->id)->map(fn (Appointment $a) => new AppointmentView(
$a->id,
Utils::formatDay($a->day),
$a->slot->time(),
$a->slot->doctor->label(),
// link to the doctor's schedule on that day: the patient sees their name there
route('schedule', ['doctorId' => $a->slot->doctor_id, 'day' => Utils::iso($a->day)])))
->all();
return view('area', ['pageTitle' => 'AREA.TITLE', 'patient' => $patient->label(),
'appointments' => $appointments]);
}
// -----------------------------------------------------------------------
// POST /my-area/cancel field: appointmentId
// -----------------------------------------------------------------------
public function cancelAppointment(Request $request): RedirectResponse
{
$patient = Utils::connectedPatient($this->application);
$appointmentId = Utils::positiveInteger($request->input('appointmentId'));
$appointment = $appointmentId !== null ? $this->application->getAppointmentById($appointmentId) : null;
// an appointment that does not exist, and that of ANOTHER patient, get the
// same answer: we reveal nothing about the appointments of the others
if ($appointment === null || $appointment->client_id !== $patient->id) {
Flash::error('ERRORS.APPOINTMENT_NOT_FOUND');
} else {
$this->application->deleteAppointment($appointment);
Flash::success('SCHEDULE.CANCELLED');
}
return redirect()->route('area');
}
// -----------------------------------------------------------------------
// GET /my-account: the form, pre-filled with the patient's values
// -----------------------------------------------------------------------
public function accountForm(): View
{
$patient = Utils::connectedPatient($this->application);
return view('account', ['pageTitle' => 'MY_ACCOUNT.TITLE', 'patient' => $patient]);
}
// -----------------------------------------------------------------------
// POST /my-account (MyAccountRequest has validated the input)
// -----------------------------------------------------------------------
public function editAccount(MyAccountRequest $request): RedirectResponse
{
$patient = Utils::connectedPatient($this->application);
// the current password; wrong: the message goes UNDER the field, and at the bottom of the page
$error = $this->checkCurrentPassword($request, $patient, $request->input('current_password'));
if ($error !== null) {
Flash::error($error->key, $error->params);
return back()->withInput($request->except(['password', 'password_confirmation', 'current_password']))
->withErrors(['current_password' => __($error->key, $error->params)]);
}
try {
$client = $this->application->editClient($patient->id, $request->personData());
} catch (BusinessException $e) {
Flash::error($e->key, $e->params);
return back()->withInput($request->except(['password', 'password_confirmation', 'current_password']))
->with('invalidFields', PersonsController::BUSINESS_ERROR_FIELDS[$e->key] ?? []);
}
// the login and the displayed name are IN the token: we issue a new one,
// otherwise the navigation bar would keep the old name
Flash::success('MY_ACCOUNT.UPDATED');
return redirect()->route('area')->withCookie($this->auth->tokenCookie($client->account));
}
// -----------------------------------------------------------------------
// POST /my-account/delete field: current_password
// The account, the client and ALL their appointments disappear together, then the
// patient is logged out.
// -----------------------------------------------------------------------
public function deleteAccount(Request $request): RedirectResponse
{
$patient = Utils::connectedPatient($this->application);
$error = $this->checkCurrentPassword($request, $patient, $request->input('current_password'));
if ($error !== null) {
Flash::error($error->key, $error->params);
return redirect()->route('account');
}
$this->application->deleteClient($patient->id);
Flash::success('MY_ACCOUNT.DELETED');
return redirect()->route('login')->withCookie(AuthService::clearingCookie());
}
// ----------------------------------------------------------------- internal functions
// null if the current password is correct; otherwise the error message
private function checkCurrentPassword(Request $request, Client $patient, ?string $password): ?Message
{
$ip = (string) $request->ip();
$login = $patient->account->login;
$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 if they construct their own requests.
- lines 53–66: 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 71–85: Canceling the patient’s appointment. A nonexistent appointment and another patient’s appointment both receive the same response: no information about other patients’ appointments is disclosed (not even their existence);
- lines 90–94: the “Manage My Account” form, pre-filled with the patient’s information;
- lines 99–122: 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. An incorrect current password: returns to the form, with a message below the field ([withErrors] adds an input error, as if validation had detected it) and at the bottom of the page (flash message). After the edit, a new token is generated: the login and display name are included in the token; otherwise, the navigation bar would retain the old name until the next login;
- lines 129–140: Deletion of the account, the client, and all their appointments, using the same business-logic method as for the administrator. The current password is required; the patient is then logged out (token cookie cleared);
- lines 145–158: 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.18. The Views
The views [Blade] in the folder [resources/views]: a template, fragments, components, and pages.
11.18.1. The template and fragments
11.18.1.1. resources/views/layout.blade.php
| <!doctype html>
{{-- =========================================================================
resources/views/layout.blade.php
-------------------------------------------------------------------------
The layout COMMON to all the pages (see example 15): each page
(schedule.blade.php, login.blade.php...) EXTENDS it (@extends) and fills its
[content] section.
Blade ESCAPES everything it displays with {{ ... }} (the characters < > & " '
become < >...): protection against HTML injection through a typed
name, for example. To insert HTML as is, it must be requested
explicitly ({!! ... !!}, see components/captcha.blade.php).
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); [language] and [user], added to all
the views by AppServiceProvider.
========================================================================= --}}
<html lang="{{ $language }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{{-- __($key): the translation of a key; here, the key is a VARIABLE --}}
<title>{{ __($pageTitle) }} - {{ __('APP.TITLE') }}</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')
@include('fragments.success')
<main>
@yield('content')
</main>
@include('fragments.errors')
</div>
{{-- the "Are you sure?" dialog, hidden as long as it is not needed --}}
@include('fragments.confirmation')
<script src="{{ asset('js/app.js') }}"></script>
</body>
</html>
|
The template common to all pages (Example 15): each page extends it ([@extends('layout')]) and fills in its [content] section (line 36).
- line 21: the page’s language, [fr] or [en], provided to all views by the view composer [AppServiceProvider];
- line 26: the page title, translated: [__($pageTitle)] translates the key contained in the variable [$pageTitle], which each action passes to its view;
- lines 28–29: the function [asset] renders 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;
- line 41: the “Are you sure?” window, which is hidden until needed, and the script that displays it.
[Blade] escapes everything it displays using [{{ ... }}] (Example 14): A name entered that contains HTML ([<script>]) is displayed as-is, without being interpreted. This prevents HTML (XSS) from being injected effortlessly into all pages.
11.18.1.2. resources/views/fragments/navbar.blade.php
| {{-- =========================================================================
resources/views/fragments/navbar.blade.php - the navigation bar.
Title, FR/EN, role, name, logout - and a menu whose entries depend
on the role: "Doctors" and "Clients" only appear for the ADMIN. It is
only a display convenience: the real protection is the middlewares of the
routes (a USER who types /doctors by hand gets a 403 page). A patient
(USER) also has their "My area" menu.
The menu of the current page is highlighted ("active" class):
request()->is('schedule*') is true if the path of the URL starts with "schedule".
The menu entries: an array [URL, label key, pattern of the paths for
which the entry is active], traversed by @foreach.
========================================================================= --}}
@php
$menu = [];
if ($user) {
if ($user->isPatient()) {
// active for /my-area AND /my-account
$menu[] = [route('area'), 'APP.MENU_MY_AREA', 'my-*'];
}
$menu[] = [route('schedule'), 'APP.MENU_SCHEDULE', 'schedule*'];
if ($user->isAdmin()) {
$menu[] = [route('doctors.list'), 'APP.MENU_DOCTORS', 'doctors*'];
$menu[] = [route('clients.list'), 'APP.MENU_PATIENTS', 'clients*'];
}
}
@endphp
<nav class="navbar navbar-expand-sm navbar-dark bg-primary rounded mb-4 px-3">
<span class="navbar-brand mb-0">{{ __('APP.TITLE') }}</span>
<ul class="navbar-nav me-auto">
@foreach ($menu as [$href, $key, $pattern])
<li class="nav-item">
{{-- @class: the classes whose condition is true --}}
<a @class(['nav-link', 'active' => request()->is($pattern)]) href="{{ $href }}">{{ __($key) }}</a>
</li>
@endforeach
</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">
@foreach (['fr', 'en'] as $code)
<a class="btn {{ $language === $code ? 'btn-light' : 'btn-outline-light' }}"
href="{{ route('language', ['code' => $code]) }}">{{ strtoupper($code) }}</a>
@endforeach
</div>
@if ($user)
<span class="badge text-bg-light text-primary">{{ $user->role->value }}</span>
<span class="text-white">{{ $user->name }}</span>
{{-- the logout is a POST: a form reduced to its button (and to the anti-CSRF token) --}}
<form method="post" action="{{ route('logout') }}" class="m-0">
@csrf
<button type="submit" class="btn btn-sm btn-outline-light">{{ __('APP.LOGOUT') }}</button>
</form>
@endif
</div>
</nav>
|
The navigation bar. 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 route middleware (a patient who manually types [/doctors] will receive a 403 page).
- Lines 15–28: The menu is defined in a [@php] block (Example 14), an array of entries: URL (the label key) and the pattern of paths for which the entry is active. [request()->is('schedule*')] is true if the path of the requested URL matches the pattern ([*]: any sequence of characters). The “My area” entry is active for [/my-area] and [/my-account] (pattern [my-*]). This is borderline with the “no logic in views” rule: however, this logic applies only to the display and is used only here;
- lines 33–38: [@foreach] breaks down each entry in the array into three variables (example 25). [@class([...])] (example 16) writes the [class] attribute with the classes for which the condition is true;
- lines 44–47: the buttons FR / EN, simple links ([/language/fr]), and the active language highlighted;
- lines 50–58: for a logged-in user, their role ([$user->role->value]: the string representing the enumeration case), their name, and the logout button. Logout is a request POST: a form reduced to its button, with its anti-CSRF token ([@csrf], example 18).
11.18.1.3. resources/views/fragments/success.blade.php
| {{-- =========================================================================
resources/views/fragments/success.blade.php - the SUCCESS messages put
by the previous action (appointment booked, doctor added...), displayed
only once, at the top of the page (see app/Web/Flash.php). An ERROR flash
message, for its part, is displayed at the bottom of the page, by the errors fragment.
Each message is a Message object: __($message->key, $message->params)
is the text of the key, where :name, :login... are replaced by their values.
========================================================================= --}}
@foreach (session('flash_success', []) as $message)
<div class="alert alert-success" role="status">{{ __($message->key, $message->params) }}</div>
@endforeach
|
The success flash messages, set by the previous action ([Flash::success]), are read from the session by [session('flash_success', [])] (an empty list by default). Each is a [Message] object, translated here into the page’s language: [__($message->key, $message->params)]. [role="status"]: A screen reader announces the message.
11.18.1.4. resources/views/fragments/errors.blade.php
| {{-- =========================================================================
resources/views/fragments/errors.blade.php - the error zone, at the BOTTOM of
each page, on a light pink background (class .error-zone, see public/css/styles.css).
It gathers the three possible sources of errors:
- $error: the error of the displayed page (invalid date, captcha wrongly
copied...), passed by the action;
- the error flash messages, put by the previous action before a
redirection (slot already booked, login already taken, deletion refused...);
- the input errors ($errors, 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.
========================================================================= --}}
@php
$messages = session('flash_errors', []);
if ($error ?? null) {
array_unshift($messages, $error);
}
if (! $messages && $errors->any()) {
$messages[] = new App\Web\Message('ERRORS.INVALID_FORM');
}
@endphp
@if ($messages)
<div class="error-zone" role="alert">
@foreach ($messages as $message)
<div>{{ __($message->key, $message->params) }}</div>
@endforeach
</div>
@endif
|
The error area, at the bottom of each page, on a light pink background. It lists the three possible sources of error:
- the error on the displayed page ([$error]), triggered by the action: an invalid date, a misspelled CAPTCHA, etc. [$error ?? null]: the variable does not exist on all pages; the [??] operator does not flag a nonexistent variable—it replaces it with [null];
- flash error messages, triggered by the previous action (a time slot already reserved, a username already in use, etc.);
- input errors ([$errors], example 19): their messages are displayed below the fields; here, if there are no other messages, a simple phrase appears: “The form contains errors.” On a long form, this lets the user know they need to go back up.
[array_unshift] adds an item to the beginning of a table. [role="alert"]: a screen reader announces the message as soon as the page is displayed, even if it is at the bottom. The field remains anchored at the bottom of the window when the page is long (see stylesheet, below).
11.18.1.5. resources/views/fragments/confirmation.blade.php
| {{-- =========================================================================
resources/views/fragments/confirmation.blade.php - 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') }}</h5>
<button type="button" class="btn-close" data-action="no" aria-label="{{ __('BOOKING_DIALOG.CLOSE_ARIA') }}"></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') }}</button>
<button type="button" class="btn btn-danger" data-action="yes">{{ __('CONFIRMATION.YES') }}</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 script [public/js/app.js] displays it, places the message from the form to be confirmed inside it (its attribute [data-confirmation]), and submits the form if the user confirms. The “Cancel” 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.18.1.6. resources/views/fragments/booking.blade.php
| {{-- =========================================================================
resources/views/fragments/booking.blade.php - the booking window
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 (&book=...
parameter 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="{{ route('schedule.book') }}" class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="booking-title">{{ __('BOOKING_DIALOG.TITLE') }}</h5>
<a class="btn-close" href="{{ $backUrl }}" aria-label="{{ __('BOOKING_DIALOG.CLOSE_ARIA') }}"></a>
</div>
<div class="modal-body">
<p>{{ __('BOOKING_DIALOG.SLOT', ['time' => $booking->time]) }}</p>
{{-- what the server must know in addition to the chosen patient --}}
@csrf
<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') }}</span>:
<strong>{{ $booking->patient }}</strong>
</p>
@else
<label class="form-label" for="clientId">{{ __('BOOKING_DIALOG.PATIENT_LABEL') }}</label>
<select id="clientId" name="clientId" class="form-select" required autofocus>
<option value="">{{ __('BOOKING_DIALOG.CHOOSE_PATIENT') }}</option>
@foreach ($booking->clients as $client)
<option value="{{ $client->id }}">{{ $client->label }}</option>
@endforeach
</select>
@endif
</div>
<div class="modal-footer">
<a class="btn btn-secondary" href="{{ $backUrl }}">{{ __('BOOKING_DIALOG.CANCEL') }}</a>
<button type="submit" class="btn btn-primary">{{ __('BOOKING_DIALOG.CONFIRM') }}</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 [JavaScript] from [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 makes a reservation for themselves: their name, with no list to choose from (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.18.2. The components
Three anonymous components (Example 15), in the [resources/views/components] folder, used by the pages in the form of a [<x-name>] tag.
11.18.2.1. resources/views/components/field.blade.php
| {{-- =========================================================================
resources/views/components/field.blade.php - an input field, its label,
its error message or its help (see example 19): <x-field name="login" ... />
@props: the attributes expected by the component, with their default value.
The displayed value: old($name, $value), the input to redisplay after a refusal,
otherwise the given value. A password is NEVER redisplayed.
The field is outlined in red (Bootstrap class is-invalid) if it has an input
error ($errors), or if a business error designates it (invalidFields, see
PersonsController::businessError); the error message then replaces the help.
========================================================================= --}}
@props(['name', 'label', 'type' => 'text', 'value' => null, 'help' => null, 'max' => null,
'autocomplete' => null, 'required' => false])
@php
$invalid = $errors->has($name) || in_array($name, session('invalidFields', []), true);
@endphp
<label class="form-label" for="{{ $name }}">{{ $label }}</label>
<input id="{{ $name }}" name="{{ $name }}" type="{{ $type }}" @class(['form-control', 'is-invalid' => $invalid])
@if ($type !== 'password') value="{{ old($name, $value) }}" @endif
@if ($max) maxlength="{{ $max }}" @endif
@if ($autocomplete) autocomplete="{{ $autocomplete }}" @endif
@required($required)>
@error($name)
<div class="invalid-feedback">{{ $message }}</div>
@else
@if ($help)
<div class="form-text">{{ $help }}</div>
@endif
@enderror
|
An input field, with its label, error message, or tooltip: the [x-field] component from Example 19, fully configured.
- Line 12: the component’s attributes and their default values. On a page, [<x-field name="login" :label="__('PERSONS.LOGIN_LABEL')" max="30" required />]: an attribute preceded by [:] is assigned the value of an expression [PHP], while the others are assigned a string; an attribute with no value ([required]) is set to [true];
- lines 14–16: the field is invalid if it contains an input error ([$errors->has($name)]), or if a business-layer error flags it as such: the list [invalidFields], stored in the session by the action ([PersonsController::businessError]). A username that is already taken is not an input error (it has the correct format), but the user must see which field to correct;
- lines 18–22: the displayed value is [old($name, $value)]: the input to be redisplayed after a rejection; otherwise, the value transmitted by the page (that of the modified record). A password is never redisplayed: there is no [value] attribute for a [password] field. [@required($required)] sets the Boolean attribute [required] if the condition is true;
- lines 23–29: the field’s error message ([$message], example 19), otherwise any available help. The class [invalid-feedback] of [Bootstrap] is visible only after a field of class [is-invalid].
11.18.2.2. resources/views/components/person-fields.blade.php
| {{-- =========================================================================
resources/views/components/person-fields.blade.php - the identity fields
(title, first name, last name), common to three forms: doctor/client of the ADMIN,
"Create a client account" and "Manage my account": <x-person-fields :person="..." />
:person: the person whose values are displayed (null for an empty
form). $person?->title: null if $person is null ("nullsafe" operator).
The list of titles comes from the class that validates them (PersonFieldsRequest).
========================================================================= --}}
@props(['person' => null])
@php
$chosenTitle = old('title', $person?->title ?? 'Mr');
@endphp
<div class="mb-3">
<label class="form-label" for="title">{{ __('PERSONS.TITLE_LABEL') }}</label>
<select id="title" name="title" @class(['form-select', 'is-invalid' => $errors->has('title')])>
@foreach (App\Http\Requests\PersonFieldsRequest::TITLES as $title)
<option value="{{ $title }}" @selected($title === $chosenTitle)>{{ $title }}</option>
@endforeach
</select>
@error('title')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="row g-3 mb-3">
<div class="col-sm-6">
<x-field name="first_name" :label="__('PERSONS.FIRSTNAME_LABEL')" :value="$person?->first_name" max="20" required />
</div>
<div class="col-sm-6">
<x-field name="last_name" :label="__('PERSONS.LASTNAME_LABEL')" :value="$person?->last_name" max="20" required />
</div>
</div>
|
Identity fields common to three forms: title (a list), first name, and last name (two [x-field] fields: a component can use another component). The [person] attribute is the person whose values are displayed, or [null] for an empty form. [$person?->title]: the null-safe operator returns [null] if [$person] is null-safe. The list of titles comes from the class that validates them ([PersonFieldsRequest::TITLES]): the rule is written once. [@selected(...)] writes the [selected] attribute of the selected option.
11.18.2.3. resources/views/components/captcha.blade.php
| {{-- =========================================================================
resources/views/components/captcha.blade.php - the captcha of the login
page (see app/Auth/CaptchaService.php). It protects both the
login and the access to the account creation ("Create a client account" button).
{!! ... !!}: 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 - one would
NEVER do this with an input.
The hidden field captchaId tells the server WHICH captcha to verify; the right
answer, for its part, is never sent to the browser. "Another image" simply
reloads the page (url()->current(): the URL of the page, without its parameters).
========================================================================= --}}
@props(['captcha'])
<div class="mb-3">
<label class="form-label" for="captcha">{{ __('CAPTCHA.LABEL') }}</label>
<div class="d-flex align-items-center gap-3 mb-2">
<div class="captcha-image" role="img" aria-label="{{ __('CAPTCHA.IMAGE_ALT') }}">{!! $captcha->svg !!}</div>
<a class="small" href="{{ url()->current() }}">{{ __('CAPTCHA.NEW_IMAGE') }}</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') }}</div>
</div>
|
The CAPTCHA (example 27). [{!! $captcha->svg !!}] 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 page ([url()->current()]: the page’s URL, without its parameters), and thus the CAPTCHA.
11.18.3. The pages
11.18.3.1. resources/views/login.blade.php
| {{-- =========================================================================
resources/views/login.blade.php - the login form (see
AuthController). An ordinary 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 typed, a
NEW captcha and the error, displayed at the bottom of the page by the layout.
========================================================================= --}}
@extends('layout')
@section('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') }}</h5>
<form method="post" action="{{ route('login') }}">
@csrf
<div class="mb-3">
<label class="form-label" for="login">{{ __('LOGIN.LOGIN_LABEL') }}</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 @if ($login === '') autofocus @endif>
</div>
<div class="mb-3">
<label class="form-label" for="password">{{ __('LOGIN.PASSWORD_LABEL') }}</label>
<input id="password" name="password" type="password" class="form-control"
autocomplete="current-password" required @if ($login !== '') autofocus @endif>
</div>
<x-captcha :captcha="$captcha" />
<button type="submit" class="btn btn-primary w-100">{{ __('LOGIN.SUBMIT') }}</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). There the server checks the
captcha above 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="{{ route('registration.access') }}" formnovalidate>{{ __('LOGIN.CREATE_ACCOUNT') }}</button>
<div class="form-text text-center">{{ __('LOGIN.CREATE_ACCOUNT_HELP') }}</div>
</form>
<div class="text-muted small mt-3">
<span>{{ __('LOGIN.DEMO_ACCOUNTS') }}</span>
<ul class="mb-0 mt-1">
<li><code>admin</code> - <span>{{ __('LOGIN.DEMO_ADMIN') }}</span></li>
<li><code>pelissier</code>, <code>tatou</code>, <code>dupont</code> - <span>{{ __('LOGIN.DEMO_DOCTOR') }}</span></li>
<li><code>martin</code>, <code>durand</code>, <code>bistrou</code>, <code>german</code> - <span>{{ __('LOGIN.DEMO_USER') }}</span></li>
</ul>
</div>
</div>
</div>
</div>
@endsection
|
The login form, a standard [<form>], is sent as POST to [/login].
- Lines 22–23: The entered username is redisplayed after a failed login attempt ([$login]). The [autofocus] attribute 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);
- 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.18.3.2. resources/views/schedule.blade.php
| {{-- =========================================================================
resources/views/schedule.blade.php - the main page (see ScheduleController).
Three blocks:
1. the choice of the doctor and of the day;
2. the schedule;
3. the booking window (booking fragment).
A possible error (invalid date, unknown doctor) is displayed at the bottom of
the page by the layout (errors fragment).
========================================================================= --}}
@extends('layout')
@section('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="{{ route('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') }}</label>
<select id="doctorId" name="doctorId" class="form-select" required>
<option value="">{{ __('DOCTOR_DAY_PICKER.CHOOSE_DOCTOR') }}</option>
@foreach ($doctors as $doctor)
<option value="{{ $doctor->id }}" @selected($doctor->selected)>{{ $doctor->label }}</option>
@endforeach
</select>
</div>
<div class="col-sm-4">
<label class="form-label" for="day">{{ __('DOCTOR_DAY_PICKER.DAY_LABEL') }}</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') }}</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) }}</span>
<a href="{{ route('area') }}" class="alert-link">{{ __('APP.MENU_MY_AREA') }}</a>
</div>
@endif
{{-- 2. the schedule --}}
@if ($schedule)
<div class="card p-3">
<h5>{{ __('SCHEDULE.TITLE_PREFIX') }} {{ $schedule->day }} - {{ $schedule->doctor }}</h5>
@if ($schedule->rows)
<table class="table table-hover align-middle">
<thead>
<tr>
<th>{{ __('SCHEDULE.COLUMN_SLOT') }}</th>
<th>{{ __('SCHEDULE.COLUMN_STATUS') }}</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach ($schedule->rows as $row)
<tr @class(['free-slot' => $row->bookable])>
<td>{{ $row->time }}</td>
<td>
{{-- [patient] is only set 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') }}</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') }}</span>
@endif
</td>
<td>
{{-- the displayed buttons have been decided by the controller (bookable,
appointmentId) according to the user's role. "Book" is a simple
link: it reopens this page with the booking window of this slot --}}
@if ($row->bookable)
<a class="btn btn-sm btn-outline-success" href="{{ $row->bookUrl }}">{{ __('SCHEDULE.BOOK') }}</a>
@endif
{{-- "Cancel" MODIFIES data: it is therefore a POST --}}
@if ($row->appointmentId)
<form method="post" action="{{ route('schedule.cancel') }}" class="m-0"
data-confirmation="{{ __('SCHEDULE.CANCEL_CONFIRM') }}">
@csrf
<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') }}</button>
</form>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
@else
<p class="text-muted mb-0">{{ __('SCHEDULE.NO_SLOTS') }}</p>
@endif
</div>
@else
<p class="text-muted">{{ __('SCHEDULE.EMPTY_STATE') }}</p>
@endif
{{-- 3. the booking window, only if the URL contains &book=... --}}
@if ($booking)
@include('fragments.booking')
@endif
@endsection
|
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 displays it ([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.18.3.3. resources/views/persons/list.blade.php
| {{-- =========================================================================
resources/views/persons/list.blade.php - 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
start of the route names (see PersonsController).
========================================================================= --}}
@extends('layout')
@section('content')
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">{{ __($keys->title) }}</h4>
<a class="btn btn-primary" href="{{ route("$section.addForm") }}">{{ __($keys->add) }}</a>
</div>
<div class="card p-3">
@if ($persons->isNotEmpty())
<table class="table table-hover align-middle mb-0">
<thead>
<tr>
<th>{{ __('PERSONS.COLUMN_TITLE') }}</th>
<th>{{ __('PERSONS.COLUMN_FIRSTNAME') }}</th>
<th>{{ __('PERSONS.COLUMN_LASTNAME') }}</th>
<th>{{ __('PERSONS.COLUMN_LOGIN') }}</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach ($persons as $person)
<tr>
<td>{{ $person->title }}</td>
<td>{{ $person->first_name }}</td>
<td>{{ $person->last_name }}</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="{{ route("$section.editForm", ['id' => $person->id]) }}">{{ __('PERSONS.EDIT') }}</a>
{{-- data-confirmation: a confirmation dialog will open before
the form is sent (see public/js/app.js) --}}
<form method="post" action="{{ route("$section.delete", ['id' => $person->id]) }}" class="m-0"
data-confirmation="{{ __($keys->confirmDelete) }}">
@csrf
<button type="submit" class="btn btn-sm btn-outline-danger">{{ __('PERSONS.DELETE') }}</button>
</form>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
@else
<p class="text-muted mb-0">{{ __($keys->empty) }}</p>
@endif
</div>
@endsection
|
The list of doctors or clients: the labels specific to each one are translation keys ([$keys]), and the URL values are generated from the section name ([route("$section.editForm", ['id' => ...])]: in a string enclosed in quotation marks, [$section] is replaced by its value, [doctors] or [clients]). [$person->account?->login]: the account login, loaded with the list ([with('account')], layer DAO). [isNotEmpty()]: the collection is not empty. The Delete button is a POST form, with confirmation.
| {{-- =========================================================================
resources/views/persons/form.blade.php - creation OR modification of a
doctor or of a client (ADMIN). $creating distinguishes the two cases; $person
is the modified person (null for a creation).
When the input is refused (empty field, login already taken...), the action sends
the browser back to THIS page (redirection), and the view displays:
- the values already typed (old(), in the x-field components);
- the faulty fields outlined in red (Bootstrap class "is-invalid");
- under a field refused by the validation, its message;
- the error message of the page, AT THE BOTTOM of the page (errors fragment).
========================================================================= --}}
@extends('layout')
@section('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) }}</h5>
{{-- novalidate: we let the SERVER validate (it is the one that is authoritative); the
browser's checks (required...) are only a convenience, that a
user can always bypass --}}
<form method="post" action="{{ $action }}" novalidate>
@csrf
@unless ($creating)
{{-- the version of the displayed record: the optimistic lock --}}
<input type="hidden" name="version" value="{{ old('version', $person->version) }}">
@endunless
{{-- title, first name, last name: component common to three forms --}}
<x-person-fields :person="$person" />
<h6 class="mt-4 text-muted">{{ __('PERSONS.ACCOUNT_SECTION') }}</h6>
<div class="mb-3">
<x-field name="login" :label="__('PERSONS.LOGIN_LABEL')" :value="$person?->account?->login"
max="30" autocomplete="off"
:help="__($creating ? 'PERSONS.LOGIN_HELP_CREATE' : 'PERSONS.LOGIN_HELP_EDIT')" />
</div>
<div class="mb-4">
{{-- never pre-filled: the password never goes back to the browser --}}
<x-field name="password" type="password" :label="__('PERSONS.PASSWORD_LABEL')" max="72"
autocomplete="new-password"
:help="__($creating ? 'PERSONS.PASSWORD_HELP_CREATE' : 'PERSONS.PASSWORD_HELP_EDIT')" />
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">{{ __('PERSONS.SAVE') }}</button>
<a class="btn btn-secondary" href="{{ $listUrl }}">{{ __('PERSONS.BACK') }}</a>
</div>
</form>
</div>
</div>
</div>
@endsection
|
Creating or editing a doctor or a client.
- Line 24: The [novalidate] attribute disables browser controls ([required], [maxlength]...) when the form is submitted: the server validates the form, and its validation is final. Browser validators are merely a convenience that anyone can bypass; disabling them here allows you to see the server’s messages;
- Lines 26–29: [@unless] (“unless”) is the opposite of [@if]. During a modification, the record version (the optimistic lock) is stored in a hidden field;
- line 32: title, first name, and last name, pre-filled with the person’s values;
- lines 37–44: the username, pre-filled with the account’s username ([$person?->account?->login]: two chained null-safe operators), and the password, which is never pre-filled. Their tooltips differ for creation (“defaults to the last name in lower case”) and for editing (“Leave empty to keep it unchanged”).
11.18.3.5. resources/views/registration.blade.php
| {{-- =========================================================================
resources/views/registration.blade.php - "Create a client account" (public
page, see RegistrationController). Same identity fields as the
ADMIN's form (x-person-fields component), but the login and the
password are required, with confirmation. No captcha HERE: one only gets
to this page after having copied that of the login page
("Create a client account" button).
========================================================================= --}}
@extends('layout')
@section('content')
@php($min = App\Http\Requests\PersonFieldsRequest::PASSWORD_MIN)
<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') }}</h5>
<p class="text-muted small mb-3">{{ __('REGISTRATION.INTRO') }}</p>
<form method="post" action="{{ route('registration') }}" novalidate>
@csrf
<x-person-fields />
<h6 class="mt-4 text-muted">{{ __('PERSONS.ACCOUNT_SECTION') }}</h6>
<div class="mb-3">
<x-field name="login" :label="__('PERSONS.LOGIN_LABEL')" max="30" autocomplete="username" required
:help="__('REGISTRATION.LOGIN_HELP')" />
</div>
<div class="row g-3 mb-3">
<div class="col-sm-6">
<x-field name="password" type="password" :label="__('PERSONS.PASSWORD_LABEL')" max="72"
autocomplete="new-password" required :help="__('REGISTRATION.PASSWORD_HELP', ['min' => $min])" />
</div>
<div class="col-sm-6">
<x-field name="password_confirmation" type="password" :label="__('ACCOUNT.CONFIRMATION_LABEL')"
max="72" autocomplete="new-password" required />
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">{{ __('REGISTRATION.SUBMIT') }}</button>
<a class="btn btn-secondary" href="{{ route('login') }}">{{ __('REGISTRATION.BACK_TO_LOGIN') }}</a>
</div>
</form>
</div>
</div>
</div>
@endsection
|
Creating a patient account: the identity fields (an empty form), followed by the username, password, and password confirmation, which are required. [@php($min = ...)]: the short form of [@php], for a single instruction. The password help text specifies the minimum length: [__('REGISTRATION.PASSWORD_HELP', ['min' => $min])], “At least 6 characters.” No CAPTCHA here: you can only reach this page after entering the CAPTCHA from the login page.
11.18.3.6. resources/views/area.blade.php
| {{-- =========================================================================
resources/views/area.blade.php - "My area": the home page of the patient
(USER role), displayed right after their login.
- two options: [Manage my account] and [View a schedule];
- their upcoming appointment, if they have one, with the means to cancel it.
========================================================================= --}}
@extends('layout')
@section('content')
<h4 class="mb-3">{{ __('AREA.HELLO', ['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="{{ route('account') }}">
<h5 class="mb-1">{{ __('AREA.MANAGE_ACCOUNT') }}</h5>
<span class="text-muted">{{ __('AREA.MANAGE_ACCOUNT_HELP') }}</span>
</a>
</div>
<div class="col-sm-6">
<a class="card p-4 h-100 text-decoration-none option-card" href="{{ route('schedule') }}">
<h5 class="mb-1">{{ __('AREA.VIEW_SCHEDULE') }}</h5>
<span class="text-muted">{{ __('AREA.VIEW_SCHEDULE_HELP') }}</span>
</a>
</div>
</div>
<div class="card p-3">
<h5>{{ __('AREA.MY_APPOINTMENT') }}</h5>
{{-- normally only one (rule "one upcoming appointment at a time"), but the
page can display several. @forelse ... @empty: a loop, and what
is displayed if the list is empty; $loop->last: last element? --}}
@forelse ($appointments as $appointment)
<div @class(['d-flex flex-wrap justify-content-between align-items-center gap-2 py-2',
'border-bottom' => ! $loop->last])>
<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') }}</a>
</div>
</div>
<form method="post" action="{{ route('area.cancel') }}" class="m-0"
data-confirmation="{{ __('SCHEDULE.CANCEL_CONFIRM') }}">
@csrf
<input type="hidden" name="appointmentId" value="{{ $appointment->id }}">
<button type="submit" class="btn btn-outline-danger">{{ __('AREA.CANCEL_APPOINTMENT') }}</button>
</form>
</div>
@empty
<p class="text-muted mb-0">{{ __('AREA.NO_APPOINTMENT') }}</p>
@endforelse
</div>
@endsection
|
“My area,” the patient’s home page: two large cards (“Manage my account,” “View a schedule”), and their upcoming appointment.
- Lines 32–51: [@forelse] (Example 14) is a loop, followed ([@empty]) by what to display if the list is empty. Normally, a patient has at most one upcoming appointment; however, the page is designed to display 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.18.3.7. resources/views/account.blade.php
| {{-- =========================================================================
resources/views/account.blade.php - "Manage my account" (USER role).
Two independent forms on the same page:
1. the modification (identity, login, optional new password);
2. the deletion of the account - which also cancels its appointments.
Both require the CURRENT password (current_password field).
========================================================================= --}}
@extends('layout')
@section('content')
@php($min = App\Http\Requests\PersonFieldsRequest::PASSWORD_MIN)
<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') }}</h5>
<form method="post" action="{{ route('account.edit') }}" novalidate>
@csrf
<input type="hidden" name="version" value="{{ old('version', $patient->version) }}">
<x-person-fields :person="$patient" />
<h6 class="mt-4 text-muted">{{ __('PERSONS.ACCOUNT_SECTION') }}</h6>
<div class="mb-3">
<x-field name="login" :label="__('PERSONS.LOGIN_LABEL')" :value="$patient->account->login"
max="30" autocomplete="username" required />
</div>
<div class="row g-3 mb-3">
<div class="col-sm-6">
<x-field name="password" type="password" :label="__('MY_ACCOUNT.NEW_PASSWORD_LABEL')" max="72"
autocomplete="new-password" :help="__('PERSONS.PASSWORD_HELP_EDIT')" />
</div>
<div class="col-sm-6">
<x-field name="password_confirmation" type="password" :label="__('ACCOUNT.CONFIRMATION_LABEL')"
max="72" autocomplete="new-password" />
</div>
</div>
<div class="mb-4">
<x-field name="current_password" type="password" :label="__('MY_ACCOUNT.CURRENT_PASSWORD_LABEL')"
max="72" autocomplete="current-password" required
:help="__('MY_ACCOUNT.CURRENT_PASSWORD_HELP')" />
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">{{ __('PERSONS.SAVE') }}</button>
<a class="btn btn-secondary" href="{{ route('area') }}">{{ __('MY_ACCOUNT.BACK') }}</a>
</div>
</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') }}</h5>
<p class="text-muted small">{{ __('MY_ACCOUNT.DELETE_HELP') }}</p>
<form method="post" action="{{ route('account.delete') }}" class="d-flex flex-wrap gap-2 align-items-end"
data-confirmation="{{ __('MY_ACCOUNT.DELETE_CONFIRM') }}">
@csrf
<div class="flex-grow-1">
<label class="form-label" for="delete_password">{{ __('MY_ACCOUNT.CURRENT_PASSWORD_LABEL') }}</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') }}</button>
</form>
</div>
</div>
</div>
@endsection
|
“Manage My Account”: two independent forms on the same page.
- lines 19–53: editing: the version (optimistic lock), identity fields, username, new password and its confirmation (optional), and the current password (required);
- Lines 62–71: Account deletion, with its own current password field and confirmation. The field has the same name as the one in the first form ([current_password]: only one form is submitted at a time), but not the same ID: two elements on a page cannot have the same ID, as indicated by the [for] attribute of a label.
11.18.3.8. resources/views/error.blade.php
| {{-- =========================================================================
resources/views/error.blade.php - the error page (400, 403, 404, 419,
500...), built by bootstrap/app.php (withExceptions).
========================================================================= --}}
@extends('layout')
@section('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) }}</p>
<div><a class="btn btn-primary" href="{{ route('home') }}">{{ __('ERROR_PAGE.BACK') }}</a></div>
</div>
</div>
</div>
@endsection
|
The error page, generated by [bootstrap/app.php]: 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.19. Static files
The [public] folder is the site root: 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.19.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 resources/views/fragments/errors.blade.php).
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.19.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: resources/views/fragments/confirmation.blade.php).
// 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–21: 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 upon confirmation 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. The 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–50: 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]')] identifies 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.