skilling.maSkill. Scale. Soar.
Partie 1 — Client/Serveur & Environnement HTTP Architecture
⏱ 14 min

Architecture Client/Serveur

Maîtriser les architectures 1-tiers, 2-tiers, 3-tiers, le protocole HTTP et la structure d'une URL — les fondations de tout développement web dynamique.


1
Les trois acteurs d'une architecture réseau

Toute communication web implique trois rôles distincts qui coopèrent à chaque requête.

ActeurDéfinitionExemples concrets
ClientProgramme qui émet une requête vers un serveur et attend une réponseNavigateur Chrome/Firefox, application mobile, Postman
ServeurProgramme qui écoute sur un port, reçoit les requêtes, les traite et renvoie une réponseApache, Nginx, IIS, PHP built-in server
MiddlewareCouche intermédiaire — achemine, filtre ou transforme les requêtesProxy, load balancer, cache Varnish, API Gateway
🌐 Ports réseau — numéros à retenir
PortServiceUsage
80HTTPNavigation web non chiffrée
443HTTPSNavigation web chiffrée (TLS/SSL)
3306MySQL / MariaDBBase de données relationnelle
8080HTTP alternatifServeur de développement local (WampServer)
22SSHConnexion sécurisée à un serveur distant
Le cycle requête/réponse — étape par étape

Voici ce qui se passe quand Karim tape https://boutique-darija.ma dans son navigateur :

ÉtapeQui agitCe qui se passe
1 — Résolution DNSNavigateur + serveur DNSboutique-darija.ma → adresse IP du serveur
2 — Connexion TCPNavigateur + serveurÉtablissement de la connexion réseau sur port 443
3 — Handshake TLSNavigateur + serveurNégociation du chiffrement HTTPS
4 — Requête HTTPNavigateurGET / HTTP/1.1 Host: boutique-darija.ma
5 — TraitementApache + PHPInterprétation du script, requête BDD, génération HTML
6 — Réponse HTTPServeurHTTP/1.1 200 OK + headers + corps HTML
7 — AffichageNavigateurParsing HTML, CSS, JS → rendu visuel de la page

2
Architectures 1-tiers, 2-tiers, 3-tiers, n-tiers
ArchitectureDescriptionAvantagesInconvénientsExemple
1-tiersTout sur la même machine — présentation, logique, donnéesSimple, aucun réseau requisNon partageable, pas de collaborationFichier Excel local
2-tiersClient lourd + serveur de donnéesPerformances client élevéesMises à jour complexesApplication Access + SQL Server
3-tiersNavigateur + serveur applicatif + serveur BDDMaintenabilité, sécuritéComplexité de déploiementPHP + Apache + MySQL
n-tiersCouches spécialisées : cache, CDN, microservicesScalabilité horizontaleComplexité opérationnelleArchitecture cloud AWS/Azure
⚠️ Règle de sécurité fondamentale
Dans une architecture 3-tiers correcte, le navigateur ne communique jamais directement avec la base de données. Toutes les requêtes SQL passent par le serveur PHP (tiers 2).
TiersRôleTechnologiesResponsabilité
1 — PrésentationInterface utilisateurNavigateur, HTML5, CSS3, JavaScriptAfficher les données, collecter les saisies
2 — Logique métierTraitement des règlesPHP, Apache/Nginx, sessionsValider, calculer, orchestrer les données
3 — DonnéesStockage et récupérationMySQL, MariaDB, PDOPersister, interroger, sécuriser les données

3
Protocole HTTP — méthodes, codes de retour, structure d'URL
MéthodeUsageIdempotente ?Corps de requête
GETLire/récupérer une ressourceOuiNon
POSTCréer une ressource / envoyer des donnéesNonOui
PUTRemplacer complètement une ressourceOuiOui
PATCHModifier partiellement une ressourceNonOui
DELETESupprimer une ressourceOuiNon
CodeSignificationQuand ?
200 OKSuccèsRequête traitée normalement
201 CreatedRessource crééeAprès un POST réussi
301 MovedRedirection permanenteURL déplacée définitivement
302 FoundRedirection temporaireAprès soumission de formulaire (PRG)
400 Bad RequestRequête malforméeDonnées invalides envoyées
401 UnauthorizedNon authentifiéToken manquant ou invalide
403 ForbiddenAccès interditDroits insuffisants
404 Not FoundRessource introuvableURL inexistante
500 Internal ErrorErreur serveurBug PHP, exception non gérée
🔗 Anatomie d'une URL
https://boutique-darija.ma:443/produits/categorie?ville=casablanca&page=2#resultats
PartieValeurDescription
ProtocolehttpsSchéma de communication (HTTP chiffré)
Hôteboutique-darija.maNom de domaine résolu en IP par DNS
Port443Port d'écoute du serveur (443 = HTTPS par défaut)
Chemin/produits/categorieRessource demandée sur le serveur
Query stringville=casablanca&page=2Paramètres GET — accessibles via $_GET
Fragment#resultatsAncre côté navigateur — jamais envoyé au serveur
Partie 1 — Client/Serveur & Environnement PHP 8 Historique LAMP/WAMP
⏱ 12 min

PHP dans l'écosystème web

Historique de PHP de 1994 à PHP 8.3, ses caractéristiques fondamentales, le moteur Zend Engine, et les stacks de développement LAMP/WAMP.


1
Frise chronologique PHP — de 1994 à PHP 8.4
AnnéeVersionÉvénement majeur
1994PHP/FI 1.0Rasmus Lerdorf crée « Personal Home Page Tools » — scripts Perl pour son CV en ligne, réécriture en C
1997PHP 3Zeev Suraski et Andi Gutmans réécrivent entièrement le moteur — premier vrai langage PHP
2004PHP 5POO complète : classes, interfaces, héritage, exceptions, PDO — révolution pour les développeurs
2015PHP 7.0Zend Engine 3 — performances ×2 vs PHP 5, types scalaires, null coalescing ??, return types
2020PHP 8.0JIT compiler, named arguments, match expression, Union Types, nullsafe operator ?->
2021PHP 8.1Enums natifs, Fibers (concurrence), intersection types, readonly properties
2022PHP 8.2Readonly classes, DNF types, null/false/true comme types autonomes
2023PHP 8.3Typed class constants, json_validate(), améliorations Randomizer
2024PHP 8.4Property hooks, asymmetric visibility (public/protected/private get/set séparés)

2
8 caractéristiques essentielles de PHP
CaractéristiqueDescriptionConséquence pratique
Open SourceCode source libre, licence BSD-likeGratuit, large communauté, documentation complète
InterprétéZend Engine interprète et exécute le codeDéveloppement rapide, rechargement instantané
Côté serveurLe code s'exécute sur le serveur, jamais dans le navigateurSécurité : le code source reste invisible pour l'utilisateur
Typé dynamiquementVariables sans déclaration de type obligatoireFlexibilité — mais risques si non maîtrisé
MultiplateformeFonctionne sur Linux, Windows, macOSDéveloppement sur Windows (WAMP), déploiement sur Linux
Intégré à HTMLBalises <?php ... ?> dans des fichiers .phpMélange HTML et logique dans un même fichier
+1000 fonctionsBibliothèque standard très richeMoins de dépendances externes pour les tâches courantes
Typage strictdeclare(strict_types=1) force la vérification des typesCode plus robuste et maintenable (PHP 7+)
phpexemple-dynamique.php
<?php
// ── Ce que le développeur écrit (fichier produit.php) ──────────────────
$heure     = date('H:i');
$vendeur   = 'Boutique Al Wifak — Fès';
$produit   = 'Tajine en terre cuite';
?>
<!DOCTYPE html>
<html>
  <body>
    <p>Bienvenue chez <?= $vendeur ?>, il est <?= $heure ?>.</p>
    <h2><?= $produit ?></h2>
  </body>
</html>

<!-- Ce que le navigateur reçoit (HTML pur — pas de PHP visible) -->
<!-- <p>Bienvenue chez Boutique Al Wifak — Fès, il est 10:35.</p> -->
<!-- Le code PHP est INVISIBLE pour le navigateur -->

3
Le moteur Zend Engine — traitement interne d'un script PHP
ÉtapeCe que fait Zend Engine
1 — LexingDécoupe le code source PHP en tokens (mots-clés, opérateurs, chaînes…)
2 — ParsingConstruit l'arbre syntaxique abstrait (AST) à partir des tokens
3 — CompilationTransforme l'AST en opcodes (instructions bas niveau)
4 — OPcacheMet en cache les opcodes pour éviter de recompiler à chaque requête
5 — ExécutionLa Zend Virtual Machine exécute les opcodes et génère le HTML
6 — JIT (PHP 8+)Le compilateur Just-In-Time compile certains opcodes en code machine natif

4
Stacks de développement — LAMP, WAMP, MAMP, XAMPP
StackOSComposantsUsage
LAMPLinuxLinux + Apache + MySQL + PHPServeur de production standard — hébergement web
WAMPWindowsWindows + Apache + MySQL + PHPDéveloppement local sur Windows — recommandé dans ce cours
MAMPmacOSmacOS + Apache + MySQL + PHPDéveloppement local sur Mac
XAMPPMultiCross-platform + Apache + MariaDB + PHP + PerlMultiplateforme — alternative à WAMP
💡 Différence serveur statique vs dynamique
CritèreServeur statiqueServeur dynamique
DéfinitionRetourne les fichiers tels quelsGénère le contenu à la volée selon la requête
Fichiers.html, .css, .js, imagesScripts .php traités côté serveur
Base de donnéesNon — contenu fixeOui — données lues/écrites en temps réel
ExemplesSite vitrine fixe, documentationBoutique en ligne, portail entreprise, réseau social
Partie 1 — Client/Serveur & Environnement WampServer Apache VS Code
⏱ 16 min

Préparer son environnement de développement

Installer WampServer, configurer Apache avec mod_rewrite, préparer VS Code pour PHP — un environnement professionnel prêt pour tous les projets du cours.


1
Apache vs Nginx — comparatif et choix pour ce cours
CritèreApache HTTP ServerNginx
Modèle d'exécutionPrefork/Worker — un thread par connexionÉvénementiel asynchrone — un thread pour N connexions
PerformancesBonnes pour usage moyenExcellentes sous charge élevée (>1000 connexions)
ConfigurationPar dossier via .htaccessCentralisée — pas de .htaccess
mod_rewriteOui — indispensable pour les projets MVCÉquivalent via try_files
💡 Pourquoi Apache pour ce cours ?
mod_rewrite + .htaccess est indispensable pour les projets MVC (Parties 7-8). Notre routeur PHP redirige toutes les requêtes vers index.php via une règle .htaccess — impossible sans Apache configuré correctement.

2
Installation pas à pas de WampServer
ÉtapeActionVérification
1Télécharger WampServer 3.x (64 bits) depuis wampserver.comFichier .exe téléchargé
2Installer les prérequis Visual C++ (vc_redist.x64.exe)Sans ce prérequis, WampServer ne démarre pas
3Exécuter l'installateur en tant qu'AdministrateurDossier C:\wamp64\ créé
4Accepter port 80 pour Apache, 3306 pour MySQLParamètres par défaut recommandés
5Démarrer WampServer — icône verte dans la barre des tâchesIcône verte = tout fonctionne
6Ouvrir http://localhost/ dans le navigateurPage d'accueil WAMP visible
⚠️ Problèmes courants et solutions
SymptômeCauseSolution
Icône orange — Apache ne démarre pasPort 80 occupé par Skype, IIS ou antivirusChanger le port Apache en 8080 dans httpd.conf
Icône orange — MySQL ne démarre pasPort 3306 occupéChanger le port MySQL en 3307 dans my.ini
phpMyAdmin inaccessibleService MySQL arrêtéClic icône WAMP → MySQL → Start/Resume Service
Erreur 403 ForbiddenPermissions Apache sur le dossierVérifier AllowOverride et Require all granted dans httpd.conf

3
Configuration Apache — httpd.conf et mod_rewrite
apachehttpd.conf — configurations essentielles
# ── 1. Changer le port d'écoute (si port 80 occupé) ────────────────────
# Chercher : Listen 80
# Remplacer par :
Listen 8080
# Accès ensuite via : http://localhost:8080/

# ── 2. Activer mod_rewrite (indispensable pour les projets MVC) ─────────
# Chercher et décommenter (supprimer le #) :
LoadModule rewrite_module modules/mod_rewrite.so

# ── 3. Autoriser les fichiers .htaccess ──────────────────────────────────
# Dans le bloc <Directory "c:/wamp64/www"> :
<Directory "c:/wamp64/www">
    Options Indexes FollowSymLinks
    AllowOverride All          # Doit être "All" et non "None"
    Require all granted
</Directory>

# ── 4. Après chaque modification : redémarrer Apache ────────────────────
# Clic droit icône WAMP → Restart All Services
apachepublic/.htaccess — routeur MVC
RewriteEngine On

# Rediriger tout vers index.php sauf fichiers existants
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php?url=$1 [QSA,L]

# Tester : créer public/index.php avec echo $_GET['url'] ?? 'accueil';
# Accéder à http://localhost/mon_projet/public/clients/show/5
# Doit afficher : clients/show/5

4
VS Code — configuration pour PHP
🛠️ Extensions VS Code recommandées
ExtensionAuteurRôle
PHP IntelephenseBen MewburnAutocomplétion PHP avancée, refactoring, navigation
PHP DebugXdebugDébogage pas-à-pas avec points d'arrêt
PHP DocBlockerneilbrayfieldGénération automatique des commentaires PHPDoc
GitLensGitKrakenHistorique Git intégré dans l'éditeur
jsonsettings.json — configuration PHP Intelephense
{
  "php.validate.executablePath": "C:/wamp64/bin/php/php8.2.x/php.exe",
  "intelephense.environment.phpVersion": "8.2.0",
  "editor.formatOnSave": true,
  "[php]": {
    "editor.defaultFormatter": "bmewburn.vscode-intelephense-client"
  },
  "php.suggest.basic": false
}
phpverification.php — checklist de démarrage
<?php
// Vérification de l'environnement — à exécuter à chaque séance

echo '<h2>Vérification de l\'environnement</h2>';

// 1. Version PHP
echo '<p>PHP : ' . PHP_VERSION . '</p>';  // Attendu : 8.x.x

// 2. Extensions essentielles
$extensions = ['pdo', 'pdo_mysql', 'json', 'mbstring', 'zip'];
foreach ($extensions as $ext) {
    $ok = extension_loaded($ext);
    echo "<p style='color:" . ($ok ? 'green' : 'red') . "'>";
    echo ($ok ? '✓' : '✗') . " $ext</p>";
}

// 3. phpinfo() complet
phpinfo();
?>
Partie 2 — Fondamentaux du langage Variables Types Superglobales
⏱ 18 min

Variables, types de données et superglobales PHP

Déclarer et manipuler des variables PHP (int, float, string, bool), utiliser les superglobales ($_GET, $_POST, $_SERVER, $_SESSION) — les briques fondamentales de tout script PHP.


1
Structure d'un script PHP — balises, commentaires, intégration HTML
FormeSyntaxeRecommandation
Standard (PSR-1)<?php ... ?>Toujours utiliser — compatible tous serveurs
Courte d'affichage<?= $variable ?>Équivalent de <?php echo $variable ?> — autorisée PHP 5.4+
Courte (déconseillée)<? ... ?>Désactivée par défaut — ne jamais utiliser
phpstructure-script.php
<?php
// ── Les 3 types de commentaires PHP ────────────────────────────────────

// Commentaire sur une seule ligne (style C++)
# Commentaire sur une seule ligne (style shell — moins courant)

/*
 * Commentaire sur plusieurs lignes (style C)
 * Utilisé pour les blocs de documentation
 */

/**
 * Commentaire PHPDoc — autocomplétion VS Code
 * @param string $nom   Nom du client
 * @param int    $age   Âge du client
 * @return string       Message de bienvenue
 */
function bienvenue(string $nom, int $age): string {
    return "Bonjour $nom, vous avez $age ans.";
}
?>

<!-- Intégration PHP dans HTML -->
<!DOCTYPE html>
<html lang="fr">
<body>
  <?php
      $titre = 'Boutique Al Wifak';
      echo '<h1>' . htmlspecialchars($titre) . '</h1>';
  ?>
  <p>Titre : <?= htmlspecialchars($titre) ?></p>

  <?php if ($titre !== ''): ?>
      <p>Le titre est défini.</p>
  <?php endif; ?>
</body>
</html>

<?php
// RÈGLE : si le fichier ne contient QUE du PHP, NE PAS mettre la balise ?>
// Évite les espaces blancs parasites en début de réponse HTTP

2
Variables — règles de nommage, déclaration, portée
RègleExemple valideExemple invalideRaison
Commence par $$nom, $agenom, ageLe $ est obligatoire
Lettre ou _ après $$nom, $_session$1nom, $-ageChiffre ou tiret interdit en 1ère position
Sensible à la casse$Nom ≠ $nom—$Nom et $nom sont deux variables différentes
Convention camelCase$nomClient, $prixHT$NomClientcamelCase recommandé PSR
phpvariables.php
<?php
// ── Déclaration et affectation ─────────────────────────────────────────
$nom     = 'Benali';       // string
$age     = 28;             // int
$prix    = 149.90;         // float
$actif   = true;           // bool
$adresse = null;           // null

// ── Affectation par référence (&) ──────────────────────────────────────
$original = 'Casablanca';
$copie    = $original;     // Copie de la valeur
$alias    = &$original;    // Alias — pointe vers la même mémoire
$original = 'Rabat';
echo $copie;   // 'Casablanca' — inchangée
echo $alias;   // 'Rabat' — modifiée avec $original

// ── Les 8 types PHP ────────────────────────────────────────────────────
// int     — $age = 28
// float   — $prix = 149.90
// string  — $nom = 'Benali'
// bool    — $actif = true
// array   — $villes = ['Casablanca', 'Rabat', 'Fès']
// object  — $dt = new DateTime()
// null    — $adresse = null
// resource — $fh = fopen('fichier.txt', 'r')

// ── Vérification de type ────────────────────────────────────────────────
var_dump(gettype($nom));   // string
var_dump(is_int($age));    // bool(true)
var_dump(is_null($adresse)); // bool(true)

// ── Constantes ─────────────────────────────────────────────────────────
define('TVA', 0.20);          // Constante globale
const MAX_ARTICLES = 100;     // Constante statique
echo TVA;            // 0.20 — sans $ !
echo PHP_VERSION;    // Constante prédéfinie PHP

3
Types scalaires — int, float, string, bool
phptypes-scalaires.php
<?php
// ── INT ─────────────────────────────────────────────────────────────────
$age        = 28;
$population = 37_000_000;   // Maroc — séparateur de milliers PHP 7.4+
$hexa       = 0xFF;         // 255 en hexadécimal
$binaire    = 0b1010;       // 10 en binaire

// ── FLOAT ───────────────────────────────────────────────────────────────
$prix  = 149.90;
$tva   = 0.20;
// Attention : les floats ne sont pas exacts en mémoire binaire !
var_dump(0.1 + 0.2 == 0.3);  // bool(false) — piège classique !
var_dump(abs(0.1 + 0.2 - 0.3) < PHP_FLOAT_EPSILON);  // bool(true)

// ── STRING ──────────────────────────────────────────────────────────────
$prenom = 'Youssef';
$ville  = "Casablanca";
// Guillemets simples : PAS d'interpolation, plus rapide
echo 'Bonjour $prenom';    // Affiche : Bonjour $prenom  (littéral)
// Guillemets doubles : interpolation des variables
echo "Bonjour $prenom";    // Affiche : Bonjour Youssef
echo "Bonjour {$prenom}";  // Forme explicite recommandée

// ── BOOL ────────────────────────────────────────────────────────────────
$connecte = true;
$banni    = false;
// Valeurs évaluées comme false (falsy) :
// false, 0, 0.0, '', '0', [], null
// TOUT le reste est true (truthy)
var_dump((bool)'0');     // bool(false) — piège : chaîne '0' = false !
var_dump((bool)'false'); // bool(true)  — chaîne non vide = true

// ── CONVERSION DE TYPES (casting) ─────────────────────────────────────
$chaine = '42';
$entier = (int)$chaine;         // 42
$float  = (float)'3.14abc';    // 3.14 — s'arrête au premier non-numérique
$bool   = (bool)'';            // false
$arr    = (array)'Casablanca'; // ['Casablanca']

4
Superglobales — les canaux de communication HTTP/PHP
SuperglobaleContenuRemplie par
$_GETParamètres passés dans l'URL (?cle=valeur)Navigateur — URL de la requête
$_POSTDonnées envoyées par formulaire POSTNavigateur — corps de la requête
$_SERVERInformations sur le serveur et la requête HTTPApache/PHP automatiquement
$_SESSIONDonnées de session utilisateur (persistantes entre pages)Le développeur via session_start()
$_COOKIECookies envoyés par le navigateurNavigateur — header Cookie
$_FILESFichiers uploadés via formulaireNavigateur — upload multipart
$_REQUESTFusion de $_GET + $_POST + $_COOKIEPHP automatiquement — déconseillé
phpsuperglobales.php — formulaire inscription
<?php
// ── Lire $_GET — paramètres d'URL ─────────────────────────────────────
// URL : http://localhost/clients.php?ville=casablanca&page=2
$ville = $_GET['ville'] ?? 'toutes';  // 'casablanca'
$page  = (int)($_GET['page'] ?? 1);   // 2

// ── Lire $_POST — données de formulaire ────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $nom    = trim($_POST['nom']    ?? '');
    $prenom = trim($_POST['prenom'] ?? '');
    $email  = trim($_POST['email']  ?? '');

    // Toujours sécuriser avant affichage !
    echo htmlspecialchars($nom . ' ' . $prenom);
}

// ── $_SERVER — informations de la requête ──────────────────────────────
echo $_SERVER['REQUEST_METHOD'];  // 'GET' ou 'POST'
echo $_SERVER['SERVER_NAME'];     // 'localhost'
echo $_SERVER['REMOTE_ADDR'];     // IP du visiteur
echo $_SERVER['PHP_SELF'];        // '/inscription.php'

// ── $_SESSION — données persistantes ───────────────────────────────────
session_start();  // Toujours en PREMIER, avant tout affichage HTML
$_SESSION['utilisateur'] = ['nom' => 'Benali', 'role' => 'admin'];
echo $_SESSION['utilisateur']['nom'];  // 'Benali'

// Détruire la session (déconnexion)
session_destroy();
🔒 Règle de sécurité : htmlspecialchars()
Ne jamais afficher directement une valeur venant de $_POST, $_GET ou $_COOKIE sans la passer par htmlspecialchars(). Sans cette protection, un attaquant peut injecter du code JavaScript (attaque XSS).
Quelle est la différence entre $_GET et $_POST ?
Partie 2 — Fondamentaux du langage Portée isset / empty Opérateurs
⏱ 16 min

Portée des variables, fonctions de gestion & opérateurs

Maîtriser la portée locale, globale et statique, utiliser isset(), empty(), unset(), var_dump() et les opérateurs d'affectation composés — éliminer les bugs de portée à la source.


1
Portée des variables — locale, globale, statique
PortéeDéfinitionMot-cléDurée de vie
LocaleVariable créée dans une fonction — invisible en dehors(aucun)Détruite à la fin de la fonction
GlobaleVariable créée hors de toute fonction — inaccessible dans les fonctionsglobalPendant toute l'exécution du script
StatiqueVariable locale qui conserve sa valeur entre les appelsstaticPendant toute l'exécution du script
phpportee.php
<?php
// ══ PORTÉE LOCALE ════════════════════════════════════════════════════════
$titre = 'Portail client — Agence Atlas';

function afficherTitre(): void {
    // $titre est INVISIBLE ici
    echo $titre;  // Warning: Undefined variable $titre
}

// ══ PORTÉE GLOBALE — mot-clé global ════════════════════════════════════
$compteur = 0;

function incrementer(): void {
    global $compteur;   // Déclarer l'accès à la variable globale
    $compteur++;
    echo "Compteur : $compteur";
}
incrementer();  // Compteur : 1
incrementer();  // Compteur : 2

// Préférer passer en paramètre (recommandé PSR) :
function incrementerPropre(int &$compteur): void {
    $compteur++;  // Référence — modifie la variable originale
}

// ══ PORTÉE STATIQUE — mot-clé static ═══════════════════════════════════
function compterRequetes(): int {
    static $nb = 0;   // Initialisée UNE SEULE FOIS au premier appel
    $nb++;
    return $nb;
}
echo compterRequetes();  // 1
echo compterRequetes();  // 2
echo compterRequetes();  // 3  — $nb conserve sa valeur

// ══ VARIABLES DYNAMIQUES — $$variable ══════════════════════════════════
$champ  = 'ville';
$$champ = 'Marrakech';   // Crée $ville = 'Marrakech'
echo $ville;             // 'Marrakech'
// Usage : formulaires dynamiques — peu recommandé en code moderne
// Préférer un tableau : $donnees['ville'] = 'Marrakech';

2
Fonctions de gestion — isset, empty, unset, var_dump, print_r
FonctionRetourneUsage principal
isset($var)true si définie ET non nullVérifier qu'une variable existe avant de l'utiliser
empty($var)true si "vide" (0, '', [], null, false, '0')Valider qu'un champ de formulaire a une valeur utile
unset($var)voidLibérer la mémoire ou supprimer une clé de tableau
var_dump()voidAfficher type + valeur — débogage uniquement
print_r()void|stringAfficher tableau/objet lisiblement — débogage
gettype()Nom du typeObtenir le type sous forme de chaîne
phpfonctions-gestion.php
<?php
// ── isset() ─────────────────────────────────────────────────────────────
$nom = 'Fatima';
$age = null;
var_dump(isset($nom));          // bool(true)
var_dump(isset($age));          // bool(false) — null = non défini pour isset
var_dump(isset($indefinie));    // bool(false) — sans Warning !

// Usage courant avec $_GET :
$id = (int)($_GET['id'] ?? 0);  // Null coalescing PHP 7+ — plus concis

// ── empty() — attention aux pièges ! ────────────────────────────────────
var_dump(empty(''));       // bool(true)
var_dump(empty('0'));      // bool(true) — PIÈGE ! '0' est considéré vide
var_dump(empty(0));        // bool(true)
var_dump(empty([]));       // bool(true)
var_dump(empty('false'));  // bool(false) — chaîne non vide !

// Usage formulaire :
if (empty($_POST['email'])) {
    $erreurs[] = 'L\'email est requis.';
}

// ── unset() ─────────────────────────────────────────────────────────────
$token = 'xyz789_secret';
// ... utiliser le token ...
unset($token);  // Libère la mémoire — $token n'existe plus

$villes = ['Casablanca', 'Rabat', 'Fès', 'Agadir'];
unset($villes[1]);  // Supprime 'Rabat' — indices restants : 0, 2, 3
// Pour réindexer : $villes = array_values($villes);

// ── var_dump() vs print_r() ─────────────────────────────────────────────
$client = ['nom' => 'Tazi', 'ville' => 'Tanger', 'actif' => true];
var_dump($client);   // Affiche type + valeur de chaque élément
print_r($client);    // Affichage lisible — sans les types

// Capturer print_r() dans une chaîne :
$sortie = print_r($client, true);
echo '<pre>' . htmlspecialchars($sortie) . '</pre>';

3
Opérateurs d'affectation — =, +=, .=, ??= et comparaison == vs ===
OpérateurÉquivalentExempleRésultat
=Affectation simple$x = 5$x vaut 5
+=$x = $x + n$x += 3$x vaut 8 (si $x=5)
.=$s = $s . str$s .= ' Maroc'Concaténation
??=$x = $x ?? val$x ??= 'défaut'Affecte si null/undefined (PHP 7.4+)
==Égalité lâche1 == '1'true — coercition de type
===Égalité stricte1 === '1'false — compare type ET valeur
⚠️ Toujours utiliser === sauf raison valable
== peut causer des bugs silencieux : 0 == 'bonjour' retourne true en PHP (avant PHP 8). Utilisez toujours === pour les comparaisons, sauf quand vous voulez explicitement une comparaison lâche.
Partie 2 — Fondamentaux du langage Constantes Heredoc Casting
⏱ 16 min

Constantes, types scalaires approfondis & casting

define() vs const, les 8 constantes magiques, heredoc/nowdoc, NaN/INF, transtypage complet — la précision au service de la robustesse.


1
define() vs const — différences et cas d'usage
Critèredefine()const
Position dans le codeN'importe où (y compris dans if/while)Uniquement au niveau supérieur (pas dans if/while)
Dans les classesNonOui — constante de classe
Expressions dynamiquesOui — valeur calculée au runtimeNon — valeur littérale uniquement
Recommandation PSRConfig runtime, pluginsPréféré pour les constantes de classe
phpconfig/app.php
<?php
// ── define() — constante globale ────────────────────────────────────────
define('APP_NAME',    'Gestion Clients — Atlas Digital');
define('APP_VERSION', '1.0.0');
define('MAX_UPLOAD',  2 * 1024 * 1024);  // 2 Mo — expression autorisée
define('DEBUG_MODE',  getenv('APP_ENV') === 'development');

// define() dans un bloc conditionnel (impossible avec const)
if (PHP_OS_FAMILY === 'Windows') {
    define('DS', '\\');
} else {
    define('DS', '/');
}

// Accès sans $ — case-sensitive
echo APP_NAME;    // 'Gestion Clients — Atlas Digital'
echo MAX_UPLOAD;  // 2097152

// ── const — constante statique ──────────────────────────────────────────
const DB_HOST    = 'localhost';
const DB_NAME    = 'bd_clients';
const DB_CHARSET = 'utf8mb4';

// const dans une classe
class Config {
    public const    VERSION    = '1.0.0';
    protected const MAX_RETRY  = 3;
    private const   SECRET_KEY = 'xK9#mP2!zR5@';

    public static function getVersion(): string {
        return self::VERSION;
    }
}
echo Config::VERSION;  // '1.0.0'

// ── Constantes magiques ─────────────────────────────────────────────────
echo __FILE__;      // C:/wamp64/www/atlas/config/app.php
echo __DIR__;       // C:/wamp64/www/atlas/config
echo __LINE__;      // Numéro de ligne actuelle
echo __FUNCTION__;  // Nom de la fonction courante
echo __CLASS__;     // Nom de la classe courante
echo __METHOD__;    // NomClasse::nomMethode

// Usage le plus courant — require avec chemin absolu :
require __DIR__ . '/database.php';  // Toujours relatif à CE fichier

2
String — heredoc, nowdoc, fonctions essentielles
phpstrings-avances.php
<?php
$client = 'Karim El Ouazzani';
$ville  = 'Casablanca';

// ── 3. HEREDOC — comme guillemets doubles, multiligne ────────────────────
$html = <<<EOT
<div class="client">
    <h2>Bienvenue $client</h2>
    <p>Ville : $ville</p>
</div>
EOT;
// EOT doit être seul sur sa ligne — interpolation ACTIVE

// ── 4. NOWDOC — comme guillemets simples, multiligne, PAS d'interpolation ──
$template = <<<'EOT'
<div class="client">
    <h2>Bonjour $client</h2>
    <p>Variable affichée littéralement : \$ville</p>
</div>
EOT;
// Note les guillemets simples autour de 'EOT'

// ── Fonctions string indispensables ────────────────────────────────────
$texte = '  Atlas Digital — Agence Web Casablanca  ';
echo strlen(trim($texte));              // longueur sans espaces
echo strtoupper('casablanca');          // 'CASABLANCA'
echo ucwords('karim el ouazzani');      // 'Karim El Ouazzani'
echo str_replace('Casablanca', 'Rabat', $texte);
echo str_contains($texte, 'Atlas');    // true (PHP 8+)
echo str_starts_with('AZERTY', 'AZ');  // true (PHP 8+)
echo str_ends_with('email@mail.ma', '.ma'); // true (PHP 8+)

// ── Float — valeurs spéciales ────────────────────────────────────────────
$nan = acos(2);             // NaN — Not a Number
var_dump(is_nan($nan));     // bool(true)
var_dump($nan == $nan);     // bool(false) — NaN != NaN !

$inf = INF;
var_dump(is_infinite($inf)); // bool(true)
var_dump(is_finite(42.0));   // bool(true)
Partie 2 — Fondamentaux du langage Array stdClass JSON
⏱ 14 min

Types composés, callables & sérialisation

Maîtriser les tableaux (indexé, associatif, multidimensionnel), les objets stdClass, les types spéciaux (callable, null, resource) et la sérialisation avec serialize() et json_encode().


1
Array — indexé, associatif, multidimensionnel
phptableaux.php
<?php
// ── Array indexé ───────────────────────────────────────────────────────
$villes = ['Casablanca', 'Rabat', 'Fès', 'Marrakech', 'Agadir'];
echo $villes[0];         // 'Casablanca'
echo count($villes);     // 5
$villes[] = 'Tanger';   // Ajoute à la fin — index 5

// ── Array associatif ───────────────────────────────────────────────────
$client = [
    'nom'      => 'El Ouazzani',
    'prenom'   => 'Karim',
    'email'    => 'karim@atlas.ma',
    'age'      => 34,
    'ville'    => 'Casablanca',
    'actif'    => true,
];
echo $client['nom'];           // 'El Ouazzani'
$client['telephone'] = '0661234567';  // Ajouter une clé
unset($client['actif']);              // Supprimer une clé

// ── Array multidimensionnel ─────────────────────────────────────────────
$commandes = [
    ['id' => 1, 'client' => 'El Ouazzani', 'articles' => ['Tajine', 'Couscous'], 'total' => 250.00],
    ['id' => 2, 'client' => 'Benali',      'articles' => ['Pastilla'],            'total' => 180.50],
    ['id' => 3, 'client' => 'Tazi',        'articles' => ['Harira', 'Briouats'],  'total' => 95.00],
];
echo $commandes[0]['client'];           // 'El Ouazzani'
echo $commandes[0]['articles'][1];      // 'Couscous'
echo $commandes[2]['total'];            // 95.00

// ── Déstructuration PHP 7.1+ ───────────────────────────────────────────
[$premier, $deuxieme] = $villes;
echo $premier;   // 'Casablanca'

['nom' => $nom, 'prenom' => $prenom] = $client;
echo "$prenom $nom";  // 'Karim El Ouazzani'

// ── Spread operator PHP 7.4+ ──────────────────────────────────────────
$liste1 = ['Casablanca', 'Rabat'];
$liste2 = ['Fès', 'Agadir'];
$toutes = [...$liste1, ...$liste2];  // ['Casablanca', 'Rabat', 'Fès', 'Agadir']

2
stdClass, callable, iterable, null, resource
phptypes-speciaux.php
<?php
// ── stdClass — objet générique ─────────────────────────────────────────
$produit = new stdClass();
$produit->nom   = 'Argan Bio 50ml';
$produit->prix  = 89.90;
$produit->ville = 'Essaouira';
echo $produit->nom;  // 'Argan Bio 50ml'

// Cast tableau → objet
$tableau = ['nom' => 'Miel Thym', 'prix' => 65.00];
$objet   = (object)$tableau;
echo $objet->nom;  // 'Miel Thym'

// json_decode() retourne stdClass par défaut
$json = '{"nom":"Karim","ville":"Casablanca"}';
$obj  = json_decode($json);
echo $obj->nom;   // 'Karim'

// json_decode() avec true → tableau associatif
$arr = json_decode($json, true);
echo $arr['nom'];  // 'Karim'

// ── CALLABLE ────────────────────────────────────────────────────────────
// Fonction en chaîne
$fn = 'strtoupper';
echo $fn('casablanca');  // 'CASABLANCA'

// Arrow function PHP 7.4+
$doubler = fn(float $n): float => $n * 2;
echo $doubler(89.90);  // 179.8

// Callable comme paramètre
function appliquer(callable $fn, array $liste): array {
    return array_map($fn, $liste);
}
$prix   = [89.90, 65.00, 120.00];
$ttc    = appliquer(fn($p) => $p * 1.20, $prix);  // TVA 20%

// ── NULLSAFE OPERATOR ?-> PHP 8.0 ─────────────────────────────────────
$user = null;
echo $user?->getProfile()?->getAvatar();  // null — pas d'erreur

// Null coalescing ?? — valeur par défaut si null ou non défini
$nom = $user?->nom ?? 'Visiteur';  // 'Visiteur'

// ── RESOURCE ────────────────────────────────────────────────────────────
$fh = fopen(__DIR__ . '/test.txt', 'w');
if (is_resource($fh)) {
    fwrite($fh, 'Ligne de test');
    fclose($fh);  // Toujours fermer !
}

3
Sérialisation — serialize() vs json_encode()
Critèreserialize()json_encode()
Format de sortieFormat binaire PHP propriétaireJSON — standard universel
PortabilitéPHP uniquementTout langage (JS, Python, Java…)
Objets avec méthodesOui — __sleep() / __wakeup()Non — méthodes perdues
Sécuritéunserialize() peut être dangereuxjson_decode() plus sûr
Usage recommandéCache interne PHP, sessions complexesAPIs REST, stockage BDD, échange de données
phpserialisation.php
<?php
$commande = [
    'id'      => 42,
    'client'  => 'Karim El Ouazzani',
    'ville'   => 'Casablanca',
    'total'   => 430.50,
    'articles'=> ['Tajine', 'Couscous', 'Harira'],
];

// ── json_encode() — pour les API REST ─────────────────────────────────
$json = json_encode($commande, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
echo $json;
// {
//   "id": 42,
//   "client": "Karim El Ouazzani",
//   "total": 430.5,
//   ...
// }

// Décoder JSON
$retour = json_decode($json, true);
echo $retour['client'];  // 'Karim El Ouazzani'

// Vérifier les erreurs d'encodage
if (json_last_error() !== JSON_ERROR_NONE) {
    throw new RuntimeException('JSON invalide : ' . json_last_error_msg());
}

// ── json_validate() PHP 8.3 — valider sans décoder ─────────────────────
$jsonString = '{"nom":"Fatima","ville":"Fès"}';
var_dump(json_validate($jsonString));  // bool(true)

// ── serialize() — pour le cache interne ────────────────────────────────
$serialise = serialize($commande);
$_SESSION['commande'] = $serialise;

// Récupérer
$commande_recuperee = unserialize($_SESSION['commande']);
echo $commande_recuperee['client'];  // 'Karim El Ouazzani'
Partie 2 — Fondamentaux du langage printf / sprintf require_once try/catch
⏱ 16 min

Instructions de sortie, inclusion de fichiers & gestion des erreurs

Maîtriser printf/sprintf, structurer un projet multi-fichiers avec require_once et __DIR__, configurer les niveaux d'erreur PHP et gérer les exceptions avec try/catch/finally.


1
Instructions de sortie — echo, printf, sprintf
InstructionTypeRetourneUsage
echoInstructionvoidAffichage standard — le plus courant
printf()FonctionvoidAffichage formaté avec spécificateurs %s %d %f
sprintf()FonctionstringFormatage sans affichage — retourne la chaîne
var_dump()FonctionvoidDébogage : type + valeur — ne pas garder en prod
var_export()Fonctionvoid|stringReprésentation PHP valide — peut être eval()ée
phpsortie.php
<?php
$nom  = 'Fatima Zahra';
$prix = 149.90;
$code = 42;

// ── printf() — affichage formaté ──────────────────────────────────────
printf('Bonjour %s, votre total est %.2f MAD.', $nom, $prix);
// 'Bonjour Fatima Zahra, votre total est 149.90 MAD.'

printf('Réf. : %05d', $code);     // 'Réf. : 00042' (padding zéros)
printf('%-20s %8.2f MAD', 'Tajine :', 89.90); // Alignement
printf('TVA : %.1f%%', 20.0);     // 'TVA : 20.0%' (%% pour afficher %)

// ── sprintf() — formatage sans affichage ──────────────────────────────
$facture = sprintf(
    'Facture n°%04d — Client : %s — Total : %.2f MAD',
    $code,
    $nom,
    $prix
);
// $facture = 'Facture n°0042 — Client : Fatima Zahra — Total : 149.90 MAD'

// ── number_format() — nombres lisibles ────────────────────────────────
echo number_format(1234567.891, 2, ',', ' ');  // '1 234 567,89'
echo number_format(149.9, 2, '.', '');         // '149.90'

2
Inclusion de fichiers — require_once, include, __DIR__
InstructionFichier absentDouble inclusionUtilisation recommandée
includeWarning — script continueInclus à nouveauVues optionnelles, templates partiels
requireFatal Error — script arrêtéInclus à nouveauFichiers nécessaires sans classes
include_onceWarning — script continueIgnoréeRarement utilisé
require_onceFatal Error — script arrêtéIgnoréeTOUJOURS pour config, classes, helpers
phpindex.php — architecture multi-fichiers
<?php
// ── Architecture projet ─────────────────────────────────────────────────
// atlas/
//   index.php           ← point d'entrée
//   config/
//     app.php           ← constantes et configuration
//   helpers/
//     functions.php     ← fonctions utilitaires
//   views/
//     header.php        ← entête HTML
//     footer.php        ← pied de page HTML
//     liste.php         ← contenu principal

require_once __DIR__ . '/config/app.php';       // Fatal si absent
require_once __DIR__ . '/helpers/functions.php'; // Fatal si absent

include __DIR__ . '/views/header.php';          // Warning si absent
include __DIR__ . '/views/liste.php';
include __DIR__ . '/views/footer.php';

// ── helpers/functions.php ──────────────────────────────────────────────
function formatPrix(float $prix, string $devise = 'MAD'): string {
    return number_format($prix, 2, '.', ' ') . ' ' . $devise;
}

function sanitize(string $valeur): string {
    return htmlspecialchars(trim($valeur), ENT_QUOTES, 'UTF-8');
}

function redirect(string $url): never {
    header('Location: ' . $url);
    exit;
}

// ── Inclusion sécurisée — protection contre path traversal ─────────────
$page    = $_GET['page'] ?? 'accueil';
$fichier = __DIR__ . '/views/' . basename($page) . '.php';
// basename() protège contre les attaques ../../../etc/passwd

if (file_exists($fichier)) {
    include $fichier;
} else {
    include __DIR__ . '/views/404.php';
}

3
Gestion des exceptions — try / catch / finally / throw
phpexceptions.php
<?php
// ── Hiérarchie des exceptions PHP ─────────────────────────────────────
// Throwable (interface)
//   ├── Error (erreurs PHP internes)
//   │     ├── TypeError, ParseError, ArithmeticError...
//   └── Exception (exceptions applicatives)
//         ├── RuntimeException, InvalidArgumentException...
//         └── Vos exceptions personnalisées

// ── try / catch / finally ──────────────────────────────────────────────
function connecterBase(string $dsn, string $user, string $pwd): PDO {
    try {
        $pdo = new PDO($dsn, $user, $pwd);
        $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        return $pdo;
    } catch (PDOException $e) {
        // Ne JAMAIS afficher le message d'erreur en production
        error_log('Connexion BDD échouée : ' . $e->getMessage());
        throw new RuntimeException('Service temporairement indisponible.');
    } finally {
        // finally s'exécute TOUJOURS — même si exception levée
        // Utile pour fermer des ressources, nettoyer des fichiers temporaires
    }
}

// ── Exceptions personnalisées ──────────────────────────────────────────
class ValidationException extends RuntimeException {
    private array $erreurs;

    public function __construct(array $erreurs, string $message = '') {
        parent::__construct($message ?: 'Données invalides');
        $this->erreurs = $erreurs;
    }

    public function getErreurs(): array {
        return $this->erreurs;
    }
}

function validerEmail(string $email): void {
    if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        throw new ValidationException(
            ['email' => 'Format d\'email invalide'],
            'Validation échouée'
        );
    }
}

// ── Attraper plusieurs types ───────────────────────────────────────────
try {
    validerEmail('pas-un-email');
} catch (ValidationException $e) {
    foreach ($e->getErreurs() as $champ => $msg) {
        echo "$champ : $msg";
    }
} catch (RuntimeException $e) {
    echo 'Erreur : ' . $e->getMessage();
} catch (Throwable $e) {
    // Filet de sécurité — attrape tout
    error_log($e->getMessage());
    echo 'Une erreur inattendue est survenue.';
} finally {
    echo 'Traitement terminé.';
}
⚠️ Niveaux d'erreur PHP — à configurer selon l'environnement
EnvironnementConfiguration recommandée
Développementerror_reporting(E_ALL); ini_set('display_errors', '1');
Productionerror_reporting(E_ALL); ini_set('display_errors', '0'); ini_set('log_errors', '1');
Partie 2 — Fondamentaux du langage if/elseif match PHP 8 Ternaire
⏱ 15 min

Structures de contrôle conditionnelles

if/elseif/else, switch/case, match PHP 8 (comparaison stricte), opérateurs ternaire, Elvis et null coalescing — choisir la bonne structure pour chaque cas.


1
if / elseif / else — opérateurs de comparaison
OpérateurNomExempleRésultatRemarque
==Égalité lâche1 == '1'trueCoercition de type — déconseillé
===Égalité stricte1 === '1'falseCompare valeur ET type — recommandé
<=>Spaceship PHP 75 <=> 10-1Retourne -1, 0 ou 1 — utile pour usort()
&&ET logiquetrue && falsefalseCourt-circuit : arrête si premier est false
||OU logiquetrue || falsetrueCourt-circuit : arrête si premier est true
phpconditions.php
<?php
// ── Mentions selon une note ────────────────────────────────────────────
function getMention(float $note): string {
    if ($note >= 16) {
        return 'Très bien';
    } elseif ($note >= 14) {
        return 'Bien';
    } elseif ($note >= 12) {
        return 'Assez bien';
    } elseif ($note >= 10) {
        return 'Passable';
    } else {
        return 'Insuffisant';
    }
}
echo getMention(15.5);  // 'Bien'
echo getMention(9.8);   // 'Insuffisant'

// ── Syntaxe alternative pour templates HTML ───────────────────────────
$role = 'admin';
?>
<?php if ($role === 'admin'): ?>
    <nav>
        <a href="/admin/clients">Gérer les clients</a>
        <a href="/admin/commandes">Gérer les commandes</a>
    </nav>
<?php elseif ($role === 'vendeur'): ?>
    <nav>
        <a href="/vendeur/catalogue">Catalogue produits</a>
    </nav>
<?php else: ?>
    <nav>
        <a href="/profil">Mon profil</a>
        <a href="/commandes">Mes commandes</a>
    </nav>
<?php endif; ?>

2
match PHP 8 — comparaison stricte, pas de fall-through
Critèreswitchmatch
Comparaison== (lâche)=== (stricte — type + valeur)
Valeur de retourNon — instructions uniquementOui — expression retournant une valeur
Fall-throughOui — sans breakNon — un seul bras exécuté
Default manquantRienLève UnhandledMatchError
phpmatch.php
<?php
// ── match standard — retourne une valeur ──────────────────────────────
$statut = 'expedie';

$libelle = match($statut) {
    'en_attente' => 'En attente de confirmation',
    'confirme'   => 'Commande confirmée',
    'expedie'    => 'Expédié — en cours de livraison',
    'livre'      => 'Livré',
    default      => 'Statut inconnu',
};
echo $libelle;  // 'Expédié — en cours de livraison'

// ── Plusieurs cas pour un même résultat ───────────────────────────────
$peutCommander = match($statut) {
    'en_attente', 'confirme' => true,
    'expedie', 'livre'       => false,
    default                  => false,
};

// ── match(true) — conditions complexes PHP 8.1 ─────────────────────────
function getMentionMatch(float $note): string {
    return match(true) {
        $note >= 16 => 'Très bien',
        $note >= 14 => 'Bien',
        $note >= 12 => 'Assez bien',
        $note >= 10 => 'Passable',
        default     => 'Insuffisant',
    };
}
echo getMentionMatch(13.5);  // 'Assez bien'

// ── Ternaire, Elvis, null coalescing ──────────────────────────────────
// Ternaire : condition ? si_vrai : si_faux
$badge = $note >= 10 ? 'Admis' : 'Recalé';

// Elvis ?: — retourne $a si truthy, sinon $c
$nom = '' ?: 'Visiteur';  // 'Visiteur' (chaîne vide est falsy)

// Null coalescing ?? — uniquement si null ou undefined
$ville = $_GET['ville'] ?? 'Casablanca';  // 'Casablanca' si absent

// ??= PHP 7.4 — affecte seulement si null/undefined
$config['theme'] ??= 'clair';  // Affecte 'clair' si clé absente
Quelle est la différence principale entre switch et match en PHP 8 ?
Partie 2 — Fondamentaux du langage while for foreach
⏱ 14 min

Boucles — while, do-while, for, foreach

Choisir et implémenter la boucle adaptée à chaque situation — utiliser break et continue avec niveaux, éviter les boucles infinies et les pièges de foreach par référence.


1
Choisir la bonne boucle
BoucleÉvalue la conditionCas d'usageRisque principal
whileAvant chaque itérationNombre d'itérations inconnu — lecture fichier, curseur BDDBoucle infinie si condition jamais false
do-whileAprès chaque itérationCorps exécuté au moins une fois — génération token uniqueRarement utilisé — cas spécifiques
forAvant chaque itérationCompteur connu — pagination, tables, indicesErreur hors limite avec tableaux
foreachItération automatiqueTableaux et objets Iterable — cas le plus courantRéférence &$val non détruite après la boucle

2
while, for et foreach — exemples pratiques
phpboucles.php
<?php
// ── while — lire un fichier CSV ────────────────────────────────────────
$fichier = __DIR__ . '/data/commandes.csv';
if (file_exists($fichier)) {
    $fh     = fopen($fichier, 'r');
    $entete = fgetcsv($fh, 0, ';');  // Lire la ligne d'entête
    while (($ligne = fgetcsv($fh, 0, ';')) !== false) {
        echo implode(' | ', $ligne) . PHP_EOL;
    }
    fclose($fh);  // Toujours fermer la ressource
}

// ── do-while — générer un token unique ────────────────────────────────
function genererTokenUnique(array $tokensExistants): string {
    do {
        $token = bin2hex(random_bytes(16));
    } while (in_array($token, $tokensExistants));
    return $token;
}

// ── for — générer un tableau de pagination HTML ─────────────────────
$page_courante = 3;
$total_pages   = 8;
echo '<nav>';
for ($p = 1; $p <= $total_pages; $p++) {
    $classe = ($p === $page_courante) ? 'active' : '';
    printf('<a href="?page=%d" class="%s">%d</a>', $p, $classe, $p);
}
echo '</nav>';

// ── break et continue ─────────────────────────────────────────────────
$commandes = [
    ['id' => 1, 'statut' => 'livre'],
    ['id' => 2, 'statut' => null],      // Données manquantes
    ['id' => 3, 'statut' => 'expedie'],
];
foreach ($commandes as $cmd) {
    if ($cmd['statut'] === null) continue;  // Sauter les lignes incomplètes
    echo "Commande #{$cmd['id']} : {$cmd['statut']}" . PHP_EOL;
}

// ── foreach — tableau associatif ──────────────────────────────────────
$produits = [
    ['nom' => 'Argan Bio',     'prix' => 89.90, 'stock' => 15],
    ['nom' => 'Huile d\'olive','prix' => 45.00, 'stock' =>  0],
    ['nom' => 'Miel Thym',     'prix' => 65.00, 'stock' =>  8],
];
foreach ($produits as $i => $produit) {
    $dispo = $produit['stock'] > 0 ? 'En stock' : 'Rupture';
    printf("%d. %-20s %8.2f MAD — %s\n", $i+1, $produit['nom'], $produit['prix'], $dispo);
}

// ── PIÈGE foreach avec référence — toujours unset après ──────────────
$prix = [89.90, 65.00, 45.00];
foreach ($prix as &$p) {
    $p *= 1.20;  // Appliquer TVA 20%
}
unset($p);  // OBLIGATOIRE — sinon $p reste un alias du dernier élément
// Sans unset($p), $prix[2] serait écrasé par la prochaine affectation à $p
Partie 2 — Fondamentaux du langage Formulaires $_GET / $_POST header()
⏱ 16 min

Formulaires HTML/PHP — Transmission GET/POST — Redirection

Structure d'un formulaire HTML, méthodes GET et POST, validation côté serveur avec isset() et htmlspecialchars(), redirection avec header() et le pattern PRG.


1
Structure d'un formulaire HTML — attributs essentiels
AttributRôleValeurs possibles
actionURL du script PHP qui traite les donnéesURL relative, absolue, ou "" (même page)
methodComment les données sont transmisesget (URL) ou post (corps de requête)
enctypeEncodage — requis pour l'upload de fichiersmultipart/form-data si <input type="file">
name (sur les champs)Clé de récupération en PHP — indispensableDevient $_POST['name'] ou $_GET['name']
typeRenduUsage typiqueValeur en PHP
textChamp texte libreNom, prénom, adresseChaîne de caractères
emailChamp texte + validation formatAdresse emailChaîne avec @ et .
passwordTexte masquéMot de passeChaîne masquée
numberChamp numériqueÂge, quantité, prixEntier ou décimal
selectListe déroulanteVille, catégorieValeur de l'option sélectionnée
radioBouton rond — un seul choixCivilité (M./Mme)Valeur du bouton coché
checkboxCase à cocherAccepter CGU'on' si coché, absent sinon
hiddenChamp invisibleTransmettre un IDChaîne ou entier
submitBouton d'envoiValider le formulaireValeur du bouton (si nommé)

2
Formulaire complet — inscription client boutique en ligne
phpinscription.php — formulaire + traitement + redirection
<?php
// ── Traitement du formulaire ────────────────────────────────────────────
$erreurs = [];
$succes  = false;

if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['submit'])) {
    // Récupérer et nettoyer les données
    $nom    = trim($_POST['nom']    ?? '');
    $prenom = trim($_POST['prenom'] ?? '');
    $email  = trim($_POST['email']  ?? '');
    $ville  = trim($_POST['ville']  ?? '');

    // Validation côté serveur
    if (empty($nom))    $erreurs['nom']    = 'Le nom est requis.';
    if (empty($prenom)) $erreurs['prenom'] = 'Le prénom est requis.';
    if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        $erreurs['email'] = 'Format d\'email invalide.';
    }
    if (empty($ville))  $erreurs['ville']  = 'Veuillez choisir votre ville.';

    if (empty($erreurs)) {
        // Ici : sauvegarder en BDD (voir Partie 5 — PDO)
        // Pattern PRG : Post/Redirect/Get — évite la double soumission
        header('Location: confirmation.php?email=' . urlencode($email));
        exit;  // OBLIGATOIRE après header()
    }
}
?>
<!DOCTYPE html>
<html lang="fr">
<head>
  <meta charset="UTF-8">
  <title>Inscription — Boutique Atlas</title>
</head>
<body>
<h1>Créer un compte client</h1>

<?php if (!empty($erreurs)): ?>
  <div class="erreurs">
    <ul>
      <?php foreach ($erreurs as $champ => $msg): ?>
        <li><?= htmlspecialchars($msg) ?></li>
      <?php endforeach; ?>
    </ul>
  </div>
<?php endif; ?>

<form action="" method="post">
  <label for="nom">Nom :</label>
  <input type="text" id="nom" name="nom" required
         value="<?= htmlspecialchars($nom ?? '') ?>">

  <label for="prenom">Prénom :</label>
  <input type="text" id="prenom" name="prenom" required
         value="<?= htmlspecialchars($prenom ?? '') ?>">

  <label for="email">Email :</label>
  <input type="email" id="email" name="email" required
         value="<?= htmlspecialchars($email ?? '') ?>">

  <label for="ville">Ville :</label>
  <select id="ville" name="ville">
    <option value="">-- Choisir --</option>
    <?php foreach (['Casablanca','Rabat','Fès','Marrakech','Agadir','Tanger','Meknès'] as $v): ?>
      <option value="<?= $v ?>" <?= (($ville ?? '') === $v ? 'selected' : '') ?>>
        <?= $v ?>
      </option>
    <?php endforeach; ?>
  </select>

  <input type="submit" name="submit" value="Créer mon compte">
</form>
</body>
</html>
💡 Pattern PRG — Post/Redirect/Get
Après un traitement POST réussi, toujours faire une redirection avec header('Location: ...') suivi de exit. Sans cela, si l'utilisateur recharge la page, le formulaire est soumis une deuxième fois — ce qui peut créer des doublons en base de données.

3
GET vs POST — quand utiliser lequel
CritèreGETPOST
Données dansL'URL (?cle=valeur)Le corps de la requête HTTP
VisibilitéVisible dans la barre d'adresseInvisible dans l'URL
Taille limite~2000 caractères (selon navigateur)Pratiquement illimitée
Mise en cacheOui — peut être mis en cache et bookmarkéNon
UsageRecherche, filtres, pagination, navigationCréation, modification, données sensibles
Données sensiblesJamais (mot de passe, numéro CB…)Oui (mais pas sécurisé sans HTTPS)
Partie 2 — Fondamentaux du langage $_SERVER IP client getenv()
⏱ 14 min

Variables d'environnement — La superglobale $_SERVER

Explorer $_SERVER pour accéder à l'IP du visiteur, au User-Agent, à la méthode HTTP, à l'URL courante — construire un journal de sécurité et détecter l'environnement d'exécution.


1
$_SERVER — clés essentielles à connaître
CléValeur typiqueUsage
SERVER_NAMElocalhost / boutique-atlas.maNom d'hôte configuré dans Apache
SERVER_ADDR127.0.0.1Adresse IP du serveur
SERVER_PORT80 / 443Port d'écoute d'Apache
REMOTE_ADDRIP du visiteurJournal de sécurité, géolocalisation
HTTP_USER_AGENTChaîne navigateur/OSDétection mobile/desktop, analytics
REQUEST_METHODGET / POSTDétecter si formulaire soumis
REQUEST_URI/clients?page=2URL complète avec query string
PHP_SELF/inscription.phpChemin du script courant
QUERY_STRINGpage=2&ville=casaParamètres GET bruts
DOCUMENT_ROOTC:/wamp64/wwwRacine web absolue
HTTP_REFERERURL de la page précédenteOptionnel — peut être absent
HTTPS'on'Présent seulement en HTTPS
phpserver-utils.php — cas pratiques
<?php
// ── 1. Journal de sécurité — IP + User-Agent + URL + timestamp ─────────
function journaliserConnexion(string $action = 'visite'): void {
    $ip        = $_SERVER['REMOTE_ADDR'] ?? 'inconnue';
    $ua        = $_SERVER['HTTP_USER_AGENT'] ?? 'inconnu';
    $url       = $_SERVER['REQUEST_URI'] ?? '/';
    $methode   = $_SERVER['REQUEST_METHOD'] ?? 'GET';
    $timestamp = date('Y-m-d H:i:s');

    $ligne = "[$timestamp] [$action] IP:$ip METHODE:$methode URL:$url UA:$ua\n";
    file_put_contents(__DIR__ . '/logs/securite.log', $ligne, FILE_APPEND | LOCK_EX);
}
journaliserConnexion('connexion');

// ── 2. Détecter mobile vs desktop ────────────────────────────────────
function estMobile(): bool {
    $ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
    return (bool) preg_match('/Mobile|Android|iPhone|iPad/i', $ua);
}
$layout = estMobile() ? 'mobile' : 'desktop';
echo "Layout : $layout";

// ── 3. Construire l'URL canonique (sans coder en dur le domaine) ───────
function getUrlCanonique(): string {
    $protocole = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on')
        ? 'https' : 'http';
    $hote = $_SERVER['SERVER_NAME'];
    $uri  = $_SERVER['REQUEST_URI'];
    return "$protocole://$hote$uri";
}
echo getUrlCanonique();  // ex: http://localhost/boutique/produits?cat=argan

// ── 4. Détecter l'environnement (local vs production) ─────────────────
$env = $_SERVER['SERVER_NAME'] === 'localhost' ? 'local' : 'production';
if ($env === 'local') {
    error_reporting(E_ALL);
    ini_set('display_errors', '1');
} else {
    error_reporting(E_ALL);
    ini_set('display_errors', '0');
    ini_set('log_errors', '1');
}

// ── 5. $_ENV et getenv() ──────────────────────────────────────────────
// Variables d'environnement définies dans le système ou .env
$dbHost = getenv('DB_HOST') ?: 'localhost';
$debug  = getenv('APP_DEBUG') === 'true';

// Accès via $_ENV (si variables_order inclut 'E' dans php.ini)
// echo $_ENV['DB_PASSWORD'];

// ── Clés optionnelles — toujours utiliser ?? ─────────────────────────
$referer = $_SERVER['HTTP_REFERER'] ?? 'accès direct';
// HTTP_REFERER est absent si l'utilisateur tape l'URL directement
Partie 2 — Fondamentaux du langage strlen / strpos explode / implode preg_match
⏱ 18 min

Fonctions sur les chaînes de caractères

Longueur, recherche, transformation, extraction, formatage, sécurité et expressions régulières — les 30+ fonctions PHP indispensables pour traiter le texte.


1
Longueur et recherche
phpstrings-recherche.php
<?php
// ── strlen() / mb_strlen() ─────────────────────────────────────────────
$nom = 'El Idrissi Youssef';
echo strlen($nom);      // 18

// mb_strlen pour les accents et l'arabe
$ville = 'Meknès';
echo strlen($ville);    // 7 (octets — è = 2 octets en UTF-8)
echo mb_strlen($ville); // 6 (caractères réels)

// Valider longueur mot de passe
$mdp = 'Atlas2024!';
if (mb_strlen($mdp) < 8) {
    echo 'Mot de passe trop court (minimum 8 caractères)';
}

// ── strpos() — PIÈGE : retourne false OU un entier (0 = début) ──────
$email = 'nadia.benali@atlas.ma';
$pos   = strpos($email, '@');
// JAMAIS : if ($pos != false) — retourne true même si pos = 0 !
// TOUJOURS :
if ($pos !== false) {
    echo 'Email valide — @ en position ' . $pos;  // 12
}

// strrpos() — dernière occurrence (extension de fichier)
$fichier  = 'rapport_ventes_2024.csv';
$dernPos  = strrpos($fichier, '.');
$extension = substr($fichier, $dernPos + 1);  // 'csv'

// ── PHP 8+ : retournent directement un booléen, sans piège ─────────
$emailClient = 'omar.tazi@boutique-atlas.ma';
$identifiant = 'CASA-2024-0042';
$categorie   = 'Épicerie fine — Produits locaux';

// str_contains() : contient ?
if (str_contains($emailClient, '@boutique-atlas.ma')) {
    echo 'Compte interne confirmé';
}
// str_starts_with() : commence par ?
if (str_starts_with($identifiant, 'CASA')) {
    echo 'Commande de Casablanca';
}
// str_ends_with() : se termine par ?
if (str_ends_with($categorie, 'Produits locaux')) {
    echo 'Catégorie terroir marocain';
}

2
Transformation, extraction, formatage et sécurité
phpstrings-transformation.php
<?php
// ── Transformation de casse ────────────────────────────────────────────
echo strtolower('NADIA.BENALI@ATLAS.MA');   // 'nadia.benali@atlas.ma'
echo strtoupper('casablanca');               // 'CASABLANCA'
echo ucfirst('omar tazi');                   // 'Omar tazi'
echo ucwords('omar el tazi benali');         // 'Omar El Tazi Benali'

// mb_ pour les accents
echo mb_strtolower('Prénom Été', 'UTF-8');  // 'prénom été'

// ── Nettoyage ──────────────────────────────────────────────────────────
$saisie = '  Boutique Atlas — Fès  ';
echo trim($saisie);   // 'Boutique Atlas — Fès'
echo ltrim($saisie);  // 'Boutique Atlas — Fès  ' (gauche seulement)
echo rtrim($saisie);  // '  Boutique Atlas — Fès' (droite seulement)

// ── Remplacement ──────────────────────────────────────────────────────
echo str_replace(['é','è','ê'], 'e', 'Épicerie Été'); // 'epicerie ete'
echo str_ireplace('MAROC', 'Maroc', 'Made In MAROC'); // insensible casse

// ── Extraction ────────────────────────────────────────────────────────
$description = 'Les couscous artisanaux de Fès, préparés selon une recette ancestrale.';
// Tronquer à 50 caractères avec "..."
if (mb_strlen($description) > 50) {
    $apercu = mb_substr($description, 0, 47, 'UTF-8') . '...';
    echo $apercu;  // 'Les couscous artisanaux de Fès, préparés selon...'
}

// Extraire domaine d'un email
$email  = 'omar.tazi@boutique-atlas.ma';
$parties = explode('@', $email);
$domaine = $parties[1];  // 'boutique-atlas.ma'

// Reconstituer avec implode()
$tags    = ['couscous', 'tajine', 'pastilla', 'harira'];
echo implode(', ', $tags);  // 'couscous, tajine, pastilla, harira'

// ── Formatage et padding ──────────────────────────────────────────────
echo str_pad('42', 6, '0', STR_PAD_LEFT);   // '000042'
echo str_repeat('*', 5);                      // '*****'

// ── Sécurité ──────────────────────────────────────────────────────────
// htmlspecialchars : protège contre XSS
$entree = '<script>alert("XSS")</script>';
echo htmlspecialchars($entree, ENT_QUOTES, 'UTF-8');
// '&lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;'

// strip_tags : supprimer les balises HTML
echo strip_tags('<b>Boutique</b> Atlas');  // 'Boutique Atlas'

// Hachage de mot de passe
$hash  = password_hash('MonMotDePasse2024!', PASSWORD_BCRYPT);
$valide = password_verify('MonMotDePasse2024!', $hash);  // true

// ── Expressions régulières ────────────────────────────────────────────
// preg_match() : tester si une chaîne correspond à un pattern
$telephone = '0661-234567';
if (preg_match('/^0[67]\d{2}[\s-]?\d{6}$/', $telephone)) {
    echo 'Numéro marocain valide';
}

// preg_replace() : remplacer avec pattern
$texte = 'Commande n°   42   reçue le   2024-03-15';
$propre = preg_replace('/\s+/', ' ', $texte);  // Espaces multiples → un seul

// preg_match() avec capture
$cin = 'AB123456';
if (preg_match('/^([A-Z]{1,2})(\d{6})$/', $cin, $m)) {
    echo 'Préfixe : ' . $m[1];  // 'AB'
    echo 'Numéro  : ' . $m[2];  // '123456'
}
Partie 2 — Fondamentaux du langage date() DateTime DateInterval
⏱ 16 min

Fonctions sur les dates et les heures

Timestamp Unix, date(), mktime(), strtotime(), checkdate(), la classe DateTime/DateInterval et les fuseaux horaires — tout ce qu'il faut pour gérer les dates en PHP.


1
Timestamp Unix — time() et microtime()
🕐 Qu'est-ce qu'un timestamp Unix ?
Le timestamp Unix (epoch time) est le nombre de secondes entières écoulées depuis le 1er janvier 1970 à 00:00:00 UTC. Tous les systèmes informatiques modernes utilisent cette référence. En 2024, ce nombre dépasse 1,7 milliard.
phpdates.php
<?php
// ── time() — timestamp actuel ────────────────────────────────────────
$maintenant = time();  // ex: 1704067200

// Conversions utiles
// 1 minute  = 60 secondes
// 1 heure   = 3 600 secondes
// 1 jour    = 86 400 secondes
// 1 semaine = 604 800 secondes

$dans7jours  = time() + 7 * 86400;
$il_y_a1mois = time() - 30 * 86400;

// ── microtime() — mesure de performance ────────────────────────────
$debut = microtime(true);
// Traitement à mesurer...
for ($i = 0; $i < 100000; $i++) { $x = $i * $i; }
$fin       = microtime(true);
$dureeMs   = round(($fin - $debut) * 1000, 2);
echo "Durée : {$dureeMs} ms";

// ── date() — formater un timestamp en chaîne ───────────────────────
echo date('d/m/Y');                 // ex: 15/03/2024
echo date('d/m/Y H:i:s');          // ex: 15/03/2024 10:35:22
echo date('l d F Y', $dans7jours); // ex: Friday 22 March 2024
echo date('Y-m-d');                 // Format ISO — pour BDD MySQL

// Codes de format essentiels
// d = jour 2 chiffres, j = jour sans zéro
// m = mois 2 chiffres, n = mois sans zéro, F = nom complet
// Y = année 4 chiffres, y = année 2 chiffres
// H = heure 24h, i = minutes, s = secondes
// N = 1(lundi) à 7(dimanche), w = 0(dimanche) à 6(samedi)

// ── mktime() — créer un timestamp depuis des composants ──────────
$ts_ramadan = mktime(0, 0, 0, 3, 11, 2024);  // 11 mars 2024
echo date('d/m/Y', $ts_ramadan);  // '11/03/2024'

// ── strtotime() — parser une chaîne naturelle ────────────────────
echo date('d/m/Y', strtotime('2024-09-01'));          // '01/09/2024'
echo date('d/m/Y', strtotime('next Monday'));          // Prochain lundi
echo date('d/m/Y', strtotime('+3 months'));            // Dans 3 mois
echo date('d/m/Y', strtotime('last day of this month')); // Fin de mois

// ── checkdate() — valider une date saisie ────────────────────────
var_dump(checkdate(2, 29, 2024));  // bool(true)  — 2024 est bissextile
var_dump(checkdate(2, 29, 2023));  // bool(false) — 2023 n'est pas bissextile
var_dump(checkdate(13, 1, 2024));  // bool(false) — mois 13 invalide

// ── Fuseau horaire — contexte marocain ───────────────────────────
date_default_timezone_set('Africa/Casablanca');
echo date('H:i');  // Heure locale marocaine

// ── Classe DateTime — approche orientée objet ────────────────────
$dateNaissance = new DateTime('1995-08-22');
$maintenant_dt = new DateTime();
$diff          = $maintenant_dt->diff($dateNaissance);
echo "Âge : " . $diff->y . " ans";

// Calculer jours entre deux dates
$debut   = new DateTime('2024-09-01');
$fin     = new DateTime('2024-12-31');
$duree   = $debut->diff($fin);
echo "Durée : " . $duree->days . " jours";

// DateImmutable — immuable (préférable pour éviter les bugs)
$dateCommande = new DateTimeImmutable('2024-03-15');
$dateLivraison = $dateCommande->modify('+5 days');
echo $dateCommande->format('d/m/Y');   // '15/03/2024' — inchangée
echo $dateLivraison->format('d/m/Y');  // '20/03/2024'
Partie 2 — Fondamentaux du langage QCM
⏱ 10 min

QCM — Partie 2 : Fondamentaux du langage

Variables, types, structures de contrôle, formulaires, chaînes et dates — testez vos connaissances avant de passer aux tableaux et fichiers.


1
Variables et types
Quel est le résultat de var_dump((bool)'0') en PHP ?
Pourquoi utiliser !== false et non != false avec strpos() ?

2
Structures de contrôle et boucles
Après une boucle foreach ($prix as &$p), pourquoi faut-il écrire unset($p) ?
Que fait le pattern PRG (Post/Redirect/Get) et pourquoi est-il important ?
Partie 3 — Tableaux & Fichiers Tableaux indexés Associatifs Multidim.
⏱ 18 min

Tableaux fondamentaux — indexés, associatifs, multidimensionnels

Créer, accéder, modifier et parcourir des tableaux PHP — la structure de données la plus utilisée en PHP, présente dans $_POST, $_GET et $_SERVER.


1
Tableaux indexés — création, accès, modification, suppression
phptableaux-indexes.php
<?php
// ── Création ──────────────────────────────────────────────────────────
$villes = ['Casablanca', 'Rabat', 'Fès', 'Meknès', 'Agadir'];
//          index 0         1       2       3          4

// ── Accès — indices commencent à 0 ────────────────────────────────────
echo $villes[0];  // 'Casablanca'
echo $villes[2];  // 'Fès'

// Dernier élément sans connaître l'indice
$dernier = $villes[count($villes) - 1];  // 'Agadir'

// Toujours vérifier avant d'accéder à un indice incertain
if (isset($villes[10])) {
    echo $villes[10];
} else {
    echo 'Indice 10 inexistant';
}

// ── Modification et ajout ─────────────────────────────────────────────
$villes[1] = 'Rbat';   // Modifier
$villes[1] = 'Rabat';  // Corriger
$villes[]  = 'Tanger'; // Ajouter à la fin — index automatique (5)
array_push($villes, 'Oujda');  // Équivalent — index 6

// ── Suppression ────────────────────────────────────────────────────────
unset($villes[2]);  // Supprime 'Fès' — ATTENTION : ne réindexe pas !
// Indices restants : 0, 1, 3, 4, 5, 6  (l'indice 2 est absent)

// Réindexer après unset()
$villes = array_values($villes);  // Indices : 0, 1, 2, 3, 4, 5

// ── Parcourir ─────────────────────────────────────────────────────────
foreach ($villes as $i => $ville) {
    echo ($i + 1) . '. ' . $ville . '<br>';
}

2
Tableaux associatifs et multidimensionnels
phptableaux-associatifs.php
<?php
// ── Tableau associatif — représente un enregistrement ─────────────────
$client = [
    'nom'       => 'El Ouazzani',
    'prenom'    => 'Karim',
    'email'     => 'karim@atlas.ma',
    'ville'     => 'Casablanca',
    'solde'     => 1250.00,
    'actif'     => true,
];
echo $client['nom'];    // 'El Ouazzani'
echo $client['solde'];  // 1250.0

// Ajouter / modifier une clé
$client['telephone'] = '0661-234567';
$client['ville']     = 'Rabat';

// Supprimer une clé
unset($client['actif']);

// Parcourir avec clé => valeur
foreach ($client as $cle => $valeur) {
    echo "<dt>$cle</dt><dd>" . htmlspecialchars((string)$valeur) . "</dd>";
}

// Vérifier l'existence d'une clé
if (array_key_exists('email', $client)) {
    echo 'Email : ' . $client['email'];
}

// ── Tableau multidimensionnel — liste d'enregistrements ───────────────
$produits = [
    ['ref' => 'ARG001', 'nom' => 'Huile d\'Argan Bio 100ml', 'prix' => 89.90, 'stock' => 15, 'ville' => 'Essaouira'],
    ['ref' => 'MEL002', 'nom' => 'Miel Thym Sauvage 500g',  'prix' => 65.00, 'stock' =>  0, 'ville' => 'Meknès'],
    ['ref' => 'COS003', 'nom' => 'Savon Beldi Artisanal',   'prix' => 22.50, 'stock' => 30, 'ville' => 'Fès'],
    ['ref' => 'SAF004', 'nom' => 'Safran Premium 1g',       'prix' => 45.00, 'stock' =>  8, 'ville' => 'Taliouine'],
];

// Accès imbriqué
echo $produits[0]['nom'];     // 'Huile d\'Argan Bio 100ml'
echo $produits[0]['prix'];    // 89.90

// Affichage en tableau HTML
echo '<table border="1">';
echo '<tr><th>Réf.</th><th>Produit</th><th>Prix</th><th>Stock</th><th>Origine</th></tr>';
foreach ($produits as $p) {
    $disponible = $p['stock'] > 0 ? '✓' : '✗ Rupture';
    printf(
        '<tr><td>%s</td><td>%s</td><td>%.2f MAD</td><td>%s</td><td>%s</td></tr>',
        htmlspecialchars($p['ref']),
        htmlspecialchars($p['nom']),
        $p['prix'],
        $disponible,
        htmlspecialchars($p['ville'])
    );
}
echo '</table>';

// ── Construction dynamique en boucle ──────────────────────────────────
$commandes = [];
$lignesCSV = [
    ['CMD001', 'Karim El Ouazzani', 'Casablanca', '430.50'],
    ['CMD002', 'Fatima Benali',     'Rabat',       '89.90'],
    ['CMD003', 'Omar Tazi',         'Fès',         '215.00'],
];
foreach ($lignesCSV as $ligne) {
    $commandes[] = [
        'ref'    => $ligne[0],
        'client' => $ligne[1],
        'ville'  => $ligne[2],
        'total'  => (float)$ligne[3],
    ];
}
Partie 3 — Tableaux & Fichiers sort / usort array_map / filter array_reduce
⏱ 18 min

Fonctions sur les tableaux

Tri, recherche, transformation (map/filter/reduce), manipulation et extraction — les 30+ fonctions array PHP pour traiter n'importe quelle collection de données.


1
Fonctions de tri
FonctionTrie parOrdrePréserve les clésUsage typique
sort()ValeurASCNon — réindexeTableau indexé simple
rsort()ValeurDESCNon — réindexeTableau indexé, note décroissante
asort()ValeurASCOuiTableau associatif, trier par valeur
arsort()ValeurDESCOuiTableau associatif, palmarès
ksort()CléASCOuiTrier par nom de clé
usort()ValeurPersonnaliséNon — réindexeCritère complexe (tableau imbriqué)
phptri.php
<?php
$produits = [
    ['nom' => 'Miel Thym',    'prix' => 65.00, 'ville' => 'Meknès'],
    ['nom' => 'Huile Argan',  'prix' => 89.90, 'ville' => 'Essaouira'],
    ['nom' => 'Safran',       'prix' => 45.00, 'ville' => 'Taliouine'],
    ['nom' => 'Savon Beldi',  'prix' => 22.50, 'ville' => 'Fès'],
];

// ── usort — trier par prix décroissant ────────────────────────────────
usort($produits, fn($a, $b) => $b['prix'] <=> $a['prix']);
// Résultat : Argan(89.90) > Miel(65.00) > Safran(45.00) > Savon(22.50)

// ── usort — trier par ville puis par prix ─────────────────────────────
usort($produits, function($a, $b) {
    $villeComp = strcmp($a['ville'], $b['ville']);  // strcmp retourne -1, 0, 1
    return $villeComp !== 0 ? $villeComp : $a['prix'] <=> $b['prix'];
});

// ── SORT_NATURAL — tri naturel pour les chaînes avec chiffres ─────────
$refs = ['PROD10', 'PROD2', 'PROD1', 'PROD20', 'PROD3'];
sort($refs, SORT_STRING);   // ['PROD1','PROD10','PROD2','PROD20','PROD3'] — pur alphab.
sort($refs, SORT_NATURAL);  // ['PROD1','PROD2','PROD3','PROD10','PROD20'] — naturel

2
Transformation — array_map, array_filter, array_reduce, array_walk
phptransformation.php
<?php
$produits = [
    ['nom' => 'Huile Argan', 'prix' => 89.90, 'stock' => 15],
    ['nom' => 'Miel Thym',   'prix' => 65.00, 'stock' =>  0],
    ['nom' => 'Savon Beldi', 'prix' => 22.50, 'stock' => 30],
    ['nom' => 'Safran',      'prix' => 45.00, 'stock' =>  8],
];

// ── array_map() — transformer chaque élément ──────────────────────────
// Appliquer TVA 20% sur tous les prix
$produitsTTC = array_map(function($p) {
    $p['prix_ttc'] = round($p['prix'] * 1.20, 2);
    return $p;
}, $produits);

// Syntaxe arrow function PHP 7.4+
$noms = array_map(fn($p) => $p['nom'], $produits);
// ['Huile Argan', 'Miel Thym', 'Savon Beldi', 'Safran']

// ── array_filter() — garder les éléments selon une condition ───────────
// Produits en stock uniquement
$enStock = array_filter($produits, fn($p) => $p['stock'] > 0);
$enStock = array_values($enStock);  // Réindexer

// Produits abordables (prix < 50 MAD)
$abordables = array_filter($produits, fn($p) => $p['prix'] < 50);

// array_filter sans callback — supprime les valeurs falsy
$notes = [15, 0, 18, null, 12, false, 9];
$notesValides = array_filter($notes);  // [0=>15, 2=>18, 4=>12, 6=>9]
$notesValides = array_values($notesValides);  // Réindexer

// ── array_reduce() — réduire en une valeur ────────────────────────────
// Calculer le total du panier
$total = array_reduce($produits, fn($carry, $p) => $carry + $p['prix'], 0);
echo "Total : $total MAD";  // Total : 222.40 MAD

// Compter les produits en stock
$nbEnStock = array_reduce($produits, fn($c, $p) => $c + ($p['stock'] > 0 ? 1 : 0), 0);

// ── Recherche ─────────────────────────────────────────────────────────
$villes = ['Casablanca', 'Rabat', 'Fès', 'Agadir'];

// in_array() — vérifier la présence d'une valeur
var_dump(in_array('Fès', $villes));          // bool(true)
var_dump(in_array('Tanger', $villes));       // bool(false)

// array_search() — retourner la clé (ou false)
$index = array_search('Rabat', $villes);     // 1

// array_key_exists() — vérifier la clé
$client = ['nom' => 'Benali', 'ville' => 'Fès'];
var_dump(array_key_exists('email', $client)); // bool(false)

// ── Manipulation ──────────────────────────────────────────────────────
$liste1 = ['Casablanca', 'Rabat'];
$liste2 = ['Fès', 'Agadir'];

// Fusionner
$toutes = array_merge($liste1, $liste2);

// Supprimer les doublons
$avecDoublons = ['Casablanca', 'Rabat', 'Casablanca', 'Fès'];
$unique = array_unique($avecDoublons);

// Découper en groupes de 3 (ex: paginer des résultats)
$pages = array_chunk($produits, 2);
// $pages[0] = premiers 2 produits, $pages[1] = 2 suivants

// Extraire une colonne
$noms = array_column($produits, 'nom');
// ['Huile Argan', 'Miel Thym', 'Savon Beldi', 'Safran']

// Indexer par une clé (ex: par ref)
$prodsByNom = array_column($produits, null, 'nom');
echo $prodsByNom['Safran']['prix'];  // 45.00
Partie 3 — Tableaux & Fichiers fopen / fclose fgetcsv / fputcsv $_FILES
⏱ 18 min

Lire & écrire des fichiers

Vérification, ouverture avec tous les modes fopen(), lecture (fread, fgets, file_get_contents, fgetcsv), écriture (fwrite, fputcsv) et upload sécurisé avec $_FILES.


1
Vérification et modes fopen()
ModeDescriptionPointeurFichier inexistantContenu existant
rLecture seuleDébutErreurPréservé
wÉcriture seuleDébutCrééÉCRASÉ
aÉcriture — ajoutFinCrééPréservé (ajout)
xÉcriture — création exclusiveDébutCrééErreur si existe déjà
r+Lecture + écritureDébutErreurPréservé
w+Lecture + écritureDébutCrééÉCRASÉ
phpfichiers.php — lecture, écriture, CSV
<?php
// ── Vérification avant ouverture ──────────────────────────────────────
$chemin = __DIR__ . '/data/commandes.csv';
if (!file_exists($chemin)) {
    die('Fichier introuvable : ' . $chemin);
}
if (!is_readable($chemin)) {
    die('Fichier non lisible');
}

// ── file_get_contents() — lire tout d'un coup ─────────────────────────
$contenu = file_get_contents($chemin);

// ── file_put_contents() — écrire / ajouter ────────────────────────────
$ligne = date('Y-m-d H:i:s') . " — Connexion OK\n";
file_put_contents(__DIR__ . '/logs/acces.log', $ligne, FILE_APPEND | LOCK_EX);
// FILE_APPEND : ajoute à la fin (ne pas effacer)
// LOCK_EX     : verrou exclusif (multi-utilisateurs)

// ── Lire un CSV avec fgetcsv() ────────────────────────────────────────
$fp     = fopen($chemin, 'r');
$entete = fgetcsv($fp, 0, ';');  // Première ligne = en-têtes
$commandes = [];
while (($ligne = fgetcsv($fp, 0, ';')) !== false) {
    $commandes[] = array_combine($entete, $ligne);  // Associer clés + valeurs
}
fclose($fp);

foreach ($commandes as $cmd) {
    echo $cmd['client'] . ' — ' . $cmd['total'] . ' MAD<br>';
}

// ── Écrire un CSV avec fputcsv() ──────────────────────────────────────
$fp = fopen(__DIR__ . '/exports/commandes_export.csv', 'w');
fputcsv($fp, ['Référence', 'Client', 'Ville', 'Total', 'Date'], ';');  // En-tête
foreach ($commandes as $cmd) {
    fputcsv($fp, [
        $cmd['ref']    ?? '',
        $cmd['client'] ?? '',
        $cmd['ville']  ?? '',
        $cmd['total']  ?? 0,
        $cmd['date']   ?? '',
    ], ';');
}
fclose($fp);
echo 'Export CSV généré.';

// ── Téléchargement forcé du CSV ────────────────────────────────────────
function telechargerCsv(string $fichier, string $nomTelecharge): never {
    header('Content-Type: text/csv; charset=UTF-8');
    header('Content-Disposition: attachment; filename="' . $nomTelecharge . '"');
    header('Content-Length: ' . filesize($fichier));
    readfile($fichier);
    exit;
}
// telechargerCsv(__DIR__ . '/exports/commandes_export.csv', 'commandes.csv');

2
Upload de fichiers — $_FILES et move_uploaded_file()
phpupload.php — formulaire + traitement sécurisé
<?php
// ── Structure de $_FILES après upload ─────────────────────────────────
// $_FILES['cv'] = [
//   'name'     => 'cv_karim_2024.pdf',     // Nom original (côté client)
//   'type'     => 'application/pdf',        // MIME déclaré (NON fiable !)
//   'size'     => 524288,                   // Taille en octets
//   'tmp_name' => '/tmp/phpXXXXXX',        // Chemin temporaire côté serveur
//   'error'    => 0,                        // 0 = UPLOAD_ERR_OK
// ]

function traiterUpload(array $fichier, string $dossierDest): array {
    $maxTaille   = 2 * 1024 * 1024;  // 2 Mo
    $extsAutorisees = ['pdf', 'jpg', 'jpeg', 'png'];

    // 1. Vérifier l'absence d'erreur
    if ($fichier['error'] !== UPLOAD_ERR_OK) {
        return ['ok' => false, 'erreur' => 'Erreur lors de l\'upload (code ' . $fichier['error'] . ')'];
    }

    // 2. Vérifier la taille
    if ($fichier['size'] > $maxTaille) {
        return ['ok' => false, 'erreur' => 'Fichier trop lourd (max 2 Mo)'];
    }

    // 3. Vérifier l'extension réelle — JAMAIS se fier au type MIME déclaré
    $ext = strtolower(pathinfo($fichier['name'], PATHINFO_EXTENSION));
    if (!in_array($ext, $extsAutorisees)) {
        return ['ok' => false, 'erreur' => 'Extension non autorisée : ' . $ext];
    }

    // 4. Vérifier le type MIME réel avec finfo
    $finfo    = new finfo(FILEINFO_MIME_TYPE);
    $mimeReel = $finfo->file($fichier['tmp_name']);
    $mimeOk   = ['application/pdf', 'image/jpeg', 'image/png'];
    if (!in_array($mimeReel, $mimeOk)) {
        return ['ok' => false, 'erreur' => 'Type de fichier non autorisé'];
    }

    // 5. Générer un nom unique (évite l'écrasement et les noms malveillants)
    $nomSecurise = bin2hex(random_bytes(8)) . '.' . $ext;
    $destination = rtrim($dossierDest, '/') . '/' . $nomSecurise;

    // 6. Déplacer depuis le dossier temporaire
    if (!move_uploaded_file($fichier['tmp_name'], $destination)) {
        return ['ok' => false, 'erreur' => 'Impossible de déplacer le fichier'];
    }

    return ['ok' => true, 'nom' => $nomSecurise, 'chemin' => $destination];
}

// Traitement POST
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['cv'])) {
    $resultat = traiterUpload($_FILES['cv'], __DIR__ . '/uploads/cvs');
    if ($resultat['ok']) {
        echo 'CV uploadé : ' . htmlspecialchars($resultat['nom']);
    } else {
        echo 'Erreur : ' . htmlspecialchars($resultat['erreur']);
    }
}
?>
<!-- Formulaire HTML — enctype obligatoire pour l'upload ──────────── -->
<form method="post" enctype="multipart/form-data">
  <label for="cv">Votre CV (PDF, max 2 Mo) :</label>
  <input type="file" id="cv" name="cv" accept=".pdf,.jpg,.png">
  <button type="submit">Envoyer le CV</button>
</form>
Partie 3 — Tableaux & Fichiers glob / pathinfo ZipArchive XML / JSON
⏱ 16 min

Fichiers avancés — Chemins, ZIP, CSV, XML, JSON

basename(), dirname(), pathinfo(), realpath(), glob(), ZipArchive, simplexml_load_file(), json_decode() — traitement avancé des fichiers avec un projet TP import/export complet.


1
Fonctions de chemins et listing avec glob()
phpchemins.php
<?php
$chemin = 'C:/wamp64/www/boutique/exports/commandes_2024.csv';

// ── Décomposer un chemin ──────────────────────────────────────────────
echo basename($chemin);           // 'commandes_2024.csv'
echo basename($chemin, '.csv');   // 'commandes_2024'
echo dirname($chemin);            // 'C:/wamp64/www/boutique/exports'
echo dirname($chemin, 2);         // 'C:/wamp64/www/boutique'

$info = pathinfo($chemin);
// ['dirname'=>'...exports', 'basename'=>'commandes_2024.csv',
//  'extension'=>'csv', 'filename'=>'commandes_2024']

echo pathinfo($chemin, PATHINFO_EXTENSION); // 'csv'
echo pathinfo($chemin, PATHINFO_FILENAME);  // 'commandes_2024'

// ── glob() — lister des fichiers avec un motif ────────────────────────
// Tous les CSV du dossier exports/
$csvFiles = glob(__DIR__ . '/exports/*.csv');
foreach ($csvFiles as $f) {
    echo basename($f) . ' — ' . number_format(filesize($f)/1024, 1) . ' Ko — ';
    echo date('d/m/Y H:i', filemtime($f)) . '<br>';
}

// Plusieurs extensions en une fois (GLOB_BRACE)
$images = glob(__DIR__ . '/uploads/{*.jpg,*.jpeg,*.png}', GLOB_BRACE);
echo count($images) . ' images uploadées';

// ── Sécurité — realpath() pour éviter le path traversal ──────────────
function estDansDossier(string $fichier, string $dossierAutorise): bool {
    $reel = realpath($fichier);
    $base = realpath($dossierAutorise);
    if ($reel === false || $base === false) return false;
    return strpos($reel, $base . DIRECTORY_SEPARATOR) === 0;
}

$fichierDemande = __DIR__ . '/exports/' . ($_GET['fichier'] ?? '');
if (!estDansDossier($fichierDemande, __DIR__ . '/exports')) {
    die('Accès refusé');  // Protection contre ../../etc/passwd
}

2
ZipArchive, XML et JSON
phpzip-xml-json.php
<?php
// ── ZipArchive — compresser des CVs ──────────────────────────────────
function compresserCvs(array $cheminsCvs, string $archiveDest): bool {
    $zip = new ZipArchive();
    if ($zip->open($archiveDest, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
        return false;
    }
    foreach ($cheminsCvs as $cv) {
        if (file_exists($cv)) {
            $zip->addFile($cv, basename($cv));  // Ajouter avec son nom seul
        }
    }
    $zip->close();
    return true;
}

$cvs = glob(__DIR__ . '/uploads/cvs/*.pdf');
compresserCvs($cvs, __DIR__ . '/exports/tous_les_cvs.zip');

// Extraire une archive ZIP
$zip = new ZipArchive();
if ($zip->open(__DIR__ . '/exports/tous_les_cvs.zip') === true) {
    $zip->extractTo(__DIR__ . '/uploads/cvs_extraits/');
    $zip->close();
}

// ── Parsing XML avec SimpleXML ────────────────────────────────────────
// Exemple de fichier XML : config_centres.xml
$xmlData = '<?xml version="1.0" encoding="UTF-8"?>
<centres>
  <centre id="1">
    <nom>Atlas Digital Casablanca</nom>
    <ville>Casablanca</ville>
    <capacite>120</capacite>
  </centre>
  <centre id="2">
    <nom>Atlas Digital Rabat</nom>
    <ville>Rabat</ville>
    <capacite>80</capacite>
  </centre>
</centres>';

$xml = simplexml_load_string($xmlData);
foreach ($xml->centre as $centre) {
    echo "Centre #{$centre['id']} : {$centre->nom} ({$centre->ville})<br>";
}

// Depuis un fichier
// $xml = simplexml_load_file(__DIR__ . '/config/centres.xml');

// ── JSON — import/export de données ──────────────────────────────────
$config = json_decode(
    file_get_contents(__DIR__ . '/config/parametres.json'),
    true  // true = tableau associatif
);
echo $config['app_name'] ?? 'Configuration invalide';

// Sauvegarder de la configuration en JSON
$parametres = [
    'app_name'    => 'Boutique Atlas',
    'devise'      => 'MAD',
    'tva'         => 0.20,
    'ville_defaut'=> 'Casablanca',
    'max_upload'  => 2097152,
];
file_put_contents(
    __DIR__ . '/config/parametres.json',
    json_encode($parametres, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
);

3
Gestion des répertoires — mkdir, scandir, rmdir
phprepertoires.php
<?php
// ── Créer un dossier si inexistant ───────────────────────────────────
$dossierLogs = __DIR__ . '/logs';
if (!is_dir($dossierLogs)) {
    mkdir($dossierLogs, 0755, true);  // true = récursif (crée les parents)
    echo 'Dossier logs créé';
}

// Créer l'arborescence de la saison en une fois
$annee   = date('Y');
$saison  = date('m') <= 6 ? 'S1' : 'S2';
$dossier = __DIR__ . "/archives/$annee/$saison";
if (!is_dir($dossier)) {
    mkdir($dossier, 0755, true);
}

// ── Lister le contenu d'un dossier ────────────────────────────────────
$contenu = scandir(__DIR__ . '/exports');
foreach ($contenu as $item) {
    if ($item === '.' || $item === '..') continue;
    $chemin = __DIR__ . '/exports/' . $item;
    $type   = is_dir($chemin) ? '[DIR]' : '[FILE]';
    echo "$type $item<br>";
}

// ── Supprimer un dossier vide ─────────────────────────────────────────
// rmdir() ne supprime que les dossiers VIDES
// rmdir(__DIR__ . '/exports/vieux');

// Supprimer récursivement (à utiliser avec PRÉCAUTION)
function supprimerRecursivement(string $dossier): void {
    if (!is_dir($dossier)) return;
    $items = scandir($dossier);
    foreach ($items as $item) {
        if ($item === '.' || $item === '..') continue;
        $chemin = $dossier . '/' . $item;
        is_dir($chemin) ? supprimerRecursivement($chemin) : unlink($chemin);
    }
    rmdir($dossier);
}
Partie 3 — Tableaux & Fichiers QCM
⏱ 8 min

QCM — Partie 3 : Tableaux & Fichiers

Tableaux, fonctions de tableau, fichiers et chemins — vérifiez vos acquis avant la POO.


1
Tableaux
Après unset($villes[2]), que retourne count($villes) si le tableau avait 5 éléments ?
Quelle est la différence entre array_map() et array_filter() ?

2
Fichiers et sécurité
Pourquoi ne pas se fier au type MIME déclaré dans $_FILES['fichier']['type'] pour valider un upload ?
Partie 4 — Programmation Orientée Objet class / new Attributs Getters/Setters
⏱ 20 min

Introduction à la Programmation Orientée Objet

Limites du code procédural, classe et objet, attributs (public/private/protected), constructeur __construct(), méthodes, getters/setters et encapsulation — la classe Produit complète.


1
Procédural vs POO — pourquoi changer de paradigme ?
CritèreCode procéduralProgrammation Orientée Objet
OrganisationDonnées et fonctions séparéesDonnées + comportements regroupés dans la classe
CohérenceAucune garantie — tableau peut manquer de champsLe constructeur garantit l'état initial valide
RéutilisabilitéFonctions génériques, pas liées aux donnéesMéthodes liées à l'objet, portables et testables
MaintenanceModifier un champ = modifier toutes les fonctionsModifier une classe = un seul endroit
EncapsulationDonnées exposées, modifiables de partoutAttributs privés + getters/setters contrôlés
ModélisationÉloigné du monde réelUn objet Produit = un vrai produit

2
Classe Produit — attributs, constructeur, méthodes, getters/setters
phpProduit.php — classe complète
<?php

class Produit {

    // ── Attributs — visibilité ────────────────────────────────────────
    // public    : accessible partout
    // protected : classe + sous-classes uniquement
    // private   : classe uniquement (recommandé par défaut)

    private int    $id;
    private string $nom;
    private float  $prix;
    private int    $stock;
    private string $ville;        // Ville d'origine du produit
    private bool   $actif = true; // Valeur par défaut

    // ── Constructeur — garantit l'état initial valide ────────────────
    public function __construct(
        int    $id,
        string $nom,
        float  $prix,
        int    $stock,
        string $ville
    ) {
        $this->id    = $id;
        $this->setNom($nom);    // Utiliser le setter pour valider
        $this->setPrix($prix);
        $this->setStock($stock);
        $this->ville = $ville;
    }

    // ── Getters — lire les attributs privés ───────────────────────────
    public function getId():    int    { return $this->id; }
    public function getNom():   string { return $this->nom; }
    public function getPrix():  float  { return $this->prix; }
    public function getStock(): int    { return $this->stock; }
    public function getVille(): string { return $this->ville; }
    public function isActif():  bool   { return $this->actif; }

    // ── Setters — modifier avec validation ───────────────────────────
    public function setNom(string $nom): void {
        if (empty(trim($nom))) {
            throw new InvalidArgumentException('Le nom ne peut pas être vide');
        }
        $this->nom = trim($nom);
    }

    public function setPrix(float $prix): void {
        if ($prix < 0) {
            throw new InvalidArgumentException('Le prix ne peut pas être négatif');
        }
        $this->prix = $prix;
    }

    public function setStock(int $stock): void {
        if ($stock < 0) {
            throw new InvalidArgumentException('Le stock ne peut pas être négatif');
        }
        $this->stock = $stock;
    }

    // ── Méthodes métier ───────────────────────────────────────────────
    public function estDisponible(): bool {
        return $this->actif && $this->stock > 0;
    }

    public function getPrixTTC(float $tauxTva = 0.20): float {
        return round($this->prix * (1 + $tauxTva), 2);
    }

    public function diminuerStock(int $quantite): void {
        if ($quantite > $this->stock) {
            throw new RuntimeException(
                "Stock insuffisant : {$this->stock} disponible(s), {$quantite} demandé(s)"
            );
        }
        $this->stock -= $quantite;
    }

    public function desactiver(): void {
        $this->actif = false;
    }

    // ── Méthode d'affichage ───────────────────────────────────────────
    public function __toString(): string {
        $dispo = $this->estDisponible() ? 'En stock' : 'Indisponible';
        return sprintf(
            '[%d] %s — %.2f MAD (%s) — Origine : %s',
            $this->id, $this->nom, $this->prix, $dispo, $this->ville
        );
    }

    // ── PHP 8.0 : Constructor Promotion — syntaxe concise ─────────────
    // (alternative au code ci-dessus — les deux sont équivalents)
    // public function __construct(
    //     private int    $id,
    //     private string $nom,
    //     private float  $prix,
    //     private int    $stock = 0,
    //     private string $ville = 'Maroc'
    // ) {}
}

// ── Utilisation ────────────────────────────────────────────────────────
$argan = new Produit(1, 'Huile d\'Argan Bio 100ml', 89.90, 15, 'Essaouira');
$miel  = new Produit(2, 'Miel Thym Sauvage 500g',  65.00,  0, 'Meknès');
$safran = new Produit(3, 'Safran Premium 1g',        45.00,  8, 'Taliouine');

echo $argan;                          // [1] Huile d'Argan Bio 100ml — 89.90 MAD (En stock) — Origine : Essaouira
echo $argan->getPrixTTC();            // 107.88
echo $miel->estDisponible() ? 'Dispo' : 'Rupture';  // 'Rupture'

$argan->diminuerStock(3);
echo $argan->getStock();  // 12

// Plusieurs objets — chacun a sa propre copie des attributs
$argan2 = new Produit(4, 'Huile d\'Argan 50ml', 55.00, 5, 'Essaouira');
var_dump($argan === $argan2);   // bool(false) — deux objets distincts

// Tableau d'objets
$catalogue = [$argan, $miel, $safran];
foreach ($catalogue as $produit) {
    if ($produit->estDisponible()) {
        printf("%-25s %.2f MAD TTC\n", $produit->getNom(), $produit->getPrixTTC());
    }
}
💡 Classe vs Objet — distinction fondamentale
La classe est le modèle (plan de construction) — elle existe une seule fois dans le code. L'objet est une instance : chaque appel à new Produit() crée un objet distinct en mémoire avec ses propres données. On peut avoir 500 objets Produit en mémoire, mais une seule classe Produit dans le code.
Pourquoi utiliser des getters/setters plutôt que des attributs public ?
Partie 4 — POO extends abstract implements
⏱ 18 min

Héritage, classes abstraites & interfaces

extends, abstract, implements, final, parent:: — structurer une hiérarchie de classes cohérente pour le catalogue de la boutique Atlas.


1
Héritage avec extends — classe mère et classe fille
phpheritage.php
<?php

// ── Classe mère — Article de base ──────────────────────────────────────
class Article {
    public function __construct(
        protected int    $id,
        protected string $nom,
        protected float  $prix,
        protected string $ville = 'Maroc'
    ) {}

    public function getId():   int    { return $this->id; }
    public function getNom():  string { return $this->nom; }
    public function getPrix(): float  { return $this->prix; }

    public function getPrixTTC(float $tva = 0.20): float {
        return round($this->prix * (1 + $tva), 2);
    }

    public function __toString(): string {
        return "[{$this->id}] {$this->nom} — {$this->prix} MAD";
    }
}

// ── Classe fille — Produit alimentaire ─────────────────────────────────
class ProduitAlimentaire extends Article {
    public function __construct(
        int    $id,
        string $nom,
        float  $prix,
        string $ville,
        private string $dlu,           // Date Limite d'Utilisation
        private bool   $bio = false
    ) {
        parent::__construct($id, $nom, $prix, $ville);  // Appeler le constructeur parent
    }

    public function getDlu(): string { return $this->dlu; }
    public function isBio(): bool    { return $this->bio; }

    public function estPerime(): bool {
        return strtotime($this->dlu) < time();
    }

    // Override — surcharger la méthode parent
    public function __toString(): string {
        $bio    = $this->bio ? ' [BIO]' : '';
        $statut = $this->estPerime() ? ' ⚠️ PÉRIMÉ' : '';
        return parent::__toString() . $bio . $statut . ' — DLU: ' . $this->dlu;
    }
}

// ── Classe fille — Produit artisanal ───────────────────────────────────
class ProduitArtisanal extends Article {
    public function __construct(
        int    $id,
        string $nom,
        float  $prix,
        string $ville,
        private string $artisan,
        private string $technique  // ex: 'Tissage à la main', 'Poterie au tour'
    ) {
        parent::__construct($id, $nom, $prix, $ville);
    }

    public function getArtisan():   string { return $this->artisan; }
    public function getTechnique(): string { return $this->technique; }

    public function __toString(): string {
        return parent::__toString() . " — Par {$this->artisan} ({$this->technique})";
    }
}

// ── Utilisation ────────────────────────────────────────────────────────
$miel   = new ProduitAlimentaire(1, 'Miel Thym Sauvage 500g', 65.00, 'Meknès', '2025-12-31', true);
$tapis  = new ProduitArtisanal(2, 'Tapis Berbère 2m×1.5m', 2500.00, 'Marrakech', 'Hassan Ouassou', 'Tissage traditionnel');

echo $miel . PHP_EOL;    // [1] Miel Thym Sauvage 500g — 65 MAD [BIO] — DLU: 2025-12-31
echo $tapis . PHP_EOL;   // [2] Tapis Berbère 2m×1.5m — 2500 MAD — Par Hassan Ouassou (Tissage...)

// instanceof — vérifier le type
var_dump($miel  instanceof Article);            // bool(true)
var_dump($miel  instanceof ProduitAlimentaire); // bool(true)
var_dump($miel  instanceof ProduitArtisanal);   // bool(false)
var_dump($tapis instanceof Article);            // bool(true)

2
Classes abstraites et interfaces
phpabstraites-interfaces.php
<?php

// ── Interface — contrat que les classes doivent respecter ──────────────
interface Exportable {
    public function toArray(): array;
    public function toJson(): string;
}

interface Calculable {
    public function calculerTotal(): float;
}

// ── Classe abstraite — modèle incomplet ────────────────────────────────
// Ne peut PAS être instanciée directement
abstract class DocumentCommerce {
    protected array $lignes = [];

    abstract public function getType(): string;  // Méthode à implémenter

    public function ajouterLigne(array $ligne): void {
        $this->lignes[] = $ligne;
    }

    public function getNbLignes(): int {
        return count($this->lignes);
    }
}

// ── Classe concrète — implémente et hérite ─────────────────────────────
class Facture extends DocumentCommerce implements Exportable, Calculable {
    private string $numero;
    private string $client;
    private string $date;

    public function __construct(string $client) {
        $this->client  = $client;
        $this->numero  = 'FAC-' . date('Y') . '-' . str_pad(rand(1, 9999), 4, '0', STR_PAD_LEFT);
        $this->date    = date('Y-m-d');
    }

    // Implémenter la méthode abstraite
    public function getType(): string {
        return 'Facture';
    }

    // Implémenter l'interface Calculable
    public function calculerTotal(): float {
        return array_reduce($this->lignes, fn($s, $l) => $s + ($l['prix'] * $l['qte']), 0.0);
    }

    // Implémenter l'interface Exportable
    public function toArray(): array {
        return [
            'numero' => $this->numero,
            'client' => $this->client,
            'date'   => $this->date,
            'lignes' => $this->lignes,
            'total'  => $this->calculerTotal(),
        ];
    }

    public function toJson(): string {
        return json_encode($this->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
    }
}

// ── Utilisation ────────────────────────────────────────────────────────
$facture = new Facture('Karim El Ouazzani — Casablanca');
$facture->ajouterLigne(['produit' => 'Huile Argan',  'qte' => 2, 'prix' => 89.90]);
$facture->ajouterLigne(['produit' => 'Miel Thym',    'qte' => 1, 'prix' => 65.00]);
$facture->ajouterLigne(['produit' => 'Savon Beldi',  'qte' => 3, 'prix' => 22.50]);

echo 'Type : ' . $facture->getType() . PHP_EOL;
echo 'Total : ' . $facture->calculerTotal() . ' MAD' . PHP_EOL;  // 312.30 MAD
echo $facture->toJson();
Partie 4 — POO trait static :: namespace / PSR-4
⏱ 16 min

Traits, membres statiques & Namespaces

trait use, membres statiques (static, ::, self), namespaces PHP et PSR-4 — organiser un projet professionnel avec autoloading Composer.


1
Traits — réutiliser du code sans héritage
phptraits.php
<?php

// ── Trait — bloc de méthodes réutilisables ────────────────────────────
trait Horodatage {
    private ?string $creeLe  = null;
    private ?string $misAJourLe = null;

    public function initialiserHorodatage(): void {
        $this->creeLe = date('Y-m-d H:i:s');
    }

    public function mettreAJour(): void {
        $this->misAJourLe = date('Y-m-d H:i:s');
    }

    public function getCreeLe(): ?string     { return $this->creeLe; }
    public function getMisAJourLe(): ?string  { return $this->misAJourLe; }
}

trait Serialisable {
    public function toArray(): array {
        return get_object_vars($this);
    }

    public function toJson(): string {
        return json_encode($this->toArray(), JSON_UNESCAPED_UNICODE);
    }
}

// ── Utiliser les traits dans des classes ──────────────────────────────
class Client {
    use Horodatage, Serialisable;  // Plusieurs traits séparés par virgule

    public function __construct(
        private int    $id,
        private string $nom,
        private string $email,
        private string $ville
    ) {
        $this->initialiserHorodatage();
    }
}

class Commande {
    use Horodatage;

    public function __construct(
        private int    $id,
        private string $clientNom,
        private float  $total
    ) {
        $this->initialiserHorodatage();
    }
}

// Membres statiques
class Compteur {
    private static int $instances = 0;

    public function __construct() {
        self::$instances++;
    }

    public static function getNbInstances(): int {
        return self::$instances;
    }
}

// Namespaces — organisation PSR-4
namespace App\Boutique\Models;

class Produit { /* ... */ }

namespace App\Boutique\Services;

use App\Boutique\Models\Produit;

class CatalogueService {
    public function ajouterProduit(Produit $p): void { /* ... */ }
}
Partie 4 — POO __get / __set __toString __invoke
⏱ 16 min

Méthodes magiques I

__get(), __set(), __isset(), __unset(), __toString(), __invoke(), __clone() — les méthodes déclenchées automatiquement par PHP dans des contextes spécifiques.


1
__get, __set, __isset — accès dynamique aux propriétés
phpmagiques1.php
<?php

class ConfigBoutique {
    private array $donnees = [
        'app_name'    => 'Boutique Atlas',
        'devise'      => 'MAD',
        'tva'         => 0.20,
        'ville_siege' => 'Casablanca',
    ];

    // __get() — appelé quand on accède à une propriété inaccessible
    public function __get(string $nom): mixed {
        if (array_key_exists($nom, $this->donnees)) {
            return $this->donnees[$nom];
        }
        throw new RuntimeException("Paramètre '$nom' introuvable");
    }

    // __set() — appelé quand on écrit une propriété inaccessible
    public function __set(string $nom, mixed $valeur): void {
        $this->donnees[$nom] = $valeur;
    }

    // __isset() — appelé par isset() sur une propriété inaccessible
    public function __isset(string $nom): bool {
        return isset($this->donnees[$nom]);
    }

    // __unset() — appelé par unset() sur une propriété inaccessible
    public function __unset(string $nom): void {
        unset($this->donnees[$nom]);
    }

    // __toString() — appelé lors d'une conversion en chaîne
    public function __toString(): string {
        return json_encode($this->donnees, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
    }

    // __invoke() — appelé quand l'objet est utilisé comme une fonction
    public function __invoke(string $cle): mixed {
        return $this->donnees[$cle] ?? null;
    }
}

$config = new ConfigBoutique();

// __get()
echo $config->app_name;     // 'Boutique Atlas'
echo $config->tva;          // 0.20

// __set()
$config->max_articles = 100;

// __isset()
var_dump(isset($config->devise));      // bool(true)
var_dump(isset($config->inexistant));  // bool(false)

// __toString()
echo $config;  // Affiche le JSON formaté

// __invoke()
echo $config('ville_siege');  // 'Casablanca'

// ── __clone() — appelé lors d'un clonage ───────────────────────────────
class Panier {
    private array $articles = [];
    private string $creerLe;

    public function __construct() {
        $this->creerLe = date('Y-m-d H:i:s');
    }

    public function ajouterArticle(string $nom, float $prix): void {
        $this->articles[] = ['nom' => $nom, 'prix' => $prix];
    }

    // __clone() — appelé par clone $objet
    public function __clone() {
        // Créer une vraie copie des articles (deep copy)
        $this->articles = array_map(fn($a) => $a, $this->articles);
        $this->creerLe  = date('Y-m-d H:i:s');  // Nouvelle date
    }
}

$panier1 = new Panier();
$panier1->ajouterArticle('Huile Argan', 89.90);

$panier2 = clone $panier1;  // __clone() appelé
$panier2->ajouterArticle('Miel Thym', 65.00);
// $panier1 contient 1 article, $panier2 en contient 2
Partie 4 — POO __sleep / __wakeup __call Sérialisation
⏱ 14 min

Méthodes magiques II & récapitulatif POO

__sleep(), __wakeup(), __call(), __callStatic(), __debugInfo() — sérialisation d'objets, proxys dynamiques et récapitulatif complet de la POO PHP.


1
__sleep, __wakeup et __call
phpmagiques2.php
<?php

class SessionClient {
    private string $nom;
    private string $email;
    private string $token;  // Sensible — ne pas sérialiser

    // __sleep() — appelé AVANT serialize()
    // Retourne la liste des propriétés à sérialiser
    public function __sleep(): array {
        return ['nom', 'email'];  // 'token' exclu
    }

    // __wakeup() — appelé APRÈS unserialize()
    // Restaurer les données non sérialisées
    public function __wakeup(): void {
        $this->token = '';  // Réinitialiser le token (doit être regénéré)
    }
}

// ── __call() — appel de méthode inexistante ────────────────────────────
class QueryBuilder {
    private string $table = '';
    private array  $conditions = [];

    public function table(string $table): static {
        $this->table = $table;
        return $this;
    }

    // __call() intercepte les appels à des méthodes inexistantes
    // Ici : génère dynamiquement les méthodes whereNomChamp()
    public function __call(string $methode, array $args): static {
        if (str_starts_with($methode, 'where')) {
            // whereville('Casablanca') → condition ville = 'Casablanca'
            $champ = lcfirst(substr($methode, 5));  // 'ville'
            $this->conditions[] = "$champ = '{$args[0]}'";
        }
        return $this;
    }

    public function buildSql(): string {
        $sql = "SELECT * FROM {$this->table}";
        if (!empty($this->conditions)) {
            $sql .= " WHERE " . implode(' AND ', $this->conditions);
        }
        return $sql;
    }
}

// Utilisation fluide grâce à __call()
$sql = (new QueryBuilder())
    ->table('produits')
    ->whereVille('Casablanca')
    ->whereActif('1')
    ->buildSql();
echo $sql;
// SELECT * FROM produits WHERE ville = 'Casablanca' AND actif = '1'

// ── Récapitulatif des méthodes magiques ─────────────────────────────────────
// __construct()   : appelé lors de new Classe()
// __destruct()    : appelé à la destruction de l'objet
// __get($nom)     : lecture d'une propriété inaccessible
// __set($n, $v)   : écriture d'une propriété inaccessible
// __isset($nom)   : isset() sur propriété inaccessible
// __unset($nom)   : unset() sur propriété inaccessible
// __toString()    : conversion en chaîne (echo $obj)
// __invoke(...)   : utilisation comme fonction ($obj())
// __clone()       : après clone $obj
// __sleep()       : avant serialize()
// __wakeup()      : après unserialize()
// __call($n, $a)  : appel de méthode d'instance inexistante
// __callStatic()  : appel de méthode statique inexistante
// __debugInfo()   : personnalise var_dump()
Partie 4 — POO QCM
⏱ 8 min

QCM — Partie 4 : POO

Classes, héritage, interfaces, méthodes magiques — consolidez vos acquis avant PDO.


1
Classes et héritage
Quelle est la différence entre une classe abstraite et une interface en PHP ?
À quel moment est appelée la méthode __toString() ?
Partie 5 — PDO & Base de données PDO DSN PDOException
⏱ 16 min

Connexion PDO — DSN, options et gestion des erreurs

Établir une connexion PDO sécurisée à MySQL : DSN, les 3 options indispensables, try/catch PDOException et fichier config.php centralisé. La base de toutes les interactions avec la base de données.


1
Pourquoi PDO ? — mysqli_ vs PDO
Critèremysqli_PDO
Bases de donnéesMySQL uniquementMySQL, PostgreSQL, SQLite, Oracle…
Paramètres nommésNonOui — :nom
Exceptions automatiquesNon — vérification manuelleOui avec ERRMODE_EXCEPTION
RecommandéNonOui — tout nouveau projet

PDO propose trois avantages clés : abstraction (un seul code pour plusieurs SGBD, changer le DSN suffit), sécurité (requêtes préparées qui neutralisent l'injection SQL), et portabilité (MySQL local → PostgreSQL production sans réécrire la logique PHP).


2
Le DSN, les 3 options indispensables et try/catch
phpconfig.php — connexion PDO complète
<?php
// config.php — à inclure avec require_once __DIR__.'/config.php' dans chaque page

// ── Constantes de connexion ────────────────────────────────────────────
const DB_HOST    = 'localhost';
const DB_NAME    = 'boutique_atlas';   // Nom de votre base de données
const DB_USER    = 'root';
const DB_PASS    = '';
const DB_CHARSET = 'utf8mb4';          // Supporte accents, arabe et emojis

/**
 * Retourne une connexion PDO configurée (singleton).
 * La même connexion est réutilisée pour toute la requête HTTP.
 */
function getConnexion(): PDO {
    static $pdo = null;
    if ($pdo !== null) return $pdo;

    // DSN — Data Source Name : pilote:hôte;base;encodage
    $dsn = 'mysql:host=' . DB_HOST
         . ';dbname=' . DB_NAME
         . ';charset=' . DB_CHARSET;

    $options = [
        // 1. ERRMODE_EXCEPTION — PDO lance PDOException en cas d'erreur SQL
        //    Sans cette option, les erreurs échouent silencieusement — bugs invisibles
        PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,

        // 2. FETCH_ASSOC — fetchAll() retourne des tableaux associatifs par défaut
        //    ['id'=>1, 'nom'=>'Benali'] au lieu de [0=>1, 'id'=>1, 'nom'=>'Benali', 1=>'Benali']
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,

        // 3. EMULATE_PREPARES false — requêtes préparées NATIVES côté MySQL
        //    Plus sécurisé — MySQL reçoit vraiment le SQL séparé des données
        PDO::ATTR_EMULATE_PREPARES   => false,
    ];

    $pdo = new PDO($dsn, DB_USER, DB_PASS, $options);
    return $pdo;
}
phpconnexion.php — test avec try/catch
<?php
require_once __DIR__ . '/config.php';

try {
    $pdo = getConnexion();

    // getAttribute() lit les infos de la connexion
    $version = $pdo->getAttribute(PDO::ATTR_SERVER_VERSION);
    $driver  = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);

    echo "<p style='color:green'>✓ Connexion PDO réussie !</p>";
    echo "<ul>";
    echo "<li>Pilote : $driver</li>";
    echo "<li>Version MySQL : $version</li>";
    echo "<li>Base : " . DB_NAME . "</li>";
    echo "</ul>";

    // Pilotes PDO disponibles sur ce serveur
    echo '<p>Pilotes : ' . implode(', ', PDO::getAvailableDrivers()) . '</p>';

    // Test simple — compter les produits
    $nb = $pdo->query('SELECT COUNT(*) FROM produits')->fetchColumn();
    echo "<p>Produits en base : <strong>$nb</strong></p>";

} catch (PDOException $e) {
    // EN DÉVELOPPEMENT : afficher le message pour déboguer
    echo '<p style="color:red">✗ Erreur : ' . htmlspecialchars($e->getMessage()) . '</p>';
    // EN PRODUCTION — remplacer par :
    // error_log('[PDO] ' . $e->getMessage());
    // echo '<p>Service temporairement indisponible.</p>';
}
⚠️ Règle absolue — ne jamais afficher $e->getMessage() en production
Le message de PDOException contient le DSN, le nom de la base, parfois des fragments de la requête. En production, journaliser avec error_log() et afficher uniquement un message générique à l'utilisateur.

3
Créer la base de données et la table de référence
bashscript SQL — boutique_atlas
-- Exécuter dans phpMyAdmin → onglet SQL

-- 1. Créer la base avec encodage complet (accents, arabe, emojis)
CREATE DATABASE IF NOT EXISTS boutique_atlas
    CHARACTER SET utf8mb4
    COLLATE utf8mb4_unicode_ci;

USE boutique_atlas;

-- 2. Table produits — utilisée dans toutes les séances PDO
CREATE TABLE IF NOT EXISTS produits (
    id          INT AUTO_INCREMENT PRIMARY KEY,
    nom         VARCHAR(200) NOT NULL,
    prix        DECIMAL(8,2) NOT NULL CHECK (prix >= 0),
    stock       INT          NOT NULL DEFAULT 0,
    ville       VARCHAR(100),
    categorie   VARCHAR(100),
    actif       TINYINT(1)   NOT NULL DEFAULT 1,
    created_at  DATETIME     DEFAULT CURRENT_TIMESTAMP
);

-- 3. Données de test — produits du terroir marocain
INSERT INTO produits (nom, prix, stock, ville, categorie) VALUES
    ('Huile d\'Argan Bio 100ml',      89.90, 15, 'Essaouira',  'Cosmétique'),
    ('Miel Thym Sauvage 500g',        65.00,  0, 'Meknès',     'Alimentaire'),
    ('Savon Beldi Artisanal',         22.50, 30, 'Fès',        'Cosmétique'),
    ('Safran Premium 1g',             45.00,  8, 'Taliouine',  'Épices'),
    ('Huile d\'Olive Extra Vierge 1L',85.00, 12, 'Meknès',     'Alimentaire'),
    ('Eau de Rose Damassena 100ml',   55.00,  5, 'Kelaat M''gouna', 'Cosmétique');
Partie 5 — PDO & Base de données prepare / execute Injection SQL bindValue
⏱ 18 min

Requêtes SQL via PDO — Injection SQL & requêtes préparées

Comprendre l'injection SQL par la démonstration, maîtriser prepare() + execute(), la différence entre bindParam() et bindValue(), et sécuriser une recherche LIKE.


1
Démonstration live — Injection SQL
🚨 Code vulnérable — Ne jamais faire cela

La concaténation directe d'une variable dans une requête SQL est une faille critique. Un attaquant peut contourner l'authentification, extraire toute la base, ou détruire des tables.

phpinjection-demo.php — vulnérable vs protégé
<?php
// ── CODE VULNÉRABLE — ne jamais utiliser en production ──────────────────
$nom = $_GET['nom'];  // valeur venant du navigateur — non validée

$sql  = "SELECT * FROM produits WHERE nom = '$nom'";
$stmt = $pdo->query($sql);  // DANGEREUX

// Injection 1 : récupérer tous les produits
// ?nom=' OR '1'='1
// SQL généré : SELECT * FROM produits WHERE nom = '' OR '1'='1'
// '1'='1' est toujours vrai → retourne TOUS les produits

// Injection 2 : supprimer la table (si multi-requêtes activé)
// ?nom='; DROP TABLE produits; --
// La table est détruite définitivement

// ── SOLUTION : requête préparée ─────────────────────────────────────────
require_once __DIR__ . '/config.php';
$pdo = getConnexion();

$nom = $_GET['nom'] ?? '';

// Étape 1 : envoyer la STRUCTURE SQL à MySQL — avec :nom comme espace réservé
$stmt = $pdo->prepare('SELECT * FROM produits WHERE nom = :nom');

// Étape 2 : envoyer les DONNÉES séparément — MySQL les traite comme du texte pur
$stmt->execute([':nom' => $nom]);

// Si nom = "' OR '1'='1", MySQL cherche un produit dont le nom est littéralement
// "' OR '1'='1" — aucun résultat. L'injection est neutralisée.

$produits = $stmt->fetchAll();
foreach ($produits as $p) {
    echo htmlspecialchars($p['nom']) . ' — ' . $p['prix'] . ' MAD<br>';
}
ÉtapeCe que MySQL reçoitProtection injection
query() + concaténationSQL + données mélangésAucune — vulnérable
prepare()Structure SQL compiléeTotale — MySQL ne peut plus interpréter les données comme du SQL
execute()Données pures séparées—

2
Paramètres nommés vs positionnels — bindValue vs bindParam
phprequetes-preparees.php
<?php
require_once __DIR__ . '/config.php';
$pdo = getConnexion();

// ── Paramètres NOMMÉS :nom — recommandés ──────────────────────────────
// L'ordre dans execute() est libre — plus lisible sur les requêtes longues
$stmt = $pdo->prepare(
    'SELECT * FROM produits WHERE categorie = :cat AND prix <= :prixMax AND actif = 1'
);
$stmt->execute([
    ':prixMax' => 100.00,
    ':cat'     => 'Cosmétique',   // ordre inversé — aucun problème
]);
$produits = $stmt->fetchAll();

// ── Paramètres POSITIONNELS ? — pour les requêtes courtes ────────────
$stmt = $pdo->prepare('SELECT * FROM produits WHERE ville = ? AND stock > ?');
$stmt->execute(['Essaouira', 0]);  // ordre = ordre des ? dans le SQL

// ── bindValue() — lie une COPIE de la valeur immédiatement ────────────
// La valeur est fixée au moment de l'appel à bindValue()
$stmt = $pdo->prepare('SELECT * FROM produits WHERE nom = :nom');
$stmt->bindValue(':nom', 'Safran Premium 1g', PDO::PARAM_STR);
$stmt->execute();  // cherche 'Safran Premium 1g'

// ── bindParam() — lie une RÉFÉRENCE à la variable ─────────────────────
// La valeur est lue au moment de execute() — PIÈGE en boucle !
$nom = 'Safran';
$stmt->bindParam(':nom', $nom, PDO::PARAM_STR);
$nom = 'Miel Thym';    // Modifier après bindParam() change la valeur utilisée
$stmt->execute();       // cherche 'Miel Thym' — pas 'Safran' !

// RECOMMANDATION : utiliser bindValue() ou passer dans execute()
// bindParam() = risque de bug subtil en boucle

// ── Recherche LIKE sécurisée — le % va dans la valeur PHP, pas dans le SQL ──
$recherche = $_GET['q'] ?? '';
$stmt = $pdo->prepare(
    'SELECT * FROM produits WHERE nom LIKE :q OR ville LIKE :q ORDER BY nom'
);
$stmt->execute([':q' => '%' . $recherche . '%']);
// Les % sont des données — MySQL les interprète comme des jokers LIKE
// Si recherche = "' OR '1'='1", MySQL cherche '%' OR '1'='1'% — rien trouvé

// ── ORDER BY dynamique — ne peut PAS être paramétré ───────────────────
// Utiliser une liste blanche stricte
$colonnesOk = ['nom', 'prix', 'stock', 'created_at'];
$tri = in_array($_GET['tri'] ?? '', $colonnesOk) ? $_GET['tri'] : 'nom';
$dir = ($_GET['dir'] ?? '') === 'DESC' ? 'DESC' : 'ASC';
// Interpolation sécurisée car validée par liste blanche
$stmt = $pdo->query("SELECT * FROM produits ORDER BY $tri $dir");
Karim écrit $stmt = $pdo->query("SELECT * FROM produits WHERE nom = '$nom'"). Quel est le problème ?
Partie 5 — PDO & Base de données INSERT préparé Validation serveur Pattern PRG
⏱ 18 min

Formulaire d'ajout + INSERT PDO — Validation & Pattern PRG

Créer un formulaire d'insertion complet : validation PHP côté serveur, INSERT préparé, gestion des doublons (code 23000), pattern Post/Redirect/Get pour éviter la double insertion.


1
create.php — logique complète avec PRG
phpcreate.php — formulaire + INSERT + PRG
<?php
require_once __DIR__ . '/config.php';

$erreurs = [];
$data    = ['nom' => '', 'prix' => '', 'stock' => '', 'ville' => '', 'categorie' => ''];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    // ── Récupération et nettoyage ──────────────────────────────────────
    $data['nom']       = trim($_POST['nom']       ?? '');
    $data['prix']      = trim($_POST['prix']       ?? '');
    $data['stock']     = trim($_POST['stock']      ?? '');
    $data['ville']     = trim($_POST['ville']      ?? '');
    $data['categorie'] = trim($_POST['categorie']  ?? '');

    // ── Validation côté serveur — obligatoire même si HTML5 valide ─────
    // La validation HTML5 peut être contournée avec curl ou Postman
    if (mb_strlen($data['nom']) < 2) {
        $erreurs[] = 'Le nom doit contenir au moins 2 caractères.';
    }
    $prix = filter_var($data['prix'], FILTER_VALIDATE_FLOAT);
    if ($prix === false || $prix < 0) {
        $erreurs[] = 'Prix invalide — nombre positif requis.';
    }
    $stock = filter_var($data['stock'], FILTER_VALIDATE_INT);
    if ($stock === false || $stock < 0) {
        $erreurs[] = 'Stock invalide — entier positif requis.';
    }
    if (empty($data['categorie'])) {
        $erreurs[] = 'La catégorie est obligatoire.';
    }

    if (empty($erreurs)) {
        try {
            $pdo  = getConnexion();
            $stmt = $pdo->prepare(
                'INSERT INTO produits (nom, prix, stock, ville, categorie)
                 VALUES (:nom, :prix, :stock, :ville, :categorie)'
            );
            $stmt->execute([
                ':nom'       => $data['nom'],
                ':prix'      => $prix,
                ':stock'     => $stock,
                ':ville'     => $data['ville'],
                ':categorie' => $data['categorie'],
            ]);

            $nouvelId = $pdo->lastInsertId();  // ID AUTO_INCREMENT du produit créé

            // ── Pattern PRG — Post/Redirect/Get ───────────────────────
            // Rediriger IMMÉDIATEMENT après un INSERT réussi
            // Sans PRG : F5 sur la page = deuxième INSERT → doublon
            header('Location: index.php?success=1&id=' . $nouvelId);
            exit;  // OBLIGATOIRE — arrêter l'exécution après header()

        } catch (PDOException $e) {
            // Code SQLSTATE '23000' = violation de contrainte UNIQUE
            if ($e->getCode() === '23000') {
                $erreurs[] = 'Un produit avec ce nom existe déjà dans la base.';
            } else {
                $erreurs[] = 'Erreur technique. Veuillez réessayer.';
                error_log('[PDO INSERT] ' . $e->getMessage());
            }
        }
    }
}
?>
<!DOCTYPE html>
<html lang="fr">
<head><meta charset="UTF-8"><title>Nouveau produit</title></head>
<body>
<h2>Ajouter un produit</h2>

<?php if (!empty($erreurs)): ?>
  <div style="background:#FDEDEC;border-left:4px solid #C0392B;padding:10px">
    <?php foreach ($erreurs as $e): ?>
      <p><?= htmlspecialchars($e) ?></p>
    <?php endforeach; ?>
  </div>
<?php endif; ?>

<form method="POST">
  <label>Nom :</label>
  <input name="nom" required value="<?= htmlspecialchars($data['nom']) ?>"><br>

  <label>Prix (MAD) :</label>
  <input name="prix" type="number" min="0" step="0.01" required
         value="<?= htmlspecialchars($data['prix']) ?>"><br>

  <label>Stock :</label>
  <input name="stock" type="number" min="0" required
         value="<?= htmlspecialchars($data['stock']) ?>"><br>

  <label>Ville d'origine :</label>
  <input name="ville" value="<?= htmlspecialchars($data['ville']) ?>"><br>

  <label>Catégorie :</label>
  <select name="categorie" required>
    <option value="">-- Choisir --</option>
    <?php foreach (['Alimentaire', 'Cosmétique', 'Épices', 'Artisanat'] as $cat): ?>
      <option value="<?= $cat ?>" <?= $data['categorie']===$cat?'selected':'' ?>>
        <?= $cat ?>
      </option>
    <?php endforeach; ?>
  </select><br>

  <button type="submit">Enregistrer le produit</button>
</form>
</body>
</html>
💡 Codes SQLSTATE à connaître
CodeSignificationCause typique
23000Integrity constraint violationDoublon sur contrainte UNIQUE, violation clé étrangère
42S02Table introuvableNom de table incorrect dans le SQL
HY000Erreur généraleConnexion perdue, serveur indisponible
Partie 5 — PDO & Base de données fetch / fetchAll FETCH_CLASS Pagination
⏱ 18 min

Récupération et affichage des données PDO

fetch() vs fetchAll() vs fetchColumn(), modes FETCH_ASSOC / FETCH_OBJ / FETCH_CLASS, rowCount(), et pagination avec LIMIT/OFFSET.


1
fetch(), fetchAll(), fetchColumn() — quand utiliser quoi
MéthodeRetourneMémoireQuand l'utiliser
fetch()Une ligne (tableau/objet) ou falseFaible — une ligneRécupérer UN enregistrement (fiche par ID), grande table ligne par ligne
fetchAll()Tableau de toutes les lignesProportionnelle au résultatAfficher une liste standard
fetchColumn()Une seule valeurTrès faibleCOUNT(), MAX(), MIN(), SUM()
rowCount()Lignes affectées—UPDATE / DELETE / INSERT — pas fiable pour SELECT
phpliste.php — fetch, fetchAll, FETCH_CLASS, pagination
<?php
require_once __DIR__ . '/config.php';
$pdo = getConnexion();

// ── fetchColumn() — compter sans charger toutes les lignes ────────────
$total     = (int)$pdo->query('SELECT COUNT(*) FROM produits')->fetchColumn();
$prixMoyen = (float)$pdo->query('SELECT AVG(prix) FROM produits')->fetchColumn();
$prixMax   = (float)$pdo->query('SELECT MAX(prix) FROM produits WHERE actif=1')->fetchColumn();

// ── fetchAll() — récupérer tous les produits ──────────────────────────
$stmt = $pdo->query('SELECT * FROM produits WHERE actif = 1 ORDER BY nom ASC');
$produits = $stmt->fetchAll();  // FETCH_ASSOC grâce à config.php
// $produits = [ ['id'=>1,'nom'=>'Argan',...], ['id'=>2,...], ... ]

// ── fetch() — récupérer un seul produit par ID ────────────────────────
$id    = intval($_GET['id'] ?? 0);
$stmt2 = $pdo->prepare('SELECT * FROM produits WHERE id = :id AND actif = 1');
$stmt2->execute([':id' => $id]);
$produit = $stmt2->fetch();  // Un tableau associatif ou false
if ($produit === false) {
    echo 'Produit introuvable.';
} else {
    echo htmlspecialchars($produit['nom']) . ' — ' . $produit['prix'] . ' MAD';
}

// ── FETCH_OBJ — retourne un objet stdClass ────────────────────────────
$stmt3 = $pdo->query('SELECT * FROM produits LIMIT 3');
$objets = $stmt3->fetchAll(PDO::FETCH_OBJ);
foreach ($objets as $p) {
    echo $p->nom . ' (' . $p->ville . ')<br>';  // Notation -> au lieu de []
}

// ── FETCH_CLASS — instancier directement une classe PHP ──────────────
class Produit {
    public int    $id;
    public string $nom;
    public float  $prix;
    public int    $stock;
    public string $ville;

    public function estDisponible(): bool { return $this->stock > 0; }
    public function getPrixTTC(): float   { return round($this->prix * 1.20, 2); }
    public function __toString(): string  {
        return sprintf('[%d] %s — %.2f MAD', $this->id, $this->nom, $this->prix);
    }
}

$stmt4 = $pdo->prepare('SELECT id, nom, prix, stock, ville FROM produits WHERE actif = 1');
$stmt4->execute();
$objetsProduits = $stmt4->fetchAll(PDO::FETCH_CLASS, 'Produit');
foreach ($objetsProduits as $p) {
    $dispo = $p->estDisponible() ? '✓ En stock' : '✗ Rupture';
    echo $p . ' TTC:' . $p->getPrixTTC() . ' — ' . $dispo . '<br>';
}

// ── Pagination avec LIMIT / OFFSET ────────────────────────────────────
$parPage = 3;
$page    = max(1, (int)($_GET['page'] ?? 1));
$offset  = ($page - 1) * $parPage;
$nbPages = (int)ceil($total / $parPage);

$stmt5 = $pdo->prepare(
    'SELECT * FROM produits WHERE actif = 1 ORDER BY nom ASC LIMIT :limite OFFSET :offset'
);
// PARAM_INT obligatoire pour LIMIT/OFFSET — MySQL refuse les chaînes
$stmt5->bindValue(':limite', $parPage, PDO::PARAM_INT);
$stmt5->bindValue(':offset', $offset,  PDO::PARAM_INT);
$stmt5->execute();
$pageCourante = $stmt5->fetchAll();
Partie 5 — PDO & Base de données CRUD complet UPDATE / DELETE Transactions
⏱ 22 min

CRUD complet — Update, Delete, Transactions & Organisation

Implémenter l'Update (formulaire pré-rempli + UPDATE PDO), le Delete sécurisé (confirmation PHP + POST), les transactions beginTransaction/commit/rollBack, et organiser le projet avec functions.php.


1
Sécuriser l'ID et le formulaire de modification
phpedit.php — validation ID + UPDATE
<?php
require_once __DIR__ . '/config.php';

// ── Validation de l'ID — indispensable avant toute opération ──────────
// Un attaquant peut passer ?id=abc, ?id=-1, ?id=9999 dans l'URL
if (!isset($_GET['id']) || !is_numeric($_GET['id'])) {
    header('Location: index.php?erreur=id_invalide'); exit;
}
$id = intval($_GET['id']);
if ($id <= 0) {
    header('Location: index.php?erreur=id_invalide'); exit;
}

$pdo = getConnexion();

// ── Phase POST — traitement de la modification ────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $idPost = intval($_POST['id'] ?? 0);  // ID depuis le champ caché
    if ($idPost <= 0) { header('Location: index.php'); exit; }

    $nom       = trim($_POST['nom']       ?? '');
    $prix      = filter_var($_POST['prix']  ?? '', FILTER_VALIDATE_FLOAT);
    $stock     = filter_var($_POST['stock'] ?? '', FILTER_VALIDATE_INT);
    $categorie = trim($_POST['categorie'] ?? '');

    $erreurs = [];
    if (mb_strlen($nom) < 2)        $erreurs[] = 'Nom invalide.';
    if ($prix === false || $prix < 0) $erreurs[] = 'Prix invalide.';
    if ($stock === false || $stock < 0) $erreurs[] = 'Stock invalide.';
    if (empty($categorie))            $erreurs[] = 'Catégorie obligatoire.';

    if (empty($erreurs)) {
        try {
            $stmt = $pdo->prepare(
                'UPDATE produits
                 SET nom = :nom, prix = :prix, stock = :stock, categorie = :cat
                 WHERE id = :id'
            );
            $stmt->execute([
                ':nom'   => $nom,
                ':prix'  => $prix,
                ':stock' => $stock,
                ':cat'   => $categorie,
                ':id'    => $idPost,
            ]);
            // rowCount() est fiable pour UPDATE — retourne 0 si aucune ligne modifiée
            // (données identiques à avant) — on redirige quand même

            header('Location: index.php?updated=1');
            exit;
        } catch (PDOException $e) {
            $erreurs[] = $e->getCode() === '23000'
                ? 'Ce nom est déjà utilisé.'
                : 'Erreur technique.';
            error_log($e->getMessage());
        }
    }
}

// ── Phase GET — récupérer les données pour pré-remplir le formulaire ──
$stmt = $pdo->prepare('SELECT * FROM produits WHERE id = :id');
$stmt->execute([':id' => $id]);
$p = $stmt->fetch();
if (!$p) { header('Location: index.php?erreur=introuvable'); exit; }
?>
<form method="POST">
  <!-- Champ caché : transmet l'ID du GET vers le POST -->
  <input type="hidden" name="id" value="<?= $p['id'] ?>">
  <label>Nom :</label>
  <input name="nom" required value="<?= htmlspecialchars($p['nom']) ?>"><br>
  <label>Prix :</label>
  <input name="prix" type="number" step="0.01" required value="<?= $p['prix'] ?>"><br>
  <label>Stock :</label>
  <input name="stock" type="number" required value="<?= $p['stock'] ?>"><br>
  <label>Catégorie :</label>
  <select name="categorie">
    <?php foreach (['Alimentaire','Cosmétique','Épices','Artisanat'] as $cat): ?>
    <option <?= $p['categorie']===$cat?'selected':'' ?>><?= $cat ?></option>
    <?php endforeach; ?>
  </select><br>
  <button>Enregistrer les modifications</button>
</form>

2
Delete sécurisé + Transactions PDO
phpdelete.php + transactions
<?php
require_once __DIR__ . '/config.php';
$pdo = getConnexion();

// ── DELETE uniquement sur POST — jamais sur GET ────────────────────────
// Un lien <a href="delete.php?id=5"> = accessible par robots et crawlers
// La suppression doit nécessiter une action explicite (formulaire POST)
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $id = intval($_POST['id'] ?? 0);
    if ($id <= 0) { header('Location: index.php'); exit; }

    $stmt = $pdo->prepare('DELETE FROM produits WHERE id = :id');
    $stmt->execute([':id' => $id]);

    if ($stmt->rowCount() === 0) {
        header('Location: index.php?erreur=introuvable'); exit;
    }
    header('Location: index.php?deleted=1');
    exit;
}

// ── Phase GET — afficher la page de confirmation ──────────────────────
$id = intval($_GET['id'] ?? 0);
$stmt = $pdo->prepare('SELECT * FROM produits WHERE id = :id');
$stmt->execute([':id' => $id]);
$p = $stmt->fetch();
if (!$p) { header('Location: index.php?erreur=introuvable'); exit; }
?>
<h2>Confirmer la suppression</h2>
<p>Supprimer définitivement :
   <strong><?= htmlspecialchars($p['nom']) ?></strong>
   (stock : <?= $p['stock'] ?> unités) ?
</p>
<form method="POST">
  <input type="hidden" name="id" value="<?= $p['id'] ?>">
  <button style="background:#C0392B;color:white">Oui, supprimer</button>
  <a href="index.php">Annuler</a>
</form>

<?php
// ── TRANSACTIONS PDO — atomicité sur plusieurs opérations ─────────────
// Exemple : commander un produit = décrémenter le stock ET créer une commande
// Les deux doivent réussir ensemble ou être entièrement annulées

$produitId = 1;
$qteCommandee = 2;

try {
    $pdo->beginTransaction();

    // Opération 1 : vérifier et décrémenter le stock
    $verif = $pdo->prepare('SELECT stock FROM produits WHERE id = :id FOR UPDATE');
    $verif->execute([':id' => $produitId]);
    $stockActuel = (int)$verif->fetchColumn();

    if ($stockActuel < $qteCommandee) {
        throw new RuntimeException('Stock insuffisant : ' . $stockActuel . ' disponible(s)');
    }

    $update = $pdo->prepare('UPDATE produits SET stock = stock - :qte WHERE id = :id');
    $update->execute([':qte' => $qteCommandee, ':id' => $produitId]);

    // Opération 2 : enregistrer la commande (table commandes à créer)
    $insert = $pdo->prepare(
        'INSERT INTO commandes (produit_id, quantite, total, created_at)
         VALUES (:pid, :qte, :total, NOW())'
    );
    $insert->execute([
        ':pid'   => $produitId,
        ':qte'   => $qteCommandee,
        ':total' => $qteCommandee * 89.90,  // prix Argan Bio
    ]);

    // Tout a réussi — valider les deux opérations
    $pdo->commit();
    echo 'Commande enregistrée. Stock mis à jour.';

} catch (Exception $e) {
    // Une opération a échoué — annuler les deux
    if ($pdo->inTransaction()) {
        $pdo->rollBack();  // inTransaction() évite rollBack sans transaction active
    }
    echo 'Erreur — aucune modification enregistrée : ' . htmlspecialchars($e->getMessage());
    error_log($e->getMessage());
}
💡 Tableau récapitulatif CRUD complet
OpérationFichierHTTPSQLSécurité clé
Createcreate.phpPOSTINSERT INTOValidation + code 23000
Readindex.phpGETSELECT + LIKEhtmlspecialchars() affichage
Updateedit.phpGET puis POSTSELECT puis UPDATEis_numeric() + intval() sur ID
Deletedelete.phpGET puis POSTSELECT puis DELETEConfirmation PHP + POST uniquement
Pourquoi appeler $pdo->inTransaction() avant rollBack() dans le bloc catch ?

3
Organisation professionnelle — functions.php avec singleton
phpfunctions.php — CRUD centralisé
<?php
// functions.php — toutes les fonctions CRUD du projet
require_once __DIR__ . '/config.php';

// Connexion singleton déjà dans getConnexion() de config.php

function getProduits(string $q = ''): array {
    $pdo = getConnexion();
    if ($q === '') {
        return $pdo->query('SELECT * FROM produits WHERE actif=1 ORDER BY nom')->fetchAll();
    }
    $stmt = $pdo->prepare(
        'SELECT * FROM produits WHERE actif=1 AND (nom LIKE :q OR ville LIKE :q OR categorie LIKE :q) ORDER BY nom'
    );
    $stmt->execute([':q' => '%'.$q.'%']);
    return $stmt->fetchAll();
}

function getProduitById(int $id): array|null {
    $stmt = getConnexion()->prepare('SELECT * FROM produits WHERE id = :id AND actif = 1');
    $stmt->execute([':id' => $id]);
    return $stmt->fetch() ?: null;
}

/**
 * @return string|null null si succès, '23000' si doublon, 'erreur' sinon
 */
function insertProduit(array $data): ?string {
    try {
        $prix  = (float)($data['prix']  ?? 0);
        $stock = (int)  ($data['stock'] ?? 0);
        $stmt  = getConnexion()->prepare(
            'INSERT INTO produits (nom, prix, stock, ville, categorie)
             VALUES (:nom, :prix, :stock, :ville, :cat)'
        );
        $stmt->execute([
            ':nom'  => trim($data['nom']   ?? ''),
            ':prix' => $prix, ':stock' => $stock,
            ':ville'=> trim($data['ville'] ?? ''),
            ':cat'  => trim($data['categorie'] ?? ''),
        ]);
        return null;
    } catch (PDOException $e) {
        error_log('[insertProduit] ' . $e->getMessage());
        return $e->getCode() === '23000' ? '23000' : 'erreur';
    }
}

function updateProduit(int $id, array $data): ?string {
    try {
        $stmt = getConnexion()->prepare(
            'UPDATE produits SET nom=:nom, prix=:prix, stock=:stock,
             ville=:ville, categorie=:cat WHERE id=:id'
        );
        $stmt->execute([
            ':nom'  => trim($data['nom'] ?? ''),
            ':prix' => (float)($data['prix'] ?? 0),
            ':stock'=> (int)($data['stock'] ?? 0),
            ':ville'=> trim($data['ville'] ?? ''),
            ':cat'  => trim($data['categorie'] ?? ''),
            ':id'   => $id,
        ]);
        return null;
    } catch (PDOException $e) {
        error_log('[updateProduit] ' . $e->getMessage());
        return $e->getCode() === '23000' ? '23000' : 'erreur';
    }
}

function deleteProduit(int $id): bool {
    $stmt = getConnexion()->prepare('DELETE FROM produits WHERE id = :id');
    $stmt->execute([':id' => $id]);
    return $stmt->rowCount() > 0;
}

function countProduits(): int {
    return (int)getConnexion()->query('SELECT COUNT(*) FROM produits WHERE actif=1')->fetchColumn();
}
Partie 5 — PDO & Base de données QCM
⏱ 10 min

QCM — Partie 5 : PDO & Base de données

Connexion, requêtes préparées, CRUD, fetch modes et transactions — consolidez vos acquis avant la Partie 6.


1
PDO et requêtes préparées
Quelle est la différence entre fetch() et fetchAll() en termes de mémoire ?
Pourquoi utiliser PDO::PARAM_INT avec bindValue() pour les paramètres LIMIT et OFFSET ?

2
CRUD et sécurité
Quelle règle s'applique au Pattern PRG et pourquoi est-il indispensable après un INSERT ?
Partie 6 — Sessions & Sécurité setcookie() $_COOKIE httponly / SameSite
⏱ 16 min

Cookies PHP — setcookie(), $_COOKIE et attributs de sécurité

Créer, lire, modifier et supprimer des cookies PHP de manière sécurisée. Attributs httponly, secure et SameSite. Panneau de préférences utilisateur complet.


1
Cycle de vie d'un cookie HTTP — fonctionnement interne

Un cookie est un petit fichier texte que le serveur demande au navigateur de stocker. À chaque requête suivante vers le même domaine, le navigateur renvoie automatiquement tous les cookies correspondants dans l'en-tête HTTP Cookie.

CaractéristiqueValeur / Détail
Taille maximale4 096 octets (4 Ko) par cookie
Nombre max.~20 cookies par domaine
StockageCôté client (navigateur) — pas sur le serveur
FiabilitéNon fiable seul — la valeur peut être falsifiée par l'utilisateur
Usage recommandéPréférences non sensibles : ville, thème, langue

2
setcookie(), $_COOKIE et attributs de sécurité
phppreferences.php — cookies complets
<?php
// setcookie() doit être appelé AVANT tout affichage HTML
// Une sortie HTML avant setcookie() = erreur 'Cannot modify header information'

// ── Créer un cookie — syntaxe moderne PHP 7.3+ (tableau d'options) ────
setcookie('ville', 'Casablanca', [
    'expires'  => time() + 30 * 86400,  // 30 jours (86400 = secondes/jour)
    'path'     => '/',                   // Accessible sur tout le site
    'domain'   => '',                    // Domaine courant
    'secure'   => false,                 // true en production HTTPS
    'httponly' => true,                  // Cookie inaccessible au JavaScript — protège contre XSS
    'samesite' => 'Lax',                 // Protection CSRF — recommandé par défaut
]);

// ── Lire un cookie — TOUJOURS valider avant d'utiliser ───────────────
$villesOk = ['Casablanca', 'Rabat', 'Fès', 'Marrakech', 'Agadir'];

if (isset($_COOKIE['ville']) && in_array($_COOKIE['ville'], $villesOk)) {
    $ville = $_COOKIE['ville'];  // Valeur validée par liste blanche — sûre
} else {
    $ville = null;               // Cookie absent ou valeur invalide
}

// PIÈGE : ne jamais afficher $_COOKIE directement sans validation !
// Un attaquant peut modifier manuellement la valeur du cookie dans DevTools
// htmlspecialchars() est obligatoire pour l'affichage même après validation
echo isset($ville) ? htmlspecialchars($ville) : 'Aucune ville mémorisée.';

// ── Modifier un cookie — même appel avec une nouvelle valeur ──────────
// setcookie() avec le même nom et même path = remplacement
setcookie('ville', 'Rabat', ['expires' => time() + 30 * 86400, 'path' => '/']);
// Note : $_COOKIE['ville'] contient encore 'Casablanca' pour CETTE requête
// La nouvelle valeur 'Rabat' sera disponible à partir de la requête suivante

// ── Supprimer un cookie — expiration dans le passé ────────────────────
setcookie('ville', '', time() - 3600, '/');  // Déjà expiré → navigateur supprime
unset($_COOKIE['ville']);                     // Mettre à jour pour la requête courante

// ── Supprimer tous les cookies du site ───────────────────────────────
foreach ($_COOKIE as $nom => $valeur) {
    setcookie($nom, '', time() - 3600, '/');
    unset($_COOKIE[$nom]);
}
Attribut de sécuritéValeurProtection apportée
httponlytrueCookie inaccessible à JavaScript — protège contre le vol via XSS
securetrueCookie envoyé uniquement sur HTTPS — protège contre l'écoute réseau
samesite'Lax'Cookie envoyé seulement si requête depuis le même site — protège contre CSRF
samesite'Strict'Protection CSRF maximale — même les navigations directes sont bloquées
⚠️ Ne jamais faire confiance à un cookie
La valeur d'un cookie vient du navigateur — l'utilisateur peut la modifier dans les DevTools (F12 → Application → Cookies). Toujours valider avec une liste blanche et toujours appliquer htmlspecialchars() pour l'affichage.
Partie 6 — Sessions & Sécurité session_start() $_SESSION session_regenerate_id()
⏱ 18 min

Sessions PHP — Authentification sécurisée

session_start(), $_SESSION, session_regenerate_id() après connexion, destruction correcte en 4 étapes et système login/dashboard/logout complet.


1
Cookies vs Sessions — pourquoi les sessions pour l'authentification
CritèreCookie seulSession PHP
Stockage des donnéesNavigateur client — modifiableServeur — inaccessible au client
Ce que le client voitToutes les données en clairSeulement un ID aléatoire (PHPSESSID)
FalsificationTriviale (DevTools)Impossible sans accès serveur
Usage recommandéPréférences non sensiblesAuthentification, panier, données sensibles

2
Système connexion / dashboard / logout complet
phpconnexion.php — authentification avec session
<?php
// connexion.php — session_start() TOUJOURS en premier, avant tout HTML
session_start();

// Si déjà connecté → rediriger directement
if (isset($_SESSION['connecte']) && $_SESSION['connecte'] === true) {
    header('Location: dashboard.php'); exit;
}

$erreur = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $login = trim($_POST['login'] ?? '');
    $mdp   = $_POST['mdp'] ?? '';

    // En production : utiliser PDO + password_verify() (voir S39)
    // Ici : comptes de démonstration codés en dur
    $comptes = [
        'admin'    => ['hash' => password_hash('atlas2024', PASSWORD_BCRYPT), 'role' => 'admin'],
        'vendeur'  => ['hash' => password_hash('boutique!', PASSWORD_BCRYPT), 'role' => 'vendeur'],
    ];

    if (isset($comptes[$login]) && password_verify($mdp, $comptes[$login]['hash'])) {
        // ── Connexion réussie ──────────────────────────────────────────
        // OBLIGATOIRE : changer l'ID de session après connexion
        // Empêche l'attaque de fixation de session
        session_regenerate_id(true);  // true = supprimer l'ancien fichier

        $_SESSION['connecte'] = true;
        $_SESSION['user']     = $login;
        $_SESSION['role']     = $comptes[$login]['role'];
        $_SESSION['login_at'] = time();

        header('Location: dashboard.php');
        exit;
    } else {
        // Message volontairement vague — ne pas indiquer si c'est le login ou le mdp
        // Évite l'énumération des comptes existants
        $erreur = 'Identifiants incorrects.';
    }
}
?>
<!DOCTYPE html>
<html lang="fr"><head><meta charset="UTF-8"><title>Connexion — Boutique Atlas</title></head>
<body>
<h2>Connexion</h2>
<?php if (isset($_GET['deconnecte'])): ?>
  <p style="color:green">Vous avez été déconnecté.</p>
<?php endif; ?>
<?php if ($erreur): ?>
  <p style="color:red"><?= htmlspecialchars($erreur) ?></p>
<?php endif; ?>
<form method="POST">
  <label>Login : </label><input name="login" required autocomplete="username"><br>
  <label>Mot de passe : </label><input type="password" name="mdp" required autocomplete="current-password"><br>
  <button>Se connecter</button>
</form>
</body></html>
phpdashboard.php — page protégée
<?php
session_start();

// Vérification de la session — à mettre en haut de chaque page protégée
if (!isset($_SESSION['connecte']) || $_SESSION['connecte'] !== true) {
    header('Location: connexion.php');
    exit;  // OBLIGATOIRE après header()
}

// Expiration automatique de session (30 minutes d'inactivité)
$maxInactivite = 30 * 60;  // 1800 secondes
if (isset($_SESSION['derniere_activite']) &&
    (time() - $_SESSION['derniere_activite']) > $maxInactivite) {
    session_unset();
    session_destroy();
    header('Location: connexion.php?expire=1');
    exit;
}
$_SESSION['derniere_activite'] = time();

$dureeMin = isset($_SESSION['login_at'])
    ? round((time() - $_SESSION['login_at']) / 60)
    : 0;
?>
<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>Dashboard</title></head>
<body>
<h2>Tableau de bord — Boutique Atlas</h2>
<p>Bonjour, <strong><?= htmlspecialchars($_SESSION['user']) ?></strong> !</p>
<p>Rôle : <?= htmlspecialchars($_SESSION['role']) ?></p>
<p>Connecté depuis : <?= $dureeMin ?> minute(s)</p>
<p>ID session : <code><?= htmlspecialchars(session_id()) ?></code></p>
<a href="logout.php">Se déconnecter</a>
</body></html>
phplogout.php — déconnexion en 4 étapes
<?php
// Les 4 étapes obligatoires d'un logout sécurisé
session_start();   // Étape 1 : reprendre la session à détruire

session_unset();   // Étape 2 : vider le tableau $_SESSION en mémoire
                   // Après : $_SESSION = [] mais le fichier serveur existe encore

session_destroy(); // Étape 3 : supprimer le fichier de session côté serveur
                   // Après : fichier /tmp/sess_xxx supprimé

// Étape 4 : supprimer le cookie PHPSESSID dans le navigateur
// Sans cette étape, le navigateur renvoie un ID pointant vers un fichier détruit
if (ini_get('session.use_cookies')) {
    $params = session_get_cookie_params();
    setcookie(
        session_name(),   // 'PHPSESSID' par défaut
        '',
        time() - 3600,    // Déjà expiré → le navigateur supprime
        $params['path'],
        $params['domain'],
        $params['secure'],
        $params['httponly']
    );
}

header('Location: connexion.php?deconnecte=1');
exit;
Pourquoi appeler session_regenerate_id(true) après une connexion réussie ?
Partie 6 — Sessions & Sécurité SessionHandlerInterface Stockage MySQL Multi-serveurs
⏱ 16 min

SessionHandler personnalisé — Stockage MySQL

Implémenter SessionHandlerInterface pour stocker les sessions en base de données MySQL — solution indispensable pour les applications multi-serveurs (load balancing).


1
Pourquoi des sessions MySQL — et les 6 méthodes de l'interface
CritèreSessions fichiers (défaut)Sessions MySQL (personnalisé)
StockageFichiers /tmp locauxTable MySQL partagée
Multi-serveursImpossible sans NFSNatif — MySQL accessible partout
Sessions activesImpossible à listerSELECT * FROM sessions WHERE expires_at > NOW()
Révoquer une sessionImpossible à distanceDELETE FROM sessions WHERE id = :id
MéthodeDéclencheurRôle
open()Démarrage de session_start()Initialiser la connexion
close()Fin de la requêteLibérer les ressources
read()Après open() dans session_start()Retourner les données sérialisées de la session
write()Fin de requête (avant close)Sauvegarder les données
destroy()Lors de session_destroy()Supprimer la session
gc()Aléatoirement (garbage collection)Supprimer les sessions expirées

2
Table MySQL + Classe SessionHandlerDB complète
phpSQL + SessionHandlerDB.php
-- ── Table sessions dans boutique_atlas ─────────────────────────────────
CREATE TABLE sessions (
    id            VARCHAR(128)  NOT NULL PRIMARY KEY,
    data          MEDIUMTEXT    NOT NULL DEFAULT '',
    last_activity INT UNSIGNED  NOT NULL DEFAULT 0,
    expires_at    INT UNSIGNED  NOT NULL DEFAULT 0,
    INDEX idx_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
phpSessionHandlerDB.php — implémentation complète
<?php
require_once __DIR__ . '/config.php';

class SessionHandlerDB implements SessionHandlerInterface {

    private PDO $pdo;
    private int $maxLifetime;

    public function open(string $savePath, string $name): bool {
        try {
            $dsn = 'mysql:host='.DB_HOST.';dbname='.DB_NAME.';charset=utf8mb4';
            $this->pdo = new PDO($dsn, DB_USER, DB_PASS, [
                PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            ]);
            $this->maxLifetime = (int)ini_get('session.gc_maxlifetime');
            return true;
        } catch (PDOException $e) {
            error_log('[SessionHandlerDB::open] ' . $e->getMessage());
            return false;
        }
    }

    public function close(): bool { return true; }

    public function read(string $id): string|false {
        try {
            $stmt = $this->pdo->prepare(
                'SELECT data FROM sessions WHERE id = :id AND expires_at > :now'
            );
            $stmt->execute([':id' => $id, ':now' => time()]);
            $row = $stmt->fetch();
            return $row ? $row['data'] : '';  // '' si introuvable — session vide
        } catch (PDOException $e) {
            error_log('[SessionHandlerDB::read] ' . $e->getMessage());
            return '';
        }
    }

    public function write(string $id, string $data): bool {
        try {
            $now = time();
            $stmt = $this->pdo->prepare(
                'INSERT INTO sessions (id, data, last_activity, expires_at)
                 VALUES (:id, :data, :now, :exp)
                 ON DUPLICATE KEY UPDATE
                     data = VALUES(data),
                     last_activity = VALUES(last_activity),
                     expires_at = VALUES(expires_at)'
                // ON DUPLICATE KEY = INSERT si nouveau, UPDATE si existant (UPSERT)
            );
            $stmt->execute([
                ':id'   => $id,
                ':data' => $data,
                ':now'  => $now,
                ':exp'  => $now + $this->maxLifetime,
            ]);
            return true;
        } catch (PDOException $e) {
            error_log('[SessionHandlerDB::write] ' . $e->getMessage());
            return false;
        }
    }

    public function destroy(string $id): bool {
        try {
            $stmt = $this->pdo->prepare('DELETE FROM sessions WHERE id = :id');
            $stmt->execute([':id' => $id]);
            return true;
        } catch (PDOException $e) {
            error_log('[SessionHandlerDB::destroy] ' . $e->getMessage());
            return false;
        }
    }

    public function gc(int $maxLifetime): int|false {
        try {
            $stmt = $this->pdo->prepare('DELETE FROM sessions WHERE expires_at < :now');
            $stmt->execute([':now' => time()]);
            return $stmt->rowCount();  // Nombre de sessions expirées supprimées
        } catch (PDOException $e) {
            error_log('[SessionHandlerDB::gc] ' . $e->getMessage());
            return false;
        }
    }
}

// ── Enregistrement dans config.php — AVANT session_start() ────────────
// require_once __DIR__ . '/SessionHandlerDB.php';
// $handler = new SessionHandlerDB();
// session_set_save_handler($handler, true);  // true = shutdown handler auto
// // Puis dans chaque page : session_start()
Partie 6 — Sessions & Sécurité CSRF Token XSS Upload sécurisé
⏱ 18 min

Sécurité des sessions, XSS, CSRF & Upload sécurisé

Session Fixation, Hijacking, Poisoning — CSRF token avec hash_equals() — XSS et htmlspecialchars() — Upload avec triple validation (extension + MIME réel + taille) et stockage hors racine.


1
Token CSRF — protection contre les requêtes forgées
phpfunctions.php — genererTokenCsrf() et verifierTokenCsrf()
<?php
// ── Token CSRF — protège contre les requêtes forgées depuis d'autres sites ──
// Un attaquant sur site-malveillant.ma ne connaît pas ce token
// Son formulaire forgé sera rejeté car le token ne correspond pas

function genererTokenCsrf(): string {
    if (session_status() === PHP_SESSION_NONE) session_start();
    $token = bin2hex(random_bytes(32));  // 64 caractères hexadécimaux aléatoires
    $_SESSION['csrf_token'] = $token;
    return $token;
}

function verifierTokenCsrf(string $tokenSoumis): bool {
    if (session_status() === PHP_SESSION_NONE) session_start();
    $tokenSession = $_SESSION['csrf_token'] ?? '';

    // hash_equals() — comparaison en temps constant
    // Résistant aux timing attacks (contrairement à == ou ===)
    $valide = hash_equals($tokenSession, $tokenSoumis);

    unset($_SESSION['csrf_token']);  // Régénérer après chaque vérification
    return $valide;
}

// ── Utilisation dans create.php ─────────────────────────────────────────
// PHP en haut :
// $csrfToken = genererTokenCsrf();
// if (POST) : if (!verifierTokenCsrf($_POST['csrf_token'] ?? '')) { $erreurs[] = '...'; }

// Dans le formulaire HTML :
// <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken) ?>">

// ── Upload sécurisé — triple validation ────────────────────────────────
function validerUpload(array $fichier): array {
    $erreurs = [];

    // Étape 1 : erreur d'upload
    if ($fichier['error'] !== UPLOAD_ERR_OK) {
        $erreurs[] = 'Erreur upload (code ' . $fichier['error'] . ')';
        return $erreurs;
    }

    // Étape 2 : taille
    if ($fichier['size'] > 2 * 1024 * 1024) {
        $erreurs[] = 'Fichier trop lourd (max 2 Mo)';
    }

    // Étape 3 : extension — liste blanche
    $ext = strtolower(pathinfo($fichier['name'], PATHINFO_EXTENSION));
    if (!in_array($ext, ['jpg','jpeg','png','gif','webp'])) {
        $erreurs[] = 'Extension non autorisée';
    }

    // Étape 4 : MIME réel avec finfo — NE PAS utiliser $_FILES['type'] (client!)
    $finfo    = new finfo(FILEINFO_MIME_TYPE);
    $mimeReel = $finfo->file($fichier['tmp_name']);
    if (!in_array($mimeReel, ['image/jpeg','image/png','image/gif','image/webp'])) {
        $erreurs[] = 'Contenu du fichier invalide (MIME : ' . htmlspecialchars($mimeReel) . ')';
    }

    return $erreurs;
}

function deplacerFichierUpload(array $fichier, string $ext): string|false {
    // Stocker HORS de la racine web — non accessible directement par URL
    $dossier = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'storage' . DIRECTORY_SEPARATOR . 'photos' . DIRECTORY_SEPARATOR;
    if (!is_dir($dossier)) mkdir($dossier, 0750, true);

    // Nom aléatoire — jamais conserver le nom original ($_FILES['name'])
    $nomFinal = hash('sha256', uniqid('', true) . random_bytes(8)) . '.' . $ext;

    if (move_uploaded_file($fichier['tmp_name'], $dossier . $nomFinal)) {
        return $nomFinal;
    }
    return false;
}
🚨 Ne jamais se fier à $_FILES['type']
Le type MIME dans $_FILES['type'] est envoyé par le client — un attaquant peut modifier cet en-tête pour faire passer un fichier PHP malveillant pour une image. La seule vérification fiable est finfo::file() qui analyse le contenu binaire réel du fichier côté serveur (magic bytes).
Partie 6 — Sessions & Sécurité password_hash() filter_var() Moindre privilège
⏱ 16 min

Sécurité des bases de données — Mots de passe, filter_var() & Moindre privilège

password_hash() / password_verify() (bcrypt), filter_var() en mode validation vs sanitisation, moindre privilège MySQL (CREATE USER / GRANT), chiffrement AES avec openssl_encrypt().


1
Hashage des mots de passe — password_hash() et password_verify()
AlgorithmeVitesseSaléRésistanceVerdict
md5()Très rapideNonNulleJamais utiliser pour les mots de passe
sha256()RapideNonFaibleJamais utiliser seul
bcryptLent (adaptatif)Oui (intégré)ExcellentePASSWORD_BCRYPT — recommandé
Argon2idLent + mémoireOui (intégré)ExcellentePASSWORD_ARGON2ID — meilleur
phpsecurite-bdd.php — hash, connexion sécurisée, moindre privilège
<?php
// ── password_hash() — créer un hash sécurisé ────────────────────────────
$mdpBrut = 'MonMotDePasse2024!';

$hash = password_hash($mdpBrut, PASSWORD_BCRYPT);
// Résultat : '$2y$10$sel22chars.hash31chars'
// Le hash contient : algorithme + coût + sel + hash (tout en un)
// Le SEL est généré aléatoirement → deux appels donnent DEUX hashes différents
// pour le même mot de passe — les rainbow tables deviennent inutiles

// Stocker en base — colonne VARCHAR(255)
// $stmt->execute([':hash' => $hash]);

// ── password_verify() — vérifier lors de la connexion ──────────────────
if (password_verify($mdpBrut, $hash)) {
    echo 'Mot de passe correct';

    // Migration automatique si l'algorithme évolue
    if (password_needs_rehash($hash, PASSWORD_BCRYPT, ['cost' => 12])) {
        $nouveauHash = password_hash($mdpBrut, PASSWORD_BCRYPT, ['cost' => 12]);
        // UPDATE utilisateurs SET mdp_hash = :h WHERE id = :id
    }
}

// ── filter_var() — validation vs sanitisation ────────────────────────
// VALIDATION : vérifie le format → retourne la valeur ou false
$email  = filter_var('karim@boutique-atlas.ma', FILTER_VALIDATE_EMAIL); // valide
$emailX = filter_var('pas-un-email', FILTER_VALIDATE_EMAIL);            // false

$age = filter_var('25', FILTER_VALIDATE_INT,
    ['options' => ['min_range' => 18, 'max_range' => 99]]
);  // 25 — ou false si hors limite

$url = filter_var('https://boutique-atlas.ma', FILTER_VALIDATE_URL); // valide

// filter_input() — lire directement depuis $_POST/$_GET avec validation
$emailPost = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL);
$idGet     = filter_input(INPUT_GET,  'id',    FILTER_VALIDATE_INT);
// Retourne null si clé absente, false si invalide

// ── Moindre privilège MySQL ─────────────────────────────────────────────
// En production, jamais utiliser 'root' pour l'application web
// Créer un utilisateur avec droits minimaux
/*
CREATE USER 'app_boutique'@'localhost' IDENTIFIED BY 'MotDePasseComplexe!';
GRANT SELECT, INSERT, UPDATE, DELETE ON boutique_atlas.* TO 'app_boutique'@'localhost';
-- Pas de DROP, CREATE, ALTER, GRANT → même en cas d'injection SQL, l'attaquant
-- ne peut pas détruire les tables ni créer de nouveaux comptes
FLUSH PRIVILEGES;
*/

// ── Chiffrement réversible AES-256 pour données sensibles ───────────────
define('ENCRYPTION_KEY',    'cle-secrete-32-chars-exactement!');  // 32 chars = 256 bits
define('ENCRYPTION_METHOD', 'AES-256-CBC');

function chiffrer(string $donnee): string {
    $iv      = random_bytes(16);  // IV aléatoire — 16 octets pour AES-256-CBC
    $chiffre = openssl_encrypt($donnee, ENCRYPTION_METHOD, ENCRYPTION_KEY, 0, $iv);
    return base64_encode($iv . $chiffre);  // IV + données chiffrées en base64
}

function dechiffrer(string $donneeChiffree): string {
    $decode  = base64_decode($donneeChiffree);
    $iv      = substr($decode, 0, 16);
    $chiffre = substr($decode, 16);
    return openssl_decrypt($chiffre, ENCRYPTION_METHOD, ENCRYPTION_KEY, 0, $iv);
}

// Exemple : chiffrer un numéro CIN avant stockage en BDD
$cin        = 'BK654321';
$cinChiffre = chiffrer($cin);           // Stocker en base
$cinOriginal= dechiffrer($cinChiffre);  // Récupérer pour afficher
Pourquoi md5('secret') est-il insuffisant pour stocker un mot de passe ?
Partie 6 — Sessions & Sécurité QCM
⏱ 10 min

QCM — Partie 6 : Sessions, Cookies & Sécurité

Sessions, CSRF, XSS, injection SQL, upload — testez vos acquis avant l'architecture MVC.


1
Sessions et cookies
Quelles sont les 4 étapes obligatoires d'un logout sécurisé en PHP ?
Quelle est la différence entre une attaque XSS et une attaque CSRF ?
Partie 7 — Architecture MVC Code spaghetti → MVC Front Controller .htaccess
⏱ 18 min

Architecture MVC — Présentation et intérêt

Du code spaghetti à l'architecture MVC : trois couches indépendantes, le Front Controller, la structure des dossiers standard et le flux complet d'une requête HTTP en MVC natif PHP.


1
Le problème — Code spaghetti vs MVC
Problème du code procéduralConséquence concrète
HTML + PHP + SQL dans le même fichierUn changement visuel peut casser la logique métier
Logique métier non réutilisableLa même requête SQL est copiée dans 10 fichiers différents
Aucune convention de structureChaque développeur organise différemment — chaos en équipe
Tests impossiblesImpossible d'appeler getProduits() sans générer du HTML
Couche MVCResponsabilitéPeut faireNe peut jamais faire
ModelAccès aux données et logique métierRequêtes PDO, validation, calculsGénérer du HTML, accéder à $_GET/$_POST
ViewPrésentation HTMLAfficher des variables avec echo/htmlspecialcharsRequêtes SQL, logique métier, redirections
ControllerOrchestrationAppeler Model, passer données à View, redirectionsRequêtes SQL directes, générer du HTML

2
Structure des dossiers MVC standard et Front Controller
basharborescence MVC PHP natif
boutique_mvc/                     ← Racine du projet (hors www/)
│
├── app/
│   ├── controllers/
│   │   ├── ProduitController.php
│   │   └── AuthController.php
│   ├── models/
│   │   ├── ProduitModel.php
│   │   └── UtilisateurModel.php
│   └── views/
│       ├── produits/
│       │   ├── index.php     ← Liste
│       │   ├── create.php    ← Formulaire ajout
│       │   ├── edit.php      ← Formulaire modification
│       │   └── show.php      ← Détail
│       └── layouts/
│           ├── header.php
│           └── footer.php
│
├── config/
│   └── database.php          ← Constantes BDD
│
├── core/
│   ├── Controller.php        ← Classe mère — méthode render()
│   ├── Model.php             ← Classe mère — connexion PDO
│   └── Router.php            ← Routeur simple
│
├── storage/
│   └── photos/               ← Hors racine web — non accessible par URL
│
└── public/                   ← SEUL dossier accessible par le navigateur
    ├── index.php             ← Front Controller — point d'entrée unique
    ├── .htaccess             ← Réécriture d'URL → index.php
    └── assets/
        ├── css/
        └── js/
apachepublic/.htaccess — réécriture d'URL
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [QSA,L]
# Toute URL qui ne correspond pas à un fichier réel → public/index.php
# /produits          → index.php (Controller=produits, Action=index)
# /produits/show/5   → index.php (Controller=produits, Action=show, ID=5)
# /produits/create   → index.php (Controller=produits, Action=create)

3
Réécriture de la liste des produits en MVC natif — code complet
phpcore/Controller.php — classe mère
<?php
// core/Controller.php — classe mère de tous les controllers
class Controller {

    /**
     * Inclut une vue en lui passant des données.
     * @param string $view  Chemin relatif à app/views/ (ex: 'produits/index')
     * @param array  $data  Variables à extraire dans la vue
     */
    protected function render(string $view, array $data = []): void {
        // extract() crée des variables locales depuis le tableau
        // ['produits' => [...], 'q' => 'argan'] → $produits et $q disponibles dans la vue
        extract($data);

        $viewPath = dirname(__DIR__) . '/app/views/' . $view . '.php';
        if (!file_exists($viewPath)) {
            http_response_code(404);
            die('Vue introuvable : ' . htmlspecialchars($view));
        }

        require_once dirname(__DIR__) . '/app/views/layouts/header.php';
        require_once $viewPath;
        require_once dirname(__DIR__) . '/app/views/layouts/footer.php';
    }

    protected function redirect(string $url): void {
        header('Location: ' . $url); exit;
    }
}
phpapp/models/ProduitModel.php
<?php
// app/models/ProduitModel.php — logique d'accès aux données UNIQUEMENT
// Aucun HTML, aucun accès à $_GET/$_POST, aucune redirection
require_once dirname(__DIR__, 2) . '/config/database.php';

class ProduitModel {

    private PDO $pdo;

    public function __construct() {
        $dsn = 'mysql:host='.DB_HOST.';dbname='.DB_NAME.';charset=utf8mb4';
        $this->pdo = new PDO($dsn, DB_USER, DB_PASS, [
            PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        ]);
    }

    public function getAll(string $q = ''): array {
        if ($q === '') {
            return $this->pdo
                ->query('SELECT * FROM produits WHERE actif=1 ORDER BY nom')
                ->fetchAll();
        }
        $stmt = $this->pdo->prepare(
            'SELECT * FROM produits WHERE actif=1
             AND (nom LIKE :q OR ville LIKE :q OR categorie LIKE :q)
             ORDER BY nom'
        );
        $stmt->execute([':q' => '%'.$q.'%']);
        return $stmt->fetchAll();
    }

    public function getById(int $id): array|null {
        $stmt = $this->pdo->prepare('SELECT * FROM produits WHERE id=:id AND actif=1');
        $stmt->execute([':id' => $id]);
        return $stmt->fetch() ?: null;
    }

    public function insert(array $data): bool {
        $stmt = $this->pdo->prepare(
            'INSERT INTO produits (nom, prix, stock, ville, categorie)
             VALUES (:nom, :prix, :stock, :ville, :cat)'
        );
        $stmt->execute([
            ':nom'  => trim($data['nom']   ?? ''),
            ':prix' => (float)($data['prix'] ?? 0),
            ':stock'=> (int)  ($data['stock'] ?? 0),
            ':ville'=> trim($data['ville'] ?? ''),
            ':cat'  => trim($data['categorie'] ?? ''),
        ]);
        return $stmt->rowCount() > 0;
    }

    public function count(): int {
        return (int)$this->pdo
            ->query('SELECT COUNT(*) FROM produits WHERE actif=1')
            ->fetchColumn();
    }
}
phpapp/controllers/ProduitController.php
<?php
// app/controllers/ProduitController.php — orchestration UNIQUEMENT
// Ni SQL ni HTML ici — appelle Model, passe données à View
require_once dirname(__DIR__, 2) . '/core/Controller.php';
require_once dirname(__DIR__) . '/models/ProduitModel.php';

class ProduitController extends Controller {

    private ProduitModel $model;

    public function __construct() {
        $this->model = new ProduitModel();
    }

    // GET /produits — liste avec recherche
    public function index(): void {
        $q        = trim($_GET['q'] ?? '');
        $produits = $this->model->getAll($q);
        $total    = $this->model->count();

        $this->render('produits/index', [
            'produits' => $produits,
            'q'        => $q,
            'total'    => $total,
        ]);
    }

    // GET /produits/show?id=X
    public function show(): void {
        $id      = isset($_GET['id']) && is_numeric($_GET['id']) ? intval($_GET['id']) : 0;
        $produit = $this->model->getById($id);

        if (!$produit) {
            $this->redirect('/produits?erreur=introuvable');
        }

        $this->render('produits/show', ['produit' => $produit]);
    }

    // GET /produits/create
    public function create(): void {
        $this->render('produits/create', ['erreurs' => [], 'data' => []]);
    }

    // POST /produits/store
    public function store(): void {
        $data    = $_POST;
        $erreurs = [];

        if (mb_strlen($data['nom'] ?? '') < 2) $erreurs[] = 'Nom invalide.';
        if (!is_numeric($data['prix'] ?? '')) $erreurs[] = 'Prix invalide.';

        if (empty($erreurs)) {
            $this->model->insert($data);
            $this->redirect('/produits?success=1');
        } else {
            $this->render('produits/create', ['erreurs' => $erreurs, 'data' => $data]);
        }
    }
}
phpapp/views/produits/index.php — View pure
<!-- app/views/produits/index.php — UNIQUEMENT HTML + variables injectées -->
<!-- $produits, $q, $total sont injectés par render() via extract() -->
<!-- Aucun PDO, aucun $_GET, aucune logique métier ici -->

<main>
  <h1>Produits <span class="badge"><?= $total ?></span></h1>

  <form method="GET" action="/produits">
    <input name="q" value="<?= htmlspecialchars($q) ?>" placeholder="Rechercher...">
    <button>🔍</button>
    <?php if ($q): ?><a href="/produits">✕</a><?php endif; ?>
  </form>

  <a href="/produits/create">+ Nouveau produit</a>

  <?php if (empty($produits)): ?>
    <p>Aucun produit trouvé.</p>
  <?php else: ?>
    <table>
      <tr><th>Nom</th><th>Prix</th><th>Stock</th><th>Ville</th><th>Actions</th></tr>
      <?php foreach ($produits as $p): ?>
      <tr>
        <td><?= htmlspecialchars($p['nom']) ?></td>
        <td><?= number_format($p['prix'], 2) ?> MAD</td>
        <td><?= $p['stock'] > 0 ? $p['stock'] : '<span style="color:red">Rupture</span>' ?></td>
        <td><?= htmlspecialchars($p['ville'] ?? '') ?></td>
        <td>
          <a href="/produits/show?id=<?= $p['id'] ?>">Voir</a> |
          <a href="/produits/edit?id=<?= $p['id'] ?>">Modifier</a>
        </td>
      </tr>
      <?php endforeach; ?>
    </table>
  <?php endif; ?>
</main>
phppublic/index.php — Front Controller
<?php
// public/index.php — Point d'entrée UNIQUE de toute l'application
// Apache redirige toutes les URLs ici via .htaccess
session_start();

// Extraire le chemin de l'URL (sans les paramètres GET)
$url    = trim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), '/');
$parts  = explode('/', $url);
$ctrl   = $parts[0] ?: 'produits';   // 'produits' par défaut
$action = $parts[1] ?: 'index';

// Table de routage — associe les segments d'URL aux classes Controller
$routes = [
    'produits' => 'ProduitController',
    'auth'     => 'AuthController',
];

if (!array_key_exists($ctrl, $routes)) {
    http_response_code(404);
    die('Page introuvable');
}

$controllerClass = $routes[$ctrl];
$controllerFile  = dirname(__DIR__) . '/app/controllers/' . $controllerClass . '.php';
require_once $controllerFile;

$controller = new $controllerClass();

// Vérifier que l'action existe et l'appeler
if (!method_exists($controller, $action)) {
    http_response_code(404);
    die('Action introuvable : ' . htmlspecialchars($action));
}
$controller->$action();
Dans l'architecture MVC, quelle couche peut faire des requêtes PDO directement ?
Partie 7 — Architecture MVC BaseModel abstrait findAll / findById ModelException
⏱ 22 min

Le Modèle — BaseModel abstrait & CRUD générique

Classe mère abstraite avec connexion PDO singleton, méthodes CRUD génériques héritées (findAll, findById, count, exists, delete), pattern $fillable contre le mass assignment, ModelException pour distinguer erreurs techniques et erreurs métier, et tests d'intégration en isolation.


1
Pourquoi un BaseModel — le problème de la duplication

Sans BaseModel, chaque Model (Stagiaire, Formateur, Formation) répète la même connexion PDO, le même try/catch, le même findById(). Si le mot de passe change ou si la gestion d'erreurs évolue, il faut modifier trois fichiers au lieu d'un.

Sans BaseModelAvec BaseModel
Connexion PDO répétée dans chaque ModelConnexion centralisée dans BaseModel::getConnection()
findById() copié 3 foisfindById() une seule fois — hérité
Modifier la connexion → 3 fichiersModifier la connexion → 1 fichier
Incohérences possibles entre ModelsComportement garanti par la classe mère

2
core/BaseModel.php — connexion singleton + méthodes génériques
phpcore/BaseModel.php — complet
<?php
// core/BaseModel.php — classe mère abstraite de tous les Models
abstract class BaseModel {

    protected PDO $pdo;

    // Chaque sous-classe DOIT définir $table (nom de la table MySQL)
    protected string $table;

    // Clé primaire — peut être surchargée si différente de 'id'
    protected string $primaryKey = 'id';

    // ── Connexion singleton ──────────────────────────────────────────────
    // Une seule instance PDO partagée pour toute la durée de la requête HTTP
    private static ?PDO $instance = null;

    public function __construct() {
        $this->pdo = $this->getConnection();
    }

    private function getConnection(): PDO {
        if (self::$instance === null) {
            $dsn = 'mysql:host=' . DB_HOST . ';dbname=' . DB_NAME . ';charset=utf8mb4';
            self::$instance = new PDO($dsn, DB_USER, DB_PASS, [
                PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                PDO::ATTR_EMULATE_PREPARES   => false,
            ]);
        }
        return self::$instance;
    }

    // ── findAll() — retourner tous les enregistrements ───────────────────
    public function findAll(string $orderBy = ''): array {
        $order = $orderBy
            ? ' ORDER BY ' . preg_replace('/[^a-zA-Z0-9_,]/', '', $orderBy)
            : ' ORDER BY ' . $this->primaryKey;
        return $this->pdo
            ->query('SELECT * FROM `' . $this->table . '`' . $order)
            ->fetchAll();
    }

    // ── findById() — retourner un enregistrement par clé primaire ────────
    public function findById(int $id): array|null {
        $stmt = $this->pdo->prepare(
            'SELECT * FROM `' . $this->table . '`
             WHERE `' . $this->primaryKey . '` = :id'
        );
        $stmt->execute([':id' => $id]);
        return $stmt->fetch() ?: null;
    }

    // ── count() — compter avec WHERE optionnel ────────────────────────────
    public function count(string $where = '', array $params = []): int {
        $sql = 'SELECT COUNT(*) FROM `' . $this->table . '`';
        if ($where) $sql .= ' WHERE ' . $where;
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute($params);
        return (int) $stmt->fetchColumn();
    }

    // ── exists() — vérifier l'existence par ID ────────────────────────────
    public function exists(int $id): bool {
        return $this->findById($id) !== null;
    }

    // ── delete() — suppression générique par clé primaire ─────────────────
    public function delete(int $id): bool {
        $stmt = $this->pdo->prepare(
            'DELETE FROM `' . $this->table . '`
             WHERE `' . $this->primaryKey . '` = :id'
        );
        $stmt->execute([':id' => $id]);
        return $stmt->rowCount() > 0;
    }

    // ── paginate() — pagination générique héritée par tous les Models ─────
    public function paginate(int $page = 1, int $perPage = 15, string $q = '', string $orderBy = 'id'): array {
        $page    = max(1, $page);
        $offset  = ($page - 1) * $perPage;
        $orderBy = preg_replace('/[^a-zA-Z0-9_]/', '', $orderBy);

        if ($q !== '') {
            $cStmt = $this->pdo->prepare(
                'SELECT COUNT(*) FROM `' . $this->table . '`
                 WHERE nom LIKE :q OR prenom LIKE :q'
            );
            $cStmt->execute([':q' => '%' . $q . '%']);
        } else {
            $cStmt = $this->pdo->query('SELECT COUNT(*) FROM `' . $this->table . '`');
        }
        $total = (int) $cStmt->fetchColumn();
        $pages = max(1, (int) ceil($total / $perPage));
        $page  = min($page, $pages);

        if ($q !== '') {
            $dStmt = $this->pdo->prepare(
                'SELECT * FROM `' . $this->table . '`
                 WHERE nom LIKE :q OR prenom LIKE :q
                 ORDER BY ' . $orderBy . ' LIMIT :lim OFFSET :off'
            );
            $dStmt->bindValue(':q',   '%' . $q . '%');
            $dStmt->bindValue(':lim', $perPage, PDO::PARAM_INT);
            $dStmt->bindValue(':off', $offset,  PDO::PARAM_INT);
            $dStmt->execute();
        } else {
            $dStmt = $this->pdo->prepare(
                'SELECT * FROM `' . $this->table . '`
                 ORDER BY ' . $orderBy . ' LIMIT :lim OFFSET :off'
            );
            $dStmt->bindValue(':lim', $perPage, PDO::PARAM_INT);
            $dStmt->bindValue(':off', $offset,  PDO::PARAM_INT);
            $dStmt->execute();
        }

        return [
            'data'    => $dStmt->fetchAll(),
            'total'   => $total,
            'pages'   => $pages,
            'current' => $page,
            'perPage' => $perPage,
        ];
    }
}

3
StagiaireModel, FormateurModel, FormationModel — pattern $fillable
phpapp/models/StagiaireModel.php — complet
<?php
// app/models/StagiaireModel.php
require_once dirname(__DIR__, 2) . '/core/BaseModel.php';
require_once dirname(__DIR__, 2) . '/core/ModelException.php';

class StagiaireModel extends BaseModel {

    protected string $table = 'stagiaires';

    // $fillable = colonnes autorisées à la création/modification
    // Protection contre le mass assignment : $_POST peut contenir n'importe quelle clé
    // en filtrant avec $fillable, on s'assure que seules les colonnes prévues sont écrites
    private array $fillable = ['nom', 'prenom', 'email', 'telephone', 'formation_id'];

    // ── create() — insérer un nouveau stagiaire ────────────────────────────
    public function create(array $data): int {
        // Garder uniquement les clés autorisées
        $data = array_intersect_key($data, array_flip($this->fillable));
        // Construire dynamiquement les colonnes et marqueurs
        $cols = implode(', ', array_keys($data));
        $phs  = ':' . implode(', :', array_keys($data));

        try {
            $stmt = $this->pdo->prepare(
                'INSERT INTO stagiaires (' . $cols . ') VALUES (' . $phs . ')'
            );
            $stmt->execute($data);
            return (int) $this->pdo->lastInsertId();
        } catch (PDOException $e) {
            if ($e->getCode() === '23000') {
                throw new ModelException('Cet email est déjà utilisé.', 'email', 23000, $e);
            }
            error_log('[StagiaireModel::create] ' . $e->getMessage());
            throw new ModelException('Erreur technique lors de la création.', '', 500, $e);
        }
    }

    // ── update() — modifier un stagiaire existant ──────────────────────────
    public function update(int $id, array $data): bool {
        $data = array_intersect_key($data, array_flip($this->fillable));
        $set  = implode(', ', array_map(fn($k) => "$k = :$k", array_keys($data)));
        $data['id'] = $id;  // Ajouter l'ID pour le WHERE
        try {
            $stmt = $this->pdo->prepare(
                'UPDATE stagiaires SET ' . $set . ' WHERE id = :id'
            );
            $stmt->execute($data);
            return $stmt->rowCount() > 0;
        } catch (PDOException $e) {
            if ($e->getCode() === '23000') {
                throw new ModelException('Cet email est déjà utilisé.', 'email', 23000, $e);
            }
            error_log('[StagiaireModel::update] ' . $e->getMessage());
            throw new ModelException('Erreur technique lors de la modification.', '', 500, $e);
        }
    }

    // ── validate() — validation centralisée dans le Model ─────────────────
    // Appelable depuis Controller, tests, API — réutilisable partout
    public function validate(array $data, int $excludeId = 0): array {
        $erreurs = [];
        if (strlen(trim($data['nom']    ?? '')) < 2) $erreurs[] = 'Le nom doit contenir au moins 2 caractères.';
        if (empty(trim($data['prenom']  ?? ''))) $erreurs[] = 'Le prénom est obligatoire.';
        if (!filter_var($data['email'] ?? '', FILTER_VALIDATE_EMAIL)) {
            $erreurs[] = 'Adresse email invalide.';
        } elseif ($this->emailExists($data['email'], $excludeId)) {
            $erreurs[] = 'Cette adresse email est déjà utilisée.';
        }
        if (!empty($data['formation_id']) && intval($data['formation_id']) > 0) {
            $stmt = $this->pdo->prepare('SELECT id FROM formations WHERE id = :id');
            $stmt->execute([':id' => intval($data['formation_id'])]);
            if (!$stmt->fetch()) $erreurs[] = 'Formation sélectionnée invalide.';
        }
        return $erreurs;
    }

    // ── emailExists() — vérifier l'unicité de l'email ─────────────────────
    public function emailExists(string $email, int $excludeId = 0): bool {
        $sql  = 'SELECT COUNT(*) FROM stagiaires WHERE email = :e';
        $params = [':e' => $email];
        if ($excludeId > 0) { $sql .= ' AND id != :id'; $params[':id'] = $excludeId; }
        return (int) $this->pdo->prepare($sql)->execute($params) > 0 ||
               (int) $this->pdo->prepare($sql)->execute($params) > 0;
        // Version correcte :
    }

    public function emailExistsCheck(string $email, int $excludeId = 0): bool {
        $sql  = 'SELECT COUNT(*) FROM stagiaires WHERE email = :e';
        $params = [':e' => $email];
        if ($excludeId > 0) { $sql .= ' AND id != :id'; $params[':id'] = $excludeId; }
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute($params);
        return (int) $stmt->fetchColumn() > 0;
    }

    // ── search() — WHERE dynamique multi-critères ─────────────────────────
    public function search(array $filtres): array {
        $where = [];
        $params = [];
        if (!empty($filtres['q'])) {
            $where[] = '(s.nom LIKE :q OR s.prenom LIKE :q)';
            $params[':q'] = '%' . $filtres['q'] . '%';
        }
        if (!empty($filtres['formation_id']) && is_numeric($filtres['formation_id'])) {
            $where[] = 's.formation_id = :fid';
            $params[':fid'] = intval($filtres['formation_id']);
        }
        $sqlWhere = empty($where) ? '' : 'WHERE ' . implode(' AND ', $where);
        $cols = ['nom','prenom','email','created_at'];
        $sort  = in_array($filtres['sort']  ?? '', $cols) ? $filtres['sort']  : 'nom';
        $order = strtoupper($filtres['order'] ?? 'ASC') === 'DESC' ? 'DESC' : 'ASC';

        $sql = 'SELECT s.*, f.intitule AS formation_intitule
                FROM stagiaires s
                LEFT JOIN formations f ON f.id = s.formation_id
                ' . $sqlWhere . '
                ORDER BY s.' . $sort . ' ' . $order;
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute($params);
        return $stmt->fetchAll();
    }

    // ── Méthodes métier ────────────────────────────────────────────────────
    public function findByFormation(int $formationId): array {
        $stmt = $this->pdo->prepare(
            'SELECT * FROM stagiaires WHERE formation_id = :fid ORDER BY nom'
        );
        $stmt->execute([':fid' => $formationId]);
        return $stmt->fetchAll();
    }
}
phpcore/ModelException.php + FormateurModel + FormationModel
<?php
// core/ModelException.php — exception personnalisée pour distinguer erreurs métier / techniques
class ModelException extends RuntimeException {
    public function __construct(
        string $message,
        private readonly string $field = '',  // Champ qui a causé l'erreur (ex: 'email')
        int $code = 0,
        ?Throwable $previous = null
    ) {
        parent::__construct($message, $code, $previous);
    }

    // Retourne le champ impliqué — le Controller peut l'afficher à côté du bon input
    public function getField(): string { return $this->field; }
}

// ───────────────────────────────────────────────────────────────────────────
// app/models/FormateurModel.php
// ───────────────────────────────────────────────────────────────────────────
class FormateurModel extends BaseModel {
    protected string $table = 'formateurs';
    private array $fillable  = ['nom', 'prenom', 'email', 'specialite', 'disponible'];

    public function create(array $data): int {
        $data = array_intersect_key($data, array_flip($this->fillable));
        $cols = implode(', ', array_keys($data));
        $phs  = ':' . implode(', :', array_keys($data));
        try {
            $stmt = $this->pdo->prepare('INSERT INTO formateurs (' . $cols . ') VALUES (' . $phs . ')');
            $stmt->execute($data);
            return (int) $this->pdo->lastInsertId();
        } catch (PDOException $e) {
            if ($e->getCode() === '23000') throw new ModelException('Email déjà utilisé.', 'email', 23000, $e);
            throw new ModelException('Erreur technique.', '', 500, $e);
        }
    }

    public function update(int $id, array $data): bool {
        $data = array_intersect_key($data, array_flip($this->fillable));
        $set  = implode(', ', array_map(fn($k) => "$k = :$k", array_keys($data)));
        $data['id'] = $id;
        $stmt = $this->pdo->prepare('UPDATE formateurs SET ' . $set . ' WHERE id = :id');
        $stmt->execute($data);
        return $stmt->rowCount() > 0;
    }

    public function findAvailable(): array {
        return $this->pdo->query('SELECT * FROM formateurs WHERE disponible = 1 ORDER BY nom')->fetchAll();
    }
}

// ───────────────────────────────────────────────────────────────────────────
// app/models/FormationModel.php
// ───────────────────────────────────────────────────────────────────────────
class FormationModel extends BaseModel {
    protected string $table = 'formations';
    private array $fillable  = ['intitule', 'duree_heures', 'formateur_id', 'actif'];

    public function create(array $data): int {
        $data = array_intersect_key($data, array_flip($this->fillable));
        $cols = implode(', ', array_keys($data));
        $phs  = ':' . implode(', :', array_keys($data));
        $stmt = $this->pdo->prepare('INSERT INTO formations (' . $cols . ') VALUES (' . $phs . ')');
        $stmt->execute($data);
        return (int) $this->pdo->lastInsertId();
    }

    public function update(int $id, array $data): bool {
        $data = array_intersect_key($data, array_flip($this->fillable));
        $set  = implode(', ', array_map(fn($k) => "$k = :$k", array_keys($data)));
        $data['id'] = $id;
        $stmt = $this->pdo->prepare('UPDATE formations SET ' . $set . ' WHERE id = :id');
        $stmt->execute($data);
        return $stmt->rowCount() > 0;
    }

    // Formation avec nb_stagiaires et nom du formateur
    public function withDetails(): array {
        return $this->pdo->query(
            'SELECT f.*, COUNT(s.id) AS nb_stagiaires,
                    CONCAT(fr.prenom, " ", fr.nom) AS formateur_nom
             FROM formations f
             LEFT JOIN stagiaires s  ON s.formation_id = f.id
             LEFT JOIN formateurs fr ON fr.id = f.formateur_id
             GROUP BY f.id ORDER BY f.intitule'
        )->fetchAll();
    }

    public function findByFormateur(int $formateurId): array {
        $stmt = $this->pdo->prepare(
            'SELECT * FROM formations WHERE formateur_id = :fid ORDER BY intitule'
        );
        $stmt->execute([':fid' => $formateurId]);
        return $stmt->fetchAll();
    }
}

4
Tests d'intégration des Models en isolation
phptests/test_models.php — 10 assertions
<?php
// tests/test_models.php — tests d'intégration sans Front Controller
// Accès : http://localhost/boutique_mvc/tests/test_models.php
require_once dirname(__DIR__) . '/config/database.php';
require_once dirname(__DIR__) . '/core/BaseModel.php';
require_once dirname(__DIR__) . '/core/ModelException.php';
require_once dirname(__DIR__) . '/app/models/StagiaireModel.php';

$resultats = [];

function assert_test(string $nom, bool $cond, string $detail = ''): array {
    return ['nom' => $nom, 'ok' => $cond, 'detail' => $detail];
}

$m = new StagiaireModel();

// 1. findAll() retourne un tableau
$resultats[] = assert_test('findAll() → array', is_array($m->findAll()));

// 2. create() retourne un ID > 0
$id = $m->create(['nom'=>'TestNom','prenom'=>'TestPrenom','email'=>'test_'.uniqid().'@atlas.ma']);
$resultats[] = assert_test('create() → ID > 0', is_int($id) && $id > 0, 'ID = '.$id);

// 3. findById() retrouve l'enregistrement créé
$row = $m->findById($id);
$resultats[] = assert_test('findById() retrouve le stagiaire', $row && $row['nom'] === 'TestNom');

// 4. update() modifie les données
$m->update($id, ['nom' => 'NomModifie']);
$row2 = $m->findById($id);
$resultats[] = assert_test('update() modifie le nom', $row2 && $row2['nom'] === 'NomModifie');

// 5. count() retourne un entier ≥ 1
$nb = $m->count();
$resultats[] = assert_test('count() ≥ 1', is_int($nb) && $nb >= 1, 'Total = '.$nb);

// 6. exists() true pour ID valide
$resultats[] = assert_test('exists() true pour ID valide', $m->exists($id));

// 7. exists() false pour ID inexistant
$resultats[] = assert_test('exists() false pour ID inexistant', !$m->exists(999999));

// 8. validate() retourne [] pour données valides
$erreurs = $m->validate(['nom'=>'Sara','prenom'=>'Benali','email'=>'unique_'.uniqid().'@atlas.ma']);
$resultats[] = assert_test('validate() → vide si OK', empty($erreurs), implode(', ', $erreurs));

// 9. doublon email → ModelException
$emailTest = 'doublon_'.uniqid().'@atlas.ma';
$m->create(['nom'=>'A','prenom'=>'A','email'=>$emailTest]);
try {
    $m->create(['nom'=>'B','prenom'=>'B','email'=>$emailTest]);
    $resultats[] = assert_test('doublon → ModelException', false, 'Exception non levée');
} catch (ModelException $e) {
    $resultats[] = assert_test('doublon → ModelException', true, $e->getMessage());
}

// 10. delete() fonctionne
$m->delete($id);
$resultats[] = assert_test('delete() + findById() → null', $m->findById($id) === null);

$pass  = count(array_filter($resultats, fn($r) => $r['ok']));
$total = count($resultats);
?>
<!DOCTYPE html><html lang="fr"><head><meta charset="UTF-8">
<title>Tests Models</title>
<style>body{font-family:Calibri,sans-serif;padding:20px}
table{border-collapse:collapse;width:100%}th,td{border:1px solid #ccc;padding:8px 12px}
th{background:#008245;color:white}.ok{color:#008245;font-weight:bold}.fail{color:#C0392B;font-weight:bold}
</style></head><body>
<h2>Tests Models — <?= $pass ?>/<?= $total ?> passés</h2>
<table><tr><th>#</th><th>Test</th><th>Résultat</th><th>Détail</th></tr>
<?php foreach ($resultats as $i => $r): ?>
<tr><td><?= $i+1 ?></td>
    <td><?= htmlspecialchars($r['nom']) ?></td>
    <td class="<?= $r['ok']?'ok':'fail' ?>"><?= $r['ok']?'PASS':'FAIL' ?></td>
    <td><?= htmlspecialchars($r['detail']) ?></td>
</tr>
<?php endforeach; ?>
</table></body></html>
Pourquoi create() et update() ne sont-ils pas placés dans BaseModel comme findAll() ou findById() ?
Partie 7 — Architecture MVC Layouts partiels 4 vues CRUD Messages flash
⏱ 20 min

Les Vues — Layouts partiels, 4 vues CRUD & CSS

Règles absolues d'une View MVC, layouts header/navbar/footer, les 4 vues CRUD pour les stagiaires (list, form unifié create/edit, show, confirm_delete), messages flash, select dynamique avec option sélectionnée, et CSS minimal complet.


1
Règles absolues d'une View — ce qui est autorisé vs interdit
Autorisé dans une ViewInterdit dans une View
echo + htmlspecialchars() pour afficher des variablesRequêtes PDO ou appels de Model
foreach, if pour l'affichage conditionnelAccès à $_GET, $_POST, $_FILES
include d'autres partials (header, footer)Redirections header('Location:')
Affichage conditionnel si liste vide → messageLogique métier (calculs, validations)
htmlspecialchars() sur toute variable affichéenew PDO() ou toute connexion BDD
Liens href et attributs action des formulairessession_start() (sauf navbar pour $_SESSION)

2
Layouts partiels — header.php, navbar.php, footer.php
phpviews/layouts/ — 3 fichiers partiels
<!-- views/layouts/header.php — $pageTitle injecté par render() -->
<!DOCTYPE html>
<html lang="fr">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title><?= htmlspecialchars($pageTitle ?? 'skilling.ma') ?></title>
  <link rel="stylesheet" href="/assets/css/style.css">
</head>
<body>
  <?php require_once __DIR__ . '/navbar.php'; ?>
  <main class="container">

<!-- views/layouts/navbar.php — accès $_SESSION autorisé dans les partials -->
<?php
// Afficher et supprimer le message flash en une seule fois
$flash = $_SESSION['flash'] ?? null;
unset($_SESSION['flash']);
?>
<nav class="navbar">
  <a href="/" class="brand">Boutique Atlas</a>
  <ul>
    <li><a href="/produits">Produits</a></li>
    <li><a href="/commandes">Commandes</a></li>
  </ul>
  <div class="user-info">
    <?php if (isset($_SESSION['login'])): ?>
      <span><?= htmlspecialchars($_SESSION['login']) ?></span>
      <a href="/auth/logout">Déconnexion</a>
    <?php else: ?>
      <a href="/auth/login">Connexion</a>
    <?php endif; ?>
  </div>
</nav>
<?php if ($flash): ?>
  <div class="alert alert-<?= htmlspecialchars($flash['type']) ?>">
    <?= htmlspecialchars($flash['message']) ?>
  </div>
<?php endif; ?>

<!-- views/layouts/footer.php -->
  </main>
  <footer class="footer">
    <p>skilling.ma — Boutique Atlas — <?= date('Y') ?></p>
  </footer>
</body>
</html>

3
Les 4 vues CRUD pour les stagiaires
phpviews/stagiaires/list.php — liste avec recherche
<!-- views/stagiaires/list.php -->
<!-- Variables reçues : $stagiaires (array), $q (string), $pagination (array) -->

<div class="page-header">
  <h1>Stagiaires (<?= count($stagiaires) ?>)</h1>
  <a href="/stagiaires/create" class="btn btn-primary">+ Ajouter</a>
</div>

<form method="GET" action="/stagiaires" class="search-form">
  <input type="search" name="q" value="<?= htmlspecialchars($q) ?>"
         placeholder="Rechercher par nom ou prénom...">
  <button type="submit" class="btn">Rechercher</button>
  <?php if ($q): ?>
    <a href="/stagiaires" class="btn btn-link">✕ Effacer</a>
  <?php endif; ?>
</form>

<?php if (empty($stagiaires)): ?>
  <p class="empty-state">Aucun stagiaire trouvé.</p>
<?php else: ?>
  <table class="data-table">
    <thead>
      <tr>
        <th><a href="/stagiaires?sort=nom&q=<?= urlencode($q) ?>">Nom</a></th>
        <th>Prénom</th>
        <th>Email</th>
        <th>Formation</th>
        <th>Actions</th>
      </tr>
    </thead>
    <tbody>
    <?php foreach ($stagiaires as $s): ?>
      <tr>
        <td><?= htmlspecialchars($s['nom']) ?></td>
        <td><?= htmlspecialchars($s['prenom']) ?></td>
        <td><?= htmlspecialchars($s['email']) ?></td>
        <td><?= htmlspecialchars($s['formation_intitule'] ?? '—') ?></td>
        <td class="actions">
          <a href="/stagiaires/show/<?= $s['id'] ?>" class="btn btn-sm">Voir</a>
          <a href="/stagiaires/edit/<?= $s['id'] ?>" class="btn btn-sm btn-warning">Modifier</a>
          <a href="/stagiaires/destroy/<?= $s['id'] ?>" class="btn btn-sm btn-danger">Supprimer</a>
        </td>
      </tr>
    <?php endforeach; ?>
    </tbody>
  </table>

  <!-- Pagination -->
  <?php if (($pagination['pages'] ?? 1) > 1): ?>
  <nav class="pagination">
    <?php if ($pagination['current'] > 1): ?>
      <a href="?page=<?= $pagination['current']-1 ?>&q=<?= urlencode($q) ?>" class="btn btn-sm">&laquo; Préc.</a>
    <?php endif; ?>
    <?php for ($p = 1; $p <= $pagination['pages']; $p++): ?>
      <a href="?page=<?= $p ?>&q=<?= urlencode($q) ?>"
         class="btn btn-sm <?= $p === $pagination['current'] ? 'btn-primary' : '' ?>">
        <?= $p ?>
      </a>
    <?php endfor; ?>
    <?php if ($pagination['current'] < $pagination['pages']): ?>
      <a href="?page=<?= $pagination['current']+1 ?>&q=<?= urlencode($q) ?>" class="btn btn-sm">Suiv. &raquo;</a>
    <?php endif; ?>
    <span class="pagination-info">Page <?= $pagination['current'] ?>/<?= $pagination['pages'] ?> (<?= $pagination['total'] ?> résultats)</span>
  </nav>
  <?php endif; ?>
<?php endif; ?>
phpviews/stagiaires/form.php — formulaire unifié create/edit
<!-- views/stagiaires/form.php -->
<!-- Une seule vue pour CRÉER et MODIFIER —>
<!-- $stagiaire === null → création │ $stagiaire = array → édition -->
<!-- Variables reçues : $stagiaire (array|null), $erreurs, $csrfToken, $formations -->
<?php
$modeEdition = ($stagiaire !== null);
$titre  = $modeEdition ? 'Modifier le stagiaire' : 'Ajouter un stagiaire';
$action = $modeEdition
    ? '/stagiaires/update/' . $stagiaire['id']
    : '/stagiaires/store';
// Helper : retourner la valeur pour pré-remplissage
$val = fn(string $key) => htmlspecialchars($stagiaire[$key] ?? '');
?>

<h1><?= $titre ?></h1>

<!-- Erreurs de validation -->
<?php if (!empty($erreurs)): ?>
  <div class="alert alert-error">
    <ul>
    <?php foreach ($erreurs as $e): ?>
      <li><?= htmlspecialchars($e) ?></li>
    <?php endforeach; ?>
    </ul>
  </div>
<?php endif; ?>

<form method="POST" action="<?= $action ?>">
  <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken) ?>">

  <div class="form-group">
    <label for="nom">Nom *</label>
    <input type="text" id="nom" name="nom" value="<?= $val('nom') ?>" required>
  </div>

  <div class="form-group">
    <label for="prenom">Prénom *</label>
    <input type="text" id="prenom" name="prenom" value="<?= $val('prenom') ?>" required>
  </div>

  <div class="form-group">
    <label for="email">Email *</label>
    <input type="email" id="email" name="email" value="<?= $val('email') ?>" required>
  </div>

  <div class="form-group">
    <label for="telephone">Téléphone</label>
    <input type="tel" id="telephone" name="telephone" value="<?= $val('telephone') ?>">
  </div>

  <div class="form-group">
    <label for="formation_id">Formation</label>
    <select id="formation_id" name="formation_id">
      <option value="">— Aucune —</option>
      <?php foreach ($formations as $f): ?>
        <option value="<?= $f['id'] ?>"
          <?= (($stagiaire['formation_id'] ?? '') == $f['id']) ? 'selected' : '' ?>>
          <?= htmlspecialchars($f['intitule']) ?>
        </option>
      <?php endforeach; ?>
    </select>
  </div>

  <div class="form-actions">
    <button type="submit" class="btn btn-primary">
      <?= $modeEdition ? 'Enregistrer les modifications' : 'Ajouter le stagiaire' ?>
    </button>
    <a href="/stagiaires" class="btn btn-link">Annuler</a>
  </div>
</form>
phpviews/stagiaires/show.php + confirm_delete.php
<!-- views/stagiaires/show.php — détail en lecture seule -->
<div class="detail-card">
  <h1><?= htmlspecialchars($stagiaire['prenom'].' '.$stagiaire['nom']) ?></h1>
  <dl class="detail-list">
    <dt>Email</dt>    <dd><?= htmlspecialchars($stagiaire['email']) ?></dd>
    <dt>Téléphone</dt><dd><?= htmlspecialchars($stagiaire['telephone'] ?? '—') ?></dd>
    <dt>Formation</dt><dd><?= htmlspecialchars($stagiaire['formation_intitule'] ?? '—') ?></dd>
    <dt>Inscrit le</dt><dd><?= htmlspecialchars($stagiaire['created_at'] ?? '—') ?></dd>
  </dl>
  <div class="form-actions">
    <a href="/stagiaires/edit/<?= $stagiaire['id'] ?>" class="btn btn-warning">Modifier</a>
    <a href="/stagiaires/destroy/<?= $stagiaire['id'] ?>" class="btn btn-danger">Supprimer</a>
    <a href="/stagiaires" class="btn btn-link">Retour à la liste</a>
  </div>
</div>

<!-- ─────────────────────────────────────────────────────────────── -->

<!-- views/stagiaires/confirm_delete.php — confirmation obligatoire avant suppression -->
<div class="confirm-card">
  <h1>Confirmer la suppression</h1>
  <div class="alert alert-warning">
    <p>Vous êtes sur le point de supprimer définitivement :</p>
    <strong><?= htmlspecialchars($stagiaire['prenom'].' '.$stagiaire['nom']) ?></strong>
    (<?= htmlspecialchars($stagiaire['email']) ?>)
    <p>⚠️ Cette action est irréversible.</p>
  </div>
  <!-- La suppression passe par POST — jamais par un simple lien GET -->
  <form method="POST" action="/stagiaires/destroy/<?= $stagiaire['id'] ?>">
    <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken) ?>">
    <button type="submit" class="btn btn-danger">Oui, supprimer définitivement</button>
    <a href="/stagiaires" class="btn btn-link">Annuler</a>
  </form>
</div>

4
CSS minimal — public/assets/css/style.css
csspublic/assets/css/style.css — 60 règles
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: Calibri, sans-serif; background: #f5f5f5; color: #1a1a1a; }

/* Navbar */
.navbar { background: #008245; color: white; display: flex; align-items: center;
  gap: 2rem; padding: .75rem 1.5rem; }
.navbar .brand { color: white; font-weight: bold; font-size: 1.1rem; text-decoration: none; }
.navbar ul { list-style: none; display: flex; gap: 1rem; }
.navbar ul a { color: #d0eed1; text-decoration: none; }
.navbar ul a:hover { color: white; }
.navbar .user-info { margin-left: auto; font-size: .9rem; }
.navbar .user-info a { color: #d0eed1; }

/* Layout */
.container { max-width: 1100px; margin: 2rem auto; padding: 0 1rem; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; }
.page-header h1 { font-size: 1.5rem; color: #008245; }

/* Alerts */
.alert { padding: .75rem 1rem; border-radius: 4px; margin-bottom: 1rem; }
.alert-success { background: #edfaf3; border-left: 4px solid #008245; color: #005a2f; }
.alert-error   { background: #fdedec; border-left: 4px solid #c0392b; color: #7b241c; }
.alert-warning  { background: #fef9e7; border-left: 4px solid #f39c12; color: #7d6608; }
.alert ul { margin-left: 1.2rem; }

/* Table */
.data-table { width: 100%; border-collapse: collapse; background: white;
  box-shadow: 0 1px 3px rgba(0,0,0,.1); }
.data-table th { background: #008245; color: white; padding: .6rem .8rem; text-align: left; font-size: .9rem; }
.data-table th a { color: #d0eed1; }
.data-table td { padding: .55rem .8rem; border-bottom: 1px solid #e0e0e0; font-size: .9rem; }
.data-table tr:nth-child(even) { background: #f2f4f8; }
.data-table tr:hover { background: #edfaf3; }
.actions { white-space: nowrap; }

/* Buttons */
.btn { display: inline-block; padding: .4rem .9rem; border-radius: 4px;
  text-decoration: none; font-size: .88rem; cursor: pointer; border: none; }
.btn-primary { background: #008245; color: white; }
.btn-primary:hover { background: #006535; }
.btn-warning { background: #e67e22; color: white; }
.btn-danger  { background: #c0392b; color: white; }
.btn-link    { background: transparent; color: #008245; }
.btn-sm      { padding: .25rem .6rem; font-size: .8rem; }

/* Forms */
.form-group { margin-bottom: 1rem; }
.form-group label { display: block; font-weight: bold; margin-bottom: .3rem; font-size: .9rem; color: #444; }
.form-group input, .form-group select, .form-group textarea {
  width: 100%; padding: .5rem .75rem; border: 1px solid #ccc;
  border-radius: 4px; font-size: .95rem; }
.form-group input:focus, .form-group select:focus {
  border-color: #008245; outline: none; box-shadow: 0 0 0 2px #edfaf3; }
.form-actions { margin-top: 1.5rem; display: flex; gap: .75rem; align-items: center; }
.search-form { display: flex; gap: .5rem; margin-bottom: 1.5rem; }
.search-form input { flex: 1; padding: .45rem .75rem; border: 1px solid #ccc; border-radius: 4px; }

/* Cards */
.detail-card, .confirm-card { background: white; padding: 2rem;
  border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,.1); max-width: 600px; }
.detail-list dt { font-weight: bold; color: #008245; margin-top: .75rem; }
.detail-list dd { margin-left: 0; color: #333; }

/* Pagination */
.pagination { display: flex; gap: .3rem; align-items: center; margin-top: 1rem; }
.pagination-info { margin-left: .5rem; font-size: .85rem; color: #888; }

/* Footer & misc */
.footer { text-align: center; padding: 1.5rem; color: #888; font-size: .85rem;
  margin-top: 3rem; border-top: 1px solid #ddd; }
.empty-state { text-align: center; color: #888; padding: 3rem; font-style: italic; }
Partie 7 — Architecture MVC 7 actions CRUD Routeur natif requireAuth / CSRF
⏱ 22 min

Le Contrôleur & le Routeur — 7 actions CRUD & middleware

core/Controller.php version complète avec render(), redirect(), requireAuth(), requireRole(), generateCsrf(), verifyCsrf(), flash(), getFlash(). Routeur natif amélioré avec spl_autoload_register, et StagiaireController avec les 7 actions CRUD complètes (index, show, create, store, edit, update, destroy) + patron PRG.


1
core/Controller.php — version complète avec toutes les méthodes de sécurité
phpcore/Controller.php — complet
<?php
// core/Controller.php — classe mère de tous les Controllers
class Controller {

    // ── Rendu de vue ──────────────────────────────────────────────────────
    protected function render(string $view, array $data = []): void {
        // Injecter le flash dans toutes les vues automatiquement
        $data['flash'] = $this->getFlash();
        extract($data);  // Crée $stagiaires, $q, $flash... comme variables locales
        if (!isset($pageTitle)) $pageTitle = 'skilling.ma';
        $base     = dirname(__DIR__);
        $viewPath = $base . '/app/views/' . $view . '.php';
        if (!file_exists($viewPath)) {
            http_response_code(404);
            die('Vue introuvable : ' . htmlspecialchars($view));
        }
        require_once $base . '/app/views/layouts/header.php';
        require_once $viewPath;
        require_once $base . '/app/views/layouts/footer.php';
    }

    protected function redirect(string $url): void {
        header('Location: ' . $url);
        exit;
    }

    // ── Page 404 dédiée ───────────────────────────────────────────────────
    protected function notFound(string $msg = 'Ressource introuvable'): void {
        http_response_code(404);
        $this->render('errors/404', ['pageTitle' => '404', 'message' => $msg]);
        exit;
    }

    // ── Middleware d'authentification ─────────────────────────────────────
    // Redirige vers /auth/login si l'utilisateur n'est pas connecté
    protected function requireAuth(): void {
        if (!isset($_SESSION['connecte']) || !$_SESSION['connecte']) {
            $_SESSION['redirect_after_login'] = $_SERVER['REQUEST_URI'] ?? '/';
            $this->redirect('/auth/login');
        }
    }

    // Redirige si l'utilisateur n'a pas le rôle requis
    protected function requireRole(string $role): void {
        $this->requireAuth();
        if (($_SESSION['role'] ?? '') !== $role) {
            http_response_code(403);
            $this->render('errors/403', ['pageTitle' => 'Accès refusé']);
            exit;
        }
    }

    // ── Gestion CSRF ──────────────────────────────────────────────────────
    protected function generateCsrf(): string {
        $token = bin2hex(random_bytes(32));
        $_SESSION['csrf_token'] = $token;
        return $token;
    }

    protected function verifyCsrf(string $token): bool {
        $valid = hash_equals($_SESSION['csrf_token'] ?? '', $token);
        unset($_SESSION['csrf_token']);  // Token à usage unique
        return $valid;
    }

    // ── Messages flash ────────────────────────────────────────────────────
    // Appeler AVANT redirect() — disponible dans la View suivante via $flash
    protected function flash(string $type, string $message): void {
        $_SESSION['flash'] = ['type' => $type, 'message' => $message];
    }

    private function getFlash(): array|null {
        $flash = $_SESSION['flash'] ?? null;
        unset($_SESSION['flash']);  // Effacer après lecture — une seule utilisation
        return $flash;
    }
}

2
public/index.php — Front Controller avec autoloader et routeur
phppublic/index.php — Front Controller amélioré
<?php
// public/index.php — Point d'entrée UNIQUE de toute l'application
session_start();

// ── Handler global d'exceptions non catchées ─────────────────────────────
set_exception_handler(function (Throwable $e): void {
    error_log('EXCEPTION non catchée : ' . $e->getMessage()
              . ' dans ' . $e->getFile() . ':' . $e->getLine());
    http_response_code(500);
    if (defined('APP_ENV') && APP_ENV === 'development') {
        echo '<pre style="color:red">' . htmlspecialchars($e->getMessage())
             . "\n" . htmlspecialchars($e->getTraceAsString()) . '</pre>';
    } else {
        require_once dirname(__DIR__) . '/app/views/errors/500.php';
    }
});

// ── Autoloader — charge automatiquement les classes sans require_once ─────
spl_autoload_register(function (string $class): void {
    $dirs = [
        dirname(__DIR__) . '/core/',
        dirname(__DIR__) . '/app/controllers/',
        dirname(__DIR__) . '/app/models/',
    ];
    foreach ($dirs as $dir) {
        $file = $dir . $class . '.php';
        if (file_exists($file)) { require $file; return; }
    }
});

require_once dirname(__DIR__) . '/config/database.php';

// ── Parser l'URL ─────────────────────────────────────────────────────────
// .htaccess redirige toute URL vers index.php?url=stagiaires/show/5
$url    = trim($_GET['url'] ?? '', '/');
$parts  = $url !== '' ? explode('/', $url) : [];

$controllerName = $parts[0] ?? 'stagiaires';  // 'stagiaires'
$action         = $parts[1] ?? 'index';        // 'show'
$param          = isset($parts[2]) && is_numeric($parts[2])
                  ? intval($parts[2]) : null;   // 5

// ── Table de routage ──────────────────────────────────────────────────────
$routes = [
    'stagiaires'   => 'StagiaireController',
    'formateurs'   => 'FormateurController',
    'formations'   => 'FormationController',
    'inscriptions' => 'InscriptionController',
    'auth'         => 'AuthController',
];

if (!array_key_exists($controllerName, $routes)) {
    http_response_code(404);
    die('<h1>404 — Page introuvable</h1>');
}

$class      = $routes[$controllerName];
$controller = new $class();

if (!method_exists($controller, $action)) {
    http_response_code(404);
    die('<h1>404 — Action introuvable : ' . htmlspecialchars($action) . '</h1>');
}

// Appeler l'action avec le paramètre si présent
$param !== null ? $controller->$action($param) : $controller->$action();

3
StagiaireController — les 7 actions CRUD complètes
phpapp/controllers/StagiaireController.php — complet
<?php
// app/controllers/StagiaireController.php — 7 actions CRUD
class StagiaireController extends Controller {

    private StagiaireModel $model;
    private FormationModel $formationModel;

    public function __construct() {
        $this->model         = new StagiaireModel();
        $this->formationModel = new FormationModel();
    }

    // ── index() : GET /stagiaires — liste avec pagination et recherche ─────
    public function index(): void {
        $this->requireAuth();
        $q    = trim($_GET['q'] ?? '');
        $page = max(1, intval($_GET['page'] ?? 1));
        $sort = $_GET['sort'] ?? 'nom';

        $filtres    = ['q' => $q, 'sort' => $sort, 'order' => 'asc'];
        $stagiaires = $this->model->search($filtres);
        $pagination = $this->model->paginate($page, 15, $q, $sort);

        $this->render('stagiaires/list', [
            'stagiaires' => $pagination['data'],
            'pagination' => $pagination,
            'q'          => $q,
            'sort'       => $sort,
            'pageTitle'  => 'Stagiaires',
        ]);
    }

    // ── show($id) : GET /stagiaires/show/5 — détail ───────────────────────
    public function show(int $id = 0): void {
        $this->requireAuth();
        $stagiaire = $this->model->findById($id);
        if ($stagiaire === null) $this->notFound('Stagiaire introuvable (id=' . $id . ')');
        $this->render('stagiaires/show', [
            'stagiaire' => $stagiaire,
            'pageTitle' => $stagiaire['prenom'] . ' ' . $stagiaire['nom'],
        ]);
    }

    // ── create() : GET /stagiaires/create — formulaire vide ──────────────
    public function create(): void {
        $this->requireAuth();
        $this->render('stagiaires/form', [
            'stagiaire'  => null,           // null = mode création
            'erreurs'    => [],
            'csrfToken'  => $this->generateCsrf(),
            'formations' => $this->formationModel->findAll('intitule'),
            'pageTitle'  => 'Ajouter un stagiaire',
        ]);
    }

    // ── store() : POST /stagiaires/store — traiter la création (PRG) ──────
    public function store(): void {
        $this->requireAuth();
        if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
            $this->redirect('/stagiaires/create'); return;
        }
        // Vérification CSRF en premier — avant toute autre logique
        if (!$this->verifyCsrf($_POST['csrf_token'] ?? '')) {
            $this->flash('error', 'Erreur de sécurité. Rechargez le formulaire.');
            $this->redirect('/stagiaires/create'); return;
        }
        $data    = $this->collectPost(['nom','prenom','email','telephone','formation_id']);
        $erreurs = $this->model->validate($data);  // Validation dans le Model

        if (!empty($erreurs)) {
            // Erreurs → réafficher le formulaire avec les erreurs (pas de redirect)
            $this->render('stagiaires/form', [
                'stagiaire'  => $data,
                'erreurs'    => $erreurs,
                'csrfToken'  => $this->generateCsrf(),
                'formations' => $this->formationModel->findAll('intitule'),
                'pageTitle'  => 'Ajouter un stagiaire',
            ]); return;
        }
        try {
            $this->model->create($data);
            // PRG : redirect après POST réussi — F5 ne re-soumet pas
            $this->flash('success', 'Stagiaire ajouté avec succès.');
            $this->redirect('/stagiaires');
        } catch (ModelException $e) {
            $this->render('stagiaires/form', [
                'stagiaire'  => $data,
                'erreurs'    => [$e->getMessage()],
                'csrfToken'  => $this->generateCsrf(),
                'formations' => $this->formationModel->findAll('intitule'),
                'pageTitle'  => 'Ajouter un stagiaire',
            ]);
        }
    }

    // ── edit($id) : GET /stagiaires/edit/5 — formulaire pré-rempli ────────
    public function edit(int $id = 0): void {
        $this->requireAuth();
        $stagiaire = $this->model->findById($id);
        if ($stagiaire === null) { $this->redirect('/stagiaires'); return; }
        $this->render('stagiaires/form', [
            'stagiaire'  => $stagiaire,   // non null = mode édition
            'erreurs'    => [],
            'csrfToken'  => $this->generateCsrf(),
            'formations' => $this->formationModel->findAll('intitule'),
            'pageTitle'  => 'Modifier ' . $stagiaire['nom'],
        ]);
    }

    // ── update($id) : POST /stagiaires/update/5 — traiter la modification ─
    public function update(int $id = 0): void {
        $this->requireAuth();
        if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
            $this->redirect('/stagiaires/edit/' . $id); return;
        }
        if (!$this->verifyCsrf($_POST['csrf_token'] ?? '')) {
            $this->flash('error', 'Erreur de sécurité.');
            $this->redirect('/stagiaires/edit/' . $id); return;
        }
        $stagiaire = $this->model->findById($id);
        if ($stagiaire === null) { $this->redirect('/stagiaires'); return; }

        $data    = $this->collectPost(['nom','prenom','email','telephone','formation_id']);
        $erreurs = $this->model->validate($data, $id);  // $id exclut cet enregistrement pour l'email

        if (!empty($erreurs)) {
            $this->render('stagiaires/form', [
                'stagiaire'  => array_merge($stagiaire, $data),
                'erreurs'    => $erreurs,
                'csrfToken'  => $this->generateCsrf(),
                'formations' => $this->formationModel->findAll('intitule'),
                'pageTitle'  => 'Modifier ' . $stagiaire['nom'],
            ]); return;
        }
        try {
            $this->model->update($id, $data);
            $this->flash('success', 'Stagiaire modifié avec succès.');
            $this->redirect('/stagiaires');
        } catch (ModelException $e) {
            $this->render('stagiaires/form', [
                'stagiaire'  => array_merge($stagiaire, $data),
                'erreurs'    => [$e->getMessage()],
                'csrfToken'  => $this->generateCsrf(),
                'formations' => $this->formationModel->findAll('intitule'),
                'pageTitle'  => 'Modifier ' . $stagiaire['nom'],
            ]);
        }
    }

    // ── destroy($id) : GET=confirmation, POST=suppression ─────────────────
    public function destroy(int $id = 0): void {
        $this->requireAuth();
        $stagiaire = $this->model->findById($id);
        if ($stagiaire === null) { $this->redirect('/stagiaires'); return; }

        if ($_SERVER['REQUEST_METHOD'] === 'GET') {
            // Afficher la page de confirmation
            $this->render('stagiaires/confirm_delete', [
                'stagiaire' => $stagiaire,
                'csrfToken' => $this->generateCsrf(),
                'pageTitle' => 'Supprimer ' . $stagiaire['nom'],
            ]); return;
        }
        // POST — exécuter la suppression après vérification CSRF
        if (!$this->verifyCsrf($_POST['csrf_token'] ?? '')) {
            $this->flash('error', 'Erreur de sécurité.');
            $this->redirect('/stagiaires'); return;
        }
        $this->model->delete($id);
        $this->flash('success', 'Stagiaire supprimé.');
        $this->redirect('/stagiaires');
    }

    // ── Utilitaire — récupérer un sous-ensemble de $_POST nettoyé ─────────
    private function collectPost(array $keys): array {
        $data = [];
        foreach ($keys as $k) {
            $data[$k] = trim($_POST[$k] ?? '');
        }
        return $data;
    }
}
💡 Flux complet POST /stagiaires/store — 12 étapes
ÉtapeQui agitCe qui se passe
1NavigateurPOST /stagiaires/store avec données du formulaire
2.htaccessRedirige vers index.php?url=stagiaires/store
3index.phpsession_start(), autoloader enregistré
4index.phpParse URL → StagiaireController::store()
5store()requireAuth() — vérifie $_SESSION
6store()verifyCsrf() — vérifie le token
7store()collectPost() + model->validate()
8astore()Si erreurs → render('stagiaires/form', [...erreurs...])
8bstore()Si OK → model->create($data)
9store()flash('success',...) + redirect('/stagiaires')
10NavigateurGET /stagiaires (suite au redirect — PRG)
11index()model->paginate() + render('stagiaires/list',...)
12NavigateurListe avec message flash vert affiché et supprimé
Pourquoi store() appelle-t-il render() en cas d'erreur de validation, mais redirect() en cas de succès ?
Partie 7 — Architecture MVC Gestion 404/500 Relations entre entités Validation 21 routes
⏱ 20 min

Assemblage MVC complet — Relations, erreurs & validation des routes

Pages d'erreur 404/500 dédiées, set_exception_handler() global, intégration des relations entre entités (Formation→Stagiaires, Formateur→Formations), pagination avec composant réutilisable, et grille de validation des 21 routes.


1
Gestion des erreurs 404/500 et pages dédiées
phpviews/errors/404.php + 500.php
<!-- views/errors/404.php -->
<div style="text-align:center;padding:4rem">
  <h1 style="font-size:5rem;color:#008245;margin:0">404</h1>
  <h2>Page introuvable</h2>
  <p><?= htmlspecialchars($message ?? 'La ressource demandée n\'existe pas.') ?></p>
  <a href="/stagiaires" class="btn btn-primary" style="margin-top:1rem">Retour à l'accueil</a>
</div>

<!-- views/errors/500.php -->
<div style="text-align:center;padding:4rem">
  <h1 style="font-size:5rem;color:#c0392b;margin:0">500</h1>
  <h2>Erreur serveur</h2>
  <p>Une erreur inattendue s'est produite. L'équipe technique a été notifiée.</p>
  <a href="/stagiaires" class="btn btn-primary" style="margin-top:1rem">Retour à l'accueil</a>
</div>

2
Relations entre entités — jointures dans les Controllers
phpRelations Formation→Stagiaires & Formateur→Formations
<?php
// ── FormationController::show() — affiche les stagiaires inscrits ────────
public function show(int $id = 0): void {
    $this->requireAuth();
    $formation = $this->model->findById($id);
    if ($formation === null) $this->notFound();

    // Instancier StagiaireModel pour récupérer les stagiaires de cette formation
    $stagiaireModel = new StagiaireModel();
    $stagiaires     = $stagiaireModel->findByFormation($id);

    $this->render('formations/show', [
        'formation'  => $formation,
        'stagiaires' => $stagiaires,  // Passé à la View — jamais de SQL dans la View
        'pageTitle'  => $formation['intitule'],
    ]);
}

// ── FormateurController::show() — affiche les formations du formateur ─────
public function show(int $id = 0): void {
    $this->requireAuth();
    $formateur = $this->model->findById($id);
    if ($formateur === null) $this->notFound();

    $formationModel = new FormationModel();
    $formations     = $formationModel->findByFormateur($id);

    $this->render('formateurs/show', [
        'formateur'  => $formateur,
        'formations' => $formations,
        'pageTitle'  => $formateur['prenom'] . ' ' . $formateur['nom'],
    ]);
}

// ── InscriptionModel — relation many-to-many ──────────────────────────────
// Table de liaison : inscriptions(id, stagiaire_id, formation_id, statut)

class InscriptionModel extends BaseModel {
    protected string $table = 'inscriptions';

    public function inscrire(int $stagiaireId, int $formationId): int {
        if ($this->isInscrit($stagiaireId, $formationId)) {
            throw new ModelException('Ce stagiaire est déjà inscrit à cette formation.', '', 23000);
        }
        $stmt = $this->pdo->prepare(
            'INSERT INTO inscriptions (stagiaire_id, formation_id) VALUES (:sid, :fid)'
        );
        $stmt->execute([':sid' => $stagiaireId, ':fid' => $formationId]);
        return (int) $this->pdo->lastInsertId();
    }

    public function isInscrit(int $sid, int $fid): bool {
        $stmt = $this->pdo->prepare(
            'SELECT COUNT(*) FROM inscriptions WHERE stagiaire_id = :sid AND formation_id = :fid'
        );
        $stmt->execute([':sid' => $sid, ':fid' => $fid]);
        return (int) $stmt->fetchColumn() > 0;
    }

    public function findByStagiaire(int $stagiaireId): array {
        $stmt = $this->pdo->prepare(
            'SELECT i.*, f.intitule AS formation_intitule, f.duree_heures,
                    CONCAT(fr.prenom, " ", fr.nom) AS formateur_nom
             FROM inscriptions i
             JOIN formations f  ON f.id  = i.formation_id
             LEFT JOIN formateurs fr ON fr.id = f.formateur_id
             WHERE i.stagiaire_id = :sid ORDER BY i.date_inscription DESC'
        );
        $stmt->execute([':sid' => $stagiaireId]);
        return $stmt->fetchAll();
    }

    public function findByFormation(int $formationId): array {
        $stmt = $this->pdo->prepare(
            'SELECT i.*, s.nom, s.prenom, s.email, i.statut, i.date_inscription
             FROM inscriptions i
             JOIN stagiaires s ON s.id = i.stagiaire_id
             WHERE i.formation_id = :fid ORDER BY s.nom, s.prenom'
        );
        $stmt->execute([':fid' => $formationId]);
        return $stmt->fetchAll();
    }
}

3
Grille de validation — 21 routes à tester
RouteHTTPAttendu
GET /stagiairesGETListe + recherche + flash si présent
GET /stagiaires/show/1GETDétail stagiaire id=1
GET /stagiaires/show/9999GETPage 404
GET /stagiaires/createGETFormulaire vide + select formations
POST /stagiaires/store (valide)POSTRedirect + flash success
POST /stagiaires/store (email invalide)POSTFormulaire réaffiché avec erreur
POST /stagiaires/store (CSRF invalide)POSTFlash erreur sécurité + redirect
GET /stagiaires/edit/1GETFormulaire pré-rempli
POST /stagiaires/update/1 (valide)POSTRedirect + flash success
GET /stagiaires/destroy/1GETPage de confirmation
POST /stagiaires/destroy/1POSTSuppression + redirect + flash
GET /formateursGETListe formateurs
GET /formateurs/show/1GETDétail + formations du formateur
GET /formationsGETListe avec nb_stagiaires + formateur_nom
GET /formations/show/1GETDétail + stagiaires inscrits
GET /inscriptions/createGETFormulaire avec deux selects
POST /inscriptions/store (valide)POSTRedirect + flash success
POST /inscriptions/store (doublon)POSTFlash erreur 'déjà inscrit'
GET /stagiaires (non connecté)GETRedirect vers /auth/login
Route inexistante /xyzGETPage 404
F5 après création réussieGETRecharge la liste — aucune double insertion
Partie 7 — Architecture MVC QCM
⏱ 10 min

QCM — Partie 7 : Architecture MVC complète

Models, Views, Controllers, routeur, messages flash, PRG, CSRF — consolidez l'ensemble du chapitre MVC avant les Web Services.


1
Architecture MVC
Quelle est la différence entre requireAuth() dans le Controller et une vérification de session dans la View ?
Un message flash est stocké dans $_SESSION['flash'] avant un redirect(). Pourquoi est-il supprimé immédiatement après lecture dans getFlash() ?
Dans form.php unifié create/edit, comment la View sait-elle si elle doit afficher "Ajouter" ou "Modifier" ?
Partie 8 — Web Services & API REST SOAP WSDL SoapServer / SoapClient
⏱ 20 min

Web Services SOAP — Envelope, WSDL, SoapServer & SoapClient

Architecture Orientée Services (SOA), structure d'un message SOAP (Envelope/Header/Body/Fault), WSDL comme contrat du service, SoapServer PHP avec classe de service, SoapClient consommateur, et SoapFault pour les erreurs. Comparaison SOAP vs REST.


1
Qu'est-ce qu'un Web Service ? Comparaison App Web vs Web Service
CritèreApplication Web classiqueWeb Service
Client cibleNavigateur humainProgramme (mobile, ERP, autre serveur)
Format de réponseHTML (page complète)XML (SOAP) ou JSON (REST)
InteropérabilitéLimitée au navigateurPHP, Java, Python, .NET, Android…
AuthentificationSession PHP + cookieToken, clé API, WS-Security (SOAP)
CritèreSOAPREST
FormatXML obligatoireJSON principalement
ContratWSDL obligatoireOpenAPI optionnel
Méthodes HTTPPOST uniquementGET, POST, PUT, PATCH, DELETE
ComplexitéÉlevée — Envelope, WSDLFaible — URL + verbe HTTP + JSON
SécuritéWS-Security intégréHTTPS + OAuth2 + JWT
UsageERP SAP, banques, services gouvernementauxAPIs modernes, mobiles, microservices

2
Anatomie d'un message SOAP — Envelope, Header, Body, Fault
xmlRequête SOAP + Réponse succès + Réponse Fault
<!-- ── Requête SOAP : inscrire le stagiaire 3 à la formation 2 ──────────── -->
<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope
    xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
    xmlns:skilling="http://boutique-atlas.ma/inscriptions">

  <!-- Header : optionnel — authentification, tracking -->
  <soap:Header>
    <skilling:ApiKey>sk-boutique-2025-abc123</skilling:ApiKey>
  </soap:Header>

  <!-- Body : obligatoire — appel de méthode -->
  <soap:Body>
    <skilling:inscrire>
      <skilling:stagiaireId>3</skilling:stagiaireId>
      <skilling:formationId>2</skilling:formationId>
    </skilling:inscrire>
  </soap:Body>
</soap:Envelope>

<!-- ── Réponse succès ─────────────────────────────────────────────────── -->
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
  <soap:Body>
    <inscrireResponse>
      <inscriptionId>42</inscriptionId>
      <statut>en_attente</statut>
      <message>Inscription enregistrée avec succès.</message>
    </inscrireResponse>
  </soap:Body>
</soap:Envelope>

<!-- ── Réponse Fault (erreur) ─────────────────────────────────────────── -->
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
  <soap:Body>
    <soap:Fault>
      <!-- faultcode : Client (erreur de l'appelant) ou Server -->
      <faultcode>soap:Client</faultcode>
      <faultstring>Ce stagiaire est déjà inscrit à cette formation.</faultstring>
      <detail>
        <errorCode>DUPLICATE_INSCRIPTION</errorCode>
        <stagiaireId>3</stagiaireId>
        <formationId>2</formationId>
      </detail>
    </soap:Fault>
  </soap:Body>
</soap:Envelope>

3
SoapServer PHP — classe de service + server.php
phpwebservices/InscriptionService.php — classe de service
<?php
// webservices/InscriptionService.php
// Chaque méthode publique = une opération SOAP exposée
require_once dirname(__DIR__) . '/config/database.php';
require_once dirname(__DIR__) . '/core/BaseModel.php';
require_once dirname(__DIR__) . '/core/ModelException.php';
require_once dirname(__DIR__) . '/app/models/InscriptionModel.php';

class InscriptionService {

    private InscriptionModel $model;

    public function __construct() {
        $this->model = new InscriptionModel();
    }

    /**
     * Retourne toutes les inscriptions.
     */
    public function getInscriptions(): array {
        return $this->model->findAll('id');
    }

    /**
     * Inscrit un stagiaire à une formation.
     * @param int $stagiaireId
     * @param int $formationId
     * @return array ['inscriptionId', 'statut', 'message']
     */
    public function inscrire(int $stagiaireId, int $formationId): array {
        if ($this->model->isInscrit($stagiaireId, $formationId)) {
            // SoapFault → convertie automatiquement en message SOAP Fault XML
            throw new SoapFault(
                'Client',
                'Ce stagiaire est déjà inscrit à cette formation.',
                null,
                ['errorCode' => 'DUPLICATE', 'stagiaireId' => $stagiaireId]
            );
        }
        try {
            $id = $this->model->inscrire($stagiaireId, $formationId);
            return ['inscriptionId' => $id, 'statut' => 'en_attente',
                    'message' => 'Inscription enregistrée avec succès.'];
        } catch (ModelException $e) {
            throw new SoapFault('Server', $e->getMessage());
        }
    }

    /**
     * Vérifie si un stagiaire est inscrit à une formation.
     */
    public function verifierInscription(int $stagiaireId, int $formationId): bool {
        return $this->model->isInscrit($stagiaireId, $formationId);
    }
}
phppublic/webservices/server.php + test_client.php
<?php
// public/webservices/server.php — endpoint public du service SOAP
// WSDL visible via : http://localhost/mvc/public/webservices/server.php?wsdl
require_once dirname(__DIR__, 2) . '/webservices/InscriptionService.php';

$wsdlPath = dirname(__DIR__, 2) . '/webservices/inscription.wsdl';

$server = new SoapServer($wsdlPath, ['encoding' => 'UTF-8']);
$server->setClass('InscriptionService');  // Associer la classe de service
$server->handle();  // Traiter la requête SOAP entrante

// ──────────────────────────────────────────────────────────────────────────

// webservices/test_client.php — tester le service depuis CLI ou navigateur
$wsdlUrl = 'http://localhost/mvc/public/webservices/server.php?wsdl';

try {
    $client = new SoapClient($wsdlUrl, [
        'encoding'   => 'UTF-8',
        'trace'      => true,      // Activer pour voir les messages XML bruts
        'exceptions' => true,      // SoapFault PHP si erreur SOAP
    ]);

    // Test 1 : lister les inscriptions
    $inscriptions = $client->getInscriptions();
    var_dump($inscriptions);

    // Test 2 : inscrire un stagiaire
    $res = $client->inscrire(1, 2);
    echo 'ID : ' . $res->inscriptionId . ' — Statut : ' . $res->statut . "\n";

    // Test 3 : vérifier l'inscription
    $existe = $client->verifierInscription(1, 2);
    echo 'Inscrit : ' . ($existe ? 'OUI' : 'NON') . "\n";

    // Test 4 : doublon — doit lever SoapFault
    $client->inscrire(1, 2);

} catch (SoapFault $e) {
    echo "SoapFault reçue :\n";
    echo '  faultcode   : ' . $e->faultcode . "\n";
    echo '  faultstring : ' . $e->getMessage() . "\n";
    // Afficher les messages XML bruts (mode trace = true)
    if (isset($client)) {
        echo "\n-- Requête XML envoyée --\n";
        echo htmlspecialchars($client->__getLastRequest());
        echo "\n-- Réponse XML reçue --\n";
        echo htmlspecialchars($client->__getLastResponse());
    }
}
💡 SOAP vs REST — même opération inscrire(), deux formats

SOAP : 12 lignes XML avec Envelope/Body/namespaces — verbeux mais contractualisé (WSDL).

REST : POST /api/inscriptions avec body {"stagiaire_id":3,"formation_id":2} — 1 ligne JSON, léger et lisible.

Partie 8 — Web Services & API REST API REST JSON standardisé Bearer token
⏱ 24 min

API REST en PHP natif — Routeur, CRUD complet & Bearer token

6 contraintes REST, convention des 5 endpoints CRUD + sous-ressource, codes HTTP, réponse JSON standardisée, routeur REST natif (PATH_INFO + verbe HTTP), StagiaireApi avec index/show/store/update/destroy, authentification Bearer, et tests avec Thunder Client / Postman.


1
Convention REST — endpoints, verbes HTTP et codes de statut
EndpointMéthode HTTPActionCode succès
GET /api/stagiairesGETLister (+ pagination, filtres)200 OK
GET /api/stagiaires/{id}GETObtenir un stagiaire200 OK
POST /api/stagiairesPOSTCréer un stagiaire201 Created
PUT /api/stagiaires/{id}PUTRemplacer complètement200 OK
DELETE /api/stagiaires/{id}DELETESupprimer204 No Content
GET /api/stagiaires/{id}/inscriptionsGETInscriptions du stagiaire200 OK
Code HTTPNomQuand l'utiliser
200OKGET ou PUT/PATCH réussi — réponse avec données
201CreatedPOST réussi — ressource créée — inclure Location header
204No ContentDELETE réussi — aucun corps de réponse
400Bad RequestCorps JSON malformé ou paramètres manquants
401UnauthorizedToken absent ou invalide
403ForbiddenAuthentifié mais pas autorisé
404Not FoundRessource inexistante
422Unprocessable EntityDonnées invalides métier (email dupliqué)
500Internal Server ErrorErreur serveur non prévue — logger

2
Fonction jsonResponse() et routeur REST natif
phppublic/api/index.php — Front Controller REST + jsonResponse()
<?php
// public/api/index.php — Front Controller de l'API REST
header('Content-Type: application/json; charset=UTF-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');

// Preflight CORS — répondre 204 aux OPTIONS avant tout traitement
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
    http_response_code(204); exit;
}

// ── Fonction utilitaire — réponse JSON standardisée ─────────────────────
// Toutes les réponses utilisent la même enveloppe : success, data, message, errors
function jsonResponse(int $code, bool $success, mixed $data = null,
                      string $message = '', array $errors = []): never {
    http_response_code($code);
    echo json_encode([
        'success' => $success,
        'data'    => $data,
        'message' => $message,
        'errors'  => $errors ?: null,
    ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    exit;
}

// ── Autoloader ────────────────────────────────────────────────────────────
spl_autoload_register(function(string $c): void {
    $dirs = [dirname(__DIR__,2).'/core/', dirname(__DIR__,2).'/app/models/',
             dirname(__DIR__,2).'/app/api/'];
    foreach ($dirs as $d) { $f=$d.$c.'.php'; if(file_exists($f)){require $f; return;} }
});
require_once dirname(__DIR__, 2) . '/config/database.php';

// ── Parser l'URL et la méthode HTTP ──────────────────────────────────────
// PATH_INFO : /stagiaires/5/inscriptions
$path        = trim($_SERVER['PATH_INFO'] ?? '/', '/');
$parts       = $path !== '' ? explode('/', $path) : [];
$method      = $_SERVER['REQUEST_METHOD'];
$resource    = $parts[0] ?? '';          // 'stagiaires'
$id          = isset($parts[1]) && is_numeric($parts[1]) ? intval($parts[1]) : null;
$subResource = $parts[2] ?? null;        // 'inscriptions'

// ── Table de routage ──────────────────────────────────────────────────────
$routes = [
    'stagiaires'   => 'StagiaireApi',
    'formateurs'   => 'FormateurApi',
    'formations'   => 'FormationApi',
    'inscriptions' => 'InscriptionApi',
];

if (!array_key_exists($resource, $routes)) {
    jsonResponse(404, false, null, 'Ressource inconnue : ' . htmlspecialchars($resource));
}

$handler = new ($routes[$resource])();

// ── Dispatcher — méthode HTTP × présence d'ID × sous-ressource ───────────
match(true) {
    $method==='GET'    && $id===null  && $subResource===null => $handler->index(),
    $method==='GET'    && $id!==null  && $subResource===null => $handler->show($id),
    $method==='GET'    && $id!==null  && $subResource!==null => $handler->subResource($id, $subResource),
    $method==='POST'   && $id===null                         => $handler->store(),
    $method==='PUT'    && $id!==null                         => $handler->update($id),
    $method==='PATCH'  && $id!==null                         => $handler->patch($id),
    $method==='DELETE' && $id!==null                         => $handler->destroy($id),
    default => jsonResponse(405, false, null, 'Méthode HTTP non autorisée.')
};

3
StagiaireApi — 5 endpoints + authentification Bearer
phpapp/api/StagiaireApi.php — complet
<?php
// app/api/StagiaireApi.php — handler des endpoints REST stagiaires
class StagiaireApi {

    private StagiaireModel $model;

    public function __construct() {
        $this->model = new StagiaireModel();
    }

    // ── GET /api/stagiaires — liste paginée ──────────────────────────────
    public function index(): void {
        $this->requireBearer();
        $page       = max(1, intval($_GET['page'] ?? 1));
        $q          = trim($_GET['q'] ?? '');
        $pagination = $this->model->paginate($page, 20, $q);
        jsonResponse(200, true, [
            'items'   => $pagination['data'],
            'total'   => $pagination['total'],
            'pages'   => $pagination['pages'],
            'current' => $pagination['current'],
        ], 'Liste des stagiaires');
    }

    // ── GET /api/stagiaires/{id} ─────────────────────────────────────────
    public function show(int $id): void {
        $this->requireBearer();
        $s = $this->model->findById($id);
        if ($s === null) jsonResponse(404, false, null, 'Stagiaire introuvable (id=' . $id . ')');
        jsonResponse(200, true, $s, 'Stagiaire trouvé.');
    }

    // ── POST /api/stagiaires — créer ─────────────────────────────────────
    public function store(): void {
        $this->requireBearer();
        $body    = $this->readJsonBody();
        $erreurs = $this->model->validate($body);
        if (!empty($erreurs)) jsonResponse(422, false, null, 'Données invalides.', $erreurs);
        try {
            $id = $this->model->create($body);
            header('Location: /api/stagiaires/' . $id);
            jsonResponse(201, true, ['id' => $id], 'Stagiaire créé avec succès.');
        } catch (ModelException $e) {
            jsonResponse(422, false, null, $e->getMessage());
        }
    }

    // ── PUT /api/stagiaires/{id} — remplacer ─────────────────────────────
    public function update(int $id): void {
        $this->requireBearer();
        if ($this->model->findById($id) === null) jsonResponse(404, false, null, 'Introuvable.');
        $body    = $this->readJsonBody();
        $erreurs = $this->model->validate($body, $id);
        if (!empty($erreurs)) jsonResponse(422, false, null, 'Données invalides.', $erreurs);
        try {
            $this->model->update($id, $body);
            jsonResponse(200, true, $this->model->findById($id), 'Stagiaire modifié.');
        } catch (ModelException $e) { jsonResponse(422, false, null, $e->getMessage()); }
    }

    // ── DELETE /api/stagiaires/{id} — supprimer ──────────────────────────
    public function destroy(int $id): void {
        $this->requireBearer();
        if ($this->model->findById($id) === null) jsonResponse(404, false, null, 'Introuvable.');
        $this->model->delete($id);
        jsonResponse(204, true, null, 'Stagiaire supprimé.');
    }

    // ── GET /api/stagiaires/{id}/inscriptions — sous-ressource ───────────
    public function subResource(int $id, string $sub): void {
        $this->requireBearer();
        if ($sub !== 'inscriptions') jsonResponse(404, false, null, 'Sous-ressource inconnue.');
        $inscModel = new InscriptionModel();
        jsonResponse(200, true, $inscModel->findByStagiaire($id), 'Inscriptions du stagiaire.');
    }

    // ── Helpers privés ────────────────────────────────────────────────────
    private function readJsonBody(): array {
        $raw  = file_get_contents('php://input');
        $data = json_decode($raw, true);
        if ($data === null && $raw !== '') jsonResponse(400, false, null, 'Corps JSON malformé.');
        return $data ?? [];
    }

    private function requireBearer(): void {
        $auth = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
        if (!str_starts_with($auth, 'Bearer ')) {
            jsonResponse(401, false, null, 'Token Bearer requis.');
        }
        $token = substr($auth, 7);
        // Tokens définis dans config/database.php
        if (!in_array($token, API_TOKENS ?? [], true)) {
            jsonResponse(401, false, null, 'Token invalide ou expiré.');
        }
    }
}
phpconfig/database.php — tokens API
<?php
// config/database.php — ajouter les tokens API
define('DB_HOST', 'localhost');
define('DB_NAME', 'boutique_atlas');
define('DB_USER', 'root');
define('DB_PASS', '');
define('APP_ENV', 'development');  // 'development' | 'production'

// Tokens API valides — en production : stocker en BDD avec date d'expiration
define('API_TOKENS', [
    'atlas-api-token-android-2025',   // Application mobile Android
    'atlas-api-token-portail-2025',   // Portail statistiques
    'atlas-api-token-erp-2025',       // Intégration ERP
]);

4
Tests avec Thunder Client (VS Code) ou Postman
#TestMéthodeURLHeaders / BodyRéponse attendue
1Sans tokenGET/api/stagiaires(aucun)401 — token requis
2ListeGET/api/stagiairesAuthorization: Bearer atlas-api-token-android-2025200 — tableau JSON
3DétailGET/api/stagiaires/1Authorization: Bearer …200 — objet stagiaire
4ID inexistantGET/api/stagiaires/9999Authorization: Bearer …404
5CréerPOST/api/stagiairesBearer + Content-Type: application/json + body JSON201 — id créé
6Email invalidePOST/api/stagiairesBearer + {"email":"pas-un-email"}422 — errors
7ModifierPUT/api/stagiaires/1Bearer + body JSON complet200 — objet modifié
8SupprimerDELETE/api/stagiaires/1Authorization: Bearer …204 No Content
9InscriptionsGET/api/stagiaires/2/inscriptionsAuthorization: Bearer …200 — inscriptions
jsonExemples de body JSON + réponses attendues
// POST /api/stagiaires — corps de la requête
{
  "nom": "Benali",
  "prenom": "Sara",
  "email": "sara.benali@atlas.ma",
  "telephone": "0661234567",
  "formation_id": 1
}

// Réponse 201 attendue
{
  "success": true,
  "data": { "id": 42 },
  "message": "Stagiaire créé avec succès.",
  "errors": null
}

// Réponse 422 pour email invalide
{
  "success": false,
  "data": null,
  "message": "Données invalides.",
  "errors": ["Adresse email invalide."]
}

// Réponse 401 sans Bearer token
{
  "success": false,
  "data": null,
  "message": "Token Bearer requis.",
  "errors": null
}
Partie 8 — Web Services & API REST QCM
⏱ 10 min

QCM — Partie 8 : Web Services & API REST

SOAP vs REST, codes HTTP, Bearer token, SoapFault, jsonResponse() — consolidez le chapitre Web Services.


1
SOAP & REST
Quelle est la différence entre une SoapFault et une exception PHP ordinaire ?
Pourquoi une API REST utilise-t-elle 204 No Content pour un DELETE réussi plutôt que 200 OK ?
Pourquoi une API REST est-elle dite stateless (sans état) et quelle conséquence cela a-t-il sur l'authentification ?
Projet Final — DariGO PHP 8.3 MySQL 8 MVC natif PDO
⏱ 20 min

DariGO — Plateforme de gestion de locations immobilières

Cahier des charges complet, maquettes fonctionnelles et plan de développement étape par étape. DariGO est un système de gestion de biens immobiliers à louer — propriétaires, locataires, annonces, réservations et paiements — construit en PHP/MySQL avec l'architecture MVC native.


1
Contexte et objectif du projet
🏠 DariGO — «Dari» = ma maison en darija marocaine

DariGO est une plateforme web permettant à des propriétaires marocains de publier leurs biens à louer, et à des locataires de soumettre des demandes de réservation. Un administrateur valide les annonces et gère les paiements. Le projet mobilise toutes les compétences acquises : POO, PDO, MVC, sessions, sécurité, et API REST.

ActeurRôleFonctionnalités principales
PropriétairePublie et gère ses biensCréer/modifier/désactiver des annonces, consulter les réservations reçues, confirmer ou refuser
LocataireCherche et réserveParcourir les annonces, filtrer par ville/prix/type, soumettre une demande, consulter l'historique
AdministrateurSupervise la plateformeValider les annonces, gérer les utilisateurs, consulter les statistiques, exporter CSV

2
Fonctionnalités détaillées — User Stories
IDEn tant que…Je veux…Afin de…
US-01VisiteurVoir les annonces publiques avec photos et prixDécouvrir les biens disponibles sans m'inscrire
US-02VisiteurFiltrer par ville, type (appartement/villa/studio), prix min/maxTrouver rapidement ce qui correspond à mon budget
US-03LocataireM'inscrire avec email + mot de passe + téléphoneCréer un compte locataire pour faire des demandes
US-04LocataireSoumettre une demande de réservation avec datesRéserver un bien pour une période précise
US-05LocataireConsulter l'état de mes demandes (en attente/confirmée/refusée)Suivre mes réservations en temps réel
US-06PropriétaireCréer une annonce avec titre, description, photos, prix/mois, ville, typeMettre mon bien en location sur la plateforme
US-07PropriétaireConfirmer ou refuser les demandes de réservationChoisir mon locataire et éviter les doubles réservations
US-08AdministrateurValider ou rejeter les annonces publiéesGarantir la qualité du contenu sur la plateforme
US-09AdministrateurVoir les statistiques : nb annonces, réservations, CA mensuelPiloter la plateforme et détecter les problèmes
US-10AdministrateurExporter la liste des réservations en CSVTraiter les données dans Excel pour la comptabilité

3
Règles métier et contraintes techniques
RègleDescription
RG-01Une annonce ne peut être réservée que si son statut est active. Une annonce en attente de validation ou désactivée n'apparaît pas dans les résultats publics.
RG-02Un bien ne peut avoir qu'une seule réservation confirmée pour une période donnée. Les dates ne peuvent pas se chevaucher.
RG-03Quand une réservation est confirmée, toutes les autres demandes pour les mêmes dates passent automatiquement au statut refusée.
RG-04Le prix total d'une réservation = prix mensuel × nombre de mois (arrondi au mois supérieur si période fractionnaire).
RG-05Un utilisateur ne peut pas être à la fois propriétaire et locataire. Le rôle est fixé à l'inscription.
RG-06Les mots de passe sont hashés avec bcrypt (PASSWORD_BCRYPT). Jamais stockés en clair.
RG-07Toutes les actions d'écriture (POST/PUT/DELETE) sont protégées par un token CSRF.
RG-08Les photos uploadées sont validées par finfo (MIME réel) et stockées hors de la racine web.

4
Plan de développement — 6 étapes
ÉtapeLivrableCompétences mobilisées
Étape 1Base de données : MCD → tables SQL + données de testSQL, clés étrangères, contraintes, données marocaines
Étape 2Architecture MVC : arborescence + config + autoloaderArchitecture logicielle, PSR-4, constants
Étape 3Modèles : BaseModel + 5 Models métier + tests isolationPOO, PDO, héritage, ModelException
Étape 4Contrôleurs : 5 Controllers + routeur + middleware auth/rôlesMVC, sessions, CSRF, PRG
Étape 5Vues : layouts + 20 vues CRUD + CSS complet DariGOHTML/CSS, templates PHP, flash messages
Étape 6API REST : 3 endpoints JSON + authentification BearerREST, JSON, codes HTTP, Bearer token
Projet Final — DariGO MCD MySQL Clés étrangères
⏱ 25 min

Base de données — MCD, schéma SQL & données de test

Conception du Modèle Conceptuel de Données, traduction en tables MySQL avec toutes les contraintes d'intégrité (FOREIGN KEY, UNIQUE, CHECK), et jeu de données de test avec des profils marocains réalistes.


1
Modèle Conceptuel de Données — 6 entités
EntitéAttributs principauxRelations
utilisateursid, nom, prenom, email (UNIQUE), mot_de_passe, telephone, role (ENUM), ville, created_atPossède des annonces (si propriétaire) ou des réservations (si locataire)
annoncesid, titre, description, type (ENUM), ville, adresse, prix_mois, surface, nb_chambres, statut (ENUM), proprietaire_id, created_atAppartient à un utilisateur (propriétaire). A plusieurs photos. A plusieurs réservations.
photosid, annonce_id, nom_fichier, est_principale, ordre, created_atAppartient à une annonce
reservationsid, annonce_id, locataire_id, date_debut, date_fin, nb_mois, prix_total, statut (ENUM), message, created_atLie un locataire à une annonce
avisid, reservation_id, note (1-5), commentaire, created_atLie un avis à une réservation terminée
paiementsid, reservation_id, montant, methode (ENUM), reference, statut (ENUM), created_atLie un paiement à une réservation

2
Script SQL complet — création de la base darigo_db
sqldatabase/darigo_db.sql — à exécuter dans phpMyAdmin
-- ═══════════════════════════════════════════════════════════════
-- DariGO — Création de la base de données
-- À exécuter dans phpMyAdmin → onglet SQL
-- ═══════════════════════════════════════════════════════════════

CREATE DATABASE IF NOT EXISTS darigo_db
    CHARACTER SET utf8mb4
    COLLATE utf8mb4_unicode_ci;

USE darigo_db;

-- ── Table utilisateurs ────────────────────────────────────────────────────
CREATE TABLE utilisateurs (
    id           INT AUTO_INCREMENT PRIMARY KEY,
    nom          VARCHAR(100) NOT NULL,
    prenom       VARCHAR(100) NOT NULL,
    email        VARCHAR(150) NOT NULL UNIQUE,
    mot_de_passe VARCHAR(255) NOT NULL,  -- hash bcrypt
    telephone    VARCHAR(20),
    role         ENUM('admin','proprietaire','locataire') NOT NULL DEFAULT 'locataire',
    ville        VARCHAR(100),
    actif        TINYINT(1) NOT NULL DEFAULT 1,
    created_at   TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ── Table annonces ────────────────────────────────────────────────────────
CREATE TABLE annonces (
    id             INT AUTO_INCREMENT PRIMARY KEY,
    titre          VARCHAR(200)   NOT NULL,
    description    TEXT           NOT NULL,
    type           ENUM('appartement','villa','studio','riad','bureau') NOT NULL,
    ville          VARCHAR(100)   NOT NULL,
    adresse        VARCHAR(255),
    prix_mois      DECIMAL(10,2)  NOT NULL CHECK (prix_mois > 0),
    surface        INT            COMMENT 'Surface en m²',
    nb_chambres    TINYINT        DEFAULT 1,
    meuble         TINYINT(1)     DEFAULT 0,
    statut         ENUM('en_attente','active','inactive','rejetee') NOT NULL DEFAULT 'en_attente',
    proprietaire_id INT           NOT NULL,
    created_at     TIMESTAMP      DEFAULT CURRENT_TIMESTAMP,
    updated_at     TIMESTAMP      DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (proprietaire_id) REFERENCES utilisateurs(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ── Table photos ──────────────────────────────────────────────────────────
CREATE TABLE photos (
    id            INT AUTO_INCREMENT PRIMARY KEY,
    annonce_id    INT         NOT NULL,
    nom_fichier   VARCHAR(255) NOT NULL,  -- nom généré (hash), pas le nom original
    est_principale TINYINT(1) DEFAULT 0,
    ordre         TINYINT     DEFAULT 1,
    created_at    TIMESTAMP   DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (annonce_id) REFERENCES annonces(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ── Table reservations ────────────────────────────────────────────────────
CREATE TABLE reservations (
    id           INT AUTO_INCREMENT PRIMARY KEY,
    annonce_id   INT           NOT NULL,
    locataire_id INT           NOT NULL,
    date_debut   DATE          NOT NULL,
    date_fin     DATE          NOT NULL,
    nb_mois      DECIMAL(4,1)  NOT NULL,
    prix_total   DECIMAL(10,2) NOT NULL,
    statut       ENUM('en_attente','confirmee','refusee','annulee','terminee') NOT NULL DEFAULT 'en_attente',
    message      TEXT,                    -- message du locataire au propriétaire
    created_at   TIMESTAMP     DEFAULT CURRENT_TIMESTAMP,
    -- Contrainte : dates cohérentes
    CONSTRAINT chk_dates CHECK (date_fin > date_debut),
    -- Contrainte : pas de doublon (même locataire, même annonce, même période)
    UNIQUE KEY uniq_reservation (annonce_id, locataire_id, date_debut, date_fin),
    FOREIGN KEY (annonce_id)   REFERENCES annonces(id)     ON DELETE CASCADE,
    FOREIGN KEY (locataire_id) REFERENCES utilisateurs(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ── Table avis ────────────────────────────────────────────────────────────
CREATE TABLE avis (
    id             INT AUTO_INCREMENT PRIMARY KEY,
    reservation_id INT          NOT NULL UNIQUE,  -- un seul avis par réservation
    note           TINYINT      NOT NULL CHECK (note BETWEEN 1 AND 5),
    commentaire    TEXT,
    created_at     TIMESTAMP    DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (reservation_id) REFERENCES reservations(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ── Table paiements ───────────────────────────────────────────────────────
CREATE TABLE paiements (
    id             INT AUTO_INCREMENT PRIMARY KEY,
    reservation_id INT           NOT NULL,
    montant        DECIMAL(10,2) NOT NULL,
    methode        ENUM('virement','cheque','especes','cmi','paypal') NOT NULL DEFAULT 'virement',
    reference      VARCHAR(100)  UNIQUE,  -- numéro de virement ou référence externe
    statut         ENUM('en_attente','valide','rejete','rembourse') NOT NULL DEFAULT 'en_attente',
    created_at     TIMESTAMP     DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (reservation_id) REFERENCES reservations(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ── Index pour les performances ───────────────────────────────────────────
CREATE INDEX idx_annonces_ville    ON annonces(ville);
CREATE INDEX idx_annonces_statut   ON annonces(statut);
CREATE INDEX idx_annonces_type     ON annonces(type);
CREATE INDEX idx_annonces_prix     ON annonces(prix_mois);
CREATE INDEX idx_reservations_statut ON reservations(statut);
CREATE INDEX idx_reservations_dates  ON reservations(date_debut, date_fin);

3
Données de test réalistes — contexte marocain
sqldatabase/darigo_fixtures.sql — jeu de test complet
USE darigo_db;

-- ── Utilisateurs (mots de passe = 'Atlas2024!' hashé bcrypt) ──────────────
-- Pour générer les hashes : php -r "echo password_hash('Atlas2024!', PASSWORD_BCRYPT);"
INSERT INTO utilisateurs (nom, prenom, email, mot_de_passe, telephone, role, ville) VALUES
-- Admin
('Skilling', 'Admin', 'admin@darigo.ma',
 '$2y$10$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', '0522000000', 'admin', 'Casablanca'),

-- Propriétaires
('El Ouazzani', 'Karim',  'karim.ouazzani@gmail.com',
 '$2y$10$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', '0661234567', 'proprietaire', 'Casablanca'),
('Benali',      'Fatima', 'fatima.benali@gmail.com',
 '$2y$10$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', '0662345678', 'proprietaire', 'Marrakech'),
('Rachidi',     'Hassan', 'hassan.rachidi@gmail.com',
 '$2y$10$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', '0663456789', 'proprietaire', 'Rabat'),

-- Locataires
('Tazi',       'Omar',   'omar.tazi@gmail.com',
 '$2y$10$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', '0664567890', 'locataire', 'Casablanca'),
('El Idrissi', 'Sara',   'sara.elidrissi@gmail.com',
 '$2y$10$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', '0665678901', 'locataire', 'Fès'),
('Hajji',      'Youssef','youssef.hajji@gmail.com',
 '$2y$10$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', '0666789012', 'locataire', 'Agadir');

-- ── Annonces ──────────────────────────────────────────────────────────────
INSERT INTO annonces (titre, description, type, ville, adresse, prix_mois, surface, nb_chambres, meuble, statut, proprietaire_id) VALUES
('Appartement moderne Maarif — Casablanca',
 'Bel appartement lumineux au cœur du quartier Maarif. Cuisine équipée, double vitrage, gardien. Idéal jeune couple ou célibataire. Proche tramway et commerces.',
 'appartement', 'Casablanca', '45 Bd d''Anfa, Maarif', 5500.00, 85, 2, 1, 'active', 2),

('Studio meublé Guéliz — Marrakech',
 'Studio entièrement meublé et équipé dans résidence sécurisée avec piscine. Wifi inclus. Parfait pour court séjour ou mission professionnelle. Vue sur l''Atlas.',
 'studio', 'Marrakech', '12 Rue Ibn Khaldoun, Guéliz', 3200.00, 40, 1, 1, 'active', 3),

('Villa avec piscine Souissi — Rabat',
 'Magnifique villa de 4 chambres avec jardin privatif et piscine chauffée. Quartier Souissi, 10 min de la place Pietri. Idéale pour famille ou diplomatique.',
 'villa', 'Rabat', '8 Allée des Roses, Souissi', 18000.00, 320, 4, 0, 'active', 4),

('Riad traditionnel Médina — Fès',
 'Riad authentique rénové avec goût dans la médina de Fès. 3 chambres, salon berbère, terrasse avec vue panoramique. Classé patrimoine. Coup de cœur garanti.',
 'riad', 'Fès', 'Derb El Mitter, Médina', 8500.00, 180, 3, 1, 'active', 2),

('Appartement en attente validation — Tanger',
 'Appartement neuf dans résidence front de mer à Tanger. 3 chambres, terrasse avec vue mer. En cours de validation par l''équipe DariGO.',
 'appartement', 'Tanger', 'Bd Mohammed VI, Corniche', 7200.00, 120, 3, 0, 'en_attente', 3);

-- ── Réservations ──────────────────────────────────────────────────────────
INSERT INTO reservations (annonce_id, locataire_id, date_debut, date_fin, nb_mois, prix_total, statut, message) VALUES
(1, 5, '2025-01-01', '2025-06-30', 6.0, 33000.00, 'confirmee',
 'Bonjour, je cherche un logement calme pour 6 mois. Je suis consultant en mission à Casablanca.'),
(2, 6, '2025-02-01', '2025-04-30', 3.0, 9600.00, 'en_attente',
 'Séjour professionnel à Marrakech pour mission ONEE. Références disponibles sur demande.'),
(3, 7, '2025-03-15', '2025-09-14', 6.0, 108000.00, 'en_attente',
 'Famille de 4 personnes cherche villa pour contrat de 6 mois. Très bonnes références.');

-- ── Avis ──────────────────────────────────────────────────────────────────
-- (Serait rempli après des réservations terminées)
⚠️ Remplacer les hashes fictifs

Les mots de passe dans les fixtures utilisent des hashes fictifs ($2y$10$xxx...). Pour avoir de vrais hashes, exécutez dans le terminal :

php -r "echo password_hash('Atlas2024!', PASSWORD_BCRYPT) . PHP_EOL;"

Puis remplacez chaque occurrence dans le script SQL.


4
Vues SQL utiles pour l'administration
sqlRequêtes SQL avancées — statistiques et jointures
-- ── Annonces avec photo principale et propriétaire ────────────────────────
SELECT
    a.id, a.titre, a.type, a.ville, a.prix_mois, a.statut,
    CONCAT(u.prenom, ' ', u.nom)  AS proprietaire,
    p.nom_fichier                  AS photo_principale,
    COUNT(DISTINCT r.id)           AS nb_reservations
FROM annonces a
JOIN utilisateurs u ON u.id = a.proprietaire_id
LEFT JOIN photos p  ON p.annonce_id = a.id AND p.est_principale = 1
LEFT JOIN reservations r ON r.annonce_id = a.id
GROUP BY a.id
ORDER BY a.created_at DESC;

-- ── Réservations avec détails annonce et locataire ────────────────────────
SELECT
    r.id, r.statut, r.date_debut, r.date_fin, r.prix_total,
    a.titre AS annonce_titre, a.ville,
    CONCAT(loc.prenom, ' ', loc.nom) AS locataire,
    loc.telephone,
    CONCAT(pro.prenom, ' ', pro.nom) AS proprietaire
FROM reservations r
JOIN annonces     a   ON a.id = r.annonce_id
JOIN utilisateurs loc ON loc.id = r.locataire_id
JOIN utilisateurs pro ON pro.id = a.proprietaire_id
ORDER BY r.created_at DESC;

-- ── Statistiques globales (dashboard admin) ────────────────────────────────
SELECT
    (SELECT COUNT(*) FROM annonces WHERE statut = 'active')       AS annonces_actives,
    (SELECT COUNT(*) FROM annonces WHERE statut = 'en_attente')   AS annonces_attente,
    (SELECT COUNT(*) FROM reservations WHERE statut = 'confirmee') AS reservations_confirmees,
    (SELECT COUNT(*) FROM utilisateurs WHERE role = 'locataire')   AS nb_locataires,
    (SELECT COUNT(*) FROM utilisateurs WHERE role = 'proprietaire') AS nb_proprietaires,
    (SELECT COALESCE(SUM(montant),0) FROM paiements WHERE statut = 'valide') AS ca_total;

-- ── Vérification des chevauchements de réservation (RG-02) ────────────────
SELECT r1.id AS res1, r2.id AS res2, r1.annonce_id,
       r1.date_debut, r1.date_fin, r2.date_debut, r2.date_fin
FROM reservations r1
JOIN reservations r2 ON r1.annonce_id = r2.annonce_id
                     AND r1.id < r2.id
                     AND r1.statut = 'confirmee'
                     AND r2.statut = 'confirmee'
                     AND r1.date_debut <= r2.date_fin
                     AND r1.date_fin   >= r2.date_debut;
-- Ce SELECT ne doit retourner aucune ligne si RG-02 est respecté
Projet Final — DariGO Architecture MVC Autoloader config.php
⏱ 20 min

Architecture MVC — Arborescence, configuration & Front Controller

Structure complète des dossiers du projet DariGO, fichiers de configuration, autoloader PSR-4, .htaccess, Front Controller et gestion de l'environnement dev/prod.


1
Arborescence complète du projet
bashStructure DariGO — tous les fichiers
darigo/                              ← Racine (hors www/ — ex: C:\wamp64\darigo\)
│
├── config/
│   ├── database.php                 ← Constantes BDD + tokens API
│   └── app.php                      ← Constantes applicatives (APP_ENV, APP_URL...)
│
├── core/
│   ├── BaseModel.php                ← Classe mère PDO + CRUD génériques
│   ├── Controller.php               ← Classe mère + render/redirect/auth/CSRF/flash
│   ├── ModelException.php           ← Exception personnalisée
│   └── Router.php                   ← Routeur URL (optionnel — logique dans index.php)
│
├── app/
│   ├── models/
│   │   ├── UtilisateurModel.php
│   │   ├── AnnonceModel.php
│   │   ├── ReservationModel.php
│   │   ├── PhotoModel.php
│   │   └── PaiementModel.php
│   │
│   ├── controllers/
│   │   ├── AuthController.php       ← Connexion, inscription, déconnexion
│   │   ├── AnnonceController.php    ← CRUD annonces (public + propriétaire)
│   │   ├── ReservationController.php← CRUD réservations
│   │   ├── AdminController.php      ← Dashboard admin, validation, stats
│   │   └── ProfileController.php   ← Profil utilisateur, mes annonces, mes réservations
│   │
│   └── views/
│       ├── layouts/
│       │   ├── header.php
│       │   ├── navbar.php
│       │   └── footer.php
│       │
│       ├── auth/
│       │   ├── login.php
│       │   └── register.php
│       │
│       ├── annonces/
│       │   ├── index.php            ← Liste publique avec filtres
│       │   ├── show.php             ← Détail annonce + formulaire réservation
│       │   ├── form.php             ← Create/Edit unifié (propriétaire)
│       │   └── confirm_delete.php
│       │
│       ├── reservations/
│       │   ├── mes_reservations.php ← Locataire : mes demandes
│       │   ├── demandes.php         ← Propriétaire : demandes reçues
│       │   └── confirm_action.php   ← Confirmer/Refuser
│       │
│       ├── admin/
│       │   ├── dashboard.php        ← Statistiques globales
│       │   ├── annonces.php         ← Validation des annonces en attente
│       │   └── utilisateurs.php     ← Liste et gestion des users
│       │
│       ├── profile/
│       │   └── edit.php
│       │
│       └── errors/
│           ├── 404.php
│           └── 500.php
│
├── storage/                         ← HORS racine web — photos uploadées
│   └── photos/
│       └── .htaccess                ← Deny from all
│
├── database/
│   ├── darigo_db.sql                ← Script de création
│   └── darigo_fixtures.sql          ← Données de test
│
└── public/                          ← SEUL dossier accessible par URL
    ├── index.php                    ← Front Controller
    ├── .htaccess                    ← Réécriture → index.php
    └── assets/
        ├── css/
        │   └── darigo.css           ← CSS complet DariGO
        ├── js/
        │   └── darigo.js            ← JS minimal (confirmations, filtres)
        └── img/
            └── logo.svg             ← Logo DariGO

2
config/database.php + config/app.php
phpconfig/database.php
<?php
// config/database.php — identifiants BDD + tokens API

// ── Base de données ────────────────────────────────────────────────────────
define('DB_HOST',    'localhost');
define('DB_NAME',    'darigo_db');
define('DB_USER',    'root');       // En production : utiliser un compte dédié
define('DB_PASS',    '');
define('DB_CHARSET', 'utf8mb4');

// ── Tokens API REST ────────────────────────────────────────────────────────
define('API_TOKENS', [
    'darigo-api-mobile-2025',   // Application mobile
    'darigo-api-portail-2025',  // Portail externe
]);
phpconfig/app.php
<?php
// config/app.php — constantes applicatives

define('APP_ENV',     'development');  // 'development' | 'production'
define('APP_NAME',    'DariGO');
define('APP_URL',     'http://localhost/darigo/public');
define('APP_VERSION', '1.0.0');

// ── Chemins absolus ───────────────────────────────────────────────────────
define('ROOT_PATH',    dirname(__DIR__));           // C:\wamp64\darigo
define('STORAGE_PATH', ROOT_PATH . '/storage');    // Hors racine web
define('PUBLIC_PATH',  ROOT_PATH . '/public');

// ── Upload photos ─────────────────────────────────────────────────────────
define('UPLOAD_MAX_SIZE',   2 * 1024 * 1024);  // 2 Mo
define('UPLOAD_ALLOWED_MIME', ['image/jpeg', 'image/png', 'image/webp']);
define('UPLOAD_ALLOWED_EXT',  ['jpg', 'jpeg', 'png', 'webp']);

// ── Pagination ────────────────────────────────────────────────────────────
define('ITEMS_PER_PAGE', 9);  // 9 annonces par page (grille 3×3)

3
public/index.php — Front Controller & .htaccess
apachepublic/.htaccess
Options -Indexes
RewriteEngine On

# Ne pas réécrire les fichiers et dossiers existants (assets, images…)
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d

# Tout le reste → Front Controller
RewriteRule ^(.*)$ index.php?url=$1 [QSA,L]
phppublic/index.php — Front Controller complet
<?php
// public/index.php — Point d'entrée unique de DariGO
session_start();

// ── Chargement de la configuration ────────────────────────────────────────
require_once dirname(__DIR__) . '/config/app.php';
require_once dirname(__DIR__) . '/config/database.php';

// ── Gestion globale des erreurs ────────────────────────────────────────────
error_reporting(E_ALL);
if (APP_ENV === 'development') {
    ini_set('display_errors', '1');
} else {
    ini_set('display_errors', '0');
    ini_set('log_errors', '1');
}

set_exception_handler(function (Throwable $e): void {
    error_log('[DariGO] ' . $e->getMessage() . ' — ' . $e->getFile() . ':' . $e->getLine());
    http_response_code(500);
    if (APP_ENV === 'development') {
        echo '<pre style="color:red;padding:2rem">';
        echo htmlspecialchars($e->getMessage()) . "\n\n";
        echo htmlspecialchars($e->getTraceAsString());
        echo '</pre>';
    } else {
        require_once dirname(__DIR__) . '/app/views/errors/500.php';
    }
    exit;
});

// ── Autoloader PSR-4 ──────────────────────────────────────────────────────
spl_autoload_register(function (string $class): void {
    $dirs = [
        dirname(__DIR__) . '/core/',
        dirname(__DIR__) . '/app/controllers/',
        dirname(__DIR__) . '/app/models/',
    ];
    foreach ($dirs as $dir) {
        $file = $dir . $class . '.php';
        if (file_exists($file)) { require $file; return; }
    }
});

// ── Parser l'URL ──────────────────────────────────────────────────────────
$url    = trim($_GET['url'] ?? '', '/');
$parts  = $url !== '' ? explode('/', $url) : [];

$ctrl  = $parts[0] ?? 'annonces';    // Contrôleur cible
$action = $parts[1] ?? 'index';      // Action à appeler
$param  = isset($parts[2]) && is_numeric($parts[2]) ? intval($parts[2]) : null;

// ── Table de routage ──────────────────────────────────────────────────────
$routes = [
    ''             => 'AnnonceController',   // / → liste publique
    'annonces'     => 'AnnonceController',
    'reservations' => 'ReservationController',
    'auth'         => 'AuthController',
    'admin'        => 'AdminController',
    'profile'      => 'ProfileController',
];

if (!array_key_exists($ctrl, $routes)) {
    http_response_code(404);
    require_once dirname(__DIR__) . '/app/views/errors/404.php';
    exit;
}

$class      = $routes[$ctrl];
$controller = new $class();

if (!method_exists($controller, $action)) {
    http_response_code(404);
    require_once dirname(__DIR__) . '/app/views/errors/404.php';
    exit;
}

$param !== null ? $controller->$action($param) : $controller->$action();
Projet Final — DariGO BaseModel AnnonceModel ReservationModel
⏱ 30 min

Modèles — BaseModel & les 5 Models métier DariGO

BaseModel avec PDO singleton, UtilisateurModel (inscription/connexion/rôles), AnnonceModel (CRUD + filtres + pagination), ReservationModel (règles métier RG-02/RG-03), PhotoModel (upload sécurisé), PaiementModel.


1
core/BaseModel.php — connexion singleton + CRUD générique + paginate()
phpcore/BaseModel.php
<?php
abstract class BaseModel {

    protected PDO    $pdo;
    protected string $table;
    protected string $primaryKey = 'id';

    private static ?PDO $instance = null;

    public function __construct() {
        $this->pdo = $this->connect();
    }

    private function connect(): PDO {
        if (self::$instance === null) {
            $dsn = 'mysql:host=' . DB_HOST . ';dbname=' . DB_NAME . ';charset=' . DB_CHARSET;
            self::$instance = new PDO($dsn, DB_USER, DB_PASS, [
                PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                PDO::ATTR_EMULATE_PREPARES   => false,
            ]);
        }
        return self::$instance;
    }

    // ── Méthodes CRUD génériques héritées ─────────────────────────────────

    public function findAll(string $orderBy = 'id DESC'): array {
        $o = preg_replace('/[^a-zA-Z0-9_,\s]/', '', $orderBy);
        return $this->pdo->query('SELECT * FROM `' . $this->table . '` ORDER BY ' . $o)->fetchAll();
    }

    public function findById(int $id): array|null {
        $stmt = $this->pdo->prepare(
            'SELECT * FROM `' . $this->table . '` WHERE `' . $this->primaryKey . '` = :id'
        );
        $stmt->execute([':id' => $id]);
        return $stmt->fetch() ?: null;
    }

    public function count(string $where = '', array $params = []): int {
        $sql = 'SELECT COUNT(*) FROM `' . $this->table . '`';
        if ($where) $sql .= ' WHERE ' . $where;
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute($params);
        return (int) $stmt->fetchColumn();
    }

    public function exists(int $id): bool {
        return $this->findById($id) !== null;
    }

    public function delete(int $id): bool {
        $stmt = $this->pdo->prepare(
            'DELETE FROM `' . $this->table . '` WHERE `' . $this->primaryKey . '` = :id'
        );
        $stmt->execute([':id' => $id]);
        return $stmt->rowCount() > 0;
    }

    // ── Pagination générique ──────────────────────────────────────────────
    protected function paginate(string $sql, array $params, int $page, int $perPage = ITEMS_PER_PAGE): array {
        // Compter le total
        $countSql  = 'SELECT COUNT(*) FROM (' . $sql . ') AS sub';
        $countStmt = $this->pdo->prepare($countSql);
        $countStmt->execute($params);
        $total = (int) $countStmt->fetchColumn();
        $pages = max(1, (int) ceil($total / $perPage));
        $page  = max(1, min($page, $pages));
        $offset = ($page - 1) * $perPage;

        // Récupérer la page
        $dataStmt = $this->pdo->prepare($sql . ' LIMIT :lim OFFSET :off');
        foreach ($params as $k => $v) $dataStmt->bindValue($k, $v);
        $dataStmt->bindValue(':lim', $perPage, PDO::PARAM_INT);
        $dataStmt->bindValue(':off', $offset,  PDO::PARAM_INT);
        $dataStmt->execute();

        return [
            'data'    => $dataStmt->fetchAll(),
            'total'   => $total,
            'pages'   => $pages,
            'current' => $page,
            'perPage' => $perPage,
        ];
    }
}

2
UtilisateurModel — inscription, connexion, rôles
phpapp/models/UtilisateurModel.php
<?php
class UtilisateurModel extends BaseModel {

    protected string $table = 'utilisateurs';
    private array $fillable  = ['nom','prenom','email','telephone','ville','role'];

    // ── Inscription ────────────────────────────────────────────────────────
    public function inscrire(array $data): int {
        if ($this->emailExiste($data['email'])) {
            throw new ModelException('Cette adresse email est déjà utilisée.', 'email', 23000);
        }
        // Hasher le mot de passe (jamais stocker en clair)
        $hash = password_hash($data['mot_de_passe'], PASSWORD_BCRYPT);
        $stmt = $this->pdo->prepare(
            'INSERT INTO utilisateurs (nom, prenom, email, mot_de_passe, telephone, ville, role)
             VALUES (:nom, :prenom, :email, :mdp, :tel, :ville, :role)'
        );
        $stmt->execute([
            ':nom'   => trim($data['nom']),
            ':prenom'=> trim($data['prenom']),
            ':email' => strtolower(trim($data['email'])),
            ':mdp'   => $hash,
            ':tel'   => $data['telephone'] ?? null,
            ':ville' => $data['ville'] ?? null,
            ':role'  => in_array($data['role'], ['proprietaire','locataire']) ? $data['role'] : 'locataire',
        ]);
        return (int) $this->pdo->lastInsertId();
    }

    // ── Authentification ───────────────────────────────────────────────────
    public function authentifier(string $email, string $mdp): array|null {
        $stmt = $this->pdo->prepare(
            'SELECT * FROM utilisateurs WHERE email = :e AND actif = 1'
        );
        $stmt->execute([':e' => strtolower(trim($email))]);
        $user = $stmt->fetch();
        if (!$user || !password_verify($mdp, $user['mot_de_passe'])) {
            return null;  // Identifiants incorrects ou compte inactif
        }
        // Rehash automatique si coût bcrypt a été augmenté
        if (password_needs_rehash($user['mot_de_passe'], PASSWORD_BCRYPT)) {
            $this->majMotDePasse($user['id'], $mdp);
        }
        return $user;
    }

    // ── Vérifications ─────────────────────────────────────────────────────
    public function emailExiste(string $email, int $excludeId = 0): bool {
        $sql = 'SELECT COUNT(*) FROM utilisateurs WHERE email = :e';
        $params = [':e' => strtolower(trim($email))];
        if ($excludeId > 0) { $sql .= ' AND id != :id'; $params[':id'] = $excludeId; }
        return (int) $this->pdo->prepare($sql)->execute($params) > 0 ||
               (bool)((int)(function() use ($sql, $params) {
                   $s = $this->pdo->prepare($sql);
                   $s->execute($params);
                   return $s->fetchColumn();
               })());
    }

    // ── Correction de emailExiste ──────────────────────────────────────────
    public function emailExistCheck(string $email, int $excludeId = 0): bool {
        $sql = 'SELECT COUNT(*) FROM utilisateurs WHERE email = :e';
        $params = [':e' => strtolower(trim($email))];
        if ($excludeId > 0) { $sql .= ' AND id != :id'; $params[':id'] = $excludeId; }
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute($params);
        return (int)$stmt->fetchColumn() > 0;
    }

    // ── Validation ────────────────────────────────────────────────────────
    public function valider(array $data, int $excludeId = 0): array {
        $erreurs = [];
        if (strlen(trim($data['nom']    ?? '')) < 2) $erreurs[] = 'Nom invalide (2 caractères minimum).';
        if (strlen(trim($data['prenom'] ?? '')) < 2) $erreurs[] = 'Prénom invalide (2 caractères minimum).';
        if (!filter_var($data['email'] ?? '', FILTER_VALIDATE_EMAIL)) $erreurs[] = 'Email invalide.';
        elseif ($this->emailExistCheck($data['email'], $excludeId)) $erreurs[] = 'Email déjà utilisé.';
        if (isset($data['mot_de_passe']) && strlen($data['mot_de_passe']) < 8) {
            $erreurs[] = 'Mot de passe : 8 caractères minimum.';
        }
        return $erreurs;
    }

    // ── Mise à jour ───────────────────────────────────────────────────────
    public function majMotDePasse(int $id, string $mdp): void {
        $stmt = $this->pdo->prepare('UPDATE utilisateurs SET mot_de_passe = :h WHERE id = :id');
        $stmt->execute([':h' => password_hash($mdp, PASSWORD_BCRYPT), ':id' => $id]);
    }

    public function majProfil(int $id, array $data): bool {
        $stmt = $this->pdo->prepare(
            'UPDATE utilisateurs SET nom=:nom, prenom=:prenom, telephone=:tel, ville=:ville WHERE id=:id'
        );
        $stmt->execute([':nom'=>trim($data['nom']),':prenom'=>trim($data['prenom']),
                        ':tel'=>$data['telephone']??null,':ville'=>$data['ville']??null,':id'=>$id]);
        return $stmt->rowCount() > 0;
    }

    // ── Admin ─────────────────────────────────────────────────────────────
    public function findByRole(string $role): array {
        $stmt = $this->pdo->prepare(
            'SELECT id, nom, prenom, email, telephone, ville, actif, created_at
             FROM utilisateurs WHERE role = :r ORDER BY created_at DESC'
        );
        $stmt->execute([':r' => $role]);
        return $stmt->fetchAll();
    }

    public function toggleActif(int $id): bool {
        $stmt = $this->pdo->prepare('UPDATE utilisateurs SET actif = NOT actif WHERE id = :id');
        $stmt->execute([':id' => $id]);
        return $stmt->rowCount() > 0;
    }
}

3
AnnonceModel — CRUD + filtres + pagination
phpapp/models/AnnonceModel.php
<?php
class AnnonceModel extends BaseModel {

    protected string $table = 'annonces';
    private array $fillable  = ['titre','description','type','ville','adresse',
                                 'prix_mois','surface','nb_chambres','meuble'];

    // ── Liste publique avec filtres et pagination ──────────────────────────
    public function chercher(array $filtres, int $page = 1): array {
        $where  = ["a.statut = 'active'"];
        $params = [];

        if (!empty($filtres['ville'])) {
            $where[] = 'a.ville = :ville';
            $params[':ville'] = $filtres['ville'];
        }
        if (!empty($filtres['type'])) {
            $where[] = 'a.type = :type';
            $params[':type'] = $filtres['type'];
        }
        if (!empty($filtres['prix_min']) && is_numeric($filtres['prix_min'])) {
            $where[] = 'a.prix_mois >= :pmin';
            $params[':pmin'] = (float)$filtres['prix_min'];
        }
        if (!empty($filtres['prix_max']) && is_numeric($filtres['prix_max'])) {
            $where[] = 'a.prix_mois <= :pmax';
            $params[':pmax'] = (float)$filtres['prix_max'];
        }
        if (!empty($filtres['q'])) {
            $where[] = '(a.titre LIKE :q OR a.description LIKE :q OR a.ville LIKE :q)';
            $params[':q'] = '%' . $filtres['q'] . '%';
        }

        // Tri sécurisé par liste blanche
        $trisValides = ['prix_mois ASC','prix_mois DESC','created_at DESC','created_at ASC'];
        $tri = in_array($filtres['tri'] ?? '', $trisValides) ? $filtres['tri'] : 'created_at DESC';

        $sql = 'SELECT a.*, CONCAT(u.prenom, " ", u.nom) AS proprietaire_nom,
                       p.nom_fichier AS photo_principale
                FROM annonces a
                JOIN utilisateurs u ON u.id = a.proprietaire_id
                LEFT JOIN photos p ON p.annonce_id = a.id AND p.est_principale = 1
                WHERE ' . implode(' AND ', $where) . '
                ORDER BY a.' . $tri;

        return $this->paginate($sql, $params, $page);
    }

    // ── Détail annonce avec photos et note moyenne ─────────────────────────
    public function findWithDetails(int $id): array|null {
        $stmt = $this->pdo->prepare(
            'SELECT a.*,
                    CONCAT(u.prenom, " ", u.nom) AS proprietaire_nom, u.telephone AS prop_tel,
                    COALESCE(AVG(av.note), 0) AS note_moyenne,
                    COUNT(DISTINCT av.id) AS nb_avis
             FROM annonces a
             JOIN utilisateurs u ON u.id = a.proprietaire_id
             LEFT JOIN reservations r ON r.annonce_id = a.id AND r.statut = "terminee"
             LEFT JOIN avis av ON av.reservation_id = r.id
             WHERE a.id = :id
             GROUP BY a.id'
        );
        $stmt->execute([':id' => $id]);
        $annonce = $stmt->fetch();
        if (!$annonce) return null;

        // Charger les photos
        $pStmt = $this->pdo->prepare(
            'SELECT * FROM photos WHERE annonce_id = :id ORDER BY est_principale DESC, ordre ASC'
        );
        $pStmt->execute([':id' => $id]);
        $annonce['photos'] = $pStmt->fetchAll();

        return $annonce;
    }

    // ── Annonces d'un propriétaire ─────────────────────────────────────────
    public function findByProprietaire(int $userId): array {
        $stmt = $this->pdo->prepare(
            'SELECT a.*, COUNT(DISTINCT r.id) AS nb_reservations,
                    COUNT(DISTINCT r2.id) AS nb_en_attente
             FROM annonces a
             LEFT JOIN reservations r  ON r.annonce_id = a.id
             LEFT JOIN reservations r2 ON r2.annonce_id = a.id AND r2.statut = "en_attente"
             WHERE a.proprietaire_id = :uid
             GROUP BY a.id ORDER BY a.created_at DESC'
        );
        $stmt->execute([':uid' => $userId]);
        return $stmt->fetchAll();
    }

    // ── CRUD ──────────────────────────────────────────────────────────────
    public function create(array $data, int $proprietaireId): int {
        $stmt = $this->pdo->prepare(
            'INSERT INTO annonces (titre,description,type,ville,adresse,prix_mois,
                                   surface,nb_chambres,meuble,proprietaire_id)
             VALUES (:titre,:desc,:type,:ville,:adr,:prix,:surf,:chbr,:meuble,:pid)'
        );
        $stmt->execute([
            ':titre' => trim($data['titre']),
            ':desc'  => trim($data['description']),
            ':type'  => $data['type'],
            ':ville' => trim($data['ville']),
            ':adr'   => trim($data['adresse'] ?? ''),
            ':prix'  => (float)$data['prix_mois'],
            ':surf'  => !empty($data['surface']) ? (int)$data['surface'] : null,
            ':chbr'  => (int)($data['nb_chambres'] ?? 1),
            ':meuble'=> isset($data['meuble']) ? 1 : 0,
            ':pid'   => $proprietaireId,
        ]);
        return (int) $this->pdo->lastInsertId();
    }

    public function update(int $id, array $data): bool {
        $stmt = $this->pdo->prepare(
            'UPDATE annonces SET titre=:titre, description=:desc, type=:type,
             ville=:ville, adresse=:adr, prix_mois=:prix, surface=:surf,
             nb_chambres=:chbr, meuble=:meuble WHERE id=:id'
        );
        $stmt->execute([
            ':titre'=>trim($data['titre']),':desc'=>trim($data['description']),
            ':type'=>$data['type'],':ville'=>trim($data['ville']),
            ':adr'=>trim($data['adresse']??''),':prix'=>(float)$data['prix_mois'],
            ':surf'=>!empty($data['surface'])?(int)$data['surface']:null,
            ':chbr'=>(int)($data['nb_chambres']??1),
            ':meuble'=>isset($data['meuble'])?1:0,':id'=>$id,
        ]);
        return $stmt->rowCount() > 0;
    }

    public function majStatut(int $id, string $statut): bool {
        $statutsValides = ['en_attente','active','inactive','rejetee'];
        if (!in_array($statut, $statutsValides)) return false;
        $stmt = $this->pdo->prepare('UPDATE annonces SET statut=:s WHERE id=:id');
        $stmt->execute([':s'=>$statut,':id'=>$id]);
        return $stmt->rowCount() > 0;
    }

    // ── Validation ────────────────────────────────────────────────────────
    public function valider(array $data): array {
        $erreurs = [];
        if (strlen(trim($data['titre']??'')) < 10) $erreurs[] = 'Le titre doit contenir au moins 10 caractères.';
        if (strlen(trim($data['description']??'')) < 30) $erreurs[] = 'La description doit contenir au moins 30 caractères.';
        $typesValides = ['appartement','villa','studio','riad','bureau'];
        if (!in_array($data['type']??'', $typesValides)) $erreurs[] = 'Type de bien invalide.';
        if (empty(trim($data['ville']??''))) $erreurs[] = 'La ville est obligatoire.';
        $prix = filter_var($data['prix_mois']??'', FILTER_VALIDATE_FLOAT);
        if ($prix === false || $prix <= 0) $erreurs[] = 'Prix mensuel invalide (nombre positif requis).';
        return $erreurs;
    }

    // ── Villes disponibles (pour le filtre) ───────────────────────────────
    public function getVilles(): array {
        return $this->pdo->query(
            "SELECT DISTINCT ville FROM annonces WHERE statut='active' ORDER BY ville"
        )->fetchAll(PDO::FETCH_COLUMN);
    }

    // ── Admin : annonces en attente de validation ──────────────────────────
    public function findEnAttente(): array {
        $stmt = $this->pdo->query(
            "SELECT a.*, CONCAT(u.prenom,' ',u.nom) AS proprietaire_nom
             FROM annonces a JOIN utilisateurs u ON u.id=a.proprietaire_id
             WHERE a.statut='en_attente' ORDER BY a.created_at ASC"
        );
        return $stmt->fetchAll();
    }
}

4
ReservationModel — règles métier RG-02 & RG-03
phpapp/models/ReservationModel.php
<?php
class ReservationModel extends BaseModel {

    protected string $table = 'reservations';

    // ── Vérification du chevauchement de dates (RG-02) ─────────────────────
    public function aChevauvement(int $annonceId, string $debut, string $fin, int $excludeId = 0): bool {
        $sql = 'SELECT COUNT(*) FROM reservations
                WHERE annonce_id = :aid
                AND statut = "confirmee"
                AND date_debut <= :fin
                AND date_fin   >= :debut';
        $params = [':aid' => $annonceId, ':debut' => $debut, ':fin' => $fin];
        if ($excludeId > 0) { $sql .= ' AND id != :id'; $params[':id'] = $excludeId; }
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute($params);
        return (int) $stmt->fetchColumn() > 0;
    }

    // ── Créer une réservation ─────────────────────────────────────────────
    public function creer(array $data, int $locataireId): int {
        // Vérifier la disponibilité (RG-02)
        if ($this->aChevauvement($data['annonce_id'], $data['date_debut'], $data['date_fin'])) {
            throw new ModelException(
                'Ce bien est déjà réservé pour cette période. Choisissez d\'autres dates.',
                'date_debut', 23000
            );
        }
        // Calculer nb_mois et prix_total (RG-04)
        $d1     = new DateTime($data['date_debut']);
        $d2     = new DateTime($data['date_fin']);
        $jours  = $d1->diff($d2)->days;
        $nbMois = max(1.0, round($jours / 30, 1));

        // Récupérer le prix mensuel depuis l'annonce
        $prixStmt = $this->pdo->prepare('SELECT prix_mois FROM annonces WHERE id = :id');
        $prixStmt->execute([':id' => $data['annonce_id']]);
        $prixMois   = (float)$prixStmt->fetchColumn();
        $prixTotal  = round($prixMois * $nbMois, 2);

        $stmt = $this->pdo->prepare(
            'INSERT INTO reservations (annonce_id, locataire_id, date_debut, date_fin,
                                       nb_mois, prix_total, message)
             VALUES (:aid, :lid, :debut, :fin, :mois, :total, :msg)'
        );
        $stmt->execute([
            ':aid'   => (int)$data['annonce_id'],
            ':lid'   => $locataireId,
            ':debut' => $data['date_debut'],
            ':fin'   => $data['date_fin'],
            ':mois'  => $nbMois,
            ':total' => $prixTotal,
            ':msg'   => trim($data['message'] ?? ''),
        ]);
        return (int) $this->pdo->lastInsertId();
    }

    // ── Confirmer une réservation (RG-03 : refuser les autres chevauchantes)
    public function confirmer(int $id, int $proprietaireId): bool {
        // Vérifier que la réservation appartient bien à ce propriétaire
        $stmt = $this->pdo->prepare(
            'SELECT r.*, a.proprietaire_id FROM reservations r
             JOIN annonces a ON a.id = r.annonce_id WHERE r.id = :id'
        );
        $stmt->execute([':id' => $id]);
        $res = $stmt->fetch();
        if (!$res || $res['proprietaire_id'] !== $proprietaireId) return false;
        if ($res['statut'] !== 'en_attente') return false;

        // Vérifier une dernière fois qu'il n'y a pas de chevauchement
        if ($this->aChevauvement($res['annonce_id'], $res['date_debut'], $res['date_fin'], $id)) {
            throw new ModelException('Conflit : une autre réservation confirmée couvre déjà cette période.', '', 0);
        }

        $this->pdo->beginTransaction();
        try {
            // Confirmer cette réservation
            $this->pdo->prepare('UPDATE reservations SET statut="confirmee" WHERE id=:id')
                      ->execute([':id' => $id]);

            // RG-03 : refuser automatiquement toutes les autres demandes qui chevauchent
            $this->pdo->prepare(
                'UPDATE reservations SET statut="refusee"
                 WHERE annonce_id = :aid AND id != :id
                 AND statut = "en_attente"
                 AND date_debut <= :fin AND date_fin >= :debut'
            )->execute([
                ':aid'   => $res['annonce_id'],
                ':id'    => $id,
                ':debut' => $res['date_debut'],
                ':fin'   => $res['date_fin'],
            ]);

            $this->pdo->commit();
            return true;
        } catch (PDOException $e) {
            $this->pdo->rollBack();
            error_log('[ReservationModel::confirmer] ' . $e->getMessage());
            throw new ModelException('Erreur lors de la confirmation.', '', 500, $e);
        }
    }

    // ── Refuser une réservation ───────────────────────────────────────────
    public function refuser(int $id, int $proprietaireId): bool {
        $stmt = $this->pdo->prepare(
            'UPDATE reservations r JOIN annonces a ON a.id = r.annonce_id
             SET r.statut = "refusee"
             WHERE r.id = :id AND a.proprietaire_id = :pid AND r.statut = "en_attente"'
        );
        $stmt->execute([':id' => $id, ':pid' => $proprietaireId]);
        return $stmt->rowCount() > 0;
    }

    // ── Réservations d'un locataire ───────────────────────────────────────
    public function findByLocataire(int $locataireId): array {
        $stmt = $this->pdo->prepare(
            'SELECT r.*, a.titre, a.ville, a.type, a.prix_mois,
                    CONCAT(u.prenom," ",u.nom) AS proprietaire_nom,
                    ph.nom_fichier AS photo
             FROM reservations r
             JOIN annonces a ON a.id = r.annonce_id
             JOIN utilisateurs u ON u.id = a.proprietaire_id
             LEFT JOIN photos ph ON ph.annonce_id = a.id AND ph.est_principale = 1
             WHERE r.locataire_id = :lid ORDER BY r.created_at DESC'
        );
        $stmt->execute([':lid' => $locataireId]);
        return $stmt->fetchAll();
    }

    // ── Demandes reçues par un propriétaire ──────────────────────────────
    public function findDemandesProprio(int $proprietaireId): array {
        $stmt = $this->pdo->prepare(
            'SELECT r.*, a.titre, a.ville,
                    CONCAT(loc.prenom," ",loc.nom) AS locataire_nom, loc.telephone
             FROM reservations r
             JOIN annonces a ON a.id = r.annonce_id
             JOIN utilisateurs loc ON loc.id = r.locataire_id
             WHERE a.proprietaire_id = :pid ORDER BY r.created_at DESC'
        );
        $stmt->execute([':pid' => $proprietaireId]);
        return $stmt->fetchAll();
    }

    // ── Statistiques admin ────────────────────────────────────────────────
    public function statsAdmin(): array {
        $row = $this->pdo->query(
            'SELECT
               COUNT(*) AS total,
               SUM(statut="en_attente") AS en_attente,
               SUM(statut="confirmee")  AS confirmees,
               SUM(statut="refusee")    AS refusees,
               SUM(prix_total) AS ca_total
             FROM reservations'
        )->fetch();
        return $row ?: [];
    }

    // ── Export CSV pour l'administrateur ─────────────────────────────────
    public function exportCsv(): array {
        return $this->pdo->query(
            'SELECT r.id, r.statut, r.date_debut, r.date_fin, r.nb_mois, r.prix_total,
                    a.titre AS annonce, a.ville,
                    CONCAT(loc.prenom," ",loc.nom) AS locataire, loc.email AS email_loc,
                    CONCAT(pro.prenom," ",pro.nom) AS proprietaire, r.created_at
             FROM reservations r
             JOIN annonces a ON a.id=r.annonce_id
             JOIN utilisateurs loc ON loc.id=r.locataire_id
             JOIN utilisateurs pro ON pro.id=a.proprietaire_id
             ORDER BY r.created_at DESC'
        )->fetchAll();
    }
}

5
PhotoModel — upload sécurisé hors racine web
phpapp/models/PhotoModel.php
<?php
class PhotoModel extends BaseModel {

    protected string $table = 'photos';

    // ── Upload et enregistrement d'une photo ──────────────────────────────
    public function uploader(array $fichier, int $annonceId, bool $estPrincipale = false): string {
        // 1. Vérifier l'absence d'erreur d'upload
        if ($fichier['error'] !== UPLOAD_ERR_OK) {
            throw new ModelException('Erreur lors du téléversement (code ' . $fichier['error'] . ').');
        }
        // 2. Vérifier la taille (RG-08)
        if ($fichier['size'] > UPLOAD_MAX_SIZE) {
            throw new ModelException('Photo trop lourde (max ' . (UPLOAD_MAX_SIZE / 1024 / 1024) . ' Mo).');
        }
        // 3. Vérifier l'extension (liste blanche)
        $ext = strtolower(pathinfo($fichier['name'], PATHINFO_EXTENSION));
        if (!in_array($ext, UPLOAD_ALLOWED_EXT)) {
            throw new ModelException('Format non autorisé. Formats acceptés : JPG, PNG, WEBP.');
        }
        // 4. Vérifier le MIME réel avec finfo (RG-08) — jamais se fier à $_FILES['type']
        $finfo    = new finfo(FILEINFO_MIME_TYPE);
        $mimeReel = $finfo->file($fichier['tmp_name']);
        if (!in_array($mimeReel, UPLOAD_ALLOWED_MIME)) {
            throw new ModelException('Le contenu du fichier n\'est pas une image valide.');
        }
        // 5. Nom aléatoire — jamais conserver le nom original (RG-08)
        $nomFichier  = hash('sha256', uniqid('', true) . random_bytes(8)) . '.' . $ext;
        $dossier     = STORAGE_PATH . '/photos/';
        if (!is_dir($dossier)) mkdir($dossier, 0750, true);
        $destination = $dossier . $nomFichier;

        if (!move_uploaded_file($fichier['tmp_name'], $destination)) {
            throw new ModelException('Impossible de sauvegarder la photo.');
        }

        // 6. Si c'est la photo principale, retirer l'ancienne
        if ($estPrincipale) {
            $this->pdo->prepare('UPDATE photos SET est_principale=0 WHERE annonce_id=:id')
                      ->execute([':id' => $annonceId]);
        }

        // 7. Calculer l'ordre
        $ordre = 1 + (int)$this->count('annonce_id = ' . $annonceId);

        // 8. Insérer en base
        $stmt = $this->pdo->prepare(
            'INSERT INTO photos (annonce_id, nom_fichier, est_principale, ordre)
             VALUES (:aid, :nom, :prin, :ord)'
        );
        $stmt->execute([
            ':aid'  => $annonceId,
            ':nom'  => $nomFichier,
            ':prin' => $estPrincipale ? 1 : 0,
            ':ord'  => $ordre,
        ]);
        return $nomFichier;
    }

    // ── Supprimer une photo (fichier + enregistrement) ────────────────────
    public function supprimer(int $photoId, int $annonceId): bool {
        $photo = $this->findById($photoId);
        if (!$photo || $photo['annonce_id'] !== $annonceId) return false;

        // Supprimer le fichier physique
        $chemin = STORAGE_PATH . '/photos/' . $photo['nom_fichier'];
        if (file_exists($chemin)) unlink($chemin);

        return $this->delete($photoId);
    }

    // ── Servir une image via proxy sécurisé ───────────────────────────────
    // Appel : /annonces/photo/{nom_fichier}
    public function servir(string $nomFichier): void {
        // Protection path traversal — basename() supprime tous les ../
        $nomFichier = basename($nomFichier);
        $chemin     = realpath(STORAGE_PATH . '/photos/' . $nomFichier);
        $base       = realpath(STORAGE_PATH . '/photos');

        if (!$chemin || strpos($chemin, $base . DIRECTORY_SEPARATOR) !== 0
                     || !file_exists($chemin)) {
            http_response_code(404); exit;
        }
        $finfo = new finfo(FILEINFO_MIME_TYPE);
        $mime  = $finfo->file($chemin);
        header('Content-Type: ' . $mime);
        header('Content-Length: ' . filesize($chemin));
        header('Cache-Control: public, max-age=86400');
        readfile($chemin);
        exit;
    }
}
Projet Final — DariGO AuthController AnnonceController AdminController
⏱ 30 min

Contrôleurs — AuthController, AnnonceController, ReservationController & AdminController

Controller de base avec toutes les méthodes de sécurité, puis les 4 controllers métier complets : authentification (inscription/login/logout), gestion des annonces (CRUD propriétaire + liste publique), réservations (créer/confirmer/refuser), et dashboard admin (validation + stats + export CSV).


1
core/Controller.php — classe mère complète
phpcore/Controller.php
<?php
class Controller {

    // ── Rendu de vue ──────────────────────────────────────────────────────
    protected function render(string $view, array $data = []): void {
        $data['flash']    = $this->getFlash();
        $data['session']  = $_SESSION;       // Accessible dans toutes les vues
        extract($data);
        if (!isset($pageTitle)) $pageTitle = APP_NAME;
        $base = ROOT_PATH . '/app/views/';
        $file = $base . $view . '.php';
        if (!file_exists($file)) { http_response_code(404); die('Vue introuvable : ' . htmlspecialchars($view)); }
        require_once ROOT_PATH . '/app/views/layouts/header.php';
        require_once $file;
        require_once ROOT_PATH . '/app/views/layouts/footer.php';
    }

    protected function redirect(string $url): never {
        header('Location: ' . APP_URL . $url); exit;
    }

    protected function notFound(string $msg = 'Page introuvable'): never {
        http_response_code(404);
        $this->render('errors/404', ['pageTitle' => '404', 'message' => $msg]);
        exit;
    }

    // ── Middleware d'authentification ─────────────────────────────────────
    protected function requireAuth(): void {
        if (!isset($_SESSION['user_id'])) {
            $_SESSION['redirect_after_login'] = $_SERVER['REQUEST_URI'] ?? '/';
            $this->redirect('/auth/login');
        }
    }

    protected function requireRole(string ...$roles): void {
        $this->requireAuth();
        if (!in_array($_SESSION['user_role'] ?? '', $roles)) {
            http_response_code(403);
            $this->render('errors/403', ['pageTitle' => 'Accès refusé']);
            exit;
        }
    }

    // ── CSRF ───────────────────────────────────────────────────────────────
    protected function genererCsrf(): string {
        $token = bin2hex(random_bytes(32));
        $_SESSION['csrf_token'] = $token;
        return $token;
    }

    protected function verifierCsrf(): bool {
        $token = $_POST['csrf_token'] ?? '';
        $valid = hash_equals($_SESSION['csrf_token'] ?? '', $token);
        unset($_SESSION['csrf_token']);
        return $valid;
    }

    // ── Flash messages ────────────────────────────────────────────────────
    protected function flash(string $type, string $message): void {
        $_SESSION['flash'] = ['type' => $type, 'message' => $message];
    }

    private function getFlash(): array|null {
        $f = $_SESSION['flash'] ?? null;
        unset($_SESSION['flash']);
        return $f;
    }

    // ── Lire le corps JSON (pour l'API REST) ──────────────────────────────
    protected function readJson(): array {
        $raw  = file_get_contents('php://input');
        $data = json_decode($raw, true);
        if ($data === null && $raw !== '') {
            http_response_code(400);
            echo json_encode(['success'=>false,'message'=>'JSON malformé.']);
            exit;
        }
        return $data ?? [];
    }

    // ── Réponse JSON (pour l'API REST) ────────────────────────────────────
    protected function json(int $code, bool $ok, mixed $data = null, string $msg = '', array $errors = []): never {
        http_response_code($code);
        header('Content-Type: application/json; charset=UTF-8');
        echo json_encode(['success'=>$ok,'data'=>$data,'message'=>$msg,'errors'=>$errors?:null],
                         JSON_UNESCAPED_UNICODE);
        exit;
    }

    // ── Bearer token (API REST) ───────────────────────────────────────────
    protected function requireBearer(): void {
        $auth = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
        if (!str_starts_with($auth, 'Bearer ')) $this->json(401, false, null, 'Token Bearer requis.');
        if (!in_array(substr($auth, 7), API_TOKENS ?? [], true))
            $this->json(401, false, null, 'Token invalide.');
    }
}

2
AuthController — inscription, connexion, déconnexion
phpapp/controllers/AuthController.php
<?php
class AuthController extends Controller {

    private UtilisateurModel $model;

    public function __construct() {
        $this->model = new UtilisateurModel();
    }

    // ── GET /auth/login ───────────────────────────────────────────────────
    public function login(): void {
        if (isset($_SESSION['user_id'])) { $this->redirect('/annonces'); }
        $this->render('auth/login', [
            'pageTitle' => 'Connexion — DariGO',
            'csrfToken' => $this->genererCsrf(),
        ]);
    }

    // ── POST /auth/loginPost ──────────────────────────────────────────────
    public function loginPost(): void {
        if (!$this->verifierCsrf()) {
            $this->flash('error', 'Erreur de sécurité. Réessayez.');
            $this->redirect('/auth/login');
        }
        $email = trim($_POST['email'] ?? '');
        $mdp   = $_POST['mot_de_passe'] ?? '';
        $user  = $this->model->authentifier($email, $mdp);

        if (!$user) {
            $this->flash('error', 'Email ou mot de passe incorrect.');
            $this->redirect('/auth/login');
        }

        // Régénérer l'ID de session (protection Session Fixation)
        session_regenerate_id(true);
        $_SESSION['user_id']    = $user['id'];
        $_SESSION['user_nom']   = $user['prenom'] . ' ' . $user['nom'];
        $_SESSION['user_role']  = $user['role'];
        $_SESSION['user_email'] = $user['email'];

        $redirect = $_SESSION['redirect_after_login'] ?? null;
        unset($_SESSION['redirect_after_login']);

        $this->flash('success', 'Bienvenue ' . $user['prenom'] . ' !');
        $this->redirect($redirect ? str_replace(APP_URL, '', $redirect) : '/annonces');
    }

    // ── GET /auth/register ────────────────────────────────────────────────
    public function register(): void {
        if (isset($_SESSION['user_id'])) { $this->redirect('/annonces'); }
        $this->render('auth/register', [
            'pageTitle' => 'S\'inscrire — DariGO',
            'csrfToken' => $this->genererCsrf(),
        ]);
    }

    // ── POST /auth/registerPost ───────────────────────────────────────────
    public function registerPost(): void {
        if (!$this->verifierCsrf()) {
            $this->flash('error', 'Erreur de sécurité.');
            $this->redirect('/auth/register');
        }
        $data    = $_POST;
        $erreurs = $this->model->valider($data);

        // Confirmer le mot de passe
        if (($data['mot_de_passe'] ?? '') !== ($data['mdp_confirm'] ?? '')) {
            $erreurs[] = 'Les mots de passe ne correspondent pas.';
        }

        if (!empty($erreurs)) {
            $_SESSION['form_errors'] = $erreurs;
            $_SESSION['form_data']   = $data;
            $this->redirect('/auth/register');
        }

        try {
            $id = $this->model->inscrire($data);
            session_regenerate_id(true);
            $user = $this->model->findById($id);
            $_SESSION['user_id']    = $user['id'];
            $_SESSION['user_nom']   = $user['prenom'] . ' ' . $user['nom'];
            $_SESSION['user_role']  = $user['role'];
            $_SESSION['user_email'] = $user['email'];
            $this->flash('success', 'Compte créé avec succès. Bienvenue sur DariGO !');
            $this->redirect('/annonces');
        } catch (ModelException $e) {
            $this->flash('error', $e->getMessage());
            $this->redirect('/auth/register');
        }
    }

    // ── GET /auth/logout ──────────────────────────────────────────────────
    public function logout(): void {
        session_unset();
        session_destroy();
        if (ini_get('session.use_cookies')) {
            $p = session_get_cookie_params();
            setcookie(session_name(), '', time()-3600, $p['path'], $p['domain'], $p['secure'], $p['httponly']);
        }
        $this->redirect('/auth/login');
    }
}

3
AnnonceController — liste publique + CRUD propriétaire
phpapp/controllers/AnnonceController.php
<?php
class AnnonceController extends Controller {

    private AnnonceModel     $model;
    private PhotoModel       $photoModel;
    private ReservationModel $resModel;

    public function __construct() {
        $this->model      = new AnnonceModel();
        $this->photoModel = new PhotoModel();
        $this->resModel   = new ReservationModel();
    }

    // ── GET /annonces — Liste publique avec filtres et pagination ──────────
    public function index(): void {
        $filtres = [
            'ville'    => trim($_GET['ville']    ?? ''),
            'type'     => trim($_GET['type']     ?? ''),
            'prix_min' => trim($_GET['prix_min'] ?? ''),
            'prix_max' => trim($_GET['prix_max'] ?? ''),
            'q'        => trim($_GET['q']        ?? ''),
            'tri'      => trim($_GET['tri']      ?? 'created_at DESC'),
        ];
        $page     = max(1, intval($_GET['page'] ?? 1));
        $resultat = $this->model->chercher($filtres, $page);
        $villes   = $this->model->getVilles();

        $this->render('annonces/index', [
            'annonces'   => $resultat['data'],
            'pagination' => $resultat,
            'filtres'    => $filtres,
            'villes'     => $villes,
            'pageTitle'  => 'Trouver une location — DariGO',
        ]);
    }

    // ── GET /annonces/show/{id} — Détail d'une annonce ──────────────────
    public function show(int $id): void {
        $annonce = $this->model->findWithDetails($id);
        if (!$annonce || $annonce['statut'] !== 'active') $this->notFound();

        $csrfToken = null;
        if (isset($_SESSION['user_id']) && $_SESSION['user_role'] === 'locataire') {
            $csrfToken = $this->genererCsrf();
        }

        $this->render('annonces/show', [
            'annonce'   => $annonce,
            'csrfToken' => $csrfToken,
            'pageTitle' => $annonce['titre'] . ' — DariGO',
        ]);
    }

    // ── GET /annonces/create ─────────────────────────────────────────────
    public function create(): void {
        $this->requireRole('proprietaire');
        $this->render('annonces/form', [
            'annonce'   => null,
            'erreurs'   => [],
            'csrfToken' => $this->genererCsrf(),
            'pageTitle' => 'Publier une annonce — DariGO',
        ]);
    }

    // ── POST /annonces/store ─────────────────────────────────────────────
    public function store(): void {
        $this->requireRole('proprietaire');
        if (!$this->verifierCsrf()) {
            $this->flash('error', 'Erreur de sécurité.');
            $this->redirect('/annonces/create');
        }
        $erreurs = $this->model->valider($_POST);
        if (!empty($erreurs)) {
            $this->render('annonces/form', [
                'annonce'   => $_POST,
                'erreurs'   => $erreurs,
                'csrfToken' => $this->genererCsrf(),
                'pageTitle' => 'Publier une annonce',
            ]); return;
        }

        $annonceId = $this->model->create($_POST, $_SESSION['user_id']);

        // Upload photos (optionnel)
        if (!empty($_FILES['photos']['name'][0])) {
            $estPremiere = true;
            foreach ($_FILES['photos']['tmp_name'] as $i => $tmpName) {
                if ($_FILES['photos']['error'][$i] !== UPLOAD_ERR_OK) continue;
                $fichier = [
                    'name'     => $_FILES['photos']['name'][$i],
                    'tmp_name' => $tmpName,
                    'error'    => $_FILES['photos']['error'][$i],
                    'size'     => $_FILES['photos']['size'][$i],
                    'type'     => $_FILES['photos']['type'][$i],
                ];
                try {
                    $this->photoModel->uploader($fichier, $annonceId, $estPremiere);
                    $estPremiere = false;
                } catch (ModelException $e) {
                    // Photo invalide — continuer avec les autres
                }
            }
        }

        $this->flash('success', 'Annonce publiée ! Elle sera visible après validation par notre équipe.');
        $this->redirect('/profile/mes-annonces');
    }

    // ── GET /annonces/edit/{id} ──────────────────────────────────────────
    public function edit(int $id): void {
        $this->requireRole('proprietaire');
        $annonce = $this->model->findById($id);
        if (!$annonce || $annonce['proprietaire_id'] !== $_SESSION['user_id']) $this->notFound();
        $this->render('annonces/form', [
            'annonce'   => $annonce,
            'erreurs'   => [],
            'csrfToken' => $this->genererCsrf(),
            'pageTitle' => 'Modifier l\'annonce — DariGO',
        ]);
    }

    // ── POST /annonces/update/{id} ───────────────────────────────────────
    public function update(int $id): void {
        $this->requireRole('proprietaire');
        if (!$this->verifierCsrf()) { $this->flash('error','Erreur de sécurité.'); $this->redirect('/annonces/edit/'.$id); }
        $annonce = $this->model->findById($id);
        if (!$annonce || $annonce['proprietaire_id'] !== $_SESSION['user_id']) $this->notFound();

        $erreurs = $this->model->valider($_POST);
        if (!empty($erreurs)) {
            $this->render('annonces/form', [
                'annonce'   => array_merge($annonce, $_POST),
                'erreurs'   => $erreurs,
                'csrfToken' => $this->genererCsrf(),
                'pageTitle' => 'Modifier l\'annonce',
            ]); return;
        }
        $this->model->update($id, $_POST);
        // Repassage en "en_attente" si modification majeure
        $this->model->majStatut($id, 'en_attente');
        $this->flash('success', 'Annonce modifiée. Elle sera re-validée par notre équipe.');
        $this->redirect('/profile/mes-annonces');
    }

    // ── GET /annonces/delete/{id} — confirmation ──────────────────────────
    public function delete(int $id): void {
        $this->requireRole('proprietaire');
        $annonce = $this->model->findById($id);
        if (!$annonce || $annonce['proprietaire_id'] !== $_SESSION['user_id']) $this->notFound();
        $this->render('annonces/confirm_delete', [
            'annonce'   => $annonce,
            'csrfToken' => $this->genererCsrf(),
            'pageTitle' => 'Supprimer l\'annonce',
        ]);
    }

    // ── POST /annonces/destroy/{id} ───────────────────────────────────────
    public function destroy(int $id): void {
        $this->requireRole('proprietaire');
        if (!$this->verifierCsrf()) { $this->flash('error','Erreur de sécurité.'); $this->redirect('/profile/mes-annonces'); }
        $annonce = $this->model->findById($id);
        if (!$annonce || $annonce['proprietaire_id'] !== $_SESSION['user_id']) $this->notFound();
        $this->model->delete($id);
        $this->flash('success', 'Annonce supprimée.');
        $this->redirect('/profile/mes-annonces');
    }

    // ── GET /annonces/photo/{nom} — proxy sécurisé ────────────────────────
    public function photo(int $id = 0): void {
        // $id non utilisé ici — on passe via PATH_INFO
        $nom = basename($_GET['nom'] ?? '');
        $this->photoModel->servir($nom);
    }
}

4
ReservationController + AdminController
phpapp/controllers/ReservationController.php
<?php
class ReservationController extends Controller {

    private ReservationModel $model;

    public function __construct() { $this->model = new ReservationModel(); }

    // ── POST /reservations/store — créer une demande ──────────────────────
    public function store(): void {
        $this->requireRole('locataire');
        if (!$this->verifierCsrf()) {
            $this->flash('error', 'Erreur de sécurité.');
            $this->redirect('/annonces/' . intval($_POST['annonce_id'] ?? 0));
        }

        // Valider les dates
        $debut = $_POST['date_debut'] ?? '';
        $fin   = $_POST['date_fin']   ?? '';
        if (!$debut || !$fin || strtotime($fin) <= strtotime($debut)) {
            $this->flash('error', 'Dates invalides. La date de fin doit être après la date de début.');
            $this->redirect('/annonces/' . intval($_POST['annonce_id'] ?? 0));
        }
        if (strtotime($debut) < strtotime('today')) {
            $this->flash('error', 'La date de début ne peut pas être dans le passé.');
            $this->redirect('/annonces/' . intval($_POST['annonce_id'] ?? 0));
        }

        try {
            $this->model->creer($_POST, $_SESSION['user_id']);
            $this->flash('success', 'Votre demande a été envoyée au propriétaire. Vous serez notifié dès sa réponse.');
            $this->redirect('/reservations/mes-reservations');
        } catch (ModelException $e) {
            $this->flash('error', $e->getMessage());
            $this->redirect('/annonces/' . intval($_POST['annonce_id'] ?? 0));
        }
    }

    // ── GET /reservations/mes-reservations — locataire ──────────────────
    public function mesReservations(): void {
        $this->requireRole('locataire');
        $reservations = $this->model->findByLocataire($_SESSION['user_id']);
        $this->render('reservations/mes_reservations', [
            'reservations' => $reservations,
            'pageTitle'    => 'Mes réservations — DariGO',
        ]);
    }

    // ── GET /reservations/demandes — propriétaire ────────────────────────
    public function demandes(): void {
        $this->requireRole('proprietaire');
        $demandes = $this->model->findDemandesProprio($_SESSION['user_id']);
        $this->render('reservations/demandes', [
            'demandes'  => $demandes,
            'csrfToken' => $this->genererCsrf(),
            'pageTitle' => 'Demandes reçues — DariGO',
        ]);
    }

    // ── POST /reservations/confirmer/{id} ─────────────────────────────────
    public function confirmer(int $id): void {
        $this->requireRole('proprietaire');
        if (!$this->verifierCsrf()) { $this->flash('error','Erreur de sécurité.'); $this->redirect('/reservations/demandes'); }
        try {
            $this->model->confirmer($id, $_SESSION['user_id']);
            $this->flash('success', 'Réservation confirmée. Les autres demandes en conflit ont été automatiquement refusées.');
        } catch (ModelException $e) {
            $this->flash('error', $e->getMessage());
        }
        $this->redirect('/reservations/demandes');
    }

    // ── POST /reservations/refuser/{id} ──────────────────────────────────
    public function refuser(int $id): void {
        $this->requireRole('proprietaire');
        if (!$this->verifierCsrf()) { $this->flash('error','Erreur de sécurité.'); $this->redirect('/reservations/demandes'); }
        $this->model->refuser($id, $_SESSION['user_id']);
        $this->flash('success', 'Réservation refusée.');
        $this->redirect('/reservations/demandes');
    }
}
phpapp/controllers/AdminController.php
<?php
class AdminController extends Controller {

    private AnnonceModel     $annonceModel;
    private ReservationModel $resModel;
    private UtilisateurModel $userModel;

    public function __construct() {
        $this->annonceModel = new AnnonceModel();
        $this->resModel     = new ReservationModel();
        $this->userModel    = new UtilisateurModel();
    }

    // ── GET /admin — Dashboard ────────────────────────────────────────────
    public function index(): void {
        $this->requireRole('admin');
        $statsRes = $this->resModel->statsAdmin();
        $stats = [
            'annonces_actives'   => $this->annonceModel->count("statut='active'"),
            'annonces_attente'   => $this->annonceModel->count("statut='en_attente'"),
            'nb_proprietaires'   => $this->userModel->count("role='proprietaire'"),
            'nb_locataires'      => $this->userModel->count("role='locataire'"),
            'reservations_total' => $statsRes['total'] ?? 0,
            'reservations_conf'  => $statsRes['confirmees'] ?? 0,
            'ca_total'           => $statsRes['ca_total'] ?? 0,
        ];
        $annoncesPendantes = $this->annonceModel->findEnAttente();
        $this->render('admin/dashboard', [
            'stats'             => $stats,
            'annoncesPendantes' => $annoncesPendantes,
            'csrfToken'         => $this->genererCsrf(),
            'pageTitle'         => 'Administration — DariGO',
        ]);
    }

    // ── POST /admin/valider/{id} — Valider une annonce ──────────────────
    public function valider(int $id): void {
        $this->requireRole('admin');
        if (!$this->verifierCsrf()) { $this->flash('error','Erreur.'); $this->redirect('/admin'); }
        $this->annonceModel->majStatut($id, 'active');
        $this->flash('success', 'Annonce activée et visible sur la plateforme.');
        $this->redirect('/admin');
    }

    // ── POST /admin/rejeter/{id} — Rejeter une annonce ──────────────────
    public function rejeter(int $id): void {
        $this->requireRole('admin');
        if (!$this->verifierCsrf()) { $this->flash('error','Erreur.'); $this->redirect('/admin'); }
        $this->annonceModel->majStatut($id, 'rejetee');
        $this->flash('success', 'Annonce rejetée.');
        $this->redirect('/admin');
    }

    // ── GET /admin/utilisateurs — Liste des utilisateurs ─────────────────
    public function utilisateurs(): void {
        $this->requireRole('admin');
        $proprietaires = $this->userModel->findByRole('proprietaire');
        $locataires    = $this->userModel->findByRole('locataire');
        $this->render('admin/utilisateurs', [
            'proprietaires' => $proprietaires,
            'locataires'    => $locataires,
            'csrfToken'     => $this->genererCsrf(),
            'pageTitle'     => 'Utilisateurs — DariGO Admin',
        ]);
    }

    // ── POST /admin/toggle-user/{id} — Activer/désactiver ────────────────
    public function toggleUser(int $id): void {
        $this->requireRole('admin');
        if (!$this->verifierCsrf()) { $this->flash('error','Erreur.'); $this->redirect('/admin/utilisateurs'); }
        $this->userModel->toggleActif($id);
        $this->flash('success', 'Statut de l\'utilisateur mis à jour.');
        $this->redirect('/admin/utilisateurs');
    }

    // ── GET /admin/export-csv — Export réservations ───────────────────────
    public function exportCsv(): void {
        $this->requireRole('admin');
        $reservations = $this->resModel->exportCsv();

        // Envoyer les en-têtes HTTP pour le téléchargement
        header('Content-Type: text/csv; charset=UTF-8');
        header('Content-Disposition: attachment; filename="darigo_reservations_' . date('Y-m-d') . '.csv"');
        header('Pragma: no-cache');
        header('Expires: 0');

        // Écriture CSV avec fputcsv
        $fp = fopen('php://output', 'w');
        fprintf($fp, chr(0xEF).chr(0xBB).chr(0xBF));  // BOM UTF-8 pour Excel

        // En-têtes du CSV
        fputcsv($fp, ['ID','Statut','Date début','Date fin','Nb mois','Prix total (MAD)',
                      'Annonce','Ville','Locataire','Email','Propriétaire','Créé le'], ';');

        // Données
        foreach ($reservations as $r) {
            fputcsv($fp, [
                $r['id'], $r['statut'], $r['date_debut'], $r['date_fin'],
                $r['nb_mois'], $r['prix_total'], $r['annonce'], $r['ville'],
                $r['locataire'], $r['email_loc'], $r['proprietaire'],
                date('d/m/Y H:i', strtotime($r['created_at'])),
            ], ';');
        }
        fclose($fp);
        exit;
    }
}
Projet Final — DariGO Layouts Vues CRUD CSS DariGO
⏱ 35 min

Vues & CSS — Layouts, toutes les vues CRUD & CSS complet DariGO

Layouts header/navbar/footer, vue liste publique des annonces avec filtres et pagination en grille, vue détail avec formulaire de réservation, formulaire create/edit unifié, dashboard admin avec statistiques, vues réservations, et le CSS complet DariGO (palette terracotta et vert olive, responsive).


1
Layouts — header.php, navbar.php, footer.php
phpapp/views/layouts/header.php
<!DOCTYPE html>
<html lang="fr">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title><?= htmlspecialchars($pageTitle ?? 'DariGO') ?></title>
  <link rel="stylesheet" href="<?= APP_URL ?>/assets/css/darigo.css">
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
</head>
<body>
  <?php require_once ROOT_PATH . '/app/views/layouts/navbar.php'; ?>
  <main class="main-content">
phpapp/views/layouts/navbar.php
<?php
// Flash message affiché et supprimé ici
$flash = $flash ?? null;  // Injecté par Controller::render()
?>
<nav class="navbar">
  <div class="nav-inner">
    <a href="<?= APP_URL ?>/annonces" class="nav-brand">
      <span class="brand-dari">Dari</span><span class="brand-go">GO</span>
    </a>
    <ul class="nav-links">
      <li><a href="<?= APP_URL ?>/annonces">Annonces</a></li>
      <?php if (isset($session['user_id'])): ?>
        <?php if ($session['user_role'] === 'proprietaire'): ?>
          <li><a href="<?= APP_URL ?>/annonces/create">+ Publier</a></li>
          <li><a href="<?= APP_URL ?>/reservations/demandes">Demandes</a></li>
        <?php endif; ?>
        <?php if ($session['user_role'] === 'locataire'): ?>
          <li><a href="<?= APP_URL ?>/reservations/mes-reservations">Mes réservations</a></li>
        <?php endif; ?>
        <?php if ($session['user_role'] === 'admin'): ?>
          <li><a href="<?= APP_URL ?>/admin">Admin</a></li>
        <?php endif; ?>
      <?php endif; ?>
    </ul>
    <div class="nav-user">
      <?php if (isset($session['user_id'])): ?>
        <span class="nav-username">👤 <?= htmlspecialchars($session['user_nom']) ?></span>
        <span class="nav-role role-<?= $session['user_role'] ?>"><?= $session['user_role'] ?></span>
        <a href="<?= APP_URL ?>/auth/logout" class="btn-nav-logout">Déconnexion</a>
      <?php else: ?>
        <a href="<?= APP_URL ?>/auth/login"    class="btn-nav">Connexion</a>
        <a href="<?= APP_URL ?>/auth/register" class="btn-nav btn-nav-primary">S'inscrire</a>
      <?php endif; ?>
    </div>
  </div>
</nav>

<?php if ($flash): ?>
  <div class="flash flash-<?= htmlspecialchars($flash['type']) ?>">
    <span><?= htmlspecialchars($flash['message']) ?></span>
    <button onclick="this.parentElement.remove()">✕</button>
  </div>
<?php endif; ?>
phpapp/views/layouts/footer.php
  </main><!-- /.main-content -->
  <footer class="site-footer">
    <div class="footer-inner">
      <p><span class="brand-dari">Dari</span><span class="brand-go">GO</span>
         — Plateforme de location immobilière au Maroc</p>
      <p class="footer-links">
        <a href="<?= APP_URL ?>/annonces">Annonces</a> ·
        <a href="<?= APP_URL ?>/auth/register">S'inscrire</a> ·
        <a href="mailto:contact@darigo.ma">Contact</a>
      </p>
      <p class="footer-copy">© <?= date('Y') ?> DariGO — Casablanca, Maroc</p>
    </div>
  </footer>
  <script src="<?= APP_URL ?>/assets/js/darigo.js"></script>
</body>
</html>

2
Vue liste publique des annonces — annonces/index.php
phpapp/views/annonces/index.php
<!-- Variables reçues : $annonces, $pagination, $filtres, $villes -->

<!-- Bannière héro -->
<section class="hero">
  <div class="hero-inner">
    <h1>Trouvez votre <strong>location idéale</strong> au Maroc</h1>
    <p>Appartements, villas, studios, riads — partout au Maroc</p>
    <form method="GET" action="<?= APP_URL ?>/annonces" class="hero-search">
      <input type="search" name="q" placeholder="Quartier, ville, type..."
             value="<?= htmlspecialchars($filtres['q']) ?>">
      <button type="submit">Rechercher</button>
    </form>
  </div>
</section>

<div class="container">
  <div class="listing-layout">

    <!-- Sidebar filtres -->
    <aside class="filters-sidebar">
      <h3>Filtrer</h3>
      <form method="GET" action="<?= APP_URL ?>/annonces">
        <?php if ($filtres['q']): ?>
          <input type="hidden" name="q" value="<?= htmlspecialchars($filtres['q']) ?>">
        <?php endif; ?>

        <div class="filter-group">
          <label>Ville</label>
          <select name="ville">
            <option value="">Toutes les villes</option>
            <?php foreach ($villes as $v): ?>
              <option value="<?= htmlspecialchars($v) ?>"
                      <?= $filtres['ville']===$v ? 'selected' : '' ?>>
                <?= htmlspecialchars($v) ?>
              </option>
            <?php endforeach; ?>
          </select>
        </div>

        <div class="filter-group">
          <label>Type</label>
          <select name="type">
            <option value="">Tous les types</option>
            <?php foreach (['appartement','villa','studio','riad','bureau'] as $t): ?>
              <option value="<?= $t ?>" <?= $filtres['type']===$t ? 'selected' : '' ?>>
                <?= ucfirst($t) ?>
              </option>
            <?php endforeach; ?>
          </select>
        </div>

        <div class="filter-group">
          <label>Prix mensuel (MAD)</label>
          <div class="price-range">
            <input type="number" name="prix_min" placeholder="Min"
                   value="<?= htmlspecialchars($filtres['prix_min']) ?>" min="0">
            <span>—</span>
            <input type="number" name="prix_max" placeholder="Max"
                   value="<?= htmlspecialchars($filtres['prix_max']) ?>" min="0">
          </div>
        </div>

        <div class="filter-group">
          <label>Trier par</label>
          <select name="tri">
            <option value="created_at DESC" <?= $filtres['tri']==='created_at DESC'?'selected':'' ?>>Plus récentes</option>
            <option value="prix_mois ASC"   <?= $filtres['tri']==='prix_mois ASC'?'selected':'' ?>>Prix croissant</option>
            <option value="prix_mois DESC"  <?= $filtres['tri']==='prix_mois DESC'?'selected':'' ?>>Prix décroissant</option>
          </select>
        </div>

        <button type="submit" class="btn btn-primary btn-full">Appliquer</button>
        <a href="<?= APP_URL ?>/annonces" class="btn btn-ghost btn-full">Réinitialiser</a>
      </form>
    </aside>

    <!-- Grille d'annonces -->
    <div class="listings-main">
      <div class="listings-header">
        <p><strong><?= $pagination['total'] ?></strong> annonce(s) trouvée(s)</p>
      </div>

      <?php if (empty($annonces)): ?>
        <div class="empty-state">
          <p>🔍 Aucune annonce ne correspond à vos critères.</p>
          <a href="<?= APP_URL ?>/annonces" class="btn btn-ghost">Voir toutes les annonces</a>
        </div>
      <?php else: ?>
        <div class="annonces-grid">
          <?php foreach ($annonces as $a): ?>
            <article class="annonce-card">
              <a href="<?= APP_URL ?>/annonces/show/<?= $a['id'] ?>" class="card-img-link">
                <?php if ($a['photo_principale']): ?>
                  <img src="<?= APP_URL ?>/annonces/photo?nom=<?= urlencode($a['photo_principale']) ?>"
                       alt="<?= htmlspecialchars($a['titre']) ?>" loading="lazy">
                <?php else: ?>
                  <div class="card-no-photo">🏠</div>
                <?php endif; ?>
                <span class="card-badge-type"><?= ucfirst($a['type']) ?></span>
              </a>
              <div class="card-body">
                <h3 class="card-title">
                  <a href="<?= APP_URL ?>/annonces/show/<?= $a['id'] ?>">
                    <?= htmlspecialchars($a['titre']) ?>
                  </a>
                </h3>
                <p class="card-ville">📍 <?= htmlspecialchars($a['ville']) ?></p>
                <div class="card-meta">
                  <?php if ($a['surface']): ?>
                    <span>📐 <?= $a['surface'] ?> m²</span>
                  <?php endif; ?>
                  <span>🛏 <?= $a['nb_chambres'] ?> ch.</span>
                  <?php if ($a['meuble']): ?><span class="badge-meuble">Meublé</span><?php endif; ?>
                </div>
                <div class="card-footer">
                  <strong class="card-prix"><?= number_format($a['prix_mois'], 0, ',', ' ') ?> MAD</strong>
                  <span class="card-prix-unit">/ mois</span>
                  <a href="<?= APP_URL ?>/annonces/show/<?= $a['id'] ?>" class="btn btn-sm btn-primary">Voir</a>
                </div>
              </div>
            </article>
          <?php endforeach; ?>
        </div>

        <!-- Pagination -->
        <?php if ($pagination['pages'] > 1): ?>
          <nav class="pagination">
            <?php
            $qStr = http_build_query(array_filter(array_diff_key($filtres, ['page'=>1])));
            for ($p = 1; $p <= $pagination['pages']; $p++):
            ?>
              <a href="?page=<?= $p ?>&<?= $qStr ?>"
                 class="page-btn <?= $p===$pagination['current']?'active':'' ?>">
                <?= $p ?>
              </a>
            <?php endfor; ?>
          </nav>
        <?php endif; ?>
      <?php endif; ?>
    </div>
  </div>
</div>

3
Vue détail annonce + formulaire de réservation — annonces/show.php
phpapp/views/annonces/show.php
<!-- Variables reçues : $annonce (avec photos[], proprietaire_nom, note_moyenne), $csrfToken -->
<div class="container show-layout">

  <!-- Galerie photos -->
  <div class="show-gallery">
    <?php if (!empty($annonce['photos'])): ?>
      <?php $principale = $annonce['photos'][0]; ?>
      <img id="main-photo"
           src="<?= APP_URL ?>/annonces/photo?nom=<?= urlencode($principale['nom_fichier']) ?>"
           alt="<?= htmlspecialchars($annonce['titre']) ?>" class="gallery-main">
      <div class="gallery-thumbs">
        <?php foreach ($annonce['photos'] as $photo): ?>
          <img src="<?= APP_URL ?>/annonces/photo?nom=<?= urlencode($photo['nom_fichier']) ?>"
               alt="" class="gallery-thumb"
               onclick="document.getElementById('main-photo').src=this.src">
        <?php endforeach; ?>
      </div>
    <?php else: ?>
      <div class="gallery-placeholder">🏠 Aucune photo disponible</div>
    <?php endif; ?>
  </div>

  <div class="show-content">
    <!-- Infos annonce -->
    <div class="show-main">
      <div class="show-badges">
        <span class="badge-type"><?= ucfirst($annonce['type']) ?></span>
        <?php if ($annonce['meuble']): ?><span class="badge-meuble">Meublé</span><?php endif; ?>
      </div>
      <h1 class="show-title"><?= htmlspecialchars($annonce['titre']) ?></h1>
      <p class="show-ville">📍 <?= htmlspecialchars($annonce['adresse'] ?: $annonce['ville']) ?></p>

      <div class="show-specs">
        <div class="spec"><span>📐</span><strong><?= $annonce['surface'] ?? '—' ?> m²</strong></div>
        <div class="spec"><span>🛏</span><strong><?= $annonce['nb_chambres'] ?> chambre(s)</strong></div>
        <?php if ($annonce['nb_avis'] > 0): ?>
          <div class="spec"><span>⭐</span><strong><?= number_format($annonce['note_moyenne'],1) ?>/5 (<?= $annonce['nb_avis'] ?> avis)</strong></div>
        <?php endif; ?>
      </div>

      <h2>Description</h2>
      <div class="show-description">
        <?= nl2br(htmlspecialchars($annonce['description'])) ?>
      </div>

      <div class="show-proprietaire">
        <h3>Proposé par</h3>
        <p>👤 <?= htmlspecialchars($annonce['proprietaire_nom']) ?></p>
        <?php if (isset($session['user_id'])): ?>
          <p>📞 <?= htmlspecialchars($annonce['prop_tel'] ?? 'Non communiqué') ?></p>
        <?php else: ?>
          <p><a href="<?= APP_URL ?>/auth/login">Connectez-vous pour voir le téléphone</a></p>
        <?php endif; ?>
      </div>
    </div>

    <!-- Sidebar : prix + formulaire réservation -->
    <aside class="show-sidebar">
      <div class="price-card">
        <div class="price-main">
          <strong><?= number_format($annonce['prix_mois'], 0, ',', ' ') ?> MAD</strong>
          <span>/ mois</span>
        </div>

        <?php if (isset($session['user_id']) && $session['user_role'] === 'locataire'): ?>
          <h3>Demander une réservation</h3>
          <form method="POST" action="<?= APP_URL ?>/reservations/store" class="booking-form">
            <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken) ?>">
            <input type="hidden" name="annonce_id" value="<?= $annonce['id'] ?>">

            <label>Date d'entrée</label>
            <input type="date" name="date_debut" required
                   min="<?= date('Y-m-d', strtotime('+1 day')) ?>">

            <label>Date de sortie</label>
            <input type="date" name="date_fin" required
                   min="<?= date('Y-m-d', strtotime('+2 days')) ?>">

            <label>Message au propriétaire (optionnel)</label>
            <textarea name="message" rows="3"
                      placeholder="Présentez-vous brièvement..."></textarea>

            <button type="submit" class="btn btn-primary btn-full">
              Envoyer la demande
            </button>
          </form>
        <?php elseif (!isset($session['user_id'])): ?>
          <a href="<?= APP_URL ?>/auth/login" class="btn btn-primary btn-full">
            Connectez-vous pour réserver
          </a>
        <?php elseif ($session['user_role'] === 'proprietaire'): ?>
          <p class="info-note">Vous êtes propriétaire — vous ne pouvez pas réserver.</p>
        <?php endif; ?>
      </div>
    </aside>
  </div>
</div>

4
Formulaire create/edit unifié + vues auth + dashboard admin
phpapp/views/annonces/form.php — create & edit unifiés
<?php
// $annonce === null → création │ $annonce = array → édition
$edit   = $annonce !== null;
$action = $edit
    ? APP_URL . '/annonces/update/' . $annonce['id']
    : APP_URL . '/annonces/store';
$val = fn($k) => htmlspecialchars($annonce[$k] ?? $_POST[$k] ?? '');
?>

<div class="container form-container">
  <h1><?= $edit ? 'Modifier l\'annonce' : 'Publier une annonce' ?></h1>
  <p class="form-intro">
    <?= $edit
      ? 'Modifiez les informations de votre bien. L\'annonce sera re-soumise à validation.'
      : 'Remplissez le formulaire. Votre annonce sera visible après validation par notre équipe (24h max).' ?>
  </p>

  <?php if (!empty($erreurs)): ?>
    <div class="alert alert-error">
      <ul><?php foreach ($erreurs as $e): ?><li><?= htmlspecialchars($e) ?></li><?php endforeach; ?></ul>
    </div>
  <?php endif; ?>

  <form method="POST" action="<?= $action ?>" enctype="multipart/form-data" class="form-card">
    <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken) ?>">

    <div class="form-grid">
      <div class="form-group span-2">
        <label for="titre">Titre de l'annonce *</label>
        <input type="text" id="titre" name="titre" required
               placeholder="Ex: Appartement lumineux Maarif — 3 pièces"
               value="<?= $val('titre') ?>">
      </div>

      <div class="form-group">
        <label for="type">Type de bien *</label>
        <select id="type" name="type" required>
          <option value="">— Choisir —</option>
          <?php foreach (['appartement','villa','studio','riad','bureau'] as $t): ?>
            <option value="<?= $t ?>" <?= ($val('type')===$t)?'selected':'' ?>>
              <?= ucfirst($t) ?>
            </option>
          <?php endforeach; ?>
        </select>
      </div>

      <div class="form-group">
        <label for="ville">Ville *</label>
        <input type="text" id="ville" name="ville" required
               placeholder="Ex: Casablanca" value="<?= $val('ville') ?>" list="villes-list">
        <datalist id="villes-list">
          <?php foreach (['Casablanca','Rabat','Marrakech','Fès','Agadir','Tanger','Meknès','Oujda','Tétouan'] as $v): ?>
            <option value="<?= $v ?>">
          <?php endforeach; ?>
        </datalist>
      </div>

      <div class="form-group span-2">
        <label for="adresse">Adresse (optionnelle)</label>
        <input type="text" id="adresse" name="adresse"
               placeholder="Ex: 12 Rue Ibn Khaldoun, Guéliz"
               value="<?= $val('adresse') ?>">
      </div>

      <div class="form-group">
        <label for="prix_mois">Prix mensuel (MAD) *</label>
        <input type="number" id="prix_mois" name="prix_mois" required
               min="100" step="50" placeholder="Ex: 5500"
               value="<?= $val('prix_mois') ?>">
      </div>

      <div class="form-group">
        <label for="surface">Surface (m²)</label>
        <input type="number" id="surface" name="surface"
               min="10" placeholder="Ex: 85" value="<?= $val('surface') ?>">
      </div>

      <div class="form-group">
        <label for="nb_chambres">Nombre de chambres *</label>
        <input type="number" id="nb_chambres" name="nb_chambres"
               min="0" max="20" required value="<?= $val('nb_chambres') ?: 1 ?>">
      </div>

      <div class="form-group">
        <label class="checkbox-label">
          <input type="checkbox" name="meuble" value="1"
                 <?= ($annonce['meuble'] ?? $_POST['meuble'] ?? false) ? 'checked' : '' ?>>
          Bien meublé
        </label>
      </div>

      <div class="form-group span-2">
        <label for="description">Description *</label>
        <textarea id="description" name="description" rows="6" required
                  placeholder="Décrivez votre bien en détail : équipements, environnement, transports..."><?= htmlspecialchars($annonce['description'] ?? $_POST['description'] ?? '') ?></textarea>
      </div>

      <?php if (!$edit): ?>
      <div class="form-group span-2">
        <label for="photos">Photos (max 5, JPG/PNG/WEBP, 2 Mo max chacune)</label>
        <input type="file" id="photos" name="photos[]"
               multiple accept=".jpg,.jpeg,.png,.webp">
        <p class="form-hint">La première photo sera utilisée comme photo principale.</p>
      </div>
      <?php endif; ?>
    </div>

    <div class="form-actions">
      <button type="submit" class="btn btn-primary">
        <?= $edit ? '💾 Enregistrer les modifications' : '🚀 Publier l\'annonce' ?>
      </button>
      <a href="<?= APP_URL ?>/profile/mes-annonces" class="btn btn-ghost">Annuler</a>
    </div>
  </form>
</div>
phpapp/views/auth/login.php
<div class="auth-page">
  <div class="auth-card">
    <div class="auth-brand">
      <span class="brand-dari">Dari</span><span class="brand-go">GO</span>
    </div>
    <h1>Connexion</h1>

    <form method="POST" action="<?= APP_URL ?>/auth/loginPost">
      <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken) ?>">

      <div class="form-group">
        <label for="email">Adresse email</label>
        <input type="email" id="email" name="email" required
               autocomplete="email" placeholder="votre@email.ma">
      </div>

      <div class="form-group">
        <label for="mot_de_passe">Mot de passe</label>
        <input type="password" id="mot_de_passe" name="mot_de_passe"
               required autocomplete="current-password">
      </div>

      <button type="submit" class="btn btn-primary btn-full">Se connecter</button>
    </form>

    <p class="auth-footer">
      Pas encore de compte ?
      <a href="<?= APP_URL ?>/auth/register">S'inscrire gratuitement</a>
    </p>
  </div>
</div>
phpapp/views/auth/register.php
<?php
// Récupérer les erreurs/données si renvoyées depuis registerPost
$formErrors = $_SESSION['form_errors'] ?? [];
$formData   = $_SESSION['form_data']   ?? [];
unset($_SESSION['form_errors'], $_SESSION['form_data']);
?>
<div class="auth-page">
  <div class="auth-card auth-card-wide">
    <div class="auth-brand">
      <span class="brand-dari">Dari</span><span class="brand-go">GO</span>
    </div>
    <h1>Créer un compte</h1>

    <?php if (!empty($formErrors)): ?>
      <div class="alert alert-error">
        <ul><?php foreach ($formErrors as $e): ?><li><?= htmlspecialchars($e) ?></li><?php endforeach; ?></ul>
      </div>
    <?php endif; ?>

    <form method="POST" action="<?= APP_URL ?>/auth/registerPost">
      <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken) ?>">

      <div class="form-grid">
        <div class="form-group">
          <label for="nom">Nom *</label>
          <input type="text" id="nom" name="nom" required
                 value="<?= htmlspecialchars($formData['nom'] ?? '') ?>">
        </div>
        <div class="form-group">
          <label for="prenom">Prénom *</label>
          <input type="text" id="prenom" name="prenom" required
                 value="<?= htmlspecialchars($formData['prenom'] ?? '') ?>">
        </div>
        <div class="form-group span-2">
          <label for="email">Email *</label>
          <input type="email" id="email" name="email" required
                 value="<?= htmlspecialchars($formData['email'] ?? '') ?>">
        </div>
        <div class="form-group">
          <label for="telephone">Téléphone</label>
          <input type="tel" id="telephone" name="telephone"
                 placeholder="06xx-xxxxxx"
                 value="<?= htmlspecialchars($formData['telephone'] ?? '') ?>">
        </div>
        <div class="form-group">
          <label for="ville">Ville</label>
          <input type="text" id="ville" name="ville"
                 value="<?= htmlspecialchars($formData['ville'] ?? '') ?>">
        </div>
        <div class="form-group">
          <label for="mot_de_passe">Mot de passe *</label>
          <input type="password" id="mot_de_passe" name="mot_de_passe"
                 required minlength="8" autocomplete="new-password">
        </div>
        <div class="form-group">
          <label for="mdp_confirm">Confirmer le mot de passe *</label>
          <input type="password" id="mdp_confirm" name="mdp_confirm"
                 required autocomplete="new-password">
        </div>
        <div class="form-group span-2">
          <label>Je souhaite m'inscrire en tant que *</label>
          <div class="role-choice">
            <label class="role-option <?= ($formData['role']??'')==='locataire'?'selected':'' ?>">
              <input type="radio" name="role" value="locataire" required
                     <?= ($formData['role']??'locataire')==='locataire'?'checked':'' ?>>
              🏡 Locataire — Je cherche un bien à louer
            </label>
            <label class="role-option <?= ($formData['role']??'')==='proprietaire'?'selected':'' ?>">
              <input type="radio" name="role" value="proprietaire"
                     <?= ($formData['role']??'')==='proprietaire'?'checked':'' ?>>
              🏗 Propriétaire — Je mets mon bien en location
            </label>
          </div>
        </div>
      </div>

      <button type="submit" class="btn btn-primary btn-full">Créer mon compte</button>
    </form>

    <p class="auth-footer">
      Déjà inscrit ? <a href="<?= APP_URL ?>/auth/login">Se connecter</a>
    </p>
  </div>
</div>
phpapp/views/admin/dashboard.php
<div class="container">
  <div class="admin-header">
    <h1>🛡 Dashboard Administration</h1>
    <a href="<?= APP_URL ?>/admin/export-csv" class="btn btn-outline">
      📥 Exporter les réservations CSV
    </a>
  </div>

  <!-- KPIs -->
  <div class="stats-grid">
    <div class="stat-card">
      <div class="stat-value"><?= $stats['annonces_actives'] ?></div>
      <div class="stat-label">Annonces actives</div>
    </div>
    <div class="stat-card stat-warning">
      <div class="stat-value"><?= $stats['annonces_attente'] ?></div>
      <div class="stat-label">En attente de validation</div>
    </div>
    <div class="stat-card">
      <div class="stat-value"><?= $stats['reservations_conf'] ?></div>
      <div class="stat-label">Réservations confirmées</div>
    </div>
    <div class="stat-card stat-success">
      <div class="stat-value"><?= number_format($stats['ca_total'], 0, ',', ' ') ?> MAD</div>
      <div class="stat-label">CA total généré</div>
    </div>
    <div class="stat-card">
      <div class="stat-value"><?= $stats['nb_proprietaires'] ?></div>
      <div class="stat-label">Propriétaires</div>
    </div>
    <div class="stat-card">
      <div class="stat-value"><?= $stats['nb_locataires'] ?></div>
      <div class="stat-label">Locataires</div>
    </div>
  </div>

  <!-- Annonces en attente de validation -->
  <section class="admin-section">
    <h2>Annonces en attente de validation (<?= count($annoncesPendantes) ?>)</h2>
    <?php if (empty($annoncesPendantes)): ?>
      <p class="empty-state">✓ Aucune annonce en attente — tout est à jour.</p>
    <?php else: ?>
      <div class="admin-table-wrap">
        <table class="admin-table">
          <thead>
            <tr><th>#</th><th>Titre</th><th>Type</th><th>Ville</th><th>Prix/mois</th><th>Propriétaire</th><th>Actions</th></tr>
          </thead>
          <tbody>
            <?php foreach ($annoncesPendantes as $a): ?>
              <tr>
                <td>#<?= $a['id'] ?></td>
                <td>
                  <a href="<?= APP_URL ?>/annonces/show/<?= $a['id'] ?>" target="_blank">
                    <?= htmlspecialchars(mb_strimwidth($a['titre'],0,50,'…')) ?>
                  </a>
                </td>
                <td><?= ucfirst($a['type']) ?></td>
                <td><?= htmlspecialchars($a['ville']) ?></td>
                <td><?= number_format($a['prix_mois'],0,',',' ') ?> MAD</td>
                <td><?= htmlspecialchars($a['proprietaire_nom']) ?></td>
                <td class="admin-actions">
                  <form method="POST" action="<?= APP_URL ?>/admin/valider/<?= $a['id'] ?>" style="display:inline">
                    <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken) ?>">
                    <button class="btn btn-sm btn-success">✓ Valider</button>
                  </form>
                  <form method="POST" action="<?= APP_URL ?>/admin/rejeter/<?= $a['id'] ?>" style="display:inline">
                    <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken) ?>">
                    <button class="btn btn-sm btn-danger" onclick="return confirm('Rejeter cette annonce ?')">✗ Rejeter</button>
                  </form>
                </td>
              </tr>
            <?php endforeach; ?>
          </tbody>
        </table>
      </div>
    <?php endif; ?>
  </section>
</div>

5
CSS complet DariGO — public/assets/css/darigo.css
csspublic/assets/css/darigo.css — palette Terracotta & Olive
/* ═══════════════════════════════════════════════════════════
   DariGO — CSS complet
   Palette : Terracotta (#C0522B) + Olive (#4A7C59) + Sable (#F5F0EB)
   ═══════════════════════════════════════════════════════════ */

/* ── Variables globales ──────────────────────────────────── */
:root {
  --primary:      #C0522B;   /* Terracotta marocain */
  --primary-dark: #9E3D1A;
  --secondary:    #4A7C59;   /* Vert olive */
  --accent:       #E8A87C;   /* Sable doré */
  --bg:           #F5F0EB;   /* Fond principal — parchemin */
  --surface:      #FFFFFF;
  --text:         #1A1A1A;
  --text-light:   #6B6B6B;
  --border:       #DDD6CE;
  --success:      #2E7D52;
  --warning:      #B7791F;
  --danger:       #C0392B;
  --radius:       8px;
  --shadow:       0 2px 8px rgba(0,0,0,.08);
  --shadow-hover: 0 6px 20px rgba(0,0,0,.14);
  --font:         'Inter', system-ui, -apple-system, sans-serif;
}

/* ── Reset & base ─────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body { font-family: var(--font); background: var(--bg); color: var(--text);
       line-height: 1.6; font-size: 16px; }
a { color: var(--primary); text-decoration: none; }
a:hover { color: var(--primary-dark); text-decoration: underline; }
img { max-width: 100%; height: auto; display: block; }

/* ── Layout ───────────────────────────────────────────────── */
.container { max-width: 1200px; margin: 0 auto; padding: 0 1.5rem; }
.main-content { min-height: calc(100vh - 70px - 80px); }

/* ── Navbar ───────────────────────────────────────────────── */
.navbar { background: var(--surface); border-bottom: 2px solid var(--primary);
          position: sticky; top: 0; z-index: 100;
          box-shadow: 0 2px 8px rgba(0,0,0,.1); }
.nav-inner { max-width: 1200px; margin: 0 auto; padding: 0 1.5rem;
             display: flex; align-items: center; gap: 2rem; height: 65px; }
.nav-brand { font-size: 1.6rem; font-weight: 700; text-decoration: none; }
.brand-dari { color: var(--primary); }
.brand-go   { color: var(--secondary); }
.nav-links  { display: flex; list-style: none; gap: 1.5rem; margin: 0; }
.nav-links a { color: var(--text); font-weight: 500; transition: color .2s; }
.nav-links a:hover { color: var(--primary); text-decoration: none; }
.nav-user { margin-left: auto; display: flex; align-items: center; gap: .75rem; }
.nav-username { font-weight: 600; font-size: .9rem; }
.nav-role { font-size: .75rem; padding: .2rem .6rem; border-radius: 12px; font-weight: 600; }
.role-admin        { background: #FEE2E2; color: #991B1B; }
.role-proprietaire { background: #FEF3C7; color: #92400E; }
.role-locataire    { background: #D1FAE5; color: #065F46; }

/* ── Buttons ──────────────────────────────────────────────── */
.btn { display: inline-flex; align-items: center; gap: .4rem;
       padding: .5rem 1.2rem; border-radius: var(--radius); font-size: .9rem;
       font-weight: 500; cursor: pointer; border: 2px solid transparent;
       transition: all .2s; text-decoration: none; }
.btn-primary { background: var(--primary); color: #fff; border-color: var(--primary); }
.btn-primary:hover { background: var(--primary-dark); border-color: var(--primary-dark); color: #fff; text-decoration: none; }
.btn-secondary { background: var(--secondary); color: #fff; border-color: var(--secondary); }
.btn-success { background: var(--success); color: #fff; }
.btn-danger  { background: var(--danger);  color: #fff; }
.btn-outline { background: transparent; border-color: var(--primary); color: var(--primary); }
.btn-outline:hover { background: var(--primary); color: #fff; text-decoration: none; }
.btn-ghost  { background: transparent; color: var(--text-light); border-color: var(--border); }
.btn-ghost:hover { background: var(--bg); }
.btn-full   { width: 100%; justify-content: center; }
.btn-sm     { padding: .3rem .75rem; font-size: .82rem; }
.btn-nav    { padding: .4rem 1rem; font-size: .88rem; border-radius: 6px;
              color: var(--text); border: 1px solid var(--border); }
.btn-nav-primary { background: var(--primary); color: #fff; border-color: var(--primary); }
.btn-nav-logout  { padding: .4rem 1rem; border-radius: 6px; font-size: .88rem;
                   background: transparent; color: var(--danger);
                   border: 1px solid var(--danger); cursor: pointer; }
.btn-nav-logout:hover { background: var(--danger); color: #fff; text-decoration: none; }

/* ── Hero section ─────────────────────────────────────────── */
.hero { background: linear-gradient(135deg, var(--primary) 0%, #8B3A1E 100%);
        color: #fff; padding: 5rem 1.5rem 4rem; text-align: center; }
.hero-inner { max-width: 700px; margin: 0 auto; }
.hero h1 { font-size: 2.4rem; margin-bottom: .75rem; }
.hero p  { font-size: 1.1rem; opacity: .85; margin-bottom: 2rem; }
.hero-search { display: flex; gap: .5rem; max-width: 550px; margin: 0 auto; }
.hero-search input  { flex: 1; padding: .85rem 1.2rem; border: none; border-radius: var(--radius);
                       font-size: 1rem; outline: none; }
.hero-search button { padding: .85rem 1.5rem; background: var(--secondary); color: #fff;
                       border: none; border-radius: var(--radius); font-size: 1rem;
                       font-weight: 600; cursor: pointer; }
.hero-search button:hover { background: #3A6649; }

/* ── Listing layout (sidebar + grille) ───────────────────── */
.listing-layout { display: grid; grid-template-columns: 260px 1fr; gap: 2rem;
                  padding: 2.5rem 0; }
.filters-sidebar { background: var(--surface); border-radius: var(--radius);
                   padding: 1.5rem; height: fit-content;
                   box-shadow: var(--shadow); position: sticky; top: 80px; }
.filters-sidebar h3 { font-size: 1.1rem; margin-bottom: 1.2rem; color: var(--primary); }
.filter-group { margin-bottom: 1.2rem; }
.filter-group label { display: block; font-size: .88rem; font-weight: 600;
                       color: var(--text-light); margin-bottom: .4rem; }
.filter-group select,
.filter-group input { width: 100%; padding: .5rem .75rem; border: 1px solid var(--border);
                      border-radius: 6px; font-size: .9rem; background: var(--bg); }
.price-range { display: flex; align-items: center; gap: .5rem; }
.price-range input { width: 0; flex: 1; }
.price-range span { color: var(--text-light); }

/* ── Grille des annonces ──────────────────────────────────── */
.listings-header { display: flex; justify-content: space-between; align-items: center;
                   margin-bottom: 1.5rem; }
.annonces-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 1.5rem; }

/* ── Carte annonce ────────────────────────────────────────── */
.annonce-card { background: var(--surface); border-radius: var(--radius);
                box-shadow: var(--shadow); overflow: hidden;
                transition: transform .2s, box-shadow .2s; }
.annonce-card:hover { transform: translateY(-4px); box-shadow: var(--shadow-hover); }
.card-img-link { display: block; position: relative; height: 200px; overflow: hidden;
                 background: #EEE; }
.card-img-link img { width: 100%; height: 100%; object-fit: cover; transition: transform .3s; }
.annonce-card:hover .card-img-link img { transform: scale(1.05); }
.card-no-photo { height: 200px; display: flex; align-items: center; justify-content: center;
                 font-size: 3rem; background: var(--bg); }
.card-badge-type { position: absolute; top: .75rem; left: .75rem;
                   background: var(--primary); color: #fff;
                   padding: .2rem .7rem; border-radius: 20px; font-size: .78rem;
                   font-weight: 600; text-transform: uppercase; }
.card-body { padding: 1.2rem; }
.card-title { font-size: .95rem; font-weight: 600; margin-bottom: .4rem;
              line-height: 1.4; }
.card-title a { color: var(--text); }
.card-title a:hover { color: var(--primary); text-decoration: none; }
.card-ville { font-size: .85rem; color: var(--text-light); margin-bottom: .6rem; }
.card-meta  { display: flex; flex-wrap: wrap; gap: .5rem; font-size: .82rem;
              color: var(--text-light); margin-bottom: .75rem; }
.badge-meuble { background: var(--accent); color: #7A3A1A; padding: .15rem .5rem;
                border-radius: 10px; font-size: .78rem; font-weight: 600; }
.card-footer { display: flex; align-items: center; justify-content: space-between;
               padding-top: .75rem; border-top: 1px solid var(--border); }
.card-prix      { font-size: 1.1rem; color: var(--primary); }
.card-prix-unit { font-size: .82rem; color: var(--text-light); margin-left: .2rem; }

/* ── Détail annonce ───────────────────────────────────────── */
.show-layout { padding: 2.5rem 0; }
.show-gallery { margin-bottom: 2rem; }
.gallery-main { width: 100%; max-height: 480px; object-fit: cover;
                border-radius: var(--radius); }
.gallery-thumbs { display: flex; gap: .5rem; margin-top: .5rem; }
.gallery-thumb  { width: 80px; height: 60px; object-fit: cover;
                  border-radius: 6px; cursor: pointer; opacity: .7;
                  transition: opacity .2s; border: 2px solid transparent; }
.gallery-thumb:hover { opacity: 1; border-color: var(--primary); }
.gallery-placeholder { background: var(--bg); border-radius: var(--radius);
                       padding: 4rem; text-align: center; font-size: 2rem; }
.show-content   { display: grid; grid-template-columns: 1fr 340px; gap: 2.5rem; }
.show-badges    { display: flex; gap: .5rem; margin-bottom: .75rem; }
.badge-type     { background: var(--primary); color: #fff; padding: .25rem .9rem;
                  border-radius: 20px; font-size: .82rem; font-weight: 600; }
.show-title     { font-size: 1.8rem; font-weight: 700; margin-bottom: .5rem; }
.show-ville     { color: var(--text-light); margin-bottom: 1.5rem; font-size: 1rem; }
.show-specs     { display: flex; gap: 1.5rem; margin-bottom: 2rem; flex-wrap: wrap; }
.spec           { display: flex; flex-direction: column; align-items: center;
                  background: var(--bg); padding: .75rem 1.2rem;
                  border-radius: var(--radius); min-width: 90px; }
.show-description { color: var(--text); line-height: 1.8; margin-bottom: 2rem; }
.show-proprietaire { background: var(--bg); padding: 1.2rem; border-radius: var(--radius); }
.show-proprietaire h3 { margin-bottom: .5rem; font-size: 1rem; color: var(--text-light); }

/* ── Price card + booking form ────────────────────────────── */
.show-sidebar { }
.price-card { background: var(--surface); border: 2px solid var(--primary);
              border-radius: var(--radius); padding: 1.5rem;
              box-shadow: var(--shadow); position: sticky; top: 85px; }
.price-main { display: flex; align-items: baseline; gap: .3rem; margin-bottom: 1.5rem; }
.price-main strong { font-size: 2rem; color: var(--primary); }
.price-main span   { color: var(--text-light); }
.booking-form label { display: block; font-size: .85rem; font-weight: 600;
                       margin-bottom: .3rem; margin-top: .9rem; color: var(--text-light); }
.booking-form input[type="date"],
.booking-form textarea { width: 100%; padding: .55rem .75rem; border: 1px solid var(--border);
                          border-radius: 6px; font-size: .9rem; }
.booking-form input[type="date"]:focus,
.booking-form textarea:focus { outline: none; border-color: var(--primary);
                                box-shadow: 0 0 0 3px rgba(192,82,43,.15); }
.booking-form button { margin-top: 1.2rem; }
.info-note { color: var(--text-light); font-size: .9rem; text-align: center; margin-top: 1rem; }

/* ── Flash messages ───────────────────────────────────────── */
.flash { display: flex; justify-content: space-between; align-items: center;
         padding: 1rem 1.5rem; margin: .75rem 1.5rem;
         border-radius: var(--radius); font-weight: 500; }
.flash-success { background: #D1FAE5; border-left: 4px solid var(--success); color: #065F46; }
.flash-error   { background: #FEE2E2; border-left: 4px solid var(--danger);  color: #991B1B; }
.flash-warning { background: #FEF3C7; border-left: 4px solid var(--warning); color: #92400E; }
.flash button  { background: none; border: none; font-size: 1.1rem; cursor: pointer;
                 color: currentColor; opacity: .6; padding: 0 .25rem; }
.flash button:hover { opacity: 1; }

/* ── Alerts ───────────────────────────────────────────────── */
.alert { padding: 1rem 1.2rem; border-radius: var(--radius); margin-bottom: 1.5rem; }
.alert-error { background: #FEE2E2; border-left: 4px solid var(--danger); color: #991B1B; }
.alert ul { margin-left: 1.2rem; }

/* ── Forms généraux ───────────────────────────────────────── */
.form-container { padding: 2.5rem 0; max-width: 860px; margin: 0 auto; }
.form-container h1 { margin-bottom: .5rem; font-size: 1.7rem; }
.form-intro { color: var(--text-light); margin-bottom: 2rem; }
.form-card  { background: var(--surface); border-radius: var(--radius);
              padding: 2rem; box-shadow: var(--shadow); }
.form-grid  { display: grid; grid-template-columns: 1fr 1fr; gap: 1.2rem; }
.form-group { display: flex; flex-direction: column; }
.span-2 { grid-column: span 2; }
.form-group label { font-size: .88rem; font-weight: 600; color: var(--text-light);
                     margin-bottom: .35rem; }
.form-group input:not([type="checkbox"]):not([type="radio"]),
.form-group select,
.form-group textarea { padding: .6rem .85rem; border: 1px solid var(--border);
                        border-radius: 6px; font-size: .95rem;
                        transition: border-color .2s, box-shadow .2s; }
.form-group input:focus, .form-group select:focus, .form-group textarea:focus {
  outline: none; border-color: var(--primary);
  box-shadow: 0 0 0 3px rgba(192,82,43,.12); }
.form-hint { font-size: .82rem; color: var(--text-light); margin-top: .25rem; }
.form-actions { display: flex; gap: .75rem; margin-top: 1.5rem; align-items: center; }
.checkbox-label { display: flex; align-items: center; gap: .5rem;
                  cursor: pointer; font-size: .95rem; margin-top: .5rem; }
.checkbox-label input { width: 18px; height: 18px; accent-color: var(--primary); }

/* ── Auth ─────────────────────────────────────────────────── */
.auth-page { min-height: 100vh; display: flex; align-items: center;
             justify-content: center; padding: 2rem 1rem; }
.auth-card { background: var(--surface); border-radius: 12px;
             padding: 2.5rem; box-shadow: 0 4px 24px rgba(0,0,0,.12);
             width: 100%; max-width: 420px; }
.auth-card-wide { max-width: 640px; }
.auth-brand { text-align: center; font-size: 2rem; font-weight: 800;
              margin-bottom: 1.5rem; }
.auth-card h1 { text-align: center; font-size: 1.4rem; margin-bottom: 1.5rem; }
.auth-footer { text-align: center; margin-top: 1.5rem; font-size: .9rem;
               color: var(--text-light); }
.role-choice  { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem; margin-top: .35rem; }
.role-option  { display: flex; align-items: flex-start; gap: .5rem;
                padding: .9rem; border: 2px solid var(--border);
                border-radius: 8px; cursor: pointer; font-size: .88rem;
                transition: border-color .2s, background .2s; line-height: 1.4; }
.role-option:has(input:checked) { border-color: var(--primary); background: #FFF5F2; }
.role-option input { margin-top: .15rem; accent-color: var(--primary); flex-shrink: 0; }

/* ── Admin ────────────────────────────────────────────────── */
.admin-header { display: flex; justify-content: space-between; align-items: center;
                padding: 2rem 0 1.5rem; }
.admin-header h1 { font-size: 1.6rem; }
.stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.2rem;
              margin-bottom: 2.5rem; }
.stat-card { background: var(--surface); border-radius: var(--radius);
             padding: 1.5rem; text-align: center; box-shadow: var(--shadow);
             border-top: 4px solid var(--primary); }
.stat-card.stat-warning { border-top-color: var(--warning); }
.stat-card.stat-success { border-top-color: var(--success); }
.stat-value { font-size: 2rem; font-weight: 700; color: var(--primary); }
.stat-card.stat-warning .stat-value { color: var(--warning); }
.stat-card.stat-success .stat-value { color: var(--success); }
.stat-label { font-size: .85rem; color: var(--text-light); margin-top: .3rem; }
.admin-section h2 { font-size: 1.2rem; margin-bottom: 1rem; }
.admin-table-wrap { overflow-x: auto; }
.admin-table { width: 100%; border-collapse: collapse; background: var(--surface);
               border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow); }
.admin-table th { background: var(--primary); color: #fff; padding: .7rem 1rem;
                  text-align: left; font-size: .88rem; }
.admin-table td { padding: .65rem 1rem; border-bottom: 1px solid var(--border); font-size: .9rem; }
.admin-table tr:hover { background: #FFF5F2; }
.admin-actions { display: flex; gap: .4rem; flex-wrap: wrap; }

/* ── Réservations ─────────────────────────────────────────── */
.reservations-list { padding: 2.5rem 0; }
.reservation-card { background: var(--surface); border-radius: var(--radius);
                    padding: 1.5rem; box-shadow: var(--shadow); margin-bottom: 1.2rem;
                    display: grid; grid-template-columns: 1fr auto; gap: 1rem;
                    align-items: start; }
.res-info h3 { font-size: 1rem; font-weight: 600; margin-bottom: .3rem; }
.res-dates  { font-size: .88rem; color: var(--text-light); }
.res-statut { display: inline-block; padding: .3rem .85rem; border-radius: 20px;
              font-size: .82rem; font-weight: 700; }
.statut-en_attente { background: #FEF3C7; color: #92400E; }
.statut-confirmee  { background: #D1FAE5; color: #065F46; }
.statut-refusee    { background: #FEE2E2; color: #991B1B; }
.statut-annulee    { background: #F3F4F6; color: #6B7280; }
.statut-terminee   { background: #EDE9FE; color: #5B21B6; }

/* ── Pagination ───────────────────────────────────────────── */
.pagination { display: flex; justify-content: center; gap: .4rem; margin-top: 2.5rem; }
.page-btn { display: inline-flex; align-items: center; justify-content: center;
            width: 38px; height: 38px; border-radius: 6px; font-size: .9rem;
            font-weight: 500; border: 1px solid var(--border); color: var(--text);
            transition: all .2s; }
.page-btn:hover  { background: var(--primary); color: #fff; border-color: var(--primary); text-decoration: none; }
.page-btn.active { background: var(--primary); color: #fff; border-color: var(--primary); }

/* ── Empty state ──────────────────────────────────────────── */
.empty-state { text-align: center; padding: 4rem 2rem; color: var(--text-light);
               background: var(--surface); border-radius: var(--radius);
               box-shadow: var(--shadow); }
.empty-state p { font-size: 1.1rem; margin-bottom: 1.2rem; }

/* ── Footer ───────────────────────────────────────────────── */
.site-footer { background: #1A1A1A; color: #aaa; padding: 2.5rem 1.5rem;
               margin-top: 4rem; text-align: center; }
.footer-inner { max-width: 800px; margin: 0 auto; }
.site-footer .brand-dari { color: var(--accent); }
.site-footer .brand-go   { color: var(--secondary); }
.footer-links { margin: .75rem 0; }
.footer-links a { color: #aaa; margin: 0 .5rem; }
.footer-links a:hover { color: #fff; }
.footer-copy { font-size: .85rem; opacity: .6; }

/* ── Responsive ───────────────────────────────────────────── */
@media (max-width: 1024px) {
  .annonces-grid { grid-template-columns: repeat(2,1fr); }
  .stats-grid    { grid-template-columns: repeat(2,1fr); }
  .show-content  { grid-template-columns: 1fr; }
  .price-card    { position: static; }
}

@media (max-width: 768px) {
  .listing-layout { grid-template-columns: 1fr; }
  .filters-sidebar { position: static; }
  .annonces-grid { grid-template-columns: 1fr; }
  .form-grid     { grid-template-columns: 1fr; }
  .span-2        { grid-column: span 1; }
  .role-choice   { grid-template-columns: 1fr; }
  .nav-links     { display: none; }
  .hero h1       { font-size: 1.7rem; }
  .hero-search   { flex-direction: column; }
  .stats-grid    { grid-template-columns: 1fr 1fr; }
}

6
JavaScript minimal — confirmations & galerie
jspublic/assets/js/darigo.js
// ── Confirmation avant suppression ────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {

  // Tous les formulaires de suppression
  document.querySelectorAll('[data-confirm]').forEach(el => {
    el.addEventListener('click', e => {
      if (!confirm(el.dataset.confirm || 'Confirmer cette action ?')) {
        e.preventDefault();
      }
    });
  });

  // Galerie photos — clic sur miniature
  document.querySelectorAll('.gallery-thumb').forEach(thumb => {
    thumb.addEventListener('click', () => {
      const main = document.getElementById('main-photo');
      if (main) main.src = thumb.src;
      document.querySelectorAll('.gallery-thumb').forEach(t => t.classList.remove('active'));
      thumb.classList.add('active');
    });
  });

  // Validation dates de réservation côté client
  const dateDebut = document.querySelector('[name="date_debut"]');
  const dateFin   = document.querySelector('[name="date_fin"]');
  if (dateDebut && dateFin) {
    dateDebut.addEventListener('change', () => {
      if (dateDebut.value) {
        const minFin = new Date(dateDebut.value);
        minFin.setMonth(minFin.getMonth() + 1);
        dateFin.min = minFin.toISOString().split('T')[0];
        if (dateFin.value && dateFin.value <= dateDebut.value) dateFin.value = '';
      }
    });
  }

  // Calcul dynamique du prix total dans le formulaire de réservation
  function calculerPrix() {
    if (!dateDebut || !dateFin) return;
    const d1 = new Date(dateDebut.value);
    const d2 = new Date(dateFin.value);
    if (!isNaN(d1) && !isNaN(d2) && d2 > d1) {
      const jours  = Math.ceil((d2 - d1) / 86400000);
      const mois   = Math.max(1, Math.round(jours / 30 * 10) / 10);
      const prixEl = document.querySelector('[data-prix-mois]');
      const totalEl= document.getElementById('prix-calcule');
      if (prixEl && totalEl) {
        const prix = parseFloat(prixEl.dataset.prixMois || 0);
        totalEl.textContent = (prix * mois).toLocaleString('fr-MA') + ' MAD (' + mois + ' mois)';
        totalEl.parentElement.style.display = 'block';
      }
    }
  }
  dateDebut && dateDebut.addEventListener('change', calculerPrix);
  dateFin   && dateFin.addEventListener('change',   calculerPrix);

  // Fermeture automatique des flash après 4 secondes
  setTimeout(() => {
    document.querySelectorAll('.flash').forEach(f => f.style.opacity = '0');
    setTimeout(() => document.querySelectorAll('.flash').forEach(f => f.remove()), 500);
  }, 4000);
});