Aller au contenu principal

4) Sauvegarder les données

Sauvegarder les données avec SQLite

Schéma Dps/Mana

Objectifs de la séance

  • Comprendre pourquoi les variables PHP disparaissent à chaque requête et comment y remédier.
  • Créer et interroger une base de données SQLite depuis PHP avec PDO.
  • Écrire les cinq opérations fondamentales en SQL : créer une table, insérer, lire, modifier, supprimer.
  • Protéger ses requêtes contre les injections SQL avec les requêtes préparées.
  • Encapsuler toute la logique d'accès aux données dans une classe Database dédiée.
  • Vérifier la persistance des données entre deux rechargements de page.

Notions théoriques

Le problème de la mémoire courte de PHP

PHP fonctionne à la requête. Quand un navigateur demande une page, PHP démarre, exécute le code, envoie le HTML, puis s'arrête. Toutes les variables créées pendant l'exécution sont détruites. À la prochaine requête, PHP repart de zéro.

C'est pour cette raison que si l'on crée un personnage dans index.php et qu'on recharge la page, le personnage a disparu. Il n'existe aucun moyen de "garder en vie" une variable PHP d'une requête à l'autre.

Pour persister des données entre les requêtes, il faut les stocker ailleurs : dans un fichier, dans une base de données, ou dans une session (côté serveur). Dans ce projet, on choisit une base de données SQLite.

Qu'est-ce que SQLite ?

SQLite est un moteur de base de données relationnelle. Contrairement à MySQL ou PostgreSQL, il ne nécessite pas de serveur séparé : la base entière est stockée dans un seul fichier sur le disque (par exemple jeu.db). C'est le choix idéal pour des projets de petite taille, des prototypes, et des applications embarquées.

Une base de données relationnelle organise les données en tables. Une table ressemble à un tableau : elle a des colonnes (les champs) et des lignes (les enregistrements).

Le langage SQL

SQL (Structured Query Language) est le langage utilisé pour interagir avec une base de données. Les cinq instructions à connaître pour cette séance :

InstructionRôle
CREATE TABLECréer une nouvelle table
INSERT INTOAjouter une ligne dans une table
SELECTLire des données
UPDATEModifier des données existantes
DELETESupprimer des données

PDO : l'interface PHP vers les bases de données

PDO (PHP Data Objects) est une extension PHP qui fournit une interface uniforme pour communiquer avec différents moteurs de bases de données. Que l'on utilise SQLite, MySQL ou PostgreSQL, le code PHP reste quasiment identique.

Pour ouvrir une connexion SQLite avec PDO :

$pdo = new PDO("sqlite:jeu.db");

PHP crée le fichier jeu.db s'il n'existe pas encore. Le fichier sera créé dans le même dossier que le script PHP en cours d'exécution.

Il est recommandé d'activer le mode exception dès l'ouverture, pour que les erreurs SQL provoquent une exception PHP visible plutôt que d'échouer silencieusement :

$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

Exécuter une requête simple

Pour une requête qui ne renvoie pas de résultats (création de table, suppression...) :

$pdo->exec("CREATE TABLE IF NOT EXISTS test (id INTEGER PRIMARY KEY, nom TEXT)");

Les requêtes préparées

Pour les requêtes qui insèrent ou modifient des données, on ne construit jamais la requête par concaténation de chaînes.

Voici pourquoi.

Si l'on écrivait :

$nom = $_POST['nom']; // valeur venant de l'utilisateur
$pdo->exec("INSERT INTO personnage (nom) VALUES ('" . $nom . "')");

Un utilisateur malveillant pourrait saisir comme nom : '); DROP TABLE personnage; -- et la requête construite deviendrait :

INSERT INTO personnage (nom) VALUES (''); DROP TABLE personnage; --')
attention

Ce type d'attaque s'appelle une injection SQL. Elle peut détruire des données, les exfiltrer, ou donner un accès non autorisé à la base.

astuce

La solution : les requêtes préparées. On écrit la requête avec des paramètres nommés (préfixés par :) à la place des valeurs variables, puis on fournit les valeurs séparément. PDO se charge de les insérer de manière sécurisée.

$stmt = $pdo->prepare("INSERT INTO personnage (nom, vie) VALUES (:nom, :vie)");
$stmt->execute([':nom' => "Lara", ':vie' => 100]);

Lire des résultats

Après un SELECT, on récupère les lignes avec fetchAll :

$stmt = $pdo->query("SELECT * FROM personnage");
$resultats = $stmt->fetchAll(PDO::FETCH_ASSOC);

foreach ($resultats as $ligne) {
print $ligne['nom'] . " a " . $ligne['vie'] . " PV";
}

PDO::FETCH_ASSOC renvoie chaque ligne sous forme de tableau associatif, avec les noms de colonnes comme clés. C'est bien plus lisible que $ligne[0], $ligne[1]...

Les tableaux associatifs en PHP

Jusqu'ici on a vu des tableaux indexés numériquement : $tab[0], $tab[1]... Un tableau associatif utilise des clés textuelles à la place des indices numériques :

$personnage = [
'nom' => "Lara",
'vie' => 100,
'type' => "guerrier"
];

print $personnage['nom']; // Lara
print $personnage['vie']; // 100

C'est le format dans lequel PDO retourne les lignes de la base de données.


Test de mémorisation/compréhension


Pourquoi un personnage créé en PHP disparaît-il systématiquement lorsqu'on recharge la page ?


Quelle est la particularité fondamentale de SQLite par rapport à MySQL ou PostgreSQL ?


Quels sont les 3 contextes d'utilisation étant le choix idéal pour SQLite ?


Que représentent les « colonnes » et les « lignes » d'une table d'une base de données relationnelle ?


Quelle instruction SQL est utilisée pour modifier des données qui existent déjà dans la table ?


Que se passe-t-il concrètement si le fichier `jeu.db` n'existe pas encore au moment d'exécuter `new PDO("sqlite:jeu.db")` ?


Pour quelle raison principale il est recommandé d'activer le mode `PDO::ERRMODE_EXCEPTION` dès l'ouverture de la connexion ?


Quelle méthode de l'objet `$pdo` est spécifiquement utilisée pour exécuter une requête qui ne renvoie pas de résultats (comme `CREATE TABLE`) ?


Dans l'exemple d'injection SQL présenté, quelle erreur fondamentale de conception permet l'attaque ?


Comment les variables sont-elles remplacées dans la chaîne de la requête lors de l'utilisation des requêtes préparées ?


Pour exécuter une requête de type `SELECT`, quelle méthode est utilisée sur l'objet PDO dans le cours ?


Que permet de faire la méthode `fetchAll()` appelée sur le résultat d'un `SELECT` ?


Quel est le rôle exact de la constante `PDO::FETCH_ASSOC` passée en paramètre à `fetchAll()` ?


Pourquoi l'utilisation de `$ligne['nom']` est beaucoup plus lisible que `$ligne[0]` ?


Quelle est la différence fondamentale entre un tableau « indexé numériquement » et un « tableau associatif » ?



TP pour réfléchir et résoudre des problèmes

Persister les personnages avec SQLite

Ce TP reprend le projet issu de la séance 3. Le dossier contient : Personnage.php, Guerrier.php, Mage.php et index.php.

À la fin de cette séance, le projet contiendra un fichier supplémentaire :

FichierStatutDescription
Personnage.phpinchangéclasse abstraite parente
Guerrier.phpinchangéclasse fille guerrier
Mage.phpinchangéclasse fille mage
Database.phpnouveauclasse encapsulant toute la logique SQLite
index.phpmodifiéutilise Database pour sauvegarder et charger les personnages
jeu.dbgénéréfichier de base de données SQLite (créé automatiquement)

Étape 1 - Vérifier que l'extension SQLite est disponible

Avant d'écrire du code, il faut vérifier que PHP dispose bien de l'extension PDO SQLite.

Créer un fichier temporaire check.php :

<?php
print "<pre>";
print_r(PDO::getAvailableDrivers());
print "</pre>";

Ouvrir http://localhost:8000/check.php dans le navigateur. La liste affichée doit contenir sqlite. Si ce n'est pas le cas, l'extension n'est pas activée sur cette installation de PHP.

Sur la plupart des systèmes, SQLite est inclus par défaut avec PHP. Si sqlite n'apparaît pas, il faut décommenter la ligne extension=pdo_sqlite dans le fichier php.ini (dont le chemin est indiqué par phpinfo()).

Ce fichier check.php peut être supprimé une fois la vérification faite.


Bonne pratique - Vérifier les prérequis

Avant de commencer à développer une fonctionnalité qui dépend d'une extension ou d'un service externe, toujours vérifier qu'elle est disponible dans l'environnement. Diagnostiquer un problème de configuration au début est infiniment plus rapide qu'au milieu d'un développement.

Étape 2 - Créer la classe Database et ouvrir la connexion

Créer un fichier Database.php.

La classe Database a une propriété privée $pdo qui contiendra l'objet de connexion.

Le constructeur doit :

  1. Instancier PDO avec le pilote SQLite et le nom de fichier jeu.db (new PDO("sqlite:jeu.db")).
  2. Activer le mode exception pour que les erreurs SQL soient visibles (PDO::ATTR_ERRMODE avec la valeur PDO::ERRMODE_EXCEPTION).
  3. Appeler une méthode privée creerTables() (qu'on écrira à l'étape suivante).

Ajouter une méthode publique getPdo() qui renvoie $this->pdo. Elle servira en dernier recours si l'on a besoin d'accéder directement à l'objet PDO depuis l'extérieur.

Tester dans index.php que la connexion fonctionne sans erreur :

require_once "Database.php";
$db = new Database();
print "<p>Connexion établie.</p>";

Si une erreur s'affiche, elle vient généralement d'un problème de droits d'écriture dans le dossier du projet (PHP doit pouvoir créer le fichier jeu.db).


Bonne pratique - Activer le mode exception

Il est conseillé d'activer PDO::ERRMODE_EXCEPTION dès l'ouverture de la connexion. Sans cette configuration, PDO gère les erreurs SQL en silence : une requête échouée retourne simplement false sans aucun message. Avec le mode exception, PHP lève une exception qui s'affiche clairement et indique précisément quelle requête a échoué et pourquoi.

Étape 3 - Créer la table des personnages

Compléter la méthode creerTables() dans Database.php.

Elle doit exécuter une requête SQL CREATE TABLE IF NOT EXISTS pour créer la table personnage avec les colonnes suivantes :

ColonneType SQLContrainteDescription
idINTEGERPRIMARY KEY AUTOINCREMENTidentifiant unique auto-incrémenté
nomTEXTNOT NULLnom du personnage
typeTEXTNOT NULL"guerrier" ou "mage"
vieINTEGERNOT NULLpoints de vie courants
dpsINTEGERNOT NULLpuissance d'attaque (frappe pour un guerrier, sort pour un mage)
xINTEGERDEFAULT 0coordonnée horizontale
yINTEGERDEFAULT 0coordonnée verticale
manaINTEGERDEFAULT 0ressource défensive (armure pour un guerrier, mana pour un mage)
Une seule colonne pour deux concepts

En PHP, $dps et $mana sont des propriétés de Personnage héritées par Guerrier et Mage. La base de données n'a pas besoin d'en savoir plus : la colonne dps stocke la puissance d'attaque quel que soit le type, et la colonne mana stocke la ressource défensive quel que soit le type. La distinction "armure vs mana" ou "frappe vs sort" est une affaire de PHP — la colonne type suffit pour retrouver le contexte à la lecture. Il n'y a donc pas besoin de colonnes séparées armure, mana_max ou puissance_sort.

Utiliser $this->pdo->exec(...) pour exécuter cette requête.

Après l'avoir écrite, recharger index.php. PHP ne doit pas afficher d'erreur. Pour vérifier que la table a bien été créée, on peut inspecter le fichier jeu.db avec un outil comme DB Browser for SQLite (téléchargeable sur https://sqlitebrowser.org).


Bonne pratique - Utiliser IF NOT EXISTS

Il est conseillé de toujours utiliser CREATE TABLE IF NOT EXISTS plutôt que CREATE TABLE. Le constructeur de Database sera appelé à chaque requête HTTP. Sans IF NOT EXISTS, PHP tenterait de recréer la table à chaque chargement de page et échouerait dès la deuxième fois.

Étape 4 - Insérer un personnage dans la base

Ajouter dans la classe Database une méthode publique sauvegarderPersonnage($personnage).

Elle doit insérer une ligne dans la table personnage avec toutes les informations du personnage passé en paramètre.

Grâce à l'abstraction PHP, l'insertion est identique pour un guerrier et un mage : getMana() renvoie l'armure d'un guerrier comme la mana d'un mage, et getDps() renvoie la puissance d'attaque dans les deux cas. Il n'y a pas de branchement selon le type.

Voici l'algorithme à implémenter :

  1. Déterminer le type du personnage en utilisant instanceof : "guerrier" ou "mage".
  2. Préparer la requête INSERT INTO avec des paramètres nommés (:nom, :type, etc.).
  3. Exécuter la requête avec les valeurs correspondantes, en appelant directement $personnage->getMana() et $personnage->getDps().

Tester dans index.php : créer deux personnages, les sauvegarder, puis vérifier dans DB Browser (ou en interrogeant la base directement) que les lignes sont bien présentes.

Ne pas oublier d'ajouter require_once "Guerrier.php" et require_once "Mage.php" dans Database.php, car la méthode utilise instanceof Guerrier et instanceof Mage.


Bonne pratique - Utiliser des requêtes préparées

Il est conseillé de toujours utiliser des requêtes préparées pour insérer des données. La méthode prepare() + execute() est la seule manière correcte de passer des valeurs variables dans une requête SQL. Ne jamais écrire "INSERT INTO personnage (nom) VALUES ('" . $nom . "')" : cette forme est vulnérable aux injections SQL, quelle que soit la source des données.

Étape 5 - Lire les personnages depuis la base

Ajouter dans la classe Database une méthode publique chargerPersonnages().

Elle doit :

  1. Exécuter un SELECT * FROM personnage avec $this->pdo->query(...).
  2. Récupérer toutes les lignes avec fetchAll(PDO::FETCH_ASSOC).
  3. Pour chaque ligne, créer l'objet PHP correspondant (Guerrier ou Mage) en fonction de la valeur de la colonne type.
  4. Ajouter chaque objet dans un tableau $personnages.
  5. Renvoyer ce tableau avec return.

Pour créer un objet depuis une ligne de base de données (identique pour Guerrier et Mage) :

$p = new Guerrier(
$ligne['nom'],
$ligne['vie'],
$ligne['dps'],
$ligne['mana'], // armure du guerrier, stockée dans la colonne mana
$ligne['x'],
$ligne['y']
);

Modifier index.php pour tester : commenter les lignes d'insertion, et à la place, charger les personnages depuis la base et les afficher. Recharger la page plusieurs fois et vérifier que les mêmes personnages s'affichent à chaque fois (persistance).


Bonne pratique - Utiliser PDO::FETCH_ASSOC

PDO::FETCH_ASSOC est préférable à PDO::FETCH_NUM (indices numériques) ou au mode par défaut. Avec les noms de colonnes comme clés, le code est lisible et résiste aux changements d'ordre des colonnes dans la table. $ligne['nom'] est toujours correct, même si on ajoute une colonne avant nom dans la table demain.

Étape 6 - Récupérer l'identifiant après une insertion

Lors de la sauvegarde d'un personnage, SQLite attribue automatiquement un identifiant id. Cet identifiant sera indispensable à la séance 5 pour mettre à jour un personnage précis après un déplacement ou une attaque.

PHP fournit une méthode PDO pour récupérer le dernier identifiant inséré : lastInsertId().

Modifier la méthode sauvegarderPersonnage pour qu'elle renvoie cet identifiant après l'insertion.

Modifier également la classe Personnage pour lui ajouter une propriété privée $id, avec ses accesseurs getId() et setId($id). L'identifiant est null tant que le personnage n'a pas été sauvegardé.

Après avoir sauvegardé un personnage, appeler $personnage->setId(...) pour lui attribuer l'identifiant retourné par sauvegarderPersonnage.

Enfin, modifier chargerPersonnages() pour qu'elle appelle $p->setId($ligne['id']) sur chaque personnage chargé depuis la base.


Bonne pratique - Utiliser lastInsertId()

lastInsertId() renvoie une chaîne de caractères. La conversion explicite avec intval() en fait un entier, ce qui est cohérent avec le type INTEGER de la colonne en base. Préférer les types cohérents entre la base et le code évite des comparaisons inattendues ("1" == 1 est vrai en PHP, mais "1" === 1 est faux).

Étape 7 - Mettre à jour un personnage existant

Après un combat ou un déplacement, les données en mémoire (les objets PHP) diffèrent de ce qui est stocké en base. Il faut les synchroniser.

Ajouter dans Database.php une méthode mettreAJourPersonnage($id, $personnage).

Elle doit exécuter un UPDATE qui modifie les colonnes qui peuvent changer au cours du jeu : vie, x, y. Les colonnes nom, type, dps et mana sont fixes une fois le personnage créé.

Tester dans index.php :

  1. Vider la base (voir étape suivante) et créer deux personnages frais.
  2. Sauvegarder les deux et mémoriser leurs identifiants.
  3. Faire attaquer le guerrier sur le mage.
  4. Mettre à jour le mage en base.
  5. Recharger les personnages depuis la base et vérifier que la vie du mage a bien diminué.

Bonne pratique - Mettre à jour uniquement les colonnes nécessaires

Il est conseillé de ne mettre à jour, en base de données, que les colonnes susceptibles de changer. Éviter un UPDATE global qui réécrit toutes les colonnes, même celles qu'on n'a pas modifiées. C'est plus efficace et cela évite d'écraser accidentellement des données récentes si plusieurs requêtes s'exécutent en parallèle.

Étape 8 - Supprimer tous les personnages

Pour recommencer une partie à zéro, il faut vider la table. Ajouter dans Database.php une méthode supprimerTousLesPersonnages().

Elle exécute simplement DELETE FROM personnage sans condition WHERE, ce qui supprime toutes les lignes de la table (mais conserve la table elle-même et sa structure).

Tester : appeler cette méthode, puis charger les personnages. Le tableau retourné doit être vide.


Bonne pratique - Protéger les opérations destructives

Attention, un DELETE FROM sans clause WHERE supprime absolument toutes les lignes de la table, sans confirmation ni retour arrière possible. Dans une vraie application, cette opération serait protégée par une confirmation côté utilisateur, et une sauvegarde préalable des données. Ici, son usage est limité à la réinitialisation d'une partie.

Étape 9 - Assembler un index.php qui combine persistance et affichage du plateau

On va maintenant assembler tout ce qui a été fait depuis la séance 3 : le plateau de jeu, les personnages et la persistance SQLite.

Dans index.php, la logique doit être la suivante :

  1. Instancier Database.
  2. Charger les personnages depuis la base.
  3. Si la base est vide (aucun personnage trouvé), créer les deux personnages par défaut (un guerrier et un mage) et les sauvegarder.
  4. Afficher le plateau de jeu (grille HTML) avec les personnages à leur position.
  5. Afficher l'état des personnages sous le plateau.

Ce comportement "créer si vide, sinon charger" est un schéma très courant dans les applications qui démarrent avec un état initial.


Bonne pratique - Charger depuis la base, initialiser si vide

Le schéma "charger depuis la base, initialiser si vide" est préférable à "toujours insérer au démarrage". Il garantit qu'un rechargement de page ne duplique pas les données. C'est la base du comportement attendu dans toute application persistante.

Étape 10 - Tester la persistance complète

Cette étape finale vérifie que la chaîne complète fonctionne : créer, modifier, recharger.

Dans index.php, après avoir affiché le plateau, ajouter quelques lignes de code PHP qui simulent une attaque, sauvegardent l'état modifié, puis rechargent depuis la base pour afficher les valeurs après rechargement.

Voici le scénario à implémenter :

  1. Charger les personnages depuis la base (ou initialiser si vide).
  2. Trouver le guerrier et le mage parmi les personnages chargés en utilisant instanceof.
  3. Faire attaquer le guerrier sur le mage.
  4. Mettre à jour les deux personnages en base avec mettreAJourPersonnage.
  5. Recharger tous les personnages depuis la base.
  6. Afficher leurs valeurs rechargées.

Vérifier que recharger manuellement la page dans le navigateur (F5) n'écrase pas les modifications : les points de vie doivent rester à leur valeur modifiée entre deux rechargements.


Bonne pratique - Vérifier la persistance en 2 temps distincts

Il est conseillé de toujours vérifier la persistance en 2 temps distincts :

  1. D'abord, écrire les données et vérifier qu'elles sont correctement enregistrées.
  2. Ensuite, effacer toutes les variables PHP et recharger les données depuis la base pour confirmer qu'elles sont identiques.

C'est le seul moyen de s'assurer que la couche de persistance fonctionne correctement, indépendamment du code qui tourne en mémoire.


Solution complète

Solution complète du TP

Exercices complémentaires

Exercice A - Inspecter la base avec DB Browser for SQLite

Télécharger DB Browser for SQLite depuis https://sqlitebrowser.org (logiciel gratuit, disponible sur Windows, macOS et Linux).

Ouvrir le fichier jeu.db avec cet outil. Explorer la table personnage et vérifier :

  • les colonnes et leur type ;
  • les valeurs insérées ;
  • l'auto-incrémentation des identifiants.

Tenter de modifier une valeur directement depuis DB Browser, puis recharger index.php et vérifier que la modification est bien prise en compte. Ce type d'inspection directe est indispensable pour déboguer les problèmes de persistance.

Exercice B - Ajouter une méthode chargerPersonnageParId

Ajouter dans Database.php une méthode chargerPersonnageParId($id) qui renvoie un seul objet (le personnage dont l'identifiant correspond) ou null si aucun personnage n'est trouvé.

Utiliser une requête SELECT * FROM personnage WHERE id = :id avec une requête préparée. Pour récupérer une seule ligne, utiliser fetch(PDO::FETCH_ASSOC) à la place de fetchAll.

Tester en passant un identifiant valide, puis un identifiant inexistant (par exemple 9999).

Exercice C - Comprendre l'injection SQL

Cet exercice illustre concrètement pourquoi les requêtes préparées sont indispensables.

Créer une version temporaire et volontairement vulnérable de l'insertion :

// NE PAS utiliser ce code en production - démonstration uniquement
public function sauvegarderVulnerable($nom) {
$this->pdo->exec("INSERT INTO personnage (nom, type, vie, dps) VALUES ('" . $nom . "', 'guerrier', 100, 10)");
}

Appeler cette méthode avec ces valeurs successivement et observer le résultat dans DB Browser :

  1. "Arthur" (insertion normale)
  2. "D'Artagnan" (le caractère ' fait échouer la requête)
  3. "x', 'guerrier', 0, 0); DELETE FROM personnage; --" (injection qui vide la table)

Pour le troisième test, activer d'abord le mode multi-instructions sur PDO :

$this->pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);

Observer comment une requête préparée (prepare + execute) neutralise ces trois cas, y compris le troisième.


Ce qu'il faut retenir

NotionRésumé
PersistanceLes variables PHP disparaissent à chaque requête. Une base de données conserve les données indéfiniment.
SQLiteBase de données stockée dans un fichier unique. Intégrée à PHP, sans serveur séparé.
PDOInterface PHP unifiée pour communiquer avec les bases de données.
exec()Exécute une requête SQL sans paramètre variable et sans retourner de résultats.
prepare() + execute()Requête préparée : sépare la structure de la requête des données, protège des injections SQL.
query() + fetchAll()Lire toutes les lignes d'un SELECT. FETCH_ASSOC renvoie des tableaux associatifs.
lastInsertId()Récupère l'identifiant de la dernière ligne insérée.
UPDATE ... WHERE id = :idModifier une ligne précise en base.
DELETE FROMSupprimer des lignes. Sans WHERE, supprime tout le contenu de la table.
Tableau associatifTableau PHP dont les clés sont des chaînes : $ligne['nom'], $ligne['vie']...

Aperçu de la prochaine séance

Le jeu dispose maintenant d'une persistance : les personnages survivent aux rechargements de page. Mais pour l'instant, toutes les actions (attaque, déplacement) sont codées directement dans index.php, et la page n'offre aucune interaction à l'utilisateur.

La séance 5 introduira les formulaires HTML avec la méthode POST, qui permettront au joueur de choisir ses actions (déplacer un personnage, attaquer) en cliquant sur des boutons. On créera également la classe Jeu, qui centralisera toute la logique du jeu et servira de chef d'orchestre entre l'interface, les personnages et la base de données.