Tutoriel
Partie 24 — Mini-projet final : application PHP complète

Partie 24 — Mini-projet final : application PHP complète

Construis une application PHP complète : authentification, CRUD, upload d’images, rôles, sécurité (CSRF/XSS/SQLi), pagination, structure propre et déploiement.

PHP 60 Mis à jour 15 hours ago
Conseil : lisez d’abord les sections clés, puis essayez un QCM lié à la même notion pour valider votre compréhension.

Partie 24 — Mini-projet final : application PHP complète

Ici on passe au niveau “vrai projet”. L’objectif n’est plus d’apprendre une fonction, mais de construire une application PHP complète, organisée, sécurisée, et facile à faire évoluer.

On va créer un mini-produit réaliste : une application “Articles/Posts” avec : authentification, CRUD, catégories, upload d’images, dashboard, et une couche de sécurité solide (PDO + CSRF + XSS).

👉 À la fin, tu auras un squelette pro que tu peux adapter à n’importe quel site : QCM, concours, blog, catalogue, admin panel…


1) Cahier des charges (clair)

Fonctionnalités minimum :

  • Register : création de compte (email unique, password_hash)
  • Login/Logout : session sécurisée + protection pages
  • Posts CRUD : créer, lire, modifier, supprimer
  • Catégories : associer un post à une catégorie
  • Upload image : image d’illustration par post
  • Recherche + pagination sur la liste
  • Rôles : user/admin (admin peut supprimer)
  • Sécurité : PDO prepare, CSRF, XSS escape, validations

Bonus (optionnel mais très utile) :

  • Slug pour URLs SEO
  • Logs d’actions (qui a modifié quoi)
  • Soft delete (supprimer sans effacer vraiment)
  • Page “Mes posts” (filtrée par user)

2) Structure du projet : MVC léger (sans framework)

Tu n’as pas besoin d’un framework pour organiser proprement. On fait une structure simple :

/app/
  /Core/
    db.php
    helpers.php
    auth.php
  /Repositories/
    UserRepository.php
    PostRepository.php
    CategoryRepository.php
/public/
  index.php
  /posts/
    index.php
    create.php
    show.php
    edit.php
    delete.php
  /auth/
    register.php
    login.php
    logout.php
  /admin/
    dashboard.php
  /uploads/
    (images)
  /assets/
    (css)
/views/
  layout.php
  posts/
    form.php
    list.php
    show.php
  auth/
    form_login.php
    form_register.php
  admin/
    dashboard.php
  

/public = le seul dossier accessible depuis le navigateur (recommandé). /app et /views restent protégés.


3) Base de données : tables du projet

On va utiliser 4 tables : users, categories, posts, logs.

3.1 users

CREATE TABLE users (
  id INT AUTO_INCREMENT PRIMARY KEY,
  name VARCHAR(120) NOT NULL,
  email VARCHAR(190) NOT NULL UNIQUE,
  password_hash VARCHAR(255) NOT NULL,
  role ENUM('user','admin') NOT NULL DEFAULT 'user',
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
  

3.2 categories

CREATE TABLE categories (
  id INT AUTO_INCREMENT PRIMARY KEY,
  name VARCHAR(120) NOT NULL UNIQUE,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
  

3.3 posts

CREATE TABLE posts (
  id INT AUTO_INCREMENT PRIMARY KEY,
  user_id INT NOT NULL,
  category_id INT NOT NULL,
  title VARCHAR(255) NOT NULL,
  slug VARCHAR(255) NOT NULL UNIQUE,
  body TEXT NOT NULL,
  image_path VARCHAR(255) NULL,
  status ENUM('draft','published') NOT NULL DEFAULT 'draft',
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME NULL,
  INDEX (user_id),
  INDEX (category_id),
  INDEX (status),
  INDEX (created_at),
  CONSTRAINT fk_posts_user FOREIGN KEY (user_id) REFERENCES users(id),
  CONSTRAINT fk_posts_category FOREIGN KEY (category_id) REFERENCES categories(id)
);
  

3.4 logs (option pro)

CREATE TABLE logs (
  id INT AUTO_INCREMENT PRIMARY KEY,
  user_id INT NOT NULL,
  action VARCHAR(60) NOT NULL,
  details VARCHAR(255) NULL,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  INDEX (user_id),
  CONSTRAINT fk_logs_user FOREIGN KEY (user_id) REFERENCES users(id)
);
  

✅ Avec ce schéma, tu peux gérer : auteur + catégorie + SEO slug + image + admin logs.


4) Core : sécurité minimum (helpers + auth guard)

Ce bloc, c’est ce qui évite les erreurs classiques :

  • XSS : tout escape avec e()
  • CSRF : token sur POST
  • Session : regenerate id au login

Tu peux reprendre ces fonctions depuis les parties 21/23 : e(), flash(), csrf_token(), csrf_check(), et un guard require_login().

⚠️ Rappel : l’auth “propre” = password_hash + password_verify + session_regenerate_id.

5) Upload d’images (sécurisé) : règles et pipeline

L’upload est une source classique de failles. Pipeline recommandé :

  1. Vérifier $_FILES (erreurs, taille)
  2. Vérifier que c’est une image (MIME réel via finfo)
  3. Renommer le fichier (nom aléatoire)
  4. Déplacer vers /public/uploads
  5. Stocker uniquement le chemin en DB
<?php
function upload_image(array $file): ?string
{
    if (($file["error"] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
        return null; // pas d'image
    }
    if (($file["error"] ?? 0) !== UPLOAD_ERR_OK) {
        throw new RuntimeException("Erreur upload.");
    }

    // taille max 2MB
    if (($file["size"] ?? 0) > 2 * 1024 * 1024) {
        throw new RuntimeException("Image trop grande (2MB max).");
    }

    // vérifier MIME réel
    $finfo = new finfo(FILEINFO_MIME_TYPE);
    $mime = $finfo->file($file["tmp_name"]);

    $allowed = [
        "image/jpeg" => "jpg",
        "image/png"  => "png",
        "image/webp" => "webp",
    ];
    if (!isset($allowed[$mime])) {
        throw new RuntimeException("Format non autorisé.");
    }

    $ext = $allowed[$mime];
    $name = bin2hex(random_bytes(16)) . "." . $ext;

    $destDir = __DIR__ . "/../public/uploads";
    if (!is_dir($destDir)) mkdir($destDir, 0755, true);

    $destPath = $destDir . "/" . $name;
    if (!move_uploaded_file($file["tmp_name"], $destPath)) {
        throw new RuntimeException("Impossible de déplacer l'image.");
    }

    return "uploads/" . $name; // chemin relatif stocké en DB
}
?>
  

✅ Ici, tu n’acceptes que jpg/png/webp + tu renomme aléatoirement. C’est la base de la sécurité upload.


6) Slug SEO : générer un slug unique

Un slug propre = URL lisible : /posts/mon-article-php. On veut 2 choses :

  • slug basé sur le titre
  • slug unique (si deux titres identiques)
<?php
function slugify(string $text): string
{
    $text = trim(mb_strtolower($text));
    $text = preg_replace("/[^a-z0-9]+/i", "-", $text);
    $text = trim($text, "-");
    return $text ?: "post";
}

function unique_slug(PDO $pdo, string $title): string
{
    $base = slugify($title);
    $slug = $base;
    $i = 2;

    while (true) {
        $stmt = $pdo->prepare("SELECT 1 FROM posts WHERE slug = :s LIMIT 1");
        $stmt->execute(["s" => $slug]);
        if (!$stmt->fetch()) break;
        $slug = $base . "-" . $i;
        $i++;
    }
    return $slug;
}
?>
  

7) CRUD Posts : flux complet (Create → Read → Update → Delete)

Au lieu de recoller tout le code (très long), je te donne le flux “pro” :

  1. Create : valider → générer slug → upload image → INSERT (PDO) → flash → redirect
  2. Read list : SELECT paginé + JOIN categories/users + recherche
  3. Show : SELECT by slug (URL SEO) + affichage safe (e())
  4. Edit : vérifier owner/admin → valider → update (slug si titre change) → redirect
  5. Delete : POST + CSRF → supprimer si admin/owner → redirect

Exemple de SELECT list (avec JOIN) :

SELECT p.id, p.title, p.slug, p.status, p.created_at,
       u.name AS author,
       c.name AS category
FROM posts p
INNER JOIN users u ON u.id = p.user_id
INNER JOIN categories c ON c.id = p.category_id
WHERE p.title LIKE :q
ORDER BY p.id DESC
LIMIT :lim OFFSET :off;
  

⚠️ Tout ce qui vient de l’utilisateur (q, id, slug, forms) = requêtes préparées + validation.


8) Dashboard + rôles (admin/user)

Règles simples :

  • user : peut créer/modifier/supprimer ses posts
  • admin : peut tout gérer (posts + catégories + users)

Exemples de contrôles (logique) :

  • Avant edit/delete : vérifier $post.user_id === $auth.id OU $auth.role === 'admin'
  • Routes /admin : accessible uniquement si role=admin

Bonus pro : enregistrer un log à chaque action : “user 5 a supprimé post 12”.


9) Sécurité : checklist finale (à appliquer)

Risque Solution
SQL Injection PDO + prepare/execute partout
XSS Escape HTML : e() sur toutes les sorties
CSRF Token sur tous les POST sensibles
Sessions session_regenerate_id au login, cookies HttpOnly/Secure
Upload finfo MIME, taille max, rename random, pas d’extensions “dangereuses”
Erreurs Logs serveur, pas d’affichage des erreurs DB en prod

10) Déploiement : réflexes (même sur un petit serveur)

  • Mettre /public comme document root (Apache/Nginx)
  • Passer les identifiants DB via .env ou config non commit
  • Activer HTTPS (cookies Secure)
  • Permissions correctes sur /public/uploads
  • Désactiver l’affichage d’erreurs en prod (log seulement)
  • Sauvegardes DB régulières

✅ Même un mini-projet peut être “pro” si tu respectes ces points.


11) Plan de travail (étapes rapides)

  1. Créer DB + tables
  2. Créer Core : db.php + helpers + auth guard
  3. Construire register/login/logout
  4. Créer categories (simple CRUD admin)
  5. Créer posts CRUD (sans image)
  6. Ajouter upload image
  7. Ajouter slug SEO + route show par slug
  8. Ajouter dashboard + permissions
  9. Ajouter recherche + pagination
  10. Passer checklist sécurité

12) Défis bonus (si tu veux pousser)

  • Soft delete posts (deleted_at)
  • Historique modifications (table post_versions)
  • Search multi-critères (title + body + category)
  • Filtrage status + tri (draft/published)
  • API JSON (GET posts) + token simple

Conclusion

Ce mini-projet réunit tout ce que tu as appris : SQL, PDO, requêtes préparées, CRUD, sessions, auth, upload, POO (si tu l’appliques aux repositories), et sécurité web. Tu as maintenant une base solide pour créer des applications PHP professionnelles.

Si tu veux, je peux aussi te générer : un pack complet de fichiers prêts à copier (structure + pages + repos + views), ou une version “Laravel-like” (routing simple + controllers + views).