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.
Toute communication web implique trois rôles distincts qui coopèrent à chaque requête.
| Acteur | Définition | Exemples concrets |
|---|---|---|
| Client | Programme qui émet une requête vers un serveur et attend une réponse | Navigateur Chrome/Firefox, application mobile, Postman |
| Serveur | Programme qui écoute sur un port, reçoit les requêtes, les traite et renvoie une réponse | Apache, Nginx, IIS, PHP built-in server |
| Middleware | Couche intermédiaire — achemine, filtre ou transforme les requêtes | Proxy, load balancer, cache Varnish, API Gateway |
| Port | Service | Usage |
|---|---|---|
| 80 | HTTP | Navigation web non chiffrée |
| 443 | HTTPS | Navigation web chiffrée (TLS/SSL) |
| 3306 | MySQL / MariaDB | Base de données relationnelle |
| 8080 | HTTP alternatif | Serveur de développement local (WampServer) |
| 22 | SSH | Connexion sécurisée à un serveur distant |
Voici ce qui se passe quand Karim tape https://boutique-darija.ma dans son navigateur :
| Étape | Qui agit | Ce qui se passe |
|---|---|---|
| 1 — Résolution DNS | Navigateur + serveur DNS | boutique-darija.ma → adresse IP du serveur |
| 2 — Connexion TCP | Navigateur + serveur | Établissement de la connexion réseau sur port 443 |
| 3 — Handshake TLS | Navigateur + serveur | Négociation du chiffrement HTTPS |
| 4 — Requête HTTP | Navigateur | GET / HTTP/1.1 Host: boutique-darija.ma |
| 5 — Traitement | Apache + PHP | Interprétation du script, requête BDD, génération HTML |
| 6 — Réponse HTTP | Serveur | HTTP/1.1 200 OK + headers + corps HTML |
| 7 — Affichage | Navigateur | Parsing HTML, CSS, JS → rendu visuel de la page |
| Architecture | Description | Avantages | Inconvénients | Exemple |
|---|---|---|---|---|
| 1-tiers | Tout sur la même machine — présentation, logique, données | Simple, aucun réseau requis | Non partageable, pas de collaboration | Fichier Excel local |
| 2-tiers | Client lourd + serveur de données | Performances client élevées | Mises à jour complexes | Application Access + SQL Server |
| 3-tiers | Navigateur + serveur applicatif + serveur BDD | Maintenabilité, sécurité | Complexité de déploiement | PHP + Apache + MySQL |
| n-tiers | Couches spécialisées : cache, CDN, microservices | Scalabilité horizontale | Complexité opérationnelle | Architecture cloud AWS/Azure |
| Tiers | Rôle | Technologies | Responsabilité |
|---|---|---|---|
| 1 — Présentation | Interface utilisateur | Navigateur, HTML5, CSS3, JavaScript | Afficher les données, collecter les saisies |
| 2 — Logique métier | Traitement des règles | PHP, Apache/Nginx, sessions | Valider, calculer, orchestrer les données |
| 3 — Données | Stockage et récupération | MySQL, MariaDB, PDO | Persister, interroger, sécuriser les données |
| Méthode | Usage | Idempotente ? | Corps de requête |
|---|---|---|---|
GET | Lire/récupérer une ressource | Oui | Non |
POST | Créer une ressource / envoyer des données | Non | Oui |
PUT | Remplacer complètement une ressource | Oui | Oui |
PATCH | Modifier partiellement une ressource | Non | Oui |
DELETE | Supprimer une ressource | Oui | Non |
| Code | Signification | Quand ? |
|---|---|---|
200 OK | Succès | Requête traitée normalement |
201 Created | Ressource créée | Après un POST réussi |
301 Moved | Redirection permanente | URL déplacée définitivement |
302 Found | Redirection temporaire | Après soumission de formulaire (PRG) |
400 Bad Request | Requête malformée | Données invalides envoyées |
401 Unauthorized | Non authentifié | Token manquant ou invalide |
403 Forbidden | Accès interdit | Droits insuffisants |
404 Not Found | Ressource introuvable | URL inexistante |
500 Internal Error | Erreur serveur | Bug PHP, exception non gérée |
https://boutique-darija.ma:443/produits/categorie?ville=casablanca&page=2#resultats
| Partie | Valeur | Description |
|---|---|---|
| Protocole | https | Schéma de communication (HTTP chiffré) |
| Hôte | boutique-darija.ma | Nom de domaine résolu en IP par DNS |
| Port | 443 | Port d'écoute du serveur (443 = HTTPS par défaut) |
| Chemin | /produits/categorie | Ressource demandée sur le serveur |
| Query string | ville=casablanca&page=2 | Paramètres GET — accessibles via $_GET |
| Fragment | #resultats | Ancre côté navigateur — jamais envoyé au serveur |
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.
| Année | Version | Événement majeur |
|---|---|---|
| 1994 | PHP/FI 1.0 | Rasmus Lerdorf crée « Personal Home Page Tools » — scripts Perl pour son CV en ligne, réécriture en C |
| 1997 | PHP 3 | Zeev Suraski et Andi Gutmans réécrivent entièrement le moteur — premier vrai langage PHP |
| 2004 | PHP 5 | POO complète : classes, interfaces, héritage, exceptions, PDO — révolution pour les développeurs |
| 2015 | PHP 7.0 | Zend Engine 3 — performances ×2 vs PHP 5, types scalaires, null coalescing ??, return types |
| 2020 | PHP 8.0 | JIT compiler, named arguments, match expression, Union Types, nullsafe operator ?-> |
| 2021 | PHP 8.1 | Enums natifs, Fibers (concurrence), intersection types, readonly properties |
| 2022 | PHP 8.2 | Readonly classes, DNF types, null/false/true comme types autonomes |
| 2023 | PHP 8.3 | Typed class constants, json_validate(), améliorations Randomizer |
| 2024 | PHP 8.4 | Property hooks, asymmetric visibility (public/protected/private get/set séparés) |
| Caractéristique | Description | Conséquence pratique |
|---|---|---|
| Open Source | Code source libre, licence BSD-like | Gratuit, large communauté, documentation complète |
| Interprété | Zend Engine interprète et exécute le code | Développement rapide, rechargement instantané |
| Côté serveur | Le code s'exécute sur le serveur, jamais dans le navigateur | Sécurité : le code source reste invisible pour l'utilisateur |
| Typé dynamiquement | Variables sans déclaration de type obligatoire | Flexibilité — mais risques si non maîtrisé |
| Multiplateforme | Fonctionne sur Linux, Windows, macOS | Développement sur Windows (WAMP), déploiement sur Linux |
| Intégré à HTML | Balises <?php ... ?> dans des fichiers .php | Mélange HTML et logique dans un même fichier |
| +1000 fonctions | Bibliothèque standard très riche | Moins de dépendances externes pour les tâches courantes |
| Typage strict | declare(strict_types=1) force la vérification des types | Code plus robuste et maintenable (PHP 7+) |
<?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 -->
| Étape | Ce que fait Zend Engine |
|---|---|
| 1 — Lexing | Découpe le code source PHP en tokens (mots-clés, opérateurs, chaînes…) |
| 2 — Parsing | Construit l'arbre syntaxique abstrait (AST) à partir des tokens |
| 3 — Compilation | Transforme l'AST en opcodes (instructions bas niveau) |
| 4 — OPcache | Met en cache les opcodes pour éviter de recompiler à chaque requête |
| 5 — Exécution | La 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 |
| Stack | OS | Composants | Usage |
|---|---|---|---|
| LAMP | Linux | Linux + Apache + MySQL + PHP | Serveur de production standard — hébergement web |
| WAMP | Windows | Windows + Apache + MySQL + PHP | Développement local sur Windows — recommandé dans ce cours |
| MAMP | macOS | macOS + Apache + MySQL + PHP | Développement local sur Mac |
| XAMPP | Multi | Cross-platform + Apache + MariaDB + PHP + Perl | Multiplateforme — alternative à WAMP |
| Critère | Serveur statique | Serveur dynamique |
|---|---|---|
| Définition | Retourne les fichiers tels quels | Génère le contenu à la volée selon la requête |
| Fichiers | .html, .css, .js, images | Scripts .php traités côté serveur |
| Base de données | Non — contenu fixe | Oui — données lues/écrites en temps réel |
| Exemples | Site vitrine fixe, documentation | Boutique en ligne, portail entreprise, réseau social |
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.
| Critère | Apache HTTP Server | Nginx |
|---|---|---|
| Modèle d'exécution | Prefork/Worker — un thread par connexion | Événementiel asynchrone — un thread pour N connexions |
| Performances | Bonnes pour usage moyen | Excellentes sous charge élevée (>1000 connexions) |
| Configuration | Par dossier via .htaccess | Centralisée — pas de .htaccess |
| mod_rewrite | Oui — indispensable pour les projets MVC | Équivalent via try_files |
index.php via une règle .htaccess — impossible sans Apache configuré correctement.
| Étape | Action | Vérification |
|---|---|---|
| 1 | Télécharger WampServer 3.x (64 bits) depuis wampserver.com | Fichier .exe téléchargé |
| 2 | Installer les prérequis Visual C++ (vc_redist.x64.exe) | Sans ce prérequis, WampServer ne démarre pas |
| 3 | Exécuter l'installateur en tant qu'Administrateur | Dossier C:\wamp64\ créé |
| 4 | Accepter port 80 pour Apache, 3306 pour MySQL | Paramètres par défaut recommandés |
| 5 | Démarrer WampServer — icône verte dans la barre des tâches | Icône verte = tout fonctionne |
| 6 | Ouvrir http://localhost/ dans le navigateur | Page d'accueil WAMP visible |
| Symptôme | Cause | Solution |
|---|---|---|
| Icône orange — Apache ne démarre pas | Port 80 occupé par Skype, IIS ou antivirus | Changer le port Apache en 8080 dans httpd.conf |
| Icône orange — MySQL ne démarre pas | Port 3306 occupé | Changer le port MySQL en 3307 dans my.ini |
| phpMyAdmin inaccessible | Service MySQL arrêté | Clic icône WAMP → MySQL → Start/Resume Service |
| Erreur 403 Forbidden | Permissions Apache sur le dossier | Vérifier AllowOverride et Require all granted dans httpd.conf |
# ── 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
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
| Extension | Auteur | Rôle |
|---|---|---|
| PHP Intelephense | Ben Mewburn | Autocomplétion PHP avancée, refactoring, navigation |
| PHP Debug | Xdebug | Débogage pas-à-pas avec points d'arrêt |
| PHP DocBlocker | neilbrayfield | Génération automatique des commentaires PHPDoc |
| GitLens | GitKraken | Historique Git intégré dans l'éditeur |
{
"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
}
<?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();
?>
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.
| Forme | Syntaxe | Recommandation |
|---|---|---|
| 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 |
<?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
| Règle | Exemple valide | Exemple invalide | Raison |
|---|---|---|---|
Commence par $ | $nom, $age | nom, age | Le $ est obligatoire |
Lettre ou _ après $ | $nom, $_session | $1nom, $-age | Chiffre ou tiret interdit en 1ère position |
| Sensible à la casse | $Nom ≠ $nom | — | $Nom et $nom sont deux variables différentes |
| Convention camelCase | $nomClient, $prixHT | $NomClient | camelCase recommandé PSR |
<?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
<?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']
| Superglobale | Contenu | Remplie par |
|---|---|---|
$_GET | Paramètres passés dans l'URL (?cle=valeur) | Navigateur — URL de la requête |
$_POST | Données envoyées par formulaire POST | Navigateur — corps de la requête |
$_SERVER | Informations sur le serveur et la requête HTTP | Apache/PHP automatiquement |
$_SESSION | Données de session utilisateur (persistantes entre pages) | Le développeur via session_start() |
$_COOKIE | Cookies envoyés par le navigateur | Navigateur — header Cookie |
$_FILES | Fichiers uploadés via formulaire | Navigateur — upload multipart |
$_REQUEST | Fusion de $_GET + $_POST + $_COOKIE | PHP automatiquement — déconseillé |
<?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();
$_POST, $_GET ou $_COOKIE sans la passer par htmlspecialchars(). Sans cette protection, un attaquant peut injecter du code JavaScript (attaque XSS).
$_GET et $_POST ?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.
| Portée | Définition | Mot-clé | Durée de vie |
|---|---|---|---|
| Locale | Variable créée dans une fonction — invisible en dehors | (aucun) | Détruite à la fin de la fonction |
| Globale | Variable créée hors de toute fonction — inaccessible dans les fonctions | global | Pendant toute l'exécution du script |
| Statique | Variable locale qui conserve sa valeur entre les appels | static | Pendant toute l'exécution du script |
<?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';
| Fonction | Retourne | Usage principal |
|---|---|---|
isset($var) | true si définie ET non null | Vé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) | void | Libérer la mémoire ou supprimer une clé de tableau |
var_dump() | void | Afficher type + valeur — débogage uniquement |
print_r() | void|string | Afficher tableau/objet lisiblement — débogage |
gettype() | Nom du type | Obtenir le type sous forme de chaîne |
<?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>';
| Opérateur | Équivalent | Exemple | Ré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âche | 1 == '1' | true — coercition de type |
=== | Égalité stricte | 1 === '1' | false — compare type ET valeur |
== 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.
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.
| Critère | define() | const |
|---|---|---|
| Position dans le code | N'importe où (y compris dans if/while) | Uniquement au niveau supérieur (pas dans if/while) |
| Dans les classes | Non | Oui — constante de classe |
| Expressions dynamiques | Oui — valeur calculée au runtime | Non — valeur littérale uniquement |
| Recommandation PSR | Config runtime, plugins | Préféré pour les constantes de classe |
<?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
<?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)
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().
<?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']
<?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 !
}
| Critère | serialize() | json_encode() |
|---|---|---|
| Format de sortie | Format binaire PHP propriétaire | JSON — standard universel |
| Portabilité | PHP uniquement | Tout langage (JS, Python, Java…) |
| Objets avec méthodes | Oui — __sleep() / __wakeup() | Non — méthodes perdues |
| Sécurité | unserialize() peut être dangereux | json_decode() plus sûr |
| Usage recommandé | Cache interne PHP, sessions complexes | APIs REST, stockage BDD, échange de données |
<?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'
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.
| Instruction | Type | Retourne | Usage |
|---|---|---|---|
echo | Instruction | void | Affichage standard — le plus courant |
printf() | Fonction | void | Affichage formaté avec spécificateurs %s %d %f |
sprintf() | Fonction | string | Formatage sans affichage — retourne la chaîne |
var_dump() | Fonction | void | Débogage : type + valeur — ne pas garder en prod |
var_export() | Fonction | void|string | Représentation PHP valide — peut être eval()ée |
<?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'
| Instruction | Fichier absent | Double inclusion | Utilisation recommandée |
|---|---|---|---|
include | Warning — script continue | Inclus à nouveau | Vues optionnelles, templates partiels |
require | Fatal Error — script arrêté | Inclus à nouveau | Fichiers nécessaires sans classes |
include_once | Warning — script continue | Ignorée | Rarement utilisé |
require_once | Fatal Error — script arrêté | Ignorée | TOUJOURS pour config, classes, helpers |
<?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';
}
<?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é.';
}
| Environnement | Configuration recommandée |
|---|---|
| Développement | error_reporting(E_ALL); ini_set('display_errors', '1'); |
| Production | error_reporting(E_ALL); ini_set('display_errors', '0'); ini_set('log_errors', '1'); |
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.
| Opérateur | Nom | Exemple | Résultat | Remarque |
|---|---|---|---|---|
== | Égalité lâche | 1 == '1' | true | Coercition de type — déconseillé |
=== | Égalité stricte | 1 === '1' | false | Compare valeur ET type — recommandé |
<=> | Spaceship PHP 7 | 5 <=> 10 | -1 | Retourne -1, 0 ou 1 — utile pour usort() |
&& | ET logique | true && false | false | Court-circuit : arrête si premier est false |
|| | OU logique | true || false | true | Court-circuit : arrête si premier est true |
<?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; ?>
| Critère | switch | match |
|---|---|---|
| Comparaison | == (lâche) | === (stricte — type + valeur) |
| Valeur de retour | Non — instructions uniquement | Oui — expression retournant une valeur |
| Fall-through | Oui — sans break | Non — un seul bras exécuté |
| Default manquant | Rien | Lève UnhandledMatchError |
<?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
switch et match en PHP 8 ?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.
| Boucle | Évalue la condition | Cas d'usage | Risque principal |
|---|---|---|---|
while | Avant chaque itération | Nombre d'itérations inconnu — lecture fichier, curseur BDD | Boucle infinie si condition jamais false |
do-while | Après chaque itération | Corps exécuté au moins une fois — génération token unique | Rarement utilisé — cas spécifiques |
for | Avant chaque itération | Compteur connu — pagination, tables, indices | Erreur hors limite avec tableaux |
foreach | Itération automatique | Tableaux et objets Iterable — cas le plus courant | Référence &$val non détruite après la boucle |
<?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
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.
| Attribut | Rôle | Valeurs possibles |
|---|---|---|
action | URL du script PHP qui traite les données | URL relative, absolue, ou "" (même page) |
method | Comment les données sont transmises | get (URL) ou post (corps de requête) |
enctype | Encodage — requis pour l'upload de fichiers | multipart/form-data si <input type="file"> |
name (sur les champs) | Clé de récupération en PHP — indispensable | Devient $_POST['name'] ou $_GET['name'] |
| type | Rendu | Usage typique | Valeur en PHP |
|---|---|---|---|
text | Champ texte libre | Nom, prénom, adresse | Chaîne de caractères |
email | Champ texte + validation format | Adresse email | Chaîne avec @ et . |
password | Texte masqué | Mot de passe | Chaîne masquée |
number | Champ numérique | Âge, quantité, prix | Entier ou décimal |
select | Liste déroulante | Ville, catégorie | Valeur de l'option sélectionnée |
radio | Bouton rond — un seul choix | Civilité (M./Mme) | Valeur du bouton coché |
checkbox | Case à cocher | Accepter CGU | 'on' si coché, absent sinon |
hidden | Champ invisible | Transmettre un ID | Chaîne ou entier |
submit | Bouton d'envoi | Valider le formulaire | Valeur du bouton (si nommé) |
<?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>
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.
| Critère | GET | POST |
|---|---|---|
| Données dans | L'URL (?cle=valeur) | Le corps de la requête HTTP |
| Visibilité | Visible dans la barre d'adresse | Invisible dans l'URL |
| Taille limite | ~2000 caractères (selon navigateur) | Pratiquement illimitée |
| Mise en cache | Oui — peut être mis en cache et bookmarké | Non |
| Usage | Recherche, filtres, pagination, navigation | Création, modification, données sensibles |
| Données sensibles | Jamais (mot de passe, numéro CB…) | Oui (mais pas sécurisé sans HTTPS) |
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.
| Clé | Valeur typique | Usage |
|---|---|---|
SERVER_NAME | localhost / boutique-atlas.ma | Nom d'hôte configuré dans Apache |
SERVER_ADDR | 127.0.0.1 | Adresse IP du serveur |
SERVER_PORT | 80 / 443 | Port d'écoute d'Apache |
REMOTE_ADDR | IP du visiteur | Journal de sécurité, géolocalisation |
HTTP_USER_AGENT | Chaîne navigateur/OS | Détection mobile/desktop, analytics |
REQUEST_METHOD | GET / POST | Détecter si formulaire soumis |
REQUEST_URI | /clients?page=2 | URL complète avec query string |
PHP_SELF | /inscription.php | Chemin du script courant |
QUERY_STRING | page=2&ville=casa | Paramètres GET bruts |
DOCUMENT_ROOT | C:/wamp64/www | Racine web absolue |
HTTP_REFERER | URL de la page précédente | Optionnel — peut être absent |
HTTPS | 'on' | Présent seulement en HTTPS |
<?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
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.
<?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';
}
<?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');
// '<script>alert("XSS")</script>'
// 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'
}
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.
<?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'
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.
var_dump((bool)'0') en PHP ?!== false et non != false avec strpos() ?foreach ($prix as &$p), pourquoi faut-il écrire unset($p) ?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.
<?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>';
}
<?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],
];
}
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.
| Fonction | Trie par | Ordre | Préserve les clés | Usage typique |
|---|---|---|---|---|
sort() | Valeur | ASC | Non — réindexe | Tableau indexé simple |
rsort() | Valeur | DESC | Non — réindexe | Tableau indexé, note décroissante |
asort() | Valeur | ASC | Oui | Tableau associatif, trier par valeur |
arsort() | Valeur | DESC | Oui | Tableau associatif, palmarès |
ksort() | Clé | ASC | Oui | Trier par nom de clé |
usort() | Valeur | Personnalisé | Non — réindexe | Critère complexe (tableau imbriqué) |
<?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
<?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
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.
| Mode | Description | Pointeur | Fichier inexistant | Contenu existant |
|---|---|---|---|---|
r | Lecture seule | Début | Erreur | Préservé |
w | Écriture seule | Début | Créé | ÉCRASÉ |
a | Écriture — ajout | Fin | Créé | Préservé (ajout) |
x | Écriture — création exclusive | Début | Créé | Erreur si existe déjà |
r+ | Lecture + écriture | Début | Erreur | Préservé |
w+ | Lecture + écriture | Début | Créé | ÉCRASÉ |
<?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');
<?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>
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.
<?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
}
<?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)
);
<?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);
}
QCM — Partie 3 : Tableaux & Fichiers
Tableaux, fonctions de tableau, fichiers et chemins — vérifiez vos acquis avant la POO.
unset($villes[2]), que retourne count($villes) si le tableau avait 5 éléments ?array_map() et array_filter() ?$_FILES['fichier']['type'] pour valider un upload ?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.
| Critère | Code procédural | Programmation Orientée Objet |
|---|---|---|
| Organisation | Données et fonctions séparées | Données + comportements regroupés dans la classe |
| Cohérence | Aucune garantie — tableau peut manquer de champs | Le constructeur garantit l'état initial valide |
| Réutilisabilité | Fonctions génériques, pas liées aux données | Méthodes liées à l'objet, portables et testables |
| Maintenance | Modifier un champ = modifier toutes les fonctions | Modifier une classe = un seul endroit |
| Encapsulation | Données exposées, modifiables de partout | Attributs privés + getters/setters contrôlés |
| Modélisation | Éloigné du monde réel | Un objet Produit = un vrai produit |
<?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());
}
}
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.
public ?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.
<?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)
<?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();
Traits, membres statiques & Namespaces
trait use, membres statiques (static, ::, self), namespaces PHP et PSR-4 — organiser un projet professionnel avec autoloading Composer.
<?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 { /* ... */ }
}
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.
<?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
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.
<?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()
QCM — Partie 4 : POO
Classes, héritage, interfaces, méthodes magiques — consolidez vos acquis avant PDO.
__toString() ?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.
| Critère | mysqli_ | PDO |
|---|---|---|
| Bases de données | MySQL uniquement | MySQL, PostgreSQL, SQLite, Oracle… |
| Paramètres nommés | Non | Oui — :nom |
| Exceptions automatiques | Non — vérification manuelle | Oui avec ERRMODE_EXCEPTION |
| Recommandé | Non | Oui — 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).
<?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;
}
<?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>';
}
error_log() et afficher uniquement un message générique à l'utilisateur.
-- 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');
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.
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.
<?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>';
}
| Étape | Ce que MySQL reçoit | Protection injection |
|---|---|---|
query() + concaténation | SQL + données mélangés | Aucune — vulnérable |
prepare() | Structure SQL compilée | Totale — MySQL ne peut plus interpréter les données comme du SQL |
execute() | Données pures séparées | — |
<?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");
$stmt = $pdo->query("SELECT * FROM produits WHERE nom = '$nom'"). Quel est le problème ?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.
<?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>
| Code | Signification | Cause typique |
|---|---|---|
23000 | Integrity constraint violation | Doublon sur contrainte UNIQUE, violation clé étrangère |
42S02 | Table introuvable | Nom de table incorrect dans le SQL |
HY000 | Erreur générale | Connexion perdue, serveur indisponible |
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.
| Méthode | Retourne | Mémoire | Quand l'utiliser |
|---|---|---|---|
fetch() | Une ligne (tableau/objet) ou false | Faible — une ligne | Récupérer UN enregistrement (fiche par ID), grande table ligne par ligne |
fetchAll() | Tableau de toutes les lignes | Proportionnelle au résultat | Afficher une liste standard |
fetchColumn() | Une seule valeur | Très faible | COUNT(), MAX(), MIN(), SUM() |
rowCount() | Lignes affectées | — | UPDATE / DELETE / INSERT — pas fiable pour SELECT |
<?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();
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.
<?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>
<?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());
}
| Opération | Fichier | HTTP | SQL | Sécurité clé |
|---|---|---|---|---|
| Create | create.php | POST | INSERT INTO | Validation + code 23000 |
| Read | index.php | GET | SELECT + LIKE | htmlspecialchars() affichage |
| Update | edit.php | GET puis POST | SELECT puis UPDATE | is_numeric() + intval() sur ID |
| Delete | delete.php | GET puis POST | SELECT puis DELETE | Confirmation PHP + POST uniquement |
$pdo->inTransaction() avant rollBack() dans le bloc catch ?<?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();
}
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.
fetch() et fetchAll() en termes de mémoire ?PDO::PARAM_INT avec bindValue() pour les paramètres LIMIT et OFFSET ?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.
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éristique | Valeur / Détail |
|---|---|
| Taille maximale | 4 096 octets (4 Ko) par cookie |
| Nombre max. | ~20 cookies par domaine |
| Stockage | Cô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 |
<?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é | Valeur | Protection apportée |
|---|---|---|
httponly | true | Cookie inaccessible à JavaScript — protège contre le vol via XSS |
secure | true | Cookie 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 |
htmlspecialchars() pour l'affichage.
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.
| Critère | Cookie seul | Session PHP |
|---|---|---|
| Stockage des données | Navigateur client — modifiable | Serveur — inaccessible au client |
| Ce que le client voit | Toutes les données en clair | Seulement un ID aléatoire (PHPSESSID) |
| Falsification | Triviale (DevTools) | Impossible sans accès serveur |
| Usage recommandé | Préférences non sensibles | Authentification, panier, données sensibles |
<?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>
<?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>
<?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;
session_regenerate_id(true) après une connexion réussie ?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).
| Critère | Sessions fichiers (défaut) | Sessions MySQL (personnalisé) |
|---|---|---|
| Stockage | Fichiers /tmp locaux | Table MySQL partagée |
| Multi-serveurs | Impossible sans NFS | Natif — MySQL accessible partout |
| Sessions actives | Impossible à lister | SELECT * FROM sessions WHERE expires_at > NOW() |
| Révoquer une session | Impossible à distance | DELETE FROM sessions WHERE id = :id |
| Méthode | Déclencheur | Rôle |
|---|---|---|
open() | Démarrage de session_start() | Initialiser la connexion |
close() | Fin de la requête | Libé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 |
-- ── 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;
<?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()
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.
<?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;
}
$_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).
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().
| Algorithme | Vitesse | Salé | Résistance | Verdict |
|---|---|---|---|---|
md5() | Très rapide | Non | Nulle | Jamais utiliser pour les mots de passe |
sha256() | Rapide | Non | Faible | Jamais utiliser seul |
| bcrypt | Lent (adaptatif) | Oui (intégré) | Excellente | PASSWORD_BCRYPT — recommandé |
| Argon2id | Lent + mémoire | Oui (intégré) | Excellente | PASSWORD_ARGON2ID — meilleur |
<?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
md5('secret') est-il insuffisant pour stocker un mot de passe ?QCM — Partie 6 : Sessions, Cookies & Sécurité
Sessions, CSRF, XSS, injection SQL, upload — testez vos acquis avant l'architecture MVC.
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.
| Problème du code procédural | Conséquence concrète |
|---|---|
| HTML + PHP + SQL dans le même fichier | Un changement visuel peut casser la logique métier |
| Logique métier non réutilisable | La même requête SQL est copiée dans 10 fichiers différents |
| Aucune convention de structure | Chaque développeur organise différemment — chaos en équipe |
| Tests impossibles | Impossible d'appeler getProduits() sans générer du HTML |
| Couche MVC | Responsabilité | Peut faire | Ne peut jamais faire |
|---|---|---|---|
| Model | Accès aux données et logique métier | Requêtes PDO, validation, calculs | Générer du HTML, accéder à $_GET/$_POST |
| View | Présentation HTML | Afficher des variables avec echo/htmlspecialchars | Requêtes SQL, logique métier, redirections |
| Controller | Orchestration | Appeler Model, passer données à View, redirections | Requêtes SQL directes, générer du HTML |
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/
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)
<?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;
}
}
<?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();
}
}
<?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]);
}
}
}
<!-- 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>
<?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();
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.
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 BaseModel | Avec BaseModel |
|---|---|
| Connexion PDO répétée dans chaque Model | Connexion centralisée dans BaseModel::getConnection() |
findById() copié 3 fois | findById() une seule fois — hérité |
| Modifier la connexion → 3 fichiers | Modifier la connexion → 1 fichier |
| Incohérences possibles entre Models | Comportement garanti par la classe mère |
<?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,
];
}
}
<?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();
}
}
<?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();
}
}
<?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>
create() et update() ne sont-ils pas placés dans BaseModel comme findAll() ou findById() ?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.
| Autorisé dans une View | Interdit dans une View |
|---|---|
echo + htmlspecialchars() pour afficher des variables | Requêtes PDO ou appels de Model |
foreach, if pour l'affichage conditionnel | Accès à $_GET, $_POST, $_FILES |
include d'autres partials (header, footer) | Redirections header('Location:') |
| Affichage conditionnel si liste vide → message | Logique métier (calculs, validations) |
htmlspecialchars() sur toute variable affichée | new PDO() ou toute connexion BDD |
Liens href et attributs action des formulaires | session_start() (sauf navbar pour $_SESSION) |
<!-- 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>
<!-- 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">« 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. »</a>
<?php endif; ?>
<span class="pagination-info">Page <?= $pagination['current'] ?>/<?= $pagination['pages'] ?> (<?= $pagination['total'] ?> résultats)</span>
</nav>
<?php endif; ?>
<?php endif; ?>
<!-- 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>
<!-- 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>
*, *::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; }
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.
<?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;
}
}
<?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();
<?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;
}
}
| Étape | Qui agit | Ce qui se passe |
|---|---|---|
| 1 | Navigateur | POST /stagiaires/store avec données du formulaire |
| 2 | .htaccess | Redirige vers index.php?url=stagiaires/store |
| 3 | index.php | session_start(), autoloader enregistré |
| 4 | index.php | Parse URL → StagiaireController::store() |
| 5 | store() | requireAuth() — vérifie $_SESSION |
| 6 | store() | verifyCsrf() — vérifie le token |
| 7 | store() | collectPost() + model->validate() |
| 8a | store() | Si erreurs → render('stagiaires/form', [...erreurs...]) |
| 8b | store() | Si OK → model->create($data) |
| 9 | store() | flash('success',...) + redirect('/stagiaires') |
| 10 | Navigateur | GET /stagiaires (suite au redirect — PRG) |
| 11 | index() | model->paginate() + render('stagiaires/list',...) |
| 12 | Navigateur | Liste avec message flash vert affiché et supprimé |
store() appelle-t-il render() en cas d'erreur de validation, mais redirect() en cas de succès ?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.
<!-- 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>
<?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();
}
}
| Route | HTTP | Attendu |
|---|---|---|
| GET /stagiaires | GET | Liste + recherche + flash si présent |
| GET /stagiaires/show/1 | GET | Détail stagiaire id=1 |
| GET /stagiaires/show/9999 | GET | Page 404 |
| GET /stagiaires/create | GET | Formulaire vide + select formations |
| POST /stagiaires/store (valide) | POST | Redirect + flash success |
| POST /stagiaires/store (email invalide) | POST | Formulaire réaffiché avec erreur |
| POST /stagiaires/store (CSRF invalide) | POST | Flash erreur sécurité + redirect |
| GET /stagiaires/edit/1 | GET | Formulaire pré-rempli |
| POST /stagiaires/update/1 (valide) | POST | Redirect + flash success |
| GET /stagiaires/destroy/1 | GET | Page de confirmation |
| POST /stagiaires/destroy/1 | POST | Suppression + redirect + flash |
| GET /formateurs | GET | Liste formateurs |
| GET /formateurs/show/1 | GET | Détail + formations du formateur |
| GET /formations | GET | Liste avec nb_stagiaires + formateur_nom |
| GET /formations/show/1 | GET | Détail + stagiaires inscrits |
| GET /inscriptions/create | GET | Formulaire avec deux selects |
| POST /inscriptions/store (valide) | POST | Redirect + flash success |
| POST /inscriptions/store (doublon) | POST | Flash erreur 'déjà inscrit' |
| GET /stagiaires (non connecté) | GET | Redirect vers /auth/login |
| Route inexistante /xyz | GET | Page 404 |
| F5 après création réussie | GET | Recharge la liste — aucune double insertion |
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.
requireAuth() dans le Controller et une vérification de session dans la View ?$_SESSION['flash'] avant un redirect(). Pourquoi est-il supprimé immédiatement après lecture dans getFlash() ?form.php unifié create/edit, comment la View sait-elle si elle doit afficher "Ajouter" ou "Modifier" ?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.
| Critère | Application Web classique | Web Service |
|---|---|---|
| Client cible | Navigateur humain | Programme (mobile, ERP, autre serveur) |
| Format de réponse | HTML (page complète) | XML (SOAP) ou JSON (REST) |
| Interopérabilité | Limitée au navigateur | PHP, Java, Python, .NET, Android… |
| Authentification | Session PHP + cookie | Token, clé API, WS-Security (SOAP) |
| Critère | SOAP | REST |
|---|---|---|
| Format | XML obligatoire | JSON principalement |
| Contrat | WSDL obligatoire | OpenAPI optionnel |
| Méthodes HTTP | POST uniquement | GET, POST, PUT, PATCH, DELETE |
| Complexité | Élevée — Envelope, WSDL | Faible — URL + verbe HTTP + JSON |
| Sécurité | WS-Security intégré | HTTPS + OAuth2 + JWT |
| Usage | ERP SAP, banques, services gouvernementaux | APIs modernes, mobiles, microservices |
<!-- ── 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>
<?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);
}
}
<?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 : 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.
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.
| Endpoint | Méthode HTTP | Action | Code succès |
|---|---|---|---|
| GET /api/stagiaires | GET | Lister (+ pagination, filtres) | 200 OK |
| GET /api/stagiaires/{id} | GET | Obtenir un stagiaire | 200 OK |
| POST /api/stagiaires | POST | Créer un stagiaire | 201 Created |
| PUT /api/stagiaires/{id} | PUT | Remplacer complètement | 200 OK |
| DELETE /api/stagiaires/{id} | DELETE | Supprimer | 204 No Content |
| GET /api/stagiaires/{id}/inscriptions | GET | Inscriptions du stagiaire | 200 OK |
| Code HTTP | Nom | Quand l'utiliser |
|---|---|---|
| 200 | OK | GET ou PUT/PATCH réussi — réponse avec données |
| 201 | Created | POST réussi — ressource créée — inclure Location header |
| 204 | No Content | DELETE réussi — aucun corps de réponse |
| 400 | Bad Request | Corps JSON malformé ou paramètres manquants |
| 401 | Unauthorized | Token absent ou invalide |
| 403 | Forbidden | Authentifié mais pas autorisé |
| 404 | Not Found | Ressource inexistante |
| 422 | Unprocessable Entity | Données invalides métier (email dupliqué) |
| 500 | Internal Server Error | Erreur serveur non prévue — logger |
<?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.')
};
<?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é.');
}
}
}
<?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
]);
| # | Test | Méthode | URL | Headers / Body | Réponse attendue |
|---|---|---|---|---|---|
| 1 | Sans token | GET | /api/stagiaires | (aucun) | 401 — token requis |
| 2 | Liste | GET | /api/stagiaires | Authorization: Bearer atlas-api-token-android-2025 | 200 — tableau JSON |
| 3 | Détail | GET | /api/stagiaires/1 | Authorization: Bearer … | 200 — objet stagiaire |
| 4 | ID inexistant | GET | /api/stagiaires/9999 | Authorization: Bearer … | 404 |
| 5 | Créer | POST | /api/stagiaires | Bearer + Content-Type: application/json + body JSON | 201 — id créé |
| 6 | Email invalide | POST | /api/stagiaires | Bearer + {"email":"pas-un-email"} | 422 — errors |
| 7 | Modifier | PUT | /api/stagiaires/1 | Bearer + body JSON complet | 200 — objet modifié |
| 8 | Supprimer | DELETE | /api/stagiaires/1 | Authorization: Bearer … | 204 No Content |
| 9 | Inscriptions | GET | /api/stagiaires/2/inscriptions | Authorization: Bearer … | 200 — inscriptions |
// 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
}
QCM — Partie 8 : Web Services & API REST
SOAP vs REST, codes HTTP, Bearer token, SoapFault, jsonResponse() — consolidez le chapitre Web Services.
SoapFault et une exception PHP ordinaire ?204 No Content pour un DELETE réussi plutôt que 200 OK ?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.
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.
| Acteur | Rôle | Fonctionnalités principales |
|---|---|---|
| Propriétaire | Publie et gère ses biens | Créer/modifier/désactiver des annonces, consulter les réservations reçues, confirmer ou refuser |
| Locataire | Cherche et réserve | Parcourir les annonces, filtrer par ville/prix/type, soumettre une demande, consulter l'historique |
| Administrateur | Supervise la plateforme | Valider les annonces, gérer les utilisateurs, consulter les statistiques, exporter CSV |
| ID | En tant que… | Je veux… | Afin de… |
|---|---|---|---|
| US-01 | Visiteur | Voir les annonces publiques avec photos et prix | Découvrir les biens disponibles sans m'inscrire |
| US-02 | Visiteur | Filtrer par ville, type (appartement/villa/studio), prix min/max | Trouver rapidement ce qui correspond à mon budget |
| US-03 | Locataire | M'inscrire avec email + mot de passe + téléphone | Créer un compte locataire pour faire des demandes |
| US-04 | Locataire | Soumettre une demande de réservation avec dates | Réserver un bien pour une période précise |
| US-05 | Locataire | Consulter l'état de mes demandes (en attente/confirmée/refusée) | Suivre mes réservations en temps réel |
| US-06 | Propriétaire | Créer une annonce avec titre, description, photos, prix/mois, ville, type | Mettre mon bien en location sur la plateforme |
| US-07 | Propriétaire | Confirmer ou refuser les demandes de réservation | Choisir mon locataire et éviter les doubles réservations |
| US-08 | Administrateur | Valider ou rejeter les annonces publiées | Garantir la qualité du contenu sur la plateforme |
| US-09 | Administrateur | Voir les statistiques : nb annonces, réservations, CA mensuel | Piloter la plateforme et détecter les problèmes |
| US-10 | Administrateur | Exporter la liste des réservations en CSV | Traiter les données dans Excel pour la comptabilité |
| Règle | Description |
|---|---|
| RG-01 | Une 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-02 | Un 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-03 | Quand une réservation est confirmée, toutes les autres demandes pour les mêmes dates passent automatiquement au statut refusée. |
| RG-04 | Le prix total d'une réservation = prix mensuel × nombre de mois (arrondi au mois supérieur si période fractionnaire). |
| RG-05 | Un utilisateur ne peut pas être à la fois propriétaire et locataire. Le rôle est fixé à l'inscription. |
| RG-06 | Les mots de passe sont hashés avec bcrypt (PASSWORD_BCRYPT). Jamais stockés en clair. |
| RG-07 | Toutes les actions d'écriture (POST/PUT/DELETE) sont protégées par un token CSRF. |
| RG-08 | Les photos uploadées sont validées par finfo (MIME réel) et stockées hors de la racine web. |
| Étape | Livrable | Compétences mobilisées |
|---|---|---|
| Étape 1 | Base de données : MCD → tables SQL + données de test | SQL, clés étrangères, contraintes, données marocaines |
| Étape 2 | Architecture MVC : arborescence + config + autoloader | Architecture logicielle, PSR-4, constants |
| Étape 3 | Modèles : BaseModel + 5 Models métier + tests isolation | POO, PDO, héritage, ModelException |
| Étape 4 | Contrôleurs : 5 Controllers + routeur + middleware auth/rôles | MVC, sessions, CSRF, PRG |
| Étape 5 | Vues : layouts + 20 vues CRUD + CSS complet DariGO | HTML/CSS, templates PHP, flash messages |
| Étape 6 | API REST : 3 endpoints JSON + authentification Bearer | REST, JSON, codes HTTP, Bearer token |
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.
| Entité | Attributs principaux | Relations |
|---|---|---|
| utilisateurs | id, nom, prenom, email (UNIQUE), mot_de_passe, telephone, role (ENUM), ville, created_at | Possède des annonces (si propriétaire) ou des réservations (si locataire) |
| annonces | id, titre, description, type (ENUM), ville, adresse, prix_mois, surface, nb_chambres, statut (ENUM), proprietaire_id, created_at | Appartient à un utilisateur (propriétaire). A plusieurs photos. A plusieurs réservations. |
| photos | id, annonce_id, nom_fichier, est_principale, ordre, created_at | Appartient à une annonce |
| reservations | id, annonce_id, locataire_id, date_debut, date_fin, nb_mois, prix_total, statut (ENUM), message, created_at | Lie un locataire à une annonce |
| avis | id, reservation_id, note (1-5), commentaire, created_at | Lie un avis à une réservation terminée |
| paiements | id, reservation_id, montant, methode (ENUM), reference, statut (ENUM), created_at | Lie un paiement à une réservation |
-- ═══════════════════════════════════════════════════════════════
-- 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);
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)
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.
-- ── 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é
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.
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
<?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
]);
<?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)
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]
<?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();
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.
<?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,
];
}
}
<?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;
}
}
<?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();
}
}
<?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();
}
}
<?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;
}
}
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).
<?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.');
}
}
<?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');
}
}
<?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);
}
}
<?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');
}
}
<?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;
}
}
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).
<!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">
<?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; ?>
</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>
<!-- 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>
<!-- 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>
<?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>
<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>
<?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>
<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>
/* ═══════════════════════════════════════════════════════════
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; }
}
// ── 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);
});