WordPress

Comprendre où va votre code, et pourquoi les hooks suffisent à tout faire sans jamais modifier le cœur.

À la fin de ce cours, vous saurez

  1. Dire où va un bout de code — thème enfant ou greffon — et justifier le choix.
  2. Distinguer une action d’un filtre, et écrire les deux.
  3. Déclarer un type de contenu personnalisé avec sa taxonomie, et le voir dans l’administration.
  4. Faire s’afficher ce type en trouvant le bon gabarit dans la hiérarchie.
  5. É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.

Sujets hors périmètre et raison de leur exclusion
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 &lt;script&gt;alert(1)&lt;/script&gt;

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.