Version
- 1.0 (01/2023)
Statut
Contexte du projet
Pour valider mon titre professionnel de Développeur web et web mobile en 2023, j’ai dû réaliser un projet d’examen avec un sujet libre. Ayant cette idée en tête depuis longtemps, j’ai choisi de créer un site personnel dédié à la lecture de mes fictions, qu’elles soient linéaires ou interactives. J’ai nommé ce site Xénophictions, un jeu de mots faisant référence à mon pseudonyme d’écriture et à la nature de mes textes.
L’idée de créer un “livre dont vous êtes le héros” en l’adaptant au web, avec une interface réactive, m’a énormément attirée. Fini la recherche fastidieuse de la bonne page pour continuer l’histoire : un simple clic sur un bouton suffit !
En plus d’être un sujet original, ce projet a soulevé plusieurs défis techniques intéressants.
Gallerie








Fonctionnalités
Pour les visiteurs :
- Liste d’œuvres : Présentation des œuvres sous forme de liste avec option de tri par genre.
- Sommaire des œuvres : Affichage du sommaire d’une œuvre pour une meilleure navigation.
- Lecture limitée : Accès à la lecture des œuvres, linéaires ou interactives, avec restriction au prologue pour les non-inscrits.
- Inscription et connexion : Possibilité de s’inscrire et de se connecter pour débloquer des fonctionnalités supplémentaires.
Pour les inscrits :
- Lecture complète : Accès à l’intégralité des œuvres, avec un sommaire qui se débloque progressivement. Retour en arrière interdit dans les œuvres interactives pour éviter de changer de choix.
- Notes et commentaires : Option de noter et commenter les œuvres.
- Sauvegarde de progression : Sauvegarde automatique de la progression dans la lecture.
- Historique de lecture : Accès à l’historique des œuvres déjà lues (en partie implémenté).
Pour l’administrateur :
- Gestion des œuvres : Contrôle complet des œuvres, y compris les informations détaillées (titre, synopsis, genre, type), les chapitres, et les sections.
- Gestion des thèmes : Ajout, modification et suppression des thèmes et de leurs sous-catégories.
- Gestion des utilisateurs : Modification et suppression des comptes utilisateurs.
- Modération des commentaires : Gestion des commentaires, avec la possibilité de les valider ou les supprimer.
Stack technique
Le projet reprend l’ensemble des compétences acquises durant ma formation. J’ai donc utilisé Bootstrap pour la mise en page et le design, PHP pour la gestion côté serveur et MySQL pour la base de données.
Ce projet sera refait un jour, et j’opterais sans doute pour une application complète intégrant notamment :
- PostgreSQL pour la base de données, car il est plus performant que MySQL sur des requêtes complexes, ce qui serait particulièrement pertinent pour ce type de site.
- Java comme langage serveur, accompagné de son framework le plus populaire, Spring, afin de bénéficier de performances supérieures à celles de PHP, qui me semble plus adapté pour des projets complexes de cette nature.
- Angular pour le front-end, qui s’intègre très bien à l’écosystème Java et offre une structure robuste pour des applications web de grande envergure.
- Tailwind CSS pour le design, qui est plus léger et flexible que Bootstrap.
Organisation du code
L’organisation suit le modèle MVC (Modèle-Vue-Contrôleur) pour garantir une séparation claire des responsabilités et une maintenabilité optimale du code. La structure du projet est la suivante :
Nom du namespace | Responsabilité |
---|---|
Config | Centralise les configurations du site. |
Controllers | Comporte la logique du site. |
Views | Regroupe les vues du site. |
Models | Définit les entités et objets métiers utilisés. |
Public | Contient les ressources statiques (images, CSS, JavaScript) dans un sous dossier Assets. |
Helpers | Contient des classes et fonctions utilitaires pour les sessions flash, le retraitement d'images, et la connexion à la BDD. |
Structure de la base de données

Ca fait pas mal d’entités tout ça ! Si certaines parlent d’elles mêmes comme users
, notes
, et comments
, d’autres méritent quelques explications.
-
themes
: Les thèmes sont des catégories générales qui regroupent les histoires. Par exemple, “Fantasy” ou “Science-fiction”. -
categories
: Les catégories sont des sous-thèmes plus spécifiques. Par exemple, “High fantasy” ou “Space opera”. -
stories
: Les histoires sont les fictions linéaires ou interactives (attributtype
). La vocation de cette table est de stocker les informations de base de l’histoire. -
chapters
: Les chapitres sont les parties d’une histoire. Ils regroupent des sections. -
sections
: Les sections sont les parties d’un chapitre. Elles contiennent le contenu de l’histoire à proprement parlé. -
sections_sections
: Il s’agit de la table de relation entre les sections, pour gérer les liens entre elles pour connaitre l’ordre de lecture, sachant que dans le cadre d’une histoire interactive, une section peut mener à plusieurs autres sections. -
characters
: Les personnages sont les acteurs de l’histoire. Ils peuvent être liés à une ou plusieurs sections (cette table n’a pas eu l’occasion d’être exploitée dans le projet). -
saves
: Les sauvegardes permettent de conserver la progression de lecture d’un utilisateur. N’étant pas sûr de moi à l’origine sur la manière de gérer la progression, j’ai créer une table avec un id propre pour chaque sauvegarde, mais il aurait probablement été plus judicieux de lier directement les sections aux utilisateurs. Les cardinalités sont aussi à revoir.
DROP DATABASE IF EXISTS `xenophictions`;
CREATE DATABASE IF NOT EXISTS `xenophictions` CHARACTER SET 'utf8';
USE `xenophictions`;
---------------------------------------------------
-- Créer la table des utilisateurs
---------------------------------------------------
CREATE TABLE `users`(
`id_users` INT AUTO_INCREMENT,
`username` VARCHAR(50) NOT NULL,
`email` VARCHAR(150) NOT NULL,
`birthdate` DATE NOT NULL,
`password` VARCHAR(255) NOT NULL,
`registered_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`validated_at` DATETIME,
`connected_at` DATETIME,
`updated_at` DATETIME,
`deleted_at` DATETIME,
`preferences` VARCHAR(500) ,
`newsletter` BOOLEAN NOT NULL DEFAULT false,
`admin` BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY(`id_users`),
UNIQUE(`username`),
UNIQUE(`email`)
)ENGINE=InnoDB;
---------------------------------------------------
-- Créer la table des histoires
---------------------------------------------------
CREATE TABLE `stories`(
`id_stories` INT AUTO_INCREMENT,
`title` VARCHAR(150) NOT NULL,
`author` VARCHAR(70) NOT NULL DEFAULT 'Xénophée',
`type` TINYINT NOT NULL,
`synopsis` VARCHAR(1500) NOT NULL,
`registered_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`published_at` DATETIME,
`updated_at` DATETIME,
`deleted_at` DATETIME,
PRIMARY KEY(`id_stories`)
)ENGINE=InnoDB;
---------------------------------------------------
-- Créer la table des thèmes
---------------------------------------------------
CREATE TABLE `themes`(
`id_themes` INT AUTO_INCREMENT,
`name` VARCHAR(70) NOT NULL,
`description` VARCHAR(300),
PRIMARY KEY(`id_themes`)
)ENGINE=InnoDB;
---------------------------------------------------
-- Créer la table des catégories
---------------------------------------------------
CREATE TABLE `categories`(
`id_categories` INT AUTO_INCREMENT,
`name` VARCHAR(70) NOT NULL,
`description` VARCHAR(300),
PRIMARY KEY(`id_categories`)
)ENGINE=InnoDB;
---------------------------------------------------
-- Créer la table des commentaires
---------------------------------------------------
CREATE TABLE `comments`(
`id_comments` INT AUTO_INCREMENT,
`comment` TEXT NOT NULL,
`sent_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`published_at` DATETIME,
`updated_at` DATETIME,
`deleted_at` DATETIME,
`id_users` INT,
`id_stories` INT NOT NULL,
PRIMARY KEY(`id_comments`),
CONSTRAINT `fk_comments_users` FOREIGN KEY(`id_users`) REFERENCES `users`(`id_users`) ON DELETE SET NULL,
CONSTRAINT `fk_comments_stories` FOREIGN KEY(`id_stories`) REFERENCES `stories`(`id_stories`) ON DELETE CASCADE
)ENGINE=InnoDB;
---------------------------------------------------
-- Créer la table des notes
---------------------------------------------------
CREATE TABLE `notes`(
`id_notes` INT AUTO_INCREMENT,
`note` TINYINT,
`noted_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME,
`deleted_at` DATETIME,
`id_users` INT,
`id_stories` INT NOT NULL,
PRIMARY KEY(`id_notes`),
CONSTRAINT `fk_notes_users` FOREIGN KEY(`id_users`) REFERENCES `users`(`id_users`) ON DELETE SET NULL,
CONSTRAINT `fk_notes_stories` FOREIGN KEY(`id_stories`) REFERENCES `stories`(`id_stories`) ON DELETE CASCADE
)ENGINE=InnoDB;
---------------------------------------------------
-- Créer la table des chapitres
---------------------------------------------------
CREATE TABLE `chapters`(
`id_chapters` INT AUTO_INCREMENT,
`title` VARCHAR(150) NOT NULL,
`index` TINYINT NOT NULL,
`summary` TEXT,
`registered_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`published_at` DATETIME,
`updated_at` DATETIME,
`deleted_at` DATETIME,
`id_stories` INT NOT NULL,
PRIMARY KEY(`id_chapters`),
CONSTRAINT `fk_chapters` FOREIGN KEY(`id_stories`) REFERENCES `stories`(`id_stories`) ON DELETE CASCADE
)ENGINE=InnoDB;
---------------------------------------------------
-- Créer la table des sections
---------------------------------------------------
CREATE TABLE `sections`(
`id_sections` INT AUTO_INCREMENT,
`title` VARCHAR(150),
`description` VARCHAR(250),
`content` TEXT NOT NULL,
`registered_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`published_at` DATETIME,
`updated_at` DATETIME,
`deleted_at` DATETIME,
PRIMARY KEY(`id_sections`)
)ENGINE=InnoDB;
---------------------------------------------------
-- Créer la table des personnages
---------------------------------------------------
CREATE TABLE `characters`(
`id_characters` INT AUTO_INCREMENT,
`name` VARCHAR(100) NOT NULL,
`role` VARCHAR(100) ,
`description` TEXT,
PRIMARY KEY(`id_characters`)
)ENGINE=InnoDB;
---------------------------------------------------
-- Créer la table des sauvegardes
---------------------------------------------------
CREATE TABLE `saves`(
`id_sections` INT,
`id_users` INT,
`id_saves` INT AUTO_INCREMENT,
`read_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(`id_saves`, `id_sections`, `id_users`),
FOREIGN KEY(`id_sections`) REFERENCES `sections`(`id_sections`) ON DELETE CASCADE,
FOREIGN KEY(`id_users`) REFERENCES `users`(`id_users`) ON DELETE CASCADE
);
---------------------------------------------------
-- Créer la table des relations entre les thèmes et les catégories
---------------------------------------------------
CREATE TABLE `themes_categories`(
`id_themes` INT,
`id_categories` INT,
PRIMARY KEY(`id_themes`, `id_categories`),
FOREIGN KEY(`id_themes`) REFERENCES `themes`(`id_themes`) ON DELETE CASCADE,
FOREIGN KEY(`id_categories`) REFERENCES `categories`(`id_categories`) ON DELETE CASCADE
)ENGINE=InnoDB;
---------------------------------------------------
-- Créer la table des relations entre les histoires et les catégories
---------------------------------------------------
CREATE TABLE `stories_categories`(
`id_stories` INT,
`id_categories` INT,
PRIMARY KEY(`id_stories`, `id_categories`),
FOREIGN KEY(`id_stories`) REFERENCES `stories`(`id_stories`) ON DELETE CASCADE,
FOREIGN KEY(`id_categories`) REFERENCES `categories`(`id_categories`) ON DELETE CASCADE
)ENGINE=InnoDB;
---------------------------------------------------
-- Créer la table des relations entre les chapitres et les sections
---------------------------------------------------
CREATE TABLE `chapters_sections`(
`id_chapters` INT,
`id_sections` INT,
PRIMARY KEY(`id_chapters`, `id_sections`),
FOREIGN KEY(`id_chapters`) REFERENCES `chapters`(`id_chapters`) ON DELETE CASCADE,
FOREIGN KEY(`id_sections`) REFERENCES `sections`(`id_sections`) ON DELETE CASCADE
)ENGINE=InnoDB;
---------------------------------------------------
-- Créer la table des relations entre sections
---------------------------------------------------
CREATE TABLE `sections_sections`(
`id_sections_parent` INT NULL,
`id_sections_child` INT NULL,
-- PRIMARY KEY(`id_sections_parent`, `id_sections_child`),
UNIQUE KEY `unique_section_sections` (`id_sections_parent`, `id_sections_child`),
FOREIGN KEY(`id_sections_parent`) REFERENCES `sections`(`id_sections`) ON DELETE CASCADE,
FOREIGN KEY(`id_sections_child`) REFERENCES `sections`(`id_sections`) ON DELETE CASCADE
)ENGINE=InnoDB;
---------------------------------------------------
-- Créer la table des relations entre les sections et les personnages
---------------------------------------------------
CREATE TABLE `sections_characters`(
`id_sections` INT,
`id_characters` INT,
PRIMARY KEY(`id_sections`, `id_characters`),
FOREIGN KEY(`id_sections`) REFERENCES `sections`(`id_sections`),
FOREIGN KEY(`id_characters`) REFERENCES `characters`(`id_characters`)
)ENGINE=InnoDB;
Développement
Avec une base de données comme celle ci, il a fallu que je recours à des requêtes SQL complexes pour récupérer les données. Par exemple, en page d’accueil, il y a une section qui affiche les histoires les plus populaires selon la moyenne des notes attribuées par les utilisateurs. Pour commencer, il faut déjà définir une connexion à la base de données, ce que j’ai fais grâce à une classe Database
qui centralise les informations de connexion et qui a la particularité d’être un singleton pour éviter les connexions multiples.
Au sein de cette classe, j’en profite pour configurer quelques options de PDO (interface d’accès aux bases de données en PHP), comme le mode d’erreur et le mode de récupération des données par défaut que je veux sous forme de tableau d’objets puisque nous sommes sur de la programmation orientée objet.
class Database
{
private static $_pdo;
public static function getInstance()
{
if (is_null(self::$_pdo)) {
self::$_pdo = new PDO(DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
self::$_pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
self::$_pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_OBJ);
}
return self::$_pdo;
}
}
Ensuite, pour récupérer ce qui m’intéresse, j’ai créé une classe Story
qui contient des méthodes statiques destinées à communiquer avec la base de données. Ainsi, pour obtenir les trois histoires les plus populaires, j’ai écrit la méthode suivante :
public static function getMostPopular(): array
{
$pdo = Database::getInstance();
$sql = 'SELECT `stories`.*,
AVG(`note`) AS `note`,
GROUP_CONCAT(DISTINCT `categories`.`name` SEPARATOR \', \') AS `categories`,
MAX(`themes`.`name`) AS `theme_name`,
MAX(`themes`.`id_themes`) AS `id_theme`
FROM `stories`
LEFT JOIN `stories_categories` ON `stories`.`id_stories` = `stories_categories`.`id_stories`
LEFT JOIN `categories` ON `stories_categories`.`id_categories` = `categories`.`id_categories`
LEFT JOIN `themes_categories` ON `stories_categories`.`id_categories` = `themes_categories`.`id_categories`
LEFT JOIN `themes` ON `themes_categories`.`id_themes` = `themes`.`id_themes`
LEFT JOIN `notes` ON `notes`.`id_stories` = `stories`.`id_stories`
GROUP BY `stories`.`id_stories`
ORDER BY AVG(`note`) DESC
LIMIT 3';
$sth = $pdo->prepare($sql);
if ($sth->execute()) {
return ($sth->fetchAll());
} else {
return [];
}
}
On commence par récupérer l’instance de la base de données avec Database::getInstance()
, puis on écrit la requête SQL. En l’occurence, celle ci réalise dans l’ordre :
stories
.* : On récupère toutes les colonnes de la tablestories
.AVG(note) AS note
: On calcule la moyenne des notes attribuées aux histoires et on l’aliasse ennote
.GROUP_CONCAT(DISTINCT categories.name SEPARATOR ', ') AS categories
: On concatène les noms des catégories associées à une histoire.DISTINCT
garantit qu’une même catégorie n’est pas répétée.MAX(themes.name) AS theme_name
: On récupère le nom du thème principal associé à chaque histoire. La fonction MAX est utilisée ici car, pour chaque histoire, il peut n’y avoir qu’un seul thème principal dans cette configuration.MAX(themes.id_themes) AS id_theme
: On récupère l’id du thème de manière similaire à la colonne précédente.- Une jointure entre les tables
stories
etstories_categories
pour récupérer les catégories associées aux histoires. - Une jointure entre
stories_categories
etcategories
pour récupérer les noms des catégories. - Une jointure entre
stories_categories
etthemes_categories
pour récupérer le thème associé aux catégories et donc aux histoires. - Une jointure entre
themes_categories
etthemes
pour récupérer les noms du thème. - Une jointure entre
stories
etnotes
pour récupérer les notes attribuées aux histoires. - Un regroupement des résultats par
id_stories
pour éviter les doublons. - Un tri par la moyenne des notes attribuées, de manière décroissante.
- Une limitation à 3 résultats.
Enfin, on prépare la requête avec $pdo->prepare($sql)
, on l’exécute avec $sth->execute()
, et on retourne le résultat avec $sth->fetchAll()
.
Note : Pour cette méthode, il n’y avait pas besoin de passer par une requête préparée puisqu’aucune variable n’a été utilisée. La requête n’étant pas dynamique, il n’y a pas de risque d’injections SQL et un simple $pdo->query($sql)
aurait suffi.