4) Sauvegarder les données
Sauvegarder les données avec SQLite

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
Databasedé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 :
| Instruction | Rôle |
|---|---|
CREATE TABLE | Créer une nouvelle table |
INSERT INTO | Ajouter une ligne dans une table |
SELECT | Lire des données |
UPDATE | Modifier des données existantes |
DELETE | Supprimer 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; --')
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.
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
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 :
| Fichier | Statut | Description |
|---|---|---|
Personnage.php | inchangé | classe abstraite parente |
Guerrier.php | inchangé | classe fille guerrier |
Mage.php | inchangé | classe fille mage |
Database.php | nouveau | classe encapsulant toute la logique SQLite |
index.php | modifié | utilise Database pour sauvegarder et charger les personnages |
jeu.db | gé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.
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 :
- Instancier PDO avec le pilote SQLite et le nom de fichier
jeu.db(new PDO("sqlite:jeu.db")). - Activer le mode exception pour que les erreurs SQL soient visibles
(
PDO::ATTR_ERRMODEavec la valeurPDO::ERRMODE_EXCEPTION). - 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).
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 :
| Colonne | Type SQL | Contrainte | Description |
|---|---|---|---|
id | INTEGER | PRIMARY KEY AUTOINCREMENT | identifiant unique auto-incrémenté |
nom | TEXT | NOT NULL | nom du personnage |
type | TEXT | NOT NULL | "guerrier" ou "mage" |
vie | INTEGER | NOT NULL | points de vie courants |
dps | INTEGER | NOT NULL | puissance d'attaque (frappe pour un guerrier, sort pour un mage) |
x | INTEGER | DEFAULT 0 | coordonnée horizontale |
y | INTEGER | DEFAULT 0 | coordonnée verticale |
mana | INTEGER | DEFAULT 0 | ressource défensive (armure pour un guerrier, mana pour un mage) |
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).
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 :
- Déterminer le type du personnage en utilisant
instanceof:"guerrier"ou"mage". - Préparer la requête
INSERT INTOavec des paramètres nommés (:nom,:type, etc.). - 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.
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 :
- Exécuter un
SELECT * FROM personnageavec$this->pdo->query(...). - Récupérer toutes les lignes avec
fetchAll(PDO::FETCH_ASSOC). - Pour chaque ligne, créer l'objet PHP correspondant (
GuerrierouMage) en fonction de la valeur de la colonnetype. - Ajouter chaque objet dans un tableau
$personnages. - 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).
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.
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 :
- Vider la base (voir étape suivante) et créer deux personnages frais.
- Sauvegarder les deux et mémoriser leurs identifiants.
- Faire attaquer le guerrier sur le mage.
- Mettre à jour le mage en base.
- Recharger les personnages depuis la base et vérifier que la vie du mage a bien diminué.
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.
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 :
- Instancier
Database. - Charger les personnages depuis la base.
- Si la base est vide (aucun personnage trouvé), créer les deux personnages par défaut (un guerrier et un mage) et les sauvegarder.
- Afficher le plateau de jeu (grille HTML) avec les personnages à leur position.
- 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.
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 :
- Charger les personnages depuis la base (ou initialiser si vide).
- Trouver le guerrier et le mage parmi les personnages chargés en utilisant
instanceof. - Faire attaquer le guerrier sur le mage.
- Mettre à jour les deux personnages en base avec
mettreAJourPersonnage. - Recharger tous les personnages depuis la base.
- 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.
Il est conseillé de toujours vérifier la persistance en 2 temps distincts :
- D'abord, écrire les données et vérifier qu'elles sont correctement enregistrées.
- 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
Vous devez être connecté pour voir le contenu.
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 :
"Arthur"(insertion normale)"D'Artagnan"(le caractère'fait échouer la requête)"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
| Notion | Résumé |
|---|---|
| Persistance | Les variables PHP disparaissent à chaque requête. Une base de données conserve les données indéfiniment. |
| SQLite | Base de données stockée dans un fichier unique. Intégrée à PHP, sans serveur séparé. |
| PDO | Interface 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 = :id | Modifier une ligne précise en base. |
DELETE FROM | Supprimer des lignes. Sans WHERE, supprime tout le contenu de la table. |
| Tableau associatif | Tableau 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.