WordPress
Comprendre où va votre code, et pourquoi les hooks suffisent à tout faire sans jamais modifier le cœur.
- Hooks
- Thème enfant et greffon
- Types de contenu
- Quatre gestes de sécurité
À la fin de ce cours, vous saurez
- Dire où va un bout de code — thème enfant ou greffon — et justifier le choix.
- Distinguer une action d’un filtre, et écrire les deux.
- Déclarer un type de contenu personnalisé avec sa taxonomie, et le voir dans l’administration.
- Faire s’afficher ce type en trouvant le bon gabarit dans la hiérarchie.
- Échapper une sortie et vérifier un nonce — c’est-à-dire ne pas ouvrir une faille en écrivant un greffon.
Avant de commencer
- PHP débutant — variables, fonctions, tableaux — WordPress reste simple de syntaxe
- HTML — un gabarit de thème est du balisage
- CSS — la feuille du thème enfant
Ce que ce cours ne couvre pas
WordPress est un monde. Cette page enseigne le développement — hooks, thème, greffon, types de contenu. Le reste a ses propres cours, ou les aura.
| Hors périmètre | Pourquoi |
|---|---|
| Installation serveur, DNS, certificats, hébergement | c’est du système, pas du WordPress — Linux |
| Blocs Gutenberg en React (@wordpress/scripts, block.json) | un cours JavaScript entier, et un outillage npm à part |
| WooCommerce, multisite, API REST avancée, cache et performance | chacun est un cours à lui seul |
| L’administration au clic — rédiger, régler, installer un thème | ce n’est pas du développement |
| Les constructeurs de pages (Elementor, Divi…) | ils remplacent précisément ce que ce cours enseigne |
Anatomie d’une installation
WordPress est un socle qu’on ne modifie pas, et deux endroits où l’on écrit. Savoir lequel des deux est la première compétence du cours.
Les trois zones
bash
wp-admin/ # le cœur : l'administration
wp-includes/ # le cœur : les fonctions de WordPress
wp-content/ # LE VÔTRE
├── themes/ # l'apparence et les gabarits
├── plugins/ # les fonctionnalités
└── uploads/ # les fichiers envoyés
wp-config.php # la configuration (base de données, clefs, débogage)
wp-admin/ et wp-includes/ sont réécrits à chaque mise à jour. Un fichier modifié là disparaît sans prévenir, et la mise à jour est le premier geste de sécurité d’un site. **On n’y touche jamais.**
Thème ou greffon : la règle de partage
Les deux peuvent tout faire techniquement. La règle n’est pas technique, elle est de survie :
Ce qui touche à l’APPARENCE va dans le thème : gabarits, feuilles de style, menus, zones de widgets.
Ce qui touche aux DONNÉES et au COMPORTEMENT va dans un greffon : types de contenu, taxonomies, champs, traitements.
La question qui tranche : « si je change de thème demain, dois-je perdre ceci ? ». Un type de contenu déclaré dans un thème disparaît avec lui — les articles restent en base, mais plus rien ne sait les afficher, ni même qu’ils existent. C’est l’erreur la plus coûteuse du développement WordPress, et elle ne se voit que le jour du changement de thème.
Le bac à sable de cette page
Tout ce que vous lirez ici a tourné sur une installation réelle. Les versions, relevées le 16 août 2026 :
Sortie
WordPress 7.0.4 (fr_FR)
WP-CLI 2.12.0
PHP 8.4.22
WordPress 7.0.4 exige PHP 7.4 et MySQL 5.5.5 au minimum (source : api.wordpress.org/core/version-check). Le fil rouge est un seul objet, construit d’un bout à l’autre : un type de contenu recette.
Les hooks : actions et filtres
C’est LE mécanisme de WordPress. Tout le reste en découle. Le cœur appelle, à des moments nommés, toutes les fonctions qu’on a inscrites — et vous n’avez jamais à modifier le cœur pour intervenir.
Deux familles, une différence
Une ACTION se déclenche à un moment donné. Elle ne rend rien : elle fait quelque chose. add_action('init', …).
Un FILTRE reçoit une valeur, et doit en RENDRE une. S’il ne rend rien, la valeur devient null et la page casse.
php
<?php
// Une ACTION : elle agit, elle ne rend rien.
add_action('init', function () {
register_post_type('recette', [/* ... */]);
});
// Un FILTRE : il reçoit, il transforme, il REND.
add_filter('the_title', function ($titre, $id = 0) {
return get_post_type($id) === 'recette' ? '🍲 ' . $titre : $titre;
}, 10, 2);
Sortie
post 4 → 🍲 Tarte aux pommes
Les deux nombres qu’on oublie
add_action et add_filter prennent deux arguments de plus, et les oublier est la cause de la moitié des pannes de débutant.
La PRIORITÉ (10 par défaut) : plus le nombre est petit, plus tôt la fonction passe. Deux greffons qui se disputent le même filtre se départagent là.
Le NOMBRE D’ARGUMENTS (1 par défaut) : sans le déclarer, votre fonction ne recevra JAMAIS le deuxième paramètre, même s’il existe. C’est le 2 de l’exemple ci-dessus.
Combien de monde y a-t-il là-dedans ?
init n’est pas un crochet vide qui vous attend. Sur l’installation de cette page — un thème, deux greffons — voici ce qui s’y trouve déjà :
php
<?php
global $wp_filter;
$hook = $wp_filter['init'];
$total = 0;
foreach ($hook->callbacks as $priorite => $fonctions) {
$total += count($fonctions);
}
echo count($hook->callbacks) . " priorités, $total fonctions accrochées sur init";
Sortie
11 priorités, 124 fonctions accrochées sur init
Cent vingt-quatre fonctions, sur un site quasi vide. C’est ce que veut dire « extensible » : vous n’êtes jamais seul sur un hook, et c’est pourquoi la priorité existe.
Le thème enfant
Modifier un thème téléchargé, c’est perdre ses modifications à la première mise à jour. Le thème ENFANT hérite de tout et ne contient que vos différences.
Deux fichiers suffisent
css
/*
Theme Name: STJO Enfant
Template: twentytwentyone
Version: 1.0.0
Text Domain: stjo-enfant
*/
Template: est la clef de voûte : c’est le nom du DOSSIER du thème parent, pas son titre affiché. Une faute de frappe ici, et WordPress refuse d’activer le thème.
php
<?php
add_action('wp_enqueue_scripts', function () {
wp_enqueue_style(
'stjo-enfant',
get_stylesheet_uri(),
['twenty-twenty-one-style'],
wp_get_theme()->get('Version')
);
});
wp_enqueue_style déclare une feuille au lieu de l’écrire à la main dans l’en-tête. WordPress gère alors l’ordre, les dépendances — le troisième argument dit « après celle du parent » — et la version, qui sert de cache-buster.
N’écrivez jamais une balise <link> ou <script> directement dans un gabarit. Vous perdez l’ordre de chargement, la déduplication et la gestion de version, et vous cassez tout greffon qui dépendait de votre feuille.
Sortie
Success: Switched to 'STJO Enfant' theme.
name version status
stjo-enfant 1.0.0 active
twentytwentyone 2.8 parent
Pourquoi ce cours enseigne un thème CLASSIQUE
Depuis WordPress 5.9, les thèmes livrés par défaut — twentytwentyfive notamment — sont des thèmes à BLOCS. Leur apparence ne se règle presque plus dans des gabarits PHP, mais dans un fichier theme.json et dans l’éditeur de site.
Ce cours part d’un thème classique, twentytwentyone, pour une raison précise : functions.php, les hooks et la hiérarchie de gabarits sont le socle TRANSFÉRABLE. Ils servent dans un thème classique, dans un greffon, et dans un thème à blocs. theme.json et l’éditeur de blocs sont un sujet à part entière, et feront leur propre cours.
Si vous ouvrez twentytwentyfive chez vous et n’y trouvez ni single.php ni archive.php, ce n’est pas une erreur : c’est un thème à blocs, et ses gabarits sont des fichiers HTML dans templates/. Ce que vous apprenez ici reste vrai — il s’applique ailleurs dans le même thème.
Écrire un greffon
Un greffon, c’est un dossier dans wp-content/plugins/ et un fichier PHP avec un en-tête en commentaire. Rien de plus.
Le fichier principal
php
<?php
/**
* Plugin Name: STJO Recettes
* Description: Déclare le type de contenu « recette », sa taxonomie et son champ de préparation.
* Version: 1.0.0
* Author: Lycée Saint Joseph Lasalle
* Text Domain: stjo-recettes
*/
if (! defined('ABSPATH')) {
exit;
}
Plugin Name: est le seul champ obligatoire : c’est lui qui fait reconnaître le fichier comme un greffon.
if (! defined('ABSPATH')) exit; est la première ligne de tout fichier de greffon. ABSPATH n’est définie que si WordPress a démarré. Sans cette garde, quiconque appelle votre fichier directement par son URL l’exécute HORS de WordPress — sans utilisateur, sans droits, sans aucune des protections du cœur.
Activation et désactivation
php
<?php
register_activation_hook(__FILE__, function () {
stjo_declarer_recette();
flush_rewrite_rules();
});
register_deactivation_hook(__FILE__, 'flush_rewrite_rules');
flush_rewrite_rules() recalcule les règles d’URL. Sans cet appel à l’activation, l’adresse /recettes/tarte-aux-pommes/ répond 404 alors que le contenu existe — c’est le grand classique du type de contenu personnalisé. Ne l’appelez QUE là : c’est une opération coûteuse, et l’exécuter à chaque chargement de page ralentit tout le site.
bash
wp plugin activate stjo-recettes
Sortie
Success: Activated 1 of 1 plugins.
Types de contenu et taxonomies
Un article et une page ne suffisent pas toujours. Une recette a une durée, une saison, une image — et elle n’a rien à faire dans le flux des actualités.
Déclarer le type
php
<?php
add_action('init', 'stjo_declarer_recette');
function stjo_declarer_recette(): void
{
register_post_type('recette', [
'labels' => [
'name' => 'Recettes',
'singular_name' => 'Recette',
'add_new_item' => 'Ajouter une recette',
],
'public' => true,
'has_archive' => true,
'menu_icon' => 'dashicons-food',
'supports' => ['title', 'editor', 'thumbnail', 'excerpt'],
'rewrite' => ['slug' => 'recettes'],
'show_in_rest' => true,
]);
}
Les réglages qui comptent vraiment
public — le type est-il visible côté site et côté administration ? À false, il devient un stockage interne, invisible partout.
has_archive — WordPress crée-t-il une page listant toutes les recettes, à /recettes/ ? Oubliez-le, et l’archive n’existe pas.
supports — quelles boîtes apparaissent dans l’éditeur. Sans thumbnail, pas d’image mise en avant ; sans excerpt, pas de résumé. La liste par défaut est courte : title et editor.
show_in_rest — à true, le type entre dans l’API REST **et** devient éditable avec Gutenberg. À false, l’éditeur reste l’ancien.
rewrite['slug'] — le morceau d’URL. Attention aux collisions : un slug identique à celui d’une page existante rend l’une des deux inaccessible.
La taxonomie qui va avec
Une taxonomie classe. saison est hiérarchique — comme les catégories, avec des parents ; une taxonomie non hiérarchique se comporte comme les étiquettes.
php
<?php
register_taxonomy('saison', 'recette', [
'labels' => ['name' => 'Saisons', 'singular_name' => 'Saison'],
'public' => true,
'hierarchical' => true,
'rewrite' => ['slug' => 'saison'],
'show_in_rest' => true,
]);
Sortie
name public has_archive
recette 1 1
name object_type
saison recette
register_post_type et register_taxonomy s’appellent sur init, jamais avant. Plus tôt, les traductions ne sont pas chargées ; plus tard, WordPress a déjà décidé quoi afficher.
La hiérarchie de gabarits
Le type existe, il a des contenus, et il s’affiche… avec le gabarit des articles. Il faut maintenant comprendre comment WordPress choisit le fichier qui rend une page.
Du plus précis au plus général
Pour une recette isolée, WordPress cherche dans cet ordre et s’arrête au premier fichier trouvé :
bash
single-recette.php # ce type précisément
single.php # n'importe quel contenu isolé
singular.php # isolé, article ou page
index.php # le dernier recours, toujours présent
Le principe vaut partout : archive-recette.php avant archive.php, taxonomy-saison.php avant taxonomy.php. Créer le fichier suffit — il n’y a rien à déclarer.
Le gabarit d’une recette
php
<?php get_header(); ?>
<main id="primary" class="site-main">
<?php while (have_posts()) : the_post(); ?>
<article <?php post_class(); ?>>
<h1><?php the_title(); ?></h1>
<?php $minutes = get_post_meta(get_the_ID(), '_stjo_minutes', true); ?>
<?php if ($minutes) : ?>
<p class="preparation">
Préparation : <?php echo esc_html($minutes); ?> minutes
</p>
<?php endif; ?>
<?php the_terms(get_the_ID(), 'saison', 'Saison : ', ', '); ?>
<div class="entry-content"><?php the_content(); ?></div>
</article>
<?php endwhile; ?>
</main>
<?php get_footer(); ?>
Vérifier ce que WordPress a choisi
On n’a pas à deviner : le filtre template_include reçoit le chemin du fichier retenu, juste avant qu’il soit inclus.
php
<?php
add_filter('template_include', function ($gabarit) {
echo 'Gabarit retenu : ' . str_replace(ABSPATH, '', $gabarit) . "\n";
return $gabarit;
}, 999);
Sortie
Gabarit retenu : wp-content/themes/stjo-enfant/single-recette.php
Préparation : 45 minutes
Saison : automne
Ce traceur est un outil de développement : la priorité 999 le fait passer en dernier, et il REND le gabarit reçu — un filtre qui ne rend rien casse la page. À retirer avant la mise en ligne.
La boucle et les requêtes
Chaque page WordPress commence par une requête, faite avant même que votre gabarit s’exécute. La boucle ne fait que parcourir son résultat.
La boucle
php
<?php
while (have_posts()) :
the_post();
the_title('<h2>', '</h2>');
the_excerpt();
endwhile;
the_post() avance d’un cran et prépare les fonctions the_*(). Sans lui, the_title() renvoie toujours le même contenu — ou rien.
Une requête secondaire
php
<?php
$q = new WP_Query([
'post_type' => 'recette',
'posts_per_page' => 3,
'tax_query' => [[
'taxonomy' => 'saison',
'field' => 'slug',
'terms' => 'automne',
]],
]);
echo $q->found_posts . " recette(s) trouvée(s)\n";
while ($q->have_posts()) {
$q->the_post();
echo '- ' . get_the_title() . "\n";
}
wp_reset_postdata();
Sortie
1 recette(s) trouvée(s)
- Tarte aux pommes
wp_reset_postdata() n’est pas optionnel. Une WP_Query secondaire remplace le contenu courant ; sans réinitialisation, tout ce qui suit dans la page parle du dernier contenu de VOTRE requête, et non de celui de la page.
Modifier la requête principale
Pour changer ce que la page affiche déjà, on ne lance pas une seconde requête : on modifie la première, avant qu’elle parte.
php
<?php
add_action('pre_get_posts', function (WP_Query $query) {
if (is_admin() || ! $query->is_main_query() || ! $query->is_home()) {
return;
}
$query->set('post_type', ['post', 'recette']);
});
Les trois gardes sont obligatoires. is_admin() évite de saccager l’administration ; is_main_query() évite de toucher les requêtes des greffons ; is_home() limite à la page voulue. Sans elles, ce hook modifie TOUTES les requêtes du site.
Ne jamais interroger la base directement
La tentation existe, $wpdb est là. Mais une requête SQL écrite à la main saute les permissions, les statuts de publication, le cache d’objets, les filtres des greffons et le support multisite. WP_Query fait tout cela. Sur les rares cas qu’elle ne couvre pas, $wpdb->prepare() est obligatoire — la concaténation est une injection SQL.
Les quatre gestes de sécurité
Un greffon, c’est du PHP exposé sur le Web. WordPress fournit quatre mécanismes, et ils ne sont pas interchangeables : chacun répond à une question différente. Les voici tous les quatre sur une seule boîte de l’éditeur — celle qui enregistre la durée d’une recette.
1. Échapper à l’AFFICHAGE
La règle : échapper le plus tard possible, et dans le contexte exact où la donnée est écrite. Il y a une fonction par contexte.
php
<?php
esc_html($x) // dans du texte
esc_attr($x) // dans un attribut
esc_url($x) // dans un href ou un src
esc_textarea($x) // dans un <textarea>
wp_kses_post($x) // du HTML autorisé, filtré sur la liste des balises d'article
php
<?php
$titre = "Tarte <script>alert(1)</script>";
echo "sans échappement : $titre\n";
echo "avec esc_html : " . esc_html($titre) . "\n";
Sortie
sans échappement : Tarte <script>alert(1)</script>
avec esc_html : Tarte <script>alert(1)</script>
La première ligne EST une faille XSS : le navigateur exécuterait ce script. La seconde affiche les caractères. C’est toute la différence, et elle tient à un appel de fonction.
2. Sanitiser à l’ENTRÉE
Échapper protège l’affichage ; sanitiser décide de la FORME de ce qu’on stocke. Les deux sont nécessaires, à deux moments différents.
php
<?php
absint($x) // un entier positif, et rien d'autre
sanitize_text_field($x) // du texte sur une ligne, balises ôtées
sanitize_email($x)
sanitize_key($x) // un identifiant : minuscules, chiffres, - et _
wp_kses_post($x) // du HTML restreint
3. Le nonce : d’où vient cette requête ?
Un nonce est un jeton à usage limité qui prouve que le formulaire soumis vient bien de la page que vous avez servie. C’est la protection contre le CSRF, la même idée que le jeton du cours sur la sécurisation des formulaires — WordPress la fournit en deux fonctions.
4. Les capacités : cette personne a-t-elle le droit ?
current_user_can() interroge le rôle. Ne testez jamais le rôle lui-même (administrator) : testez la CAPACITÉ (edit_post), qui survit aux rôles personnalisés.
Les quatre ensemble, sur la recette
php
<?php
add_action('add_meta_boxes', function () {
add_meta_box('stjo-preparation', 'Préparation', 'stjo_afficher_boite', 'recette', 'side');
});
function stjo_afficher_boite(WP_Post $post): void
{
// 1. Le nonce : il prouve que le formulaire vient bien de cette page.
wp_nonce_field('stjo_enregistrer_preparation', 'stjo_nonce');
$minutes = get_post_meta($post->ID, '_stjo_minutes', true);
// 2. Échapper à l'AFFICHAGE, dans le contexte exact — ici un attribut.
printf(
'<label for="stjo-minutes">Durée en minutes</label>
<input type="number" id="stjo-minutes" name="stjo_minutes" min="1" value="%s">',
esc_attr($minutes)
);
}
add_action('save_post_recette', function (int $id) {
// 3. Les capacités : cette personne a-t-elle le DROIT de modifier ceci ?
if (! current_user_can('edit_post', $id)) {
return;
}
if (! isset($_POST['stjo_nonce'])
|| ! wp_verify_nonce($_POST['stjo_nonce'], 'stjo_enregistrer_preparation')) {
return;
}
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
return;
}
// 4. Sanitiser à l'ENTRÉE : on décide de la forme, on ne la subit pas.
$minutes = absint($_POST['stjo_minutes'] ?? 0);
$minutes > 0
? update_post_meta($id, '_stjo_minutes', $minutes)
: delete_post_meta($id, '_stjo_minutes');
});
Sortie
Success: Updated custom field '_stjo_minutes'.
45
L’ordre des trois gardes n’est pas indifférent : les capacités D’ABORD. Vérifier un nonce avant les droits revient à demander « ce formulaire vient-il de chez nous ? » avant « cette personne a-t-elle le droit ? » — un utilisateur légitime mais sans droits passerait le premier test.
DOING_AUTOSAVE : WordPress enregistre automatiquement un brouillon sans passer par votre formulaire. Sans cette garde, l’enregistrement automatique EFFACE votre champ, puisque $_POST['stjo_minutes'] est absent. Le symptôme est déroutant : la valeur disparaît toute seule au bout d’une minute.
Le préfixe, et le tiret bas
- Préfixez TOUT ce que vous déclarez —
stjo_ici. Fonctions, méta, options, hooks : tout vit dans le même espace global, partagé avec des dizaines de greffons. - Une méta dont la clef commence par un tiret bas (
_stjo_minutes) n’apparaît pas dans la liste des champs personnalisés de l’éditeur. C’est ce qu’on veut pour un champ qui a déjà sa propre boîte.
Pour aller plus loin
Ces quatre gestes sont les OUTILS de WordPress. Le FOND — pourquoi le XSS fonctionne, ce qu’est vraiment une attaque CSRF, comment une injection SQL se construit — est traité ailleurs, et vaut pour tout PHP.
Les hooks, et tout le reste suit
Vous n’avez modifié aucun fichier du cœur, et vous avez ajouté un type de contenu, sa taxonomie, son champ, son gabarit et ses protections. C’est tout le principe de WordPress, et il tient dans deux fonctions.