Chapitre 7.9 — Tutoriel : le plugin wpr-books
⏱️ TL;DR — On assemble tout ce que la Partie 7 a enseigné en un plugin complet et sûr :
wpr-books. Il enregistre un CPTlivre+ une taxonomiegenre(ch. 7.3), attache des métadonnées ISBN/prix viaregister_post_metaet une metabox sécurisée (ch. 7.4), ajoute une page de réglages (ch. 7.5), un shortcode de listing (ch. 7.6), applique les 4 piliers de sécurité (ch. 7.7), et i18n partout (ch. 7.8). Ce plugin sera étendu en bloc React (Partie 8) puis consommé en headless (Partie 9).
🎯 Objectifs
- Structurer un plugin réel de bout en bout.
- Combiner CPT, taxonomie, méta, admin, shortcode, sécurité, i18n.
- Poser les bases pour l’extension bloc (Partie 8) et REST/headless (Partie 9).
1. Structure et fichier principal
wpr-books/
├── wpr-books.php ← en-tête + amorçage
├── uninstall.php
├── includes/
│ ├── class-plugin.php ← hooks + orchestration
│ ├── class-cpt.php ← CPT + taxonomie
│ ├── class-meta.php ← métadonnées + metabox
│ ├── class-settings.php ← page de réglages
│ └── class-shortcode.php
└── languages/wpr-books.php :
<?php
/**
* Plugin Name: WPR Books
* Description: Catalogue de livres (CPT, taxonomie, méta, réglages, shortcode).
* Version: 1.0.0
* Requires PHP: 8.1
* Text Domain: wpr-books
* Domain Path: /languages
* License: GPL-2.0-or-later
*/
if ( ! defined( 'ABSPATH' ) ) { exit; }
define( 'WPR_BOOKS_FILE', __FILE__ );
define( 'WPR_BOOKS_PATH', plugin_dir_path( __FILE__ ) );
define( 'WPR_BOOKS_URL', plugin_dir_url( __FILE__ ) );
require_once WPR_BOOKS_PATH . 'includes/class-plugin.php';
require_once WPR_BOOKS_PATH . 'includes/class-cpt.php';
require_once WPR_BOOKS_PATH . 'includes/class-meta.php';
require_once WPR_BOOKS_PATH . 'includes/class-settings.php';
require_once WPR_BOOKS_PATH . 'includes/class-shortcode.php';
// Activation : enregistrer le CPT PUIS flush (une seule fois)
register_activation_hook( __FILE__, function () {
( new \WPR\Books\CPT() )->register();
flush_rewrite_rules();
} );
register_deactivation_hook( __FILE__, 'flush_rewrite_rules' );
add_action( 'plugins_loaded', function () {
\WPR\Books\Plugin::instance();
} );2. L’orchestrateur (class-plugin.php)
<?php
namespace WPR\Books;
if ( ! defined( 'ABSPATH' ) ) { exit; }
final class Plugin {
private static ?Plugin $instance = null;
public static function instance(): Plugin {
return self::$instance ??= new self();
}
private function __construct() {
add_action( 'init', array( $this, 'i18n' ) );
( new CPT() )->hooks();
( new Meta() )->hooks();
( new Settings() )->hooks();
( new Shortcode() )->hooks();
}
public function i18n(): void {
load_plugin_textdomain( 'wpr-books', false, dirname( plugin_basename( WPR_BOOKS_FILE ) ) . '/languages' );
}
}3. CPT + taxonomie (class-cpt.php)
<?php
namespace WPR\Books;
if ( ! defined( 'ABSPATH' ) ) { exit; }
class CPT {
public function hooks(): void {
add_action( 'init', array( $this, 'register' ) );
}
public function register(): void {
register_post_type( 'livre', array(
'labels' => array(
'name' => __( 'Livres', 'wpr-books' ),
'singular_name' => __( 'Livre', 'wpr-books' ),
'add_new_item' => __( 'Ajouter un livre', 'wpr-books' ),
),
'public' => true,
'has_archive' => true,
'menu_icon' => 'dashicons-book',
'supports' => array( 'title', 'editor', 'thumbnail', 'excerpt' ),
'rewrite' => array( 'slug' => 'livres' ),
'show_in_rest' => true, // Gutenberg + headless
) );
register_taxonomy( 'genre', array( 'livre' ), array(
'labels' => array( 'name' => __( 'Genres', 'wpr-books' ), 'singular_name' => __( 'Genre', 'wpr-books' ) ),
'public' => true,
'hierarchical' => true,
'show_in_rest' => true,
'rewrite' => array( 'slug' => 'genre' ),
) );
}
}4. Métadonnées + metabox sécurisée (class-meta.php)
<?php
namespace WPR\Books;
if ( ! defined( 'ABSPATH' ) ) { exit; }
class Meta {
public function hooks(): void {
add_action( 'init', array( $this, 'register_meta' ) );
add_action( 'add_meta_boxes', array( $this, 'add_box' ) );
add_action( 'save_post_livre',array( $this, 'save' ) );
}
// Exposée en REST (headless + panneau bloc, Partie 8/9)
public function register_meta(): void {
foreach ( array( 'wpr_isbn' => 'string', 'wpr_prix' => 'number' ) as $key => $type ) {
register_post_meta( 'livre', $key, array(
'type' => $type,
'single' => true,
'show_in_rest' => true,
'auth_callback' => fn() => current_user_can( 'edit_posts' ),
'sanitize_callback' => 'wpr_isbn' === $key ? 'sanitize_text_field' : null,
) );
}
}
public function add_box(): void {
add_meta_box( 'wpr_book_details', __( 'Détails', 'wpr-books' ), array( $this, 'render' ), 'livre', 'side' );
}
public function render( \WP_Post $post ): void {
wp_nonce_field( 'wpr_book_save', 'wpr_book_nonce' );
$isbn = get_post_meta( $post->ID, 'wpr_isbn', true );
$prix = get_post_meta( $post->ID, 'wpr_prix', true );
printf(
'<p><label>%s<br><input type="text" name="wpr_isbn" value="%s" class="widefat"></label></p>',
esc_html__( 'ISBN', 'wpr-books' ), esc_attr( $isbn )
);
printf(
'<p><label>%s<br><input type="number" step="0.01" name="wpr_prix" value="%s" class="widefat"></label></p>',
esc_html__( 'Prix (€)', 'wpr-books' ), esc_attr( $prix )
);
}
public function save( int $post_id ): void {
// 4 gardes : nonce, autosave, capability, sanitize (ch. 7.4 & 7.7)
if ( ! isset( $_POST['wpr_book_nonce'] ) || ! wp_verify_nonce( $_POST['wpr_book_nonce'], 'wpr_book_save' ) ) { return; }
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { return; }
if ( ! current_user_can( 'edit_post', $post_id ) ) { return; }
if ( isset( $_POST['wpr_isbn'] ) ) {
update_post_meta( $post_id, 'wpr_isbn', sanitize_text_field( wp_unslash( $_POST['wpr_isbn'] ) ) );
}
if ( isset( $_POST['wpr_prix'] ) ) {
update_post_meta( $post_id, 'wpr_prix', (float) $_POST['wpr_prix'] );
}
}
}⚠️ Piège — la double source de vérité. Ici la meta est éditable à la fois par la metabox (PHP) et exposée en REST (
register_post_meta). C’est volontaire (compat + headless). Mais quand vous ajouterez le panneau bloc React (Partie 8), veillez à ne pas dupliquer la logique de validation : lasanitize_callbackderegister_post_metareste la garde commune.
5. Réglages + shortcode (extraits)
class-settings.php (Settings API, ch. 7.5) — une option wpr_books_options avec par_page. class-shortcode.php (ch. 7.6) — [wpr_livres genre="roman" nombre="5"] qui liste les livres (requête assainie, sorties échappées, wp_reset_postdata). Le nombre par défaut lit le réglage par_page.
// class-shortcode.php (cœur)
public function render( $atts ): string {
$opts = get_option( 'wpr_books_options', array( 'par_page' => 12 ) );
$atts = shortcode_atts( array( 'genre' => '', 'nombre' => $opts['par_page'] ), $atts, 'wpr_livres' );
$args = array( 'post_type' => 'livre', 'posts_per_page' => absint( $atts['nombre'] ) );
if ( $atts['genre'] ) {
$args['tax_query'] = array( array( 'taxonomy' => 'genre', 'field' => 'slug', 'terms' => sanitize_title( $atts['genre'] ) ) );
}
$q = new \WP_Query( $args );
ob_start();
if ( $q->have_posts() ) {
echo '<ul class="wpr-livres">';
while ( $q->have_posts() ) { $q->the_post();
$prix = get_post_meta( get_the_ID(), 'wpr_prix', true );
printf(
'<li><a href="%s">%s</a>%s</li>',
esc_url( get_permalink() ),
esc_html( get_the_title() ),
$prix ? ' — ' . esc_html( number_format_i18n( (float) $prix, 2 ) ) . ' €' : ''
);
}
echo '</ul>';
wp_reset_postdata();
}
return ob_get_clean();
}6. Ce qu’on obtient (et la suite)
Le plugin fournit :
- un catalogue de livres géré en admin (CPT + genres + ISBN/prix) ;
- des URLs propres (
/livres/…,/genre/…) ; - un shortcode de listing configurable ;
- une API REST prête (
/wp-json/wp/v2/livreavec les meta exposées) — donc headless-ready ; - du code sécurisé et traduisible.
- Partie 8 : un bloc Gutenberg en React (« Livre à la une », panneau d’ISBN/prix éditable) branché sur ces meta.
- Partie 9 : un front Next.js qui lit
/wp-json/wp/v2/livre(headless) pour afficher le catalogue. - Partie 13 : transformer
wpr-booksen plugin freemium distribuable.
💡 Pour un dev React — Vous venez de construire, en PHP, un back-office typé (un modèle « Livre » avec ses champs, son admin, ses routes, son API) — l’équivalent d’un CMS headless que vous auriez configuré, mais entièrement sous votre contrôle et extensible. À partir d’ici, tout ce que vous ajoutez (bloc React, front Next.js) se branche sur cette fondation via la REST API. C’est le pont Partie 7 → 8 → 9.
✏️ Exercices
- Dans
save(), retirez mentalement la vérification du nonce : quelle faille ouvrez-vous ? - Pourquoi
show_in_rest => truesur le CPT etregister_post_meta(..., show_in_rest => true)sont-ils tous deux nécessaires pour un front Next.js qui affiche l’ISBN ? - Le shortcode utilise
absint()etsanitize_title()sur ses attributs. Que se passerait-il sans ces assainissements ?
✅ Solution
- Sans nonce, l’action d’enregistrement devient vulnérable au CSRF : un site tiers pourrait faire enregistrer des données arbitraires au nom d’un utilisateur connecté ayant le droit. Le nonce prouve que la requête vient de votre formulaire.
show_in_restsur le CPT expose la ressource/wp/v2/livre;show_in_restsur la meta (register_post_meta) expose le champmeta.wpr_isbndans cette ressource. Sans le second, l’API renverrait le livre sans son ISBN → le front Next.js ne pourrait pas l’afficher.- Les attributs viennent du contenu (potentiellement d’un éditeur) : sans
absint/sanitize_title, une valeur malveillante ou malformée pourrait fausser/injecter la requête (tax_query) ou produire un affichage non maîtrisé. On assainit selon le type attendu (entier, slug).
🧠 Quiz de révision
1. Quels composants de la Partie 7 wpr-books combine-t-il ?
CPT + taxonomie, métadonnées (+ metabox sécurisée et REST), page de réglages, shortcode, sécurité (4 piliers), i18n.
2. Où enregistre-t-on le CPT à l’activation, et pourquoi flush ensuite ?
Dans register_activation_hook : on enregistre le CPT puis flush_rewrite_rules() une fois, pour que les URLs /livres/ fonctionnent (éviter le 404).
3. Qu’est-ce qui rend wpr-books « headless-ready » ?
show_in_rest sur le CPT/taxonomie et les meta exposées via register_post_meta : la ressource et ses champs sont dans la REST API.
4. Quelles sont les 4 gardes dans save() ?
Nonce, autosave (DOING_AUTOSAVE), capability (edit_post), sanitize de l’entrée.
5. Comment wpr-books sera-t-il étendu dans les parties suivantes ?
Par un bloc React (Partie 8) et un front Next.js headless (Partie 9), puis en plugin freemium (Partie 13).
Partie suivante : Partie 8 — Gutenberg : blocs custom en React.