Aller au contenu

Exercice : Créer une petite appli « Carnet de Contacts »

📒 Présentation

Application web CRUD minimaliste (Créer / Lire / Modifier / Supprimer) pour s'entraîner à PHP, PDO et MariaDB.

Objectif pédagogique

Comprendre comment une page PHP dialogue avec une base de données MariaDB via PDO, en manipulant des requêtes préparées, un formulaire HTML et le motif POST → redirection → GET.

🗂️ Fichiers à créer

Fichier Rôle
index.php Application complète (HTML + CSS + PHP/PDO), commentée pas à pas
schema.sql Script SQL de création de la base et des données de démonstration

⚙️ Prérequis

Environnement nécessaire

  • Apache 2.4+ avec mod_php ou PHP-FPM
  • PHP 7.4+ avec l'extension pdo_mysql activée
  • MariaDB 10.3+ (ou MySQL 5.7+)

Vérifier que PDO MySQL est bien activé :

php -m | grep pdo
# doit afficher : pdo_mysql

🚀 Installation pas à pas

1. Créer la base de données

# Se connecter à MariaDB en tant que root
mysql -u root -p

# Exécuter le script SQL depuis le terminal
mysql -u root -p < schema.sql

# OU, une fois connecté en ligne de commande MariaDB :
source /chemin/vers/schema.sql

2. Configurer la connexion dans index.php

Ouvrez index.php et ajustez les constantes en tête de fichier :

define('DB_HOST', 'localhost');  // hôte MariaDB
define('DB_NAME', 'carnet');     // nom de la base
define('DB_USER', 'root');       // utilisateur
define('DB_PASS', '');           // mot de passe

Mot de passe vide

DB_PASS vide n'est acceptable qu'en environnement de développement local, jamais en production.

3. Déployer sur Apache

# Copier le fichier dans le DocumentRoot Apache
sudo cp index.php /var/www/html/

# Sur certaines distributions : /var/www/html/ ou /srv/http/

4. Ouvrir dans le navigateur

http://localhost/index.php

🧩 Fonctionnalités

Fonctionnalité Méthode HTTP Fonction PHP
Lister les contacts GET lireTousLesContacts()
Ajouter un contact POST creerContact()
Modifier un contact GET + POST lireContact() + modifierContact()
Supprimer un contact GET supprimerContact()

🔐 Sécurité implémentée

  • Anti-injection SQL : toutes les requêtes utilisent des requêtes préparées PDO
  • Anti-XSS : toutes les sorties HTML passent par htmlspecialchars()
  • Motif PRG (Post/Redirect/Get) : les formulaires POST redirigent vers GET, ce qui évite la re-soumission
  • Cast entier : les identifiants provenant de l'URL sont castés en (int)

📖 Concepts PDO expliqués dans le code

  1. DSN (Data Source Name) — chaîne d'identification de la source de données
  2. new PDO() — ouverture de connexion
  3. Options PDOERRMODE_EXCEPTION, FETCH_ASSOC, EMULATE_PREPARES
  4. prepare() + execute() — requêtes préparées avec placeholders nommés :param
  5. query() — raccourci pour les SELECT sans paramètre
  6. fetch() — récupération d'une seule ligne
  7. fetchAll() — récupération de toutes les lignes
  8. lastInsertId() — identifiant généré par le dernier INSERT
  9. rowCount() — nombre de lignes affectées
  10. PDOException — gestion des erreurs par try/catch

🗄️ Structure de la table contacts

CREATE TABLE contacts (
    id          INT UNSIGNED  NOT NULL AUTO_INCREMENT PRIMARY KEY,
    nom         VARCHAR(100)  NOT NULL,
    prenom      VARCHAR(100)  NOT NULL,
    telephone   VARCHAR(20)   NOT NULL,
    created_at  TIMESTAMP     NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

💻 Code source complet

Copiez chacun des deux blocs ci-dessous dans un fichier du même nom.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
<?php
/**
 * ============================================================
 *  CARNET DE CONTACTS — Exercice PHP + PDO + MariaDB
 * ============================================================
 *
 * PRÉREQUIS :
 *   - Apache   : serveur web qui reçoit les requêtes HTTP
 *   - MariaDB  : serveur de base de données relationnelle
 *   - PHP      : langage côté serveur (>= 7.4), avec l'extension PDO_MySQL activée
 *
 * COMMENT LANCER CET EXERCICE :
 *   1. Copiez ce fichier dans /var/www/html/ (ou votre DocumentRoot Apache)
 *   2. Exécutez le fichier SQL schema.sql dans MariaDB pour créer la base
 *   3. Ajustez les constantes DB_* ci-dessous si nécessaire
 *   4. Ouvrez http://localhost/index.php dans votre navigateur
 * ============================================================
 */

// ────────────────────────────────────────────────────────────
//  1. CONSTANTES DE CONNEXION À LA BASE DE DONNÉES
// ────────────────────────────────────────────────────────────
// Bonne pratique : regrouper les paramètres sensibles en haut
// du fichier (ou dans un fichier config.php séparé inclus ici).

define('DB_HOST', 'localhost');   // Adresse du serveur MariaDB
define('DB_NAME', 'carnet');      // Nom de la base de données
define('DB_USER', 'root');        // Utilisateur MariaDB
define('DB_PASS', '');            // Mot de passe (vide par défaut en local)
define('DB_CHARSET', 'utf8mb4'); // Encodage : utf8mb4 gère les emojis et accents

// ────────────────────────────────────────────────────────────
//  2. FONCTION DE CONNEXION PDO
// ────────────────────────────────────────────────────────────
/**
 * PDO (PHP Data Objects) est une couche d'abstraction de base de données.
 * Avantages par rapport à l'ancien mysql_* ou mysqli :
 *   • Compatible avec plusieurs SGBD (MySQL, PostgreSQL, SQLite…)
 *   • Requêtes préparées natives → protection anti-injection SQL
 *   • Interface orientée objet propre
 *
 * DSN (Data Source Name) : chaîne qui identifie le pilote et les paramètres.
 * Format MySQL/MariaDB : "mysql:host=HÔTE;dbname=NOM;charset=ENCODAGE"
 */
function getConnection(): PDO
{
    $dsn = "mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=" . DB_CHARSET;

    /**
     * Options PDO passées au constructeur :
     *
     * PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
     *   → Lance une exception PDOException en cas d'erreur SQL.
     *     Sans cette option, les erreurs sont silencieuses (très dangereux !).
     *
     * PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
     *   → fetch() retourne un tableau associatif ['colonne' => 'valeur']
     *     au lieu d'un tableau numérique + associatif (doublon inutile).
     *
     * PDO::ATTR_EMULATE_PREPARES => false
     *   → Désactive l'émulation des requêtes préparées.
     *     PDO délègue la préparation au serveur MariaDB lui-même,
     *     ce qui est plus sûr et plus performant.
     */
    $options = [
        PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES   => false,
    ];

    // new PDO(dsn, utilisateur, motDePasse, options)
    // Le constructeur ouvre la connexion. S'il échoue → PDOException.
    return new PDO($dsn, DB_USER, DB_PASS, $options);
}

// ────────────────────────────────────────────────────────────
//  3. FONCTIONS CRUD (Create / Read / Update / Delete)
// ────────────────────────────────────────────────────────────

/**
 * LIRE — Récupère tous les contacts triés alphabétiquement.
 *
 * $pdo->query(sql) : exécute une requête SELECT sans paramètre variable.
 * fetchAll()       : retourne TOUS les résultats sous forme de tableau PHP.
 */
function lireTousLesContacts(PDO $pdo): array
{
    $stmt = $pdo->query("SELECT * FROM contacts ORDER BY nom, prenom");
    return $stmt->fetchAll(); // Tableau de tableaux associatifs
}

/**
 * LIRE UN — Récupère un seul contact par son ID.
 *
 * REQUÊTE PRÉPARÉE en deux étapes :
 *   1. prepare()  : envoie le squelette SQL au serveur. Le ":id" est un
 *                   marqueur nommé (placeholder). MariaDB sait qu'une valeur
 *                   sera fournie plus tard — il ne l'interprète PAS comme SQL.
 *   2. execute()  : envoie les vraies valeurs. Elles sont envoyées séparément
 *                   du SQL → impossible d'injecter du code malveillant.
 *
 * fetch() : retourne UNE SEULE ligne (ou false si aucun résultat).
 */
function lireContact(PDO $pdo, int $id): array|false
{
    $stmt = $pdo->prepare("SELECT * FROM contacts WHERE id = :id");
    $stmt->execute([':id' => $id]);
    return $stmt->fetch();
}

/**
 * CRÉER — Insère un nouveau contact.
 *
 * Les marqueurs :nom, :prenom, :telephone correspondent aux clés du tableau
 * passé à execute(). PDO fait la correspondance automatiquement.
 *
 * lastInsertId() : retourne l'ID auto-incrémenté généré par l'INSERT.
 */
function creerContact(PDO $pdo, string $nom, string $prenom, string $telephone): int
{
    $sql  = "INSERT INTO contacts (nom, prenom, telephone) VALUES (:nom, :prenom, :telephone)";
    $stmt = $pdo->prepare($sql);
    $stmt->execute([
        ':nom'       => $nom,
        ':prenom'    => $prenom,
        ':telephone' => $telephone,
    ]);
    return (int) $pdo->lastInsertId();
}

/**
 * MODIFIER — Met à jour un contact existant.
 *
 * rowCount() : nombre de lignes affectées par le dernier UPDATE/DELETE/INSERT.
 * Ici on s'en sert pour vérifier que la mise à jour a bien eu lieu.
 */
function modifierContact(PDO $pdo, int $id, string $nom, string $prenom, string $telephone): bool
{
    $sql = "UPDATE contacts SET nom = :nom, prenom = :prenom, telephone = :telephone WHERE id = :id";
    $stmt = $pdo->prepare($sql);
    $stmt->execute([
        ':nom'       => $nom,
        ':prenom'    => $prenom,
        ':telephone' => $telephone,
        ':id'        => $id,
    ]);
    return $stmt->rowCount() > 0; // true si au moins 1 ligne modifiée
}

/**
 * SUPPRIMER — Efface un contact.
 *
 * Même principe : requête préparée avec :id pour éviter toute injection.
 */
function supprimerContact(PDO $pdo, int $id): bool
{
    $stmt = $pdo->prepare("DELETE FROM contacts WHERE id = :id");
    $stmt->execute([':id' => $id]);
    return $stmt->rowCount() > 0;
}

// ────────────────────────────────────────────────────────────
//  4. HELPER : NETTOYAGE DES DONNÉES UTILISATEUR
// ────────────────────────────────────────────────────────────
/**
 * htmlspecialchars() convertit les caractères spéciaux HTML (<, >, ", &)
 * en entités HTML (&lt; &gt; &quot; &amp;).
 * Cela empêche les attaques XSS (Cross-Site Scripting) lors de l'affichage.
 *
 * trim() supprime les espaces en début et fin de chaîne.
 */
function nettoyer(string $valeur): string
{
    return htmlspecialchars(trim($valeur), ENT_QUOTES, 'UTF-8');
}

// ────────────────────────────────────────────────────────────
//  5. TRAITEMENT DES ACTIONS (LOGIQUE CONTRÔLEUR)
// ────────────────────────────────────────────────────────────
/**
 * Le pattern POST → redirect → GET (PRG) est une bonne pratique web :
 * après un POST, on redirige vers GET pour éviter la re-soumission du
 * formulaire si l'utilisateur rafraîchit la page.
 *
 * $_POST  : données envoyées par un formulaire en méthode POST
 * $_GET   : paramètres dans l'URL (?action=modifier&id=3)
 * $_SERVER['PHP_SELF'] : chemin du script courant
 */

$message = '';
$erreur  = false;
$contact_edition = null; // Contact à afficher dans le formulaire d'édition

try {
    // Ouverture de la connexion PDO (une seule fois pour toute la page)
    $pdo = getConnection();

    // Récupération de l'action demandée (défaut : "liste")
    // isset() vérifie qu'une variable existe et n'est pas null
    $action = isset($_GET['action']) ? $_GET['action'] : 'liste';

    // ── ACTION : AJOUTER ────────────────────────────────────
    if ($action === 'ajouter' && $_SERVER['REQUEST_METHOD'] === 'POST') {
        $nom       = trim($_POST['nom']       ?? '');
        $prenom    = trim($_POST['prenom']    ?? '');
        $telephone = trim($_POST['telephone'] ?? '');

        if ($nom && $prenom && $telephone) {
            $nouvelId = creerContact($pdo, $nom, $prenom, $telephone);
            // header() envoie un en-tête HTTP de redirection
            header('Location: ' . $_SERVER['PHP_SELF'] . '?message=ajout_ok');
            exit; // TOUJOURS appeler exit après header('Location:...')
        } else {
            $message = "Tous les champs sont obligatoires.";
            $erreur  = true;
        }

    // ── ACTION : MODIFIER (affichage du formulaire pré-rempli) ──
    } elseif ($action === 'modifier' && isset($_GET['id'])) {
        $id = (int) $_GET['id']; // cast int = protection basique contre les valeurs non numériques
        $contact_edition = lireContact($pdo, $id);
        if (!$contact_edition) {
            $message = "Contact introuvable.";
            $erreur  = true;
        }

    // ── ACTION : ENREGISTRER LA MODIFICATION ────────────────
    } elseif ($action === 'enregistrer' && $_SERVER['REQUEST_METHOD'] === 'POST') {
        $id        = (int) ($_POST['id']        ?? 0);
        $nom       = trim($_POST['nom']       ?? '');
        $prenom    = trim($_POST['prenom']    ?? '');
        $telephone = trim($_POST['telephone'] ?? '');

        if ($id && $nom && $prenom && $telephone) {
            modifierContact($pdo, $id, $nom, $prenom, $telephone);
            header('Location: ' . $_SERVER['PHP_SELF'] . '?message=modif_ok');
            exit;
        } else {
            $message = "Tous les champs sont obligatoires.";
            $erreur  = true;
        }

    // ── ACTION : SUPPRIMER ───────────────────────────────────
    } elseif ($action === 'supprimer' && isset($_GET['id'])) {
        $id = (int) $_GET['id'];
        supprimerContact($pdo, $id);
        header('Location: ' . $_SERVER['PHP_SELF'] . '?message=suppr_ok');
        exit;
    }

    // Messages de confirmation après redirection
    if (isset($_GET['message'])) {
        $messages = [
            'ajout_ok' => '✅ Contact ajouté avec succès !',
            'modif_ok' => '✅ Contact modifié avec succès !',
            'suppr_ok' => '🗑️ Contact supprimé.',
        ];
        $message = $messages[$_GET['message']] ?? '';
    }

    // Chargement de tous les contacts pour l'affichage
    $contacts = lireTousLesContacts($pdo);

} catch (PDOException $e) {
    /**
     * PDOException est levée quand :
     *   - La connexion échoue (mauvais host, mot de passe, base inexistante)
     *   - Une requête SQL est invalide
     *   - Une contrainte de base de données est violée
     *
     * En production, NE PAS afficher $e->getMessage() directement
     * (fuite d'informations sensibles). Logger dans un fichier à la place.
     */
    $message = "Erreur de base de données : " . $e->getMessage();
    $erreur  = true;
    $contacts = [];
}
?>
<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Carnet de Contacts</title>
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;600;800&family=DM+Mono:ital,wght@0,400;0,500;1,400&display=swap" rel="stylesheet">
    <style>
        /* ══════════════════════════════════════════════════════
           VARIABLES & RESET
        ══════════════════════════════════════════════════════ */
        :root {
            --bg:        #0e0f13;
            --surface:   #16181f;
            --border:    #2a2d38;
            --accent:    #5bffd8;
            --accent2:   #ff6b6b;
            --text:      #e8eaf2;
            --muted:     #6b7080;
            --radius:    10px;
            --font-head: 'Syne', sans-serif;
            --font-mono: 'DM Mono', monospace;
        }
        *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
        body {
            background: var(--bg);
            color: var(--text);
            font-family: var(--font-mono);
            font-size: 14px;
            min-height: 100vh;
            padding: 2rem 1rem 4rem;
        }

        /* ══════════════════════════════════════════════════════
           LAYOUT
        ══════════════════════════════════════════════════════ */
        .wrapper { max-width: 900px; margin: 0 auto; }

        header {
            display: flex;
            align-items: baseline;
            gap: 1rem;
            margin-bottom: 2.5rem;
            border-bottom: 1px solid var(--border);
            padding-bottom: 1.2rem;
        }
        header h1 {
            font-family: var(--font-head);
            font-size: clamp(1.6rem, 4vw, 2.4rem);
            font-weight: 800;
            letter-spacing: -1px;
            color: var(--accent);
        }
        header .subtitle {
            color: var(--muted);
            font-size: 12px;
        }

        /* ══════════════════════════════════════════════════════
           ALERTE / MESSAGE
        ══════════════════════════════════════════════════════ */
        .alert {
            padding: .75rem 1.2rem;
            border-radius: var(--radius);
            margin-bottom: 1.5rem;
            font-size: 13px;
            border-left: 3px solid;
        }
        .alert-success { background: #0e2720; border-color: var(--accent);  color: var(--accent); }
        .alert-danger  { background: #2b1515; border-color: var(--accent2); color: var(--accent2); }

        /* ══════════════════════════════════════════════════════
           FORMULAIRE
        ══════════════════════════════════════════════════════ */
        .card {
            background: var(--surface);
            border: 1px solid var(--border);
            border-radius: var(--radius);
            padding: 1.5rem;
            margin-bottom: 2rem;
        }
        .card h2 {
            font-family: var(--font-head);
            font-weight: 600;
            font-size: 1rem;
            color: var(--accent);
            margin-bottom: 1.2rem;
            text-transform: uppercase;
            letter-spacing: 1px;
        }
        .form-grid {
            display: grid;
            grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
            gap: 1rem;
            align-items: end;
        }
        .field { display: flex; flex-direction: column; gap: .4rem; }
        label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; }
        input[type="text"], input[type="tel"] {
            background: var(--bg);
            border: 1px solid var(--border);
            border-radius: 6px;
            color: var(--text);
            font-family: var(--font-mono);
            font-size: 14px;
            padding: .55rem .8rem;
            transition: border-color .2s;
            width: 100%;
        }
        input:focus { outline: none; border-color: var(--accent); }

        /* ══════════════════════════════════════════════════════
           BOUTONS
        ══════════════════════════════════════════════════════ */
        .btn {
            display: inline-flex;
            align-items: center;
            gap: .4rem;
            border: none;
            border-radius: 6px;
            cursor: pointer;
            font-family: var(--font-mono);
            font-size: 13px;
            padding: .55rem 1.1rem;
            text-decoration: none;
            transition: opacity .15s, transform .1s;
            font-weight: 500;
        }
        .btn:hover  { opacity: .85; transform: translateY(-1px); }
        .btn-primary { background: var(--accent);  color: #0e0f13; }
        .btn-warning { background: #f0c040;         color: #0e0f13; }
        .btn-danger  { background: var(--accent2);  color: #fff; }
        .btn-ghost {
            background: transparent;
            border: 1px solid var(--border);
            color: var(--muted);
        }

        /* ══════════════════════════════════════════════════════
           TABLE DES CONTACTS
        ══════════════════════════════════════════════════════ */
        .table-wrap { overflow-x: auto; }
        table {
            width: 100%;
            border-collapse: collapse;
        }
        th {
            text-align: left;
            padding: .6rem .9rem;
            font-size: 11px;
            letter-spacing: .8px;
            text-transform: uppercase;
            color: var(--muted);
            border-bottom: 1px solid var(--border);
        }
        td {
            padding: .75rem .9rem;
            border-bottom: 1px solid var(--border);
            vertical-align: middle;
        }
        tr:last-child td { border-bottom: none; }
        tr:hover td { background: rgba(91,255,216,.03); }
        .td-id { color: var(--muted); font-size: 12px; }
        .td-actions { display: flex; gap: .5rem; }

        /* ══════════════════════════════════════════════════════
           SECTION CODE (blocs pédagogiques)
        ══════════════════════════════════════════════════════ */
        .doc-section {
            margin-top: 3rem;
        }
        .doc-section h2 {
            font-family: var(--font-head);
            font-size: 1.1rem;
            font-weight: 800;
            color: var(--accent);
            margin-bottom: .4rem;
        }
        .doc-section .intro {
            color: var(--muted);
            margin-bottom: 1rem;
            line-height: 1.7;
        }
        .code-block {
            background: #111318;
            border: 1px solid var(--border);
            border-radius: var(--radius);
            padding: 1.2rem 1.4rem;
            overflow-x: auto;
            position: relative;
            margin-bottom: 1.5rem;
        }
        .code-block .label-lang {
            position: absolute;
            top: .6rem; right: .8rem;
            font-size: 10px;
            color: var(--muted);
            text-transform: uppercase;
            letter-spacing: 1px;
        }
        pre { font-family: var(--font-mono); font-size: 13px; line-height: 1.75; }
        .kw  { color: #c792ea; }   /* mot-clé PHP */
        .fn  { color: #82aaff; }   /* fonction/méthode */
        .str { color: #c3e88d; }   /* chaîne */
        .cmt { color: #546e7a; font-style: italic; } /* commentaire */
        .var { color: #f78c6c; }   /* variable */
        .acc { color: var(--accent); } /* accent */

        /* vide */
        .empty-state {
            text-align: center;
            padding: 3rem 1rem;
            color: var(--muted);
        }
        .empty-state span { font-size: 2.5rem; display: block; margin-bottom: .5rem; }

        @media(max-width:600px) {
            .form-grid { grid-template-columns: 1fr; }
            .td-actions { flex-direction: column; }
        }
    </style>
</head>
<body>
<div class="wrapper">

    <!-- ═══════════════════════════════════════════
         EN-TÊTE
    ═══════════════════════════════════════════ -->
    <header>
        <h1>Carnet&nbsp;de&nbsp;Contacts</h1>
        <span class="subtitle">PHP · PDO · MariaDB · Apache</span>
    </header>

    <!-- ═══════════════════════════════════════════
         MESSAGE DE RETOUR
    ═══════════════════════════════════════════ -->
    <?php if ($message): ?>
        <div class="alert <?= $erreur ? 'alert-danger' : 'alert-success' ?>">
            <?= nettoyer($message) ?>
        </div>
    <?php endif; ?>

    <!-- ═══════════════════════════════════════════
         FORMULAIRE AJOUT / ÉDITION
    ═══════════════════════════════════════════ -->
    <?php if ($contact_edition): ?>
        <!-- Formulaire pré-rempli pour la modification -->
        <div class="card">
            <h2>✏️ Modifier le contact</h2>
            <!-- action="?action=enregistrer" → PHP recevra $_GET['action'] = 'enregistrer' -->
            <form method="POST" action="?action=enregistrer">
                <!-- Champ caché pour transmettre l'ID sans l'afficher -->
                <input type="hidden" name="id" value="<?= (int)$contact_edition['id'] ?>">
                <div class="form-grid">
                    <div class="field">
                        <label for="nom">Nom</label>
                        <input type="text" id="nom" name="nom"
                               value="<?= nettoyer($contact_edition['nom']) ?>" required>
                    </div>
                    <div class="field">
                        <label for="prenom">Prénom</label>
                        <input type="text" id="prenom" name="prenom"
                               value="<?= nettoyer($contact_edition['prenom']) ?>" required>
                    </div>
                    <div class="field">
                        <label for="telephone">Téléphone</label>
                        <input type="tel" id="telephone" name="telephone"
                               value="<?= nettoyer($contact_edition['telephone']) ?>" required>
                    </div>
                    <div class="field">
                        <button type="submit" class="btn btn-primary">💾 Enregistrer</button>
                    </div>
                </div>
            </form>
            <br>
            <a href="<?= $_SERVER['PHP_SELF'] ?>" class="btn btn-ghost">← Annuler</a>
        </div>

    <?php else: ?>
        <!-- Formulaire d'ajout -->
        <div class="card">
            <h2>➕ Ajouter un contact</h2>
            <form method="POST" action="?action=ajouter">
                <div class="form-grid">
                    <div class="field">
                        <label for="nom">Nom</label>
                        <input type="text" id="nom" name="nom" placeholder="Dupont" required>
                    </div>
                    <div class="field">
                        <label for="prenom">Prénom</label>
                        <input type="text" id="prenom" name="prenom" placeholder="Marie" required>
                    </div>
                    <div class="field">
                        <label for="telephone">Téléphone</label>
                        <input type="tel" id="telephone" name="telephone" placeholder="06 12 34 56 78" required>
                    </div>
                    <div class="field">
                        <button type="submit" class="btn btn-primary">+ Ajouter</button>
                    </div>
                </div>
            </form>
        </div>
    <?php endif; ?>

    <!-- ═══════════════════════════════════════════
         LISTE DES CONTACTS
    ═══════════════════════════════════════════ -->
    <div class="card">
        <h2>📋 Liste des contacts (<?= count($contacts) ?>)</h2>
        <div class="table-wrap">
            <?php if (empty($contacts)): ?>
                <div class="empty-state">
                    <span>📭</span>
                    Aucun contact pour l'instant. Ajoutez-en un ci-dessus !
                </div>
            <?php else: ?>
                <table>
                    <thead>
                        <tr>
                            <th>#</th>
                            <th>Nom</th>
                            <th>Prénom</th>
                            <th>Téléphone</th>
                            <th>Actions</th>
                        </tr>
                    </thead>
                    <tbody>
                    <?php
                    /**
                     * foreach ($contacts as $c) :
                     *   $contacts est le tableau retourné par fetchAll().
                     *   Chaque $c est un tableau associatif :
                     *   ['id' => 1, 'nom' => 'Dupont', 'prenom' => 'Marie', 'telephone' => '06...']
                     */
                    foreach ($contacts as $c): ?>
                        <tr>
                            <td class="td-id"><?= (int)$c['id'] ?></td>
                            <td><?= nettoyer($c['nom']) ?></td>
                            <td><?= nettoyer($c['prenom']) ?></td>
                            <td><?= nettoyer($c['telephone']) ?></td>
                            <td class="td-actions">
                                <!-- Lien vers le formulaire d'édition -->
                                <a href="?action=modifier&id=<?= (int)$c['id'] ?>"
                                   class="btn btn-warning">✏️ Éditer</a>
                                <!-- Lien de suppression avec confirmation JS -->
                                <a href="?action=supprimer&id=<?= (int)$c['id'] ?>"
                                   class="btn btn-danger"
                                   onclick="return confirm('Supprimer ce contact ?')">🗑️</a>
                            </td>
                        </tr>
                    <?php endforeach; ?>
                    </tbody>
                </table>
            <?php endif; ?>
        </div>
    </div>

    <!-- ═══════════════════════════════════════════
         DOCUMENTATION PÉDAGOGIQUE
    ═══════════════════════════════════════════ -->

    <div class="doc-section">
        <h2>📖 Guide pédagogique — PDO & PHP</h2>
        <p class="intro">
            Les blocs ci-dessous expliquent les commandes clés utilisées dans ce script.
            Lisez-les en parallèle du code source.
        </p>

        <!-- ── 1. DSN & Connexion ── -->
        <div class="code-block">
            <span class="label-lang">PHP · Connexion</span>
            <pre>
<span class="cmt">// 1. DSN — Data Source Name</span>
<span class="var">$dsn</span> = <span class="str">"mysql:host=localhost;dbname=carnet;charset=utf8mb4"</span>;

<span class="cmt">// 2. new PDO(dsn, user, pass, options)</span>
<span class="cmt">//    Ouvre la connexion. Lance PDOException si échec.</span>
<span class="var">$pdo</span> = <span class="kw">new</span> <span class="acc">PDO</span>(<span class="var">$dsn</span>, <span class="str">'root'</span>, <span class="str">''</span>, [
    <span class="acc">PDO</span>::ATTR_ERRMODE            => <span class="acc">PDO</span>::ERRMODE_EXCEPTION,
    <span class="acc">PDO</span>::ATTR_DEFAULT_FETCH_MODE => <span class="acc">PDO</span>::FETCH_ASSOC,
    <span class="acc">PDO</span>::ATTR_EMULATE_PREPARES   => <span class="kw">false</span>,
]);</pre>
        </div>

        <!-- ── 2. Requête préparée INSERT ── -->
        <div class="code-block">
            <span class="label-lang">PHP · INSERT préparé</span>
            <pre>
<span class="cmt">// prepare() envoie le squelette SQL — les :placeholders ne sont PAS du SQL</span>
<span class="var">$stmt</span> = <span class="var">$pdo</span>-><span class="fn">prepare</span>(<span class="str">"INSERT INTO contacts (nom, prenom, telephone)
                   VALUES (:nom, :prenom, :telephone)"</span>);

<span class="cmt">// execute() lie les valeurs réelles aux placeholders (hors-bande → anti-injection)</span>
<span class="var">$stmt</span>-><span class="fn">execute</span>([
    <span class="str">':nom'</span>       => <span class="var">$nom</span>,
    <span class="str">':prenom'</span>    => <span class="var">$prenom</span>,
    <span class="str">':telephone'</span> => <span class="var">$telephone</span>,
]);

<span class="cmt">// ID auto-incrémenté de la ligne créée</span>
<span class="var">$newId</span> = <span class="var">$pdo</span>-><span class="fn">lastInsertId</span>();</pre>
        </div>

        <!-- ── 3. SELECT + fetchAll ── -->
        <div class="code-block">
            <span class="label-lang">PHP · SELECT / fetchAll</span>
            <pre>
<span class="cmt">// query() = raccourci pour les SELECT sans paramètre variable</span>
<span class="var">$stmt</span> = <span class="var">$pdo</span>-><span class="fn">query</span>(<span class="str">"SELECT * FROM contacts ORDER BY nom"</span>);

<span class="cmt">// fetchAll() : retourne TOUS les résultats en un tableau PHP</span>
<span class="var">$contacts</span> = <span class="var">$stmt</span>-><span class="fn">fetchAll</span>();
<span class="cmt">// → [ ['id'=>1,'nom'=>'Dupont','prenom'=>'Marie','telephone'=>'06...'], ... ]</span>

<span class="cmt">// fetch() : retourne UNE SEULE ligne (ou false)</span>
<span class="var">$un</span> = <span class="var">$stmt</span>-><span class="fn">fetch</span>();</pre>
        </div>

        <!-- ── 4. UPDATE ── -->
        <div class="code-block">
            <span class="label-lang">PHP · UPDATE</span>
            <pre>
<span class="var">$stmt</span> = <span class="var">$pdo</span>-><span class="fn">prepare</span>(<span class="str">"UPDATE contacts
            SET nom=:nom, prenom=:prenom, telephone=:telephone
          WHERE id=:id"</span>);
<span class="var">$stmt</span>-><span class="fn">execute</span>([<span class="str">':nom'</span>=><span class="var">$nom</span>, <span class="str">':prenom'</span>=><span class="var">$prenom</span>,
                   <span class="str">':telephone'</span>=><span class="var">$tel</span>, <span class="str">':id'</span>=><span class="var">$id</span>]);

<span class="cmt">// rowCount() : nb de lignes affectées par le dernier DML (INSERT/UPDATE/DELETE)</span>
<span class="kw">if</span> (<span class="var">$stmt</span>-><span class="fn">rowCount</span>() > 0) {
    <span class="kw">echo</span> <span class="str">"Mise à jour réussie"</span>;
}</pre>
        </div>

        <!-- ── 5. DELETE ── -->
        <div class="code-block">
            <span class="label-lang">PHP · DELETE</span>
            <pre>
<span class="var">$stmt</span> = <span class="var">$pdo</span>-><span class="fn">prepare</span>(<span class="str">"DELETE FROM contacts WHERE id = :id"</span>);
<span class="var">$stmt</span>-><span class="fn">execute</span>([<span class="str">':id'</span> => <span class="var">$id</span>]);
<span class="cmt">// Sans requête préparée : "DELETE FROM contacts WHERE id = " . $id
// → RISQUE d'injection si $id vient de l'utilisateur !</span></pre>
        </div>

        <!-- ── 6. Gestion d'erreur ── -->
        <div class="code-block">
            <span class="label-lang">PHP · Gestion d'erreurs</span>
            <pre>
<span class="kw">try</span> {
    <span class="var">$pdo</span> = <span class="kw">new</span> <span class="acc">PDO</span>(<span class="var">$dsn</span>, <span class="var">$user</span>, <span class="var">$pass</span>, <span class="var">$options</span>);
    <span class="cmt">// ... opérations ...</span>
} <span class="kw">catch</span> (<span class="acc">PDOException</span> <span class="var">$e</span>) {
    <span class="cmt">// En DEV : afficher l'erreur pour déboguer</span>
    <span class="kw">echo</span> <span class="str">"Erreur : "</span> . <span class="var">$e</span>-><span class="fn">getMessage</span>();
    <span class="cmt">// En PROD : logger dans un fichier, afficher un message générique</span>
    <span class="fn">error_log</span>(<span class="var">$e</span>-><span class="fn">getMessage</span>());
}</pre>
        </div>

        <!-- ── 7. XSS ── -->
        <div class="code-block">
            <span class="label-lang">PHP · Sécurité XSS</span>
            <pre>
<span class="cmt">// NE JAMAIS afficher une donnée utilisateur sans l'échapper :</span>
<span class="kw">echo</span> <span class="var">$_POST</span>[<span class="str">'nom'</span>];  <span class="cmt">// ❌ Dangereux : script malveillant possible</span>

<span class="cmt">// TOUJOURS utiliser htmlspecialchars() à l'affichage :</span>
<span class="kw">echo</span> <span class="fn">htmlspecialchars</span>(<span class="var">$_POST</span>[<span class="str">'nom'</span>], ENT_QUOTES, <span class="str">'UTF-8'</span>);  <span class="cmt">// ✅</span>
<span class="cmt">// Transforme < > " ' & en entités HTML inoffensives.</span></pre>
        </div>

    </div><!-- /doc-section -->

</div><!-- /wrapper -->
</body>
</html>
-- ============================================================
--  schema.sql — Structure de la base de données "carnet"
--  Exécutez ce fichier UNE SEULE FOIS pour initialiser la BDD.
--
--  Méthode 1 (terminal) :
--    mysql -u root -p < schema.sql
--
--  Méthode 2 (phpmyadmin) :
--    Onglet "SQL" → coller le contenu → Exécuter
-- ============================================================

-- ────────────────────────────────────────────────────────────
--  CRÉATION DE LA BASE DE DONNÉES
-- ────────────────────────────────────────────────────────────
-- IF NOT EXISTS : ne plante pas si la base existe déjà (idempotent)
CREATE DATABASE IF NOT EXISTS carnet
    CHARACTER SET utf8mb4       -- utf8mb4 = vrai UTF-8 (emojis inclus)
    COLLATE utf8mb4_unicode_ci; -- unicode_ci = comparaisons insensibles à la casse

-- Sélectionner la base pour les prochaines instructions
USE carnet;

-- ────────────────────────────────────────────────────────────
--  CRÉATION DE L'UTILISATEUR (optionnel, recommandé en prod)
-- ────────────────────────────────────────────────────────────
-- Bonne pratique : créer un utilisateur dédié avec le minimum de privilèges
-- (principe du moindre privilège). En développement local, "root" suffit.
--
-- CREATE USER IF NOT EXISTS 'carnet_user'@'localhost' IDENTIFIED BY 'motDePasse!';
-- GRANT SELECT, INSERT, UPDATE, DELETE ON carnet.* TO 'carnet_user'@'localhost';
-- FLUSH PRIVILEGES;

-- ────────────────────────────────────────────────────────────
--  CRÉATION DE LA TABLE contacts
-- ────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS contacts (

    -- INT UNSIGNED        : entier positif (0 à 4 milliards)
    -- NOT NULL            : la colonne ne peut pas être NULL
    -- AUTO_INCREMENT      : MariaDB attribue automatiquement 1, 2, 3…
    -- PRIMARY KEY         : clé primaire = identifiant unique de chaque ligne
    id          INT UNSIGNED   NOT NULL AUTO_INCREMENT PRIMARY KEY,

    -- VARCHAR(100)        : chaîne variable jusqu'à 100 caractères
    -- NOT NULL            : champ obligatoire
    nom         VARCHAR(100)   NOT NULL,

    prenom      VARCHAR(100)   NOT NULL,

    -- VARCHAR(20)         : suffisant pour tous les formats de téléphone
    telephone   VARCHAR(20)    NOT NULL,

    -- TIMESTAMP           : stocke date + heure
    -- DEFAULT CURRENT_TIMESTAMP : rempli automatiquement à l'insertion
    created_at  TIMESTAMP      NOT NULL DEFAULT CURRENT_TIMESTAMP

) ENGINE=InnoDB
  DEFAULT CHARSET=utf8mb4
  COLLATE=utf8mb4_unicode_ci;

-- ────────────────────────────────────────────────────────────
--  DONNÉES DE DÉMONSTRATION
-- ────────────────────────────────────────────────────────────
-- INSERT INTO ... VALUES (col1, col2, ...) : insère des lignes
-- L'id est omis → AUTO_INCREMENT le génère automatiquement.
INSERT INTO contacts (nom, prenom, telephone) VALUES
    ('Dupont',   'Marie',   '06 12 34 56 78'),
    ('Martin',   'Jean',    '07 98 76 54 32'),
    ('Bernard',  'Sophie',  '06 55 44 33 22'),
    ('Lefebvre', 'Pierre',  '09 87 65 43 21');

-- ────────────────────────────────────────────────────────────
--  COMMANDES SQL UTILES À RETENIR
-- ────────────────────────────────────────────────────────────

-- Lire toutes les lignes :
--   SELECT * FROM contacts;

-- Lire avec filtre :
--   SELECT * FROM contacts WHERE nom = 'Dupont';

-- Lire trié :
--   SELECT * FROM contacts ORDER BY nom ASC;

-- Compter les lignes :
--   SELECT COUNT(*) FROM contacts;

-- Modifier :
--   UPDATE contacts SET telephone = '06 00 00 00 00' WHERE id = 1;

-- Supprimer :
--   DELETE FROM contacts WHERE id = 1;

-- Supprimer TOUTES les lignes (garde la table) :
--   TRUNCATE TABLE contacts;

-- Supprimer la table :
--   DROP TABLE IF EXISTS contacts;

-- Supprimer la base :
--   DROP DATABASE IF EXISTS carnet;

-- Voir la structure de la table :
--   DESCRIBE contacts;
--   SHOW CREATE TABLE contacts\G

-- Voir les utilisateurs et leurs droits :
--   SELECT User, Host FROM mysql.user;
--   SHOW GRANTS FOR 'root'@'localhost';