Dans notre première version du mini-blog, les articles contenaient uniquement un titre et un texte. Cette base fonctionnait, mais elle restait limitée pour un véritable site éditorial. Nous allons maintenant ajouter deux fonctionnalités utiles : une image associée à chaque article et un système de catégories.
L’image sera téléversée depuis le formulaire d’administration, validée côté serveur, enregistrée dans le dossier uploads/ et affichée dans la liste publique ainsi que dans la page complète de l’article. Les catégories seront stockées dans une table dédiée, sélectionnables pendant la rédaction et filtrables depuis la page d’accueil.
Nouvelle structure
mini-blog/
├── admin/
├── config/
├── includes/
├── uploads/
├── index.php
└── …
Le dossier uploads/ doit être accessible en écriture par PHP (CHMOD 755).
Étape 1 : modifier la base de données
La table posts doit recevoir deux nouvelles informations : le nom du fichier image et l’identifiant de la catégorie. Nous créons également une table categories.
Dans une nouvelle installation, utilisez le fichier database.sql complet fourni dans le projet :
CREATE DATABASE IF NOT EXISTS mini_blog
CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
USE mini_blog;
CREATE TABLE IF NOT EXISTS categories (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS posts (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
image VARCHAR(255) DEFAULT NULL,
category_id INT DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_posts_category
FOREIGN KEY (category_id) REFERENCES categories(id)
ON DELETE SET NULL
ON UPDATE CASCADE
);
Si vous partez de la première version déjà installée, sauvegardez d’abord votre base, puis appliquez uniquement la migration suivante :
USE mini_blog;
CREATE TABLE categories (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE posts
ADD COLUMN image VARCHAR(255) DEFAULT NULL,
ADD COLUMN category_id INT DEFAULT NULL,
ADD CONSTRAINT fk_posts_category
FOREIGN KEY (category_id) REFERENCES categories(id)
ON DELETE SET NULL
ON UPDATE CASCADE;
La clause ON DELETE SET NULL est importante : supprimer une catégorie ne supprime pas les articles qui lui étaient associés. Ces articles restent publiés, mais passent simplement dans l’état « Sans catégorie ».
Ajoutez quelques catégories de départ :
INSERT INTO categories (name)
VALUES ('Développement web'), ('PHP'), ('Base de données');
Étape 2 : sécuriser le téléversement des images
Le formulaire HTML doit utiliser enctype= »multipart/form-data ». Sans cet attribut, le navigateur n’envoie pas le fichier au serveur.
Dans admin/create-post.php, remplacer l’intégralité du code par celui-ci :
<?php
require_once 'auth.php';
require_once '../config/database.php';
$error = '';
$success = '';
$titleValue = '';
$contentValue = '';
$categoryValue = '';
$categories = $pdo->query(
'SELECT id, name
FROM categories
ORDER BY name'
)->fetchAll(PDO::FETCH_ASSOC);
/**
* Téléverse une image dans le dossier uploads.
*
* @param array $file
* @return string|null
* @throws RuntimeException
*/
function uploadImage(array $file)
{
if (!isset($file['error'])) {
throw new RuntimeException(
'Aucun fichier image n’a été reçu.'
);
}
if ($file['error'] === UPLOAD_ERR_NO_FILE) {
return null;
}
if ($file['error'] !== UPLOAD_ERR_OK) {
throw new RuntimeException(
'Erreur PHP pendant le téléversement. Code : '
. $file['error']
);
}
if (!is_uploaded_file($file['tmp_name'])) {
throw new RuntimeException(
'Le fichier temporaire est introuvable.'
);
}
if ($file['size'] > 2 * 1024 * 1024) {
throw new RuntimeException(
'L’image ne doit pas dépasser 2 Mo.'
);
}
$uploadDirectory = dirname(__DIR__)
. DIRECTORY_SEPARATOR
. 'uploads';
if (!is_dir($uploadDirectory)) {
if (!mkdir($uploadDirectory, 0755, true)) {
throw new RuntimeException(
'Impossible de créer le dossier uploads.'
);
}
}
if (!is_writable($uploadDirectory)) {
throw new RuntimeException(
'Le dossier uploads n’est pas accessible en écriture.'
);
}
$mime = (new finfo(FILEINFO_MIME_TYPE))
->file($file['tmp_name']);
$allowedTypes = array(
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp'
);
if (!isset($allowedTypes[$mime])) {
throw new RuntimeException(
'Format refusé. Utilisez JPG, PNG, GIF ou WEBP.'
);
}
$filename = bin2hex(random_bytes(16))
. '.'
. $allowedTypes[$mime];
$destination = $uploadDirectory
. DIRECTORY_SEPARATOR
. $filename;
if (!move_uploaded_file(
$file['tmp_name'],
$destination
)) {
throw new RuntimeException(
'Le fichier n’a pas pu être déplacé vers uploads.'
);
}
return $filename;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$titleValue = trim(
isset($_POST['title']) ? $_POST['title'] : ''
);
$contentValue = trim(
isset($_POST['content']) ? $_POST['content'] : ''
);
$categoryValue = filter_input(
INPUT_POST,
'category_id',
FILTER_VALIDATE_INT
);
if (!$categoryValue) {
$categoryValue = null;
}
try {
if ($titleValue === '') {
throw new RuntimeException(
'Le titre est obligatoire.'
);
}
if ($contentValue === '') {
throw new RuntimeException(
'Le contenu est obligatoire.'
);
}
$image = null;
if (
isset($_FILES['image']) &&
$_FILES['image']['error'] !== UPLOAD_ERR_NO_FILE
) {
$image = uploadImage($_FILES['image']);
}
$query = $pdo->prepare(
'INSERT INTO posts
(title, content, image, category_id)
VALUES
(?, ?, ?, ?)'
);
$query->execute(array(
$titleValue,
$contentValue,
$image,
$categoryValue
));
header('Location: posts.php');
exit;
} catch (Throwable $exception) {
$error = $exception->getMessage();
}
}
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
>
<title>Nouvel article</title>
<link rel="stylesheet" href="../style.css">
</head>
<body>
<main class="container">
<h1>Publier un article</h1>
<?php if ($error !== ''): ?>
<p class="notice notice-error">
<?= htmlspecialchars(
$error,
ENT_QUOTES,
'UTF-8'
) ?>
</p>
<?php endif; ?>
<?php if ($success !== ''): ?>
<p class="notice notice-success">
<?= htmlspecialchars(
$success,
ENT_QUOTES,
'UTF-8'
) ?>
</p>
<?php endif; ?>
<form
method="post"
enctype="multipart/form-data"
>
<label for="title">
Titre
</label>
<input
type="text"
name="title"
id="title"
value="<?= htmlspecialchars(
$titleValue,
ENT_QUOTES,
'UTF-8'
) ?>"
required
>
<label for="category_id">
Catégorie
</label>
<select
name="category_id"
id="category_id"
>
<option value="">
Sans catégorie
</option>
<?php foreach ($categories as $category): ?>
<option
value="<?= (int) $category['id'] ?>"
<?php
if (
$categoryValue !== null &&
(int) $categoryValue ===
(int) $category['id']
) {
echo 'selected';
}
?>
>
<?= htmlspecialchars(
$category['name'],
ENT_QUOTES,
'UTF-8'
) ?>
</option>
<?php endforeach; ?>
</select>
<label for="image">
Image de l’article
</label>
<input
type="file"
name="image"
id="image"
accept="
image/jpeg,
image/png,
image/gif,
image/webp
"
>
<small>
Formats acceptés : JPG, PNG, GIF et WEBP.
Taille maximale : 2 Mo.
</small>
<label for="content">
Contenu
</label>
<textarea
name="content"
id="content"
rows="12"
required
><?= htmlspecialchars(
$contentValue,
ENT_QUOTES,
'UTF-8'
) ?></textarea>
<button
class="btn"
type="submit"
>
Publier
</button>
</form>
<p>
<a href="dashboard.php">
Retour au tableau de bord
</a>
</p>
</main>
</body>
</html>
Le traitement PHP vérifie ensuite le type MIME réel du fichier, sa taille et son déplacement vers uploads. Le nom original n’est pas conservé. Un nom aléatoire évite les collisions et empêche qu’un fichier portant un nom inattendu ne soit directement utilisé dans l’URL.
Étape 3 : afficher l’image et la catégorie
Dans index.php, remplacez la requête de lecture par une jointure avec categories :
$stmt = $pdo->query(
'SELECT
p.id,
p.title,
p.content,
p.image,
p.created_at,
c.name AS category_name
FROM posts AS p
LEFT JOIN categories AS c
ON c.id = p.category_id
ORDER BY p.created_at DESC'
);
$posts = $stmt->fetchAll(PDO::FETCH_ASSOC);
Dans la boucle d’affichage, entre les balises foreach ajoutez l’image et le nom de la catégorie :
<article class="card">
<?php if (!empty($post['image'])): ?>
<img
class="thumb"
src="uploads/<?= htmlspecialchars(
$post['image'],
ENT_QUOTES,
'UTF-8'
) ?>"
alt="<?= htmlspecialchars(
$post['title'],
ENT_QUOTES,
'UTF-8'
) ?>"
>
<?php endif; ?>
<div class="card-content">
<p class="meta">
<?= htmlspecialchars(
$post['category_name'] ?? 'Sans catégorie',
ENT_QUOTES,
'UTF-8'
) ?>
·
<?= htmlspecialchars(
$post['created_at'] ?? '',
ENT_QUOTES,
'UTF-8'
) ?>
</p>
<h2>
<?= htmlspecialchars(
$post['title'],
ENT_QUOTES,
'UTF-8'
) ?>
</h2>
<p>
<?= nl2br(
htmlspecialchars(
mb_substr($post['content'], 0, 220),
ENT_QUOTES,
'UTF-8'
)
) ?>
<?php if (mb_strlen($post['content']) > 220): ?>
…
<?php endif; ?>
</p>
<a
class="btn"
href="article.php?id=<?= (int) $post['id'] ?>"
>
Lire la suite
</a>
</div>
</article>
La même logique est utilisée dans article.php, qui récupère l’article avec sa catégorie et affiche l’image en grand au-dessus du contenu.
Ajoutez par exemple ces règles dans style.css :
.post-image {
width: 220px;
height: 150px;
object-fit: cover;
border-radius: 6px;
}
.hero-image {
width: 100%;
max-height: 440px;
object-fit: cover;
border-radius: 6px;
}
.card {
display: flex;
gap: 20px;
align-items: flex-start;
background: #ffffff;
padding: 20px;
margin: 20px 0;
border-radius: 8px;
}
.thumb {
width: 220px;
height: 150px;
object-fit: cover;
border-radius: 6px;
flex-shrink: 0;
}
.card-content {
flex: 1;
}
.meta {
color: #666;
font-size: 0.9rem;
margin-top: 0;
}
.btn {
display: inline-block;
padding: 10px 14px;
background: #0675c9;
color: #ffffff;
text-decoration: none;
border-radius: 4px;
}
.btn:hover {
background: #045b9c;
}
@media (max-width: 700px) {
.card {
display: block;
}
.thumb {
width: 100%;
height: auto;
margin-bottom: 15px;
}
}
Étape 4 : modifier ou supprimer une image
Dans admin/edit-post.php, le formulaire doit lui aussi utiliser multipart/form-data. Affichez l’image actuelle et ajoutez une case permettant de la supprimer.
Pour faire simple, remplacez l’intégralité du code de la page, par celui-ci :
<?php
require_once 'auth.php';
require_once '../config/database.php';
if (
!isset($_GET['id']) ||
!is_numeric($_GET['id'])
) {
die('Article introuvable.');
}
$id = (int) $_GET['id'];
$stmt = $pdo->prepare(
'SELECT *
FROM posts
WHERE id = ?'
);
$stmt->execute([$id]);
$post = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$post) {
die('Article inexistant.');
}
/*
|--------------------------------------------------------------------------
| Récupération des catégories
|--------------------------------------------------------------------------
*/
$categories = $pdo->query(
'SELECT id, name
FROM categories
ORDER BY name'
)->fetchAll(PDO::FETCH_ASSOC);
/*
|--------------------------------------------------------------------------
| Fonction de téléversement d'image
|--------------------------------------------------------------------------
*/
function uploadImage(array $file)
{
if (!isset($file['error'])) {
throw new RuntimeException(
'Aucun fichier image reçu.'
);
}
if ($file['error'] === UPLOAD_ERR_NO_FILE) {
return null;
}
if ($file['error'] !== UPLOAD_ERR_OK) {
throw new RuntimeException(
'Erreur pendant le téléversement. Code : '
. $file['error']
);
}
if (!is_uploaded_file($file['tmp_name'])) {
throw new RuntimeException(
'Le fichier temporaire est invalide.'
);
}
if ($file['size'] > 2 * 1024 * 1024) {
throw new RuntimeException(
'L’image ne doit pas dépasser 2 Mo.'
);
}
$uploadDirectory = dirname(__DIR__)
. DIRECTORY_SEPARATOR
. 'uploads';
if (!is_dir($uploadDirectory)) {
if (!mkdir($uploadDirectory, 0755, true)) {
throw new RuntimeException(
'Impossible de créer le dossier uploads.'
);
}
}
if (!is_writable($uploadDirectory)) {
throw new RuntimeException(
'Le dossier uploads n’est pas accessible en écriture.'
);
}
$mime = (new finfo(FILEINFO_MIME_TYPE))
->file($file['tmp_name']);
$allowedTypes = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp'
];
if (!isset($allowedTypes[$mime])) {
throw new RuntimeException(
'Format refusé. Utilisez JPG, PNG, GIF ou WEBP.'
);
}
$filename = bin2hex(random_bytes(16))
. '.'
. $allowedTypes[$mime];
$destination = $uploadDirectory
. DIRECTORY_SEPARATOR
. $filename;
if (!move_uploaded_file(
$file['tmp_name'],
$destination
)) {
throw new RuntimeException(
'Impossible de déplacer le fichier dans uploads.'
);
}
return $filename;
}
/*
|--------------------------------------------------------------------------
| Valeurs par défaut
|--------------------------------------------------------------------------
*/
$message = '';
$messageClass = '';
$error = '';
$titleValue = $post['title'];
$contentValue = $post['content'];
$categoryValue = $post['category_id'];
$currentImage = $post['image'];
$newImage = null;
/*
|--------------------------------------------------------------------------
| Traitement du formulaire
|--------------------------------------------------------------------------
*/
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$titleValue = trim(
isset($_POST['title'])
? $_POST['title']
: ''
);
$contentValue = trim(
isset($_POST['content'])
? $_POST['content']
: ''
);
$categoryValue = filter_input(
INPUT_POST,
'category_id',
FILTER_VALIDATE_INT
);
if (!$categoryValue) {
$categoryValue = null;
}
$removeImage = !empty($_POST['remove_image']);
try {
if ($titleValue === '') {
throw new RuntimeException(
'Le titre est obligatoire.'
);
}
if ($contentValue === '') {
throw new RuntimeException(
'Le contenu est obligatoire.'
);
}
/*
* On téléverse d'abord la nouvelle image.
* L'ancienne image ne sera supprimée qu'après
* la mise à jour réussie en base de données.
*/
if (
isset($_FILES['image']) &&
$_FILES['image']['error'] !== UPLOAD_ERR_NO_FILE
) {
$newImage = uploadImage($_FILES['image']);
}
if ($newImage !== null) {
$imageToSave = $newImage;
} elseif ($removeImage) {
$imageToSave = null;
} else {
$imageToSave = $currentImage;
}
$stmt = $pdo->prepare(
'UPDATE posts
SET title = ?,
content = ?,
image = ?,
category_id = ?
WHERE id = ?'
);
$stmt->execute([
$titleValue,
$contentValue,
$imageToSave,
$categoryValue,
$id
]);
/*
* Suppression de l'ancienne image uniquement
* après la mise à jour réussie en base.
*/
if (
$currentImage &&
$imageToSave !== $currentImage
) {
$oldPath = dirname(__DIR__)
. DIRECTORY_SEPARATOR
. 'uploads'
. DIRECTORY_SEPARATOR
. $currentImage;
if (is_file($oldPath)) {
unlink($oldPath);
}
}
$currentImage = $imageToSave;
$message = 'Article mis à jour avec succès.';
$messageClass = 'notice-success';
} catch (Exception $exception) {
$error = $exception->getMessage();
$messageClass = 'notice-error';
/*
* Si une nouvelle image a bien été envoyée,
* mais que l'enregistrement SQL échoue,
* on supprime le fichier devenu inutile.
*/
if ($newImage !== null) {
$newImagePath = dirname(__DIR__)
. DIRECTORY_SEPARATOR
. 'uploads'
. DIRECTORY_SEPARATOR
. $newImage;
if (is_file($newImagePath)) {
unlink($newImagePath);
}
}
}
}
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
>
<title>
Modifier un article
</title>
<link
rel="stylesheet"
href="../style.css"
>
<style>
.edit-page {
max-width: 850px;
margin: 0 auto;
padding: 40px 20px;
}
.edit-header {
margin-bottom: 30px;
}
.edit-header h1 {
margin-bottom: 8px;
}
.edit-header p {
color: #6b7280;
}
.edit-card {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 14px;
padding: 28px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.06);
}
.form-group {
margin-bottom: 22px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 700;
color: #1f2937;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
box-sizing: border-box;
padding: 12px 14px;
border: 1px solid #d1d5db;
border-radius: 8px;
font: inherit;
background: #ffffff;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: #1677c8;
box-shadow: 0 0 0 3px rgba(22, 119, 200, 0.15);
}
.image-panel {
display: flex;
gap: 20px;
align-items: flex-start;
padding: 18px;
margin-bottom: 22px;
background: #f8fafc;
border: 1px solid #e5e7eb;
border-radius: 10px;
}
.preview {
width: 180px;
height: 120px;
object-fit: cover;
border-radius: 8px;
border: 1px solid #d1d5db;
}
.image-info {
flex: 1;
}
.image-info strong {
display: block;
margin-bottom: 8px;
}
.image-help {
display: block;
margin-top: 8px;
color: #6b7280;
font-size: 0.9rem;
}
.checkbox-line {
display: flex;
gap: 8px;
align-items: center;
margin-top: 14px;
font-weight: 400 !important;
}
.checkbox-line input {
width: auto;
}
.form-actions {
display: flex;
gap: 12px;
align-items: center;
margin-top: 28px;
}
.btn-secondary {
display: inline-block;
padding: 11px 16px;
color: #374151;
text-decoration: none;
border: 1px solid #d1d5db;
border-radius: 8px;
background: #ffffff;
}
.btn-secondary:hover {
background: #f3f4f6;
}
.notice {
padding: 14px 16px;
margin-bottom: 22px;
border-radius: 8px;
}
.notice-success {
color: #166534;
background: #dcfce7;
border: 1px solid #86efac;
}
.notice-error {
color: #991b1b;
background: #fee2e2;
border: 1px solid #fca5a5;
}
@media (max-width: 600px) {
.edit-card {
padding: 20px;
}
.image-panel {
display: block;
}
.preview {
width: 100%;
height: auto;
max-height: 260px;
margin-bottom: 14px;
}
.form-actions {
display: block;
}
.form-actions .btn,
.form-actions .btn-secondary {
display: block;
width: 100%;
text-align: center;
margin-bottom: 10px;
}
}
</style>
</head>
<body>
<main class="edit-page">
<header class="edit-header">
<h1>Modifier l’article</h1>
<p>
Mettez à jour le titre, le contenu, la catégorie ou l’image.
</p>
</header>
<?php if ($message !== ''): ?>
<div class="notice <?= htmlspecialchars(
$messageClass,
ENT_QUOTES,
'UTF-8'
) ?>">
<?= htmlspecialchars(
$message,
ENT_QUOTES,
'UTF-8'
) ?>
</div>
<?php endif; ?>
<?php if ($error !== ''): ?>
<div class="notice notice-error">
<?= htmlspecialchars(
$error,
ENT_QUOTES,
'UTF-8'
) ?>
</div>
<?php endif; ?>
<section class="edit-card">
<form
method="POST"
enctype="multipart/form-data"
>
<?php if ($currentImage): ?>
<div class="image-panel">
<img
class="preview"
src="../uploads/<?= htmlspecialchars(
$currentImage,
ENT_QUOTES,
'UTF-8'
) ?>"
alt="Image actuelle de l’article"
>
<div class="image-info">
<strong>Image actuelle</strong>
<span>
Vous pouvez la conserver,
la supprimer ou la remplacer.
</span>
<label class="checkbox-line">
<input
type="checkbox"
name="remove_image"
value="1"
>
Supprimer l’image actuelle
</label>
</div>
</div>
<?php else: ?>
<div class="image-panel">
<div class="image-info">
<strong>Aucune image associée</strong>
<span>
Vous pouvez ajouter une image ci-dessous.
</span>
</div>
</div>
<?php endif; ?>
<div class="form-group">
<label for="image">
Ajouter ou remplacer l’image
</label>
<input
type="file"
name="image"
id="image"
accept="
image/jpeg,
image/png,
image/gif,
image/webp
"
>
<small class="image-help">
Formats acceptés : JPG, PNG, GIF et WEBP.
Taille maximale : 2 Mo.
</small>
</div>
<div class="form-group">
<label for="title">
Titre
</label>
<input
type="text"
name="title"
id="title"
value="<?= htmlspecialchars(
$titleValue,
ENT_QUOTES,
'UTF-8'
) ?>"
required
>
</div>
<div class="form-group">
<label for="category_id">
Catégorie
</label>
<select
name="category_id"
id="category_id"
>
<option value="">
Sans catégorie
</option>
<?php foreach ($categories as $category): ?>
<option
value="<?= (int) $category['id'] ?>"
<?php
if (
$categoryValue !== null &&
(int) $categoryValue ===
(int) $category['id']
) {
echo 'selected';
}
?>
>
<?= htmlspecialchars(
$category['name'],
ENT_QUOTES,
'UTF-8'
) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label for="content">
Contenu
</label>
<textarea
name="content"
id="content"
rows="12"
required
><?= htmlspecialchars(
$contentValue,
ENT_QUOTES,
'UTF-8'
) ?></textarea>
</div>
<div class="form-actions">
<button
class="btn"
type="submit"
>
Enregistrer les modifications
</button>
<a
class="btn-secondary"
href="posts.php"
>
Annuler
</a>
</div>
</form>
</section>
</main>
</body>
</html>
Dans admin/delete-post.php, supprimez le fichier image avant de supprimer la ligne en base :
$stmt = $pdo->prepare('SELECT image FROM posts WHERE id = ?');
$stmt->execute([$id]);
$post = $stmt->fetch();
if ($post && $post['image']) {
$path = __DIR__ . '/../uploads/' . $post['image'];
if (is_file($path)) {
unlink($path);
}
}
$stmt = $pdo->prepare('DELETE FROM posts WHERE id = ?');
$stmt->execute([$id]);
Cette étape évite de laisser des fichiers inutilisés sur le serveur après la suppression d’un article.
Étape 5 : créer la gestion des catégories
Créez admin/categories.php. Cette page doit proposer un formulaire d’ajout ou de modification, puis afficher la liste des catégories avec le nombre d’articles associés.
Voici le code intégral et commenté de la page :
<?php
require_once 'auth.php';
require_once '../config/database.php';
$error = '';
$success = '';
$editCategory = null;
/*
|--------------------------------------------------------------------------
| Récupérer une catégorie à modifier
|--------------------------------------------------------------------------
*/
if (
isset($_GET['edit']) &&
is_numeric($_GET['edit'])
) {
$editId = (int) $_GET['edit'];
$stmt = $pdo->prepare(
'SELECT id, name
FROM categories
WHERE id = ?'
);
$stmt->execute([$editId]);
$editCategory = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$editCategory) {
$error = 'Catégorie introuvable.';
}
}
/*
|--------------------------------------------------------------------------
| Ajouter ou modifier une catégorie
|--------------------------------------------------------------------------
*/
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$name = trim(
isset($_POST['name'])
? $_POST['name']
: ''
);
$categoryId = filter_input(
INPUT_POST,
'category_id',
FILTER_VALIDATE_INT
);
if ($name === '') {
$error = 'Le nom de la catégorie est obligatoire.';
} elseif (mb_strlen($name) > 100) {
$error = 'Le nom ne doit pas dépasser 100 caractères.';
} else {
try {
if ($categoryId) {
$stmt = $pdo->prepare(
'UPDATE categories
SET name = ?
WHERE id = ?'
);
$stmt->execute([
$name,
$categoryId
]);
$success = 'Catégorie modifiée avec succès.';
} else {
$stmt = $pdo->prepare(
'INSERT INTO categories (name)
VALUES (?)'
);
$stmt->execute([$name]);
$success = 'Catégorie ajoutée avec succès.';
}
$editCategory = null;
} catch (PDOException $exception) {
/*
* MySQL utilise généralement le code 1062
* pour une valeur UNIQUE déjà existante.
*/
if (
isset($exception->errorInfo[1]) &&
(int) $exception->errorInfo[1] === 1062
) {
$error = 'Cette catégorie existe déjà.';
} else {
$error = 'Une erreur est survenue pendant l’enregistrement.';
}
}
}
}
/*
|--------------------------------------------------------------------------
| Supprimer une catégorie
|--------------------------------------------------------------------------
*/
if (
$_SERVER['REQUEST_METHOD'] === 'POST' &&
isset($_POST['delete_category'])
) {
$deleteId = filter_input(
INPUT_POST,
'delete_category',
FILTER_VALIDATE_INT
);
if ($deleteId) {
try {
$stmt = $pdo->prepare(
'DELETE FROM categories
WHERE id = ?'
);
$stmt->execute([$deleteId]);
$success = 'Catégorie supprimée avec succès.';
} catch (PDOException $exception) {
$error = 'Impossible de supprimer cette catégorie.';
}
}
}
/*
|--------------------------------------------------------------------------
| Liste des catégories
|--------------------------------------------------------------------------
*/
$stmt = $pdo->query(
'SELECT
c.id,
c.name,
COUNT(p.id) AS post_count
FROM categories AS c
LEFT JOIN posts AS p
ON p.category_id = c.id
GROUP BY c.id, c.name
ORDER BY c.name ASC'
);
$categories = $stmt->fetchAll(PDO::FETCH_ASSOC);
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
>
<title>Gestion des catégories</title>
<link
rel="stylesheet"
href="../style.css"
>
<style>
.categories-page {
max-width: 1000px;
margin: 0 auto;
padding: 40px 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20px;
margin-bottom: 30px;
}
.page-header h1 {
margin: 0 0 8px;
}
.page-header p {
margin: 0;
color: #6b7280;
}
.category-layout {
display: grid;
grid-template-columns: minmax(280px, 360px) 1fr;
gap: 24px;
align-items: start;
}
.panel {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 14px;
padding: 24px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.05);
}
.panel h2 {
margin-top: 0;
margin-bottom: 20px;
}
.form-group {
margin-bottom: 18px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 700;
color: #1f2937;
}
.form-group input {
width: 100%;
box-sizing: border-box;
padding: 12px 14px;
border: 1px solid #d1d5db;
border-radius: 8px;
font: inherit;
}
.form-group input:focus {
outline: none;
border-color: #1677c8;
box-shadow: 0 0 0 3px rgba(22, 119, 200, 0.15);
}
.form-actions {
display: flex;
gap: 10px;
align-items: center;
}
.btn {
display: inline-block;
border: 0;
border-radius: 8px;
padding: 11px 15px;
background: #1677c8;
color: #ffffff;
text-decoration: none;
cursor: pointer;
font: inherit;
}
.btn:hover {
background: #0e5d9f;
}
.btn-secondary {
display: inline-block;
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 10px 14px;
background: #ffffff;
color: #374151;
text-decoration: none;
}
.btn-secondary:hover {
background: #f3f4f6;
}
.btn-danger {
background: #c53030;
}
.btn-danger:hover {
background: #9b2c2c;
}
.notice {
padding: 14px 16px;
margin-bottom: 22px;
border-radius: 8px;
}
.notice-success {
color: #166534;
background: #dcfce7;
border: 1px solid #86efac;
}
.notice-error {
color: #991b1b;
background: #fee2e2;
border: 1px solid #fca5a5;
}
.category-list {
display: grid;
gap: 12px;
}
.category-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 16px;
border: 1px solid #e5e7eb;
border-radius: 10px;
background: #f8fafc;
}
.category-name {
font-weight: 700;
color: #1f2937;
}
.category-count {
display: block;
margin-top: 5px;
color: #6b7280;
font-size: 0.9rem;
}
.category-actions {
display: flex;
align-items: center;
gap: 10px;
white-space: nowrap;
}
.link-edit {
color: #1677c8;
text-decoration: none;
}
.link-edit:hover {
text-decoration: underline;
}
.inline-form {
display: inline;
}
.empty-state {
padding: 30px;
text-align: center;
color: #6b7280;
border: 1px dashed #d1d5db;
border-radius: 10px;
}
.page-footer {
margin-top: 25px;
}
@media (max-width: 760px) {
.page-header {
display: block;
}
.category-layout {
grid-template-columns: 1fr;
}
.category-item {
display: block;
}
.category-actions {
margin-top: 14px;
}
.form-actions {
display: block;
}
.form-actions .btn,
.form-actions .btn-secondary {
display: block;
width: 100%;
text-align: center;
margin-bottom: 10px;
box-sizing: border-box;
}
}
</style>
</head>
<body>
<main class="categories-page">
<header class="page-header">
<div>
<h1>Gestion des catégories</h1>
<p>
Organisez vos articles et facilitez leur navigation.
</p>
</div>
<a
class="btn-secondary"
href="dashboard.php"
>
Retour au tableau de bord
</a>
</header>
<?php if ($success !== ''): ?>
<div class="notice notice-success">
<?= htmlspecialchars(
$success,
ENT_QUOTES,
'UTF-8'
) ?>
</div>
<?php endif; ?>
<?php if ($error !== ''): ?>
<div class="notice notice-error">
<?= htmlspecialchars(
$error,
ENT_QUOTES,
'UTF-8'
) ?>
</div>
<?php endif; ?>
<div class="category-layout">
<section class="panel">
<h2>
<?= $editCategory
? 'Modifier la catégorie'
: 'Ajouter une catégorie'
?>
</h2>
<form method="post">
<?php if ($editCategory): ?>
<input
type="hidden"
name="category_id"
value="<?= (int) $editCategory['id'] ?>"
>
<?php endif; ?>
<div class="form-group">
<label for="name">
Nom de la catégorie
</label>
<input
type="text"
name="name"
id="name"
maxlength="100"
value="<?= htmlspecialchars(
$editCategory
? $editCategory['name']
: '',
ENT_QUOTES,
'UTF-8'
) ?>"
placeholder="Exemple : PHP"
required
>
</div>
<div class="form-actions">
<button
class="btn"
type="submit"
>
<?= $editCategory
? 'Enregistrer les modifications'
: 'Ajouter la catégorie'
?>
</button>
<?php if ($editCategory): ?>
<a
class="btn-secondary"
href="categories.php"
>
Annuler
</a>
<?php endif; ?>
</div>
</form>
</section>
<section class="panel">
<h2>
Catégories existantes
</h2>
<?php if (count($categories) > 0): ?>
<div class="category-list">
<?php foreach ($categories as $category): ?>
<div class="category-item">
<div>
<div class="category-name">
<?= htmlspecialchars(
$category['name'],
ENT_QUOTES,
'UTF-8'
) ?>
</div>
<span class="category-count">
<?= (int) $category['post_count'] ?>
article<?=
(int) $category['post_count'] > 1
? 's'
: ''
?>
</span>
</div>
<div class="category-actions">
<a
class="link-edit"
href="categories.php?edit=<?=
(int) $category['id']
?>"
>
Modifier
</a>
<form
class="inline-form"
method="post"
>
<button
class="btn btn-danger"
type="submit"
name="delete_category"
value="<?= (int) $category['id'] ?>"
onclick="
return confirm(
'Supprimer cette catégorie ? ' +
'Les articles resteront publiés.'
);
"
>
Supprimer
</button>
</form>
</div>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<div class="empty-state">
Aucune catégorie n’a encore été créée.
</div>
<?php endif; ?>
</section>
</div>
<div class="page-footer">
<a href="posts.php">
Retour à la gestion des articles
</a>
</div>
</main>
</body>
</html>
Étape 6 : filtrer les articles par catégorie
Dans index.php, récupérez la catégorie éventuelle dans l’URL :
$categoryId = filter_input(
INPUT_GET,
'category',
FILTER_VALIDATE_INT
);
Utilisez ensuite une requête différente selon que le visiteur a choisi une catégorie :
if ($categoryId) {
$stmt = $pdo->prepare(
'SELECT p.*, c.name AS category_name
FROM posts p
LEFT JOIN categories c ON c.id = p.category_id
WHERE p.category_id = ?
ORDER BY p.created_at DESC'
);
$stmt->execute([$categoryId]);
} else {
$stmt = $pdo->query(
'SELECT p.*, c.name AS category_name
FROM posts p
LEFT JOIN categories c ON c.id = p.category_id
ORDER BY p.created_at DESC'
);
}
$categoriesStmt = $pdo->query(
'SELECT id, name
FROM categories
ORDER BY name ASC'
);
$categories = $categoriesStmt->fetchAll(
PDO::FETCH_ASSOC
);
Affichez les liens de filtrage au-dessus de la liste :
<a class="tag" href="index.php">Toutes les catégories</a>
<?php foreach ($categories as $category): ?>
<a
class="tag"
href="index.php?category=<?= $category['id'] ?>"
>
<?= htmlspecialchars($category['name']) ?>
</a>
<?php endforeach; ?>
Le visiteur peut maintenant afficher tous les articles ou limiter la liste à une catégorie particulière.
La page complète améliorée :
<?php
/*
|--------------------------------------------------------------------------
| Connexion à la base
|--------------------------------------------------------------------------
*/
require_once 'config/database.php';
/*
|--------------------------------------------------------------------------
| Fonction d'échappement HTML
|--------------------------------------------------------------------------
*/
function escapeHtml($value)
{
return htmlspecialchars(
(string) $value,
ENT_QUOTES,
'UTF-8'
);
}
/*
|--------------------------------------------------------------------------
| Récupération des catégories
|--------------------------------------------------------------------------
*/
$categoriesStatement = $pdo->query(
'SELECT id, name
FROM categories
ORDER BY name ASC'
);
$categories = $categoriesStatement->fetchAll(
PDO::FETCH_ASSOC
);
/*
|--------------------------------------------------------------------------
| Récupération de la catégorie sélectionnée
|--------------------------------------------------------------------------
*/
$categoryId = filter_input(
INPUT_GET,
'category',
FILTER_VALIDATE_INT
);
if (!$categoryId || $categoryId < 1) {
$categoryId = null;
}
/*
|--------------------------------------------------------------------------
| Récupération des articles
|--------------------------------------------------------------------------
*/
$sql = '
SELECT
p.id,
p.title,
p.content,
p.image,
p.created_at,
p.category_id,
c.name AS category_name
FROM posts AS p
LEFT JOIN categories AS c
ON c.id = p.category_id
';
if ($categoryId !== null) {
$sql .= '
WHERE p.category_id = :category_id
';
}
$sql .= '
ORDER BY p.created_at DESC
';
$postsStatement = $pdo->prepare($sql);
if ($categoryId !== null) {
$postsStatement->bindValue(
':category_id',
$categoryId,
PDO::PARAM_INT
);
}
$postsStatement->execute();
$posts = $postsStatement->fetchAll(
PDO::FETCH_ASSOC
);
/*
|--------------------------------------------------------------------------
| Titre de la page
|--------------------------------------------------------------------------
*/
$pageTitle = 'Derniers articles - Mini Blog';
$title = $pageTitle;
if ($categoryId !== null) {
foreach ($categories as $category) {
if ((int) $category['id'] === $categoryId) {
$pageTitle = $category['name'] . ' - Mini Blog';
$title = $pageTitle;
break;
}
}
}
include 'includes/header.php';
?>
<style>
.blog-page {
padding: 30px 0 50px;
}
.blog-header {
margin-bottom: 28px;
}
.blog-header h1 {
margin: 0 0 8px;
color: #172033;
}
.blog-header p {
margin: 0;
color: #64748b;
}
.category-navigation {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 24px 0 30px;
}
.category-tag {
display: inline-flex;
align-items: center;
min-height: 38px;
padding: 8px 14px;
border: 1px solid #dbe3ea;
border-radius: 999px;
background: #ffffff;
color: #24506f;
text-decoration: none;
transition:
background 0.2s ease,
color 0.2s ease,
border-color 0.2s ease;
}
.category-tag:hover {
background: #eaf4fb;
border-color: #7db9dc;
}
.category-tag.active {
background: #1677c8;
border-color: #1677c8;
color: #ffffff;
}
.articles-list {
display: grid;
gap: 22px;
}
.article-card {
display: grid;
grid-template-columns: 230px minmax(0, 1fr);
gap: 22px;
padding: 20px;
border: 1px solid #e5e7eb;
border-radius: 14px;
background: #ffffff;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.article-card:hover {
transform: translateY(-2px);
box-shadow: 0 14px 30px rgba(15, 23, 42, 0.10);
}
.article-image,
.article-image-placeholder {
width: 230px;
height: 155px;
border-radius: 10px;
}
.article-image {
display: block;
object-fit: cover;
background: #eef2f5;
}
.article-image-placeholder {
display: flex;
align-items: center;
justify-content: center;
background: #eef2f5;
color: #94a3b8;
font-size: 0.9rem;
}
.article-body {
min-width: 0;
}
.article-meta {
margin: 0 0 10px;
color: #64748b;
font-size: 0.9rem;
}
.article-card h2 {
margin: 0 0 12px;
color: #172033;
line-height: 1.25;
}
.article-excerpt {
margin: 0 0 18px;
color: #475569;
line-height: 1.65;
}
.article-empty {
padding: 35px;
border: 1px dashed #cbd5e1;
border-radius: 12px;
background: #f8fafc;
color: #64748b;
text-align: center;
}
.btn {
display: inline-block;
padding: 10px 15px;
border: 0;
border-radius: 8px;
background: #1677c8;
color: #ffffff;
text-decoration: none;
transition: background 0.2s ease;
}
.btn:hover {
background: #0e5d9f;
}
@media (max-width: 700px) {
.article-card {
display: block;
}
.article-image,
.article-image-placeholder {
width: 100%;
height: 220px;
margin-bottom: 18px;
}
}
</style>
<main class="blog-page">
<header class="blog-header">
<h1>
<?php if ($categoryId !== null): ?>
Articles de la catégorie sélectionnée
<?php else: ?>
Derniers articles
<?php endif; ?>
</h1>
<p>
Découvrez les dernières publications du blog.
</p>
</header>
<nav
class="category-navigation"
aria-label="Filtrer les articles par catégorie"
>
<a
class="category-tag <?php
echo $categoryId === null
? 'active'
: '';
?>"
href="index.php"
>
Toutes les catégories
</a>
<?php foreach ($categories as $category): ?>
<?php
$isActive = (
$categoryId !== null &&
(int) $category['id'] === $categoryId
);
?>
<a
class="category-tag <?=
$isActive ? 'active' : ''
?>"
href="index.php?category=<?=
(int) $category['id']
?>"
>
<?= escapeHtml($category['name']) ?>
</a>
<?php endforeach; ?>
</nav>
<?php if (count($posts) > 0): ?>
<section class="articles-list">
<?php foreach ($posts as $post): ?>
<?php
$content = (string) $post['content'];
if (function_exists('mb_substr')) {
$excerpt = mb_substr(
$content,
0,
220,
'UTF-8'
);
$hasMoreContent = mb_strlen(
$content,
'UTF-8'
) > 220;
} else {
$excerpt = substr(
$content,
0,
220
);
$hasMoreContent = strlen($content) > 220;
}
$categoryName = 'Sans catégorie';
if (
isset($post['category_name']) &&
$post['category_name'] !== ''
) {
$categoryName = $post['category_name'];
}
?>
<article class="article-card">
<?php if (
isset($post['image']) &&
$post['image'] !== ''
): ?>
<img
class="article-image"
src="uploads/<?= escapeHtml(
$post['image']
) ?>"
alt="<?= escapeHtml(
$post['title']
) ?>"
loading="lazy"
>
<?php else: ?>
<div
class="article-image-placeholder"
aria-hidden="true"
>
Aucun visuel
</div>
<?php endif; ?>
<div class="article-body">
<p class="article-meta">
<?= escapeHtml(
$categoryName
) ?>
·
<?= escapeHtml(
$post['created_at']
) ?>
</p>
<h2>
<?= escapeHtml(
$post['title']
) ?>
</h2>
<p class="article-excerpt">
<?= nl2br(
escapeHtml($excerpt)
) ?>
<?php if ($hasMoreContent): ?>
…
<?php endif; ?>
</p>
<a
class="btn"
href="article.php?id=<?= (int) $post['id'] ?>"
>
Lire la suite
</a>
</div>
</article>
<?php endforeach; ?>
</section>
<?php else: ?>
<p class="article-empty">
Aucun article ne correspond à cette sélection.
</p>
<?php endif; ?>
</main>
<?php include 'includes/footer.php'; ?>
Étape 7 : ajouter la navigation d’administration
Dans la barre latérale de admin/dashboard.php, ajoutez le lien vers la gestion des catégories :
<a href="dashboard.php">Tableau de bord</a>
<a href="create-post.php">Nouvel article</a>
<a href="posts.php">Articles</a>
<a href="categories.php">Catégories</a>
<a href="logout.php">Déconnexion</a>
Bonnes pratiques à retenir
Une image téléversée par un utilisateur ne doit jamais être acceptée uniquement à partir de son extension. Le projet vérifie donc le type MIME réel avec finfo, limite la taille à 2 Mo et renomme le fichier avec une valeur aléatoire.
Pour un site destiné à la production, ajoutez également une protection CSRF sur les formulaires, une limitation du nombre de téléversements, une configuration stricte du serveur web dans uploads/ et une validation encore plus poussée des images.
Conclusion
Le mini-blog dispose maintenant d’une véritable base éditoriale. Les images donnent davantage de relief aux articles, tandis que les catégories facilitent leur organisation et leur consultation.
Sur notre dernier article fin août, nous améliorerons l’éditeur de texte et l’aspect global. En bonus, l’archive ZIP complète du projet, prêt à installer sur votre serveur ! Rendez-vous avant le 31 !

