Si vous avez déjà ajouté un “Custom Post Type” et découvert ensuite que vous aviez oublié les permaliens, les capacités, les métaboxes sécurisées ou une taxonomie propre, vous savez à quel point ça dégénère vite en patchwork.
Le problème / Le besoin
Le besoin typique : publier un contenu qui n’est ni un article ni une page, avec ses propres champs (métadonnées) et ses propres catégories/étiquettes. Exemple concret : un site éditorial qui veut gérer des “Études de cas” avec un client, une date de livraison, un budget, et des “Secteurs” en taxonomie.
Beaucoup de sites démarrent avec un plugin de champs personnalisés, puis ajoutent un CPT à la va-vite, puis bricolent un template. Le résultat : des données mal stockées, des champs non sécurisés, des permaliens cassés, et un back-office pénible à utiliser.
À la fin, vous saurez créer un CPT complet (WordPress 6.9.4, PHP 8.1+), avec :
- un Custom Post Type “Études de cas” propre (labels, supports, REST, archives, capacités),
- deux taxonomies (une hiérarchique + une non hiérarchique),
- des métaboxes sécurisées (nonce, permissions, sanitization),
- des colonnes personnalisées dans la liste d’admin,
- un “flush” de règles de réécriture au bon endroit (sans casser les perfs).
Résumé rapide
- On crée un mini-plugin (recommandé) plutôt que de coller du code dans functions.php.
- On enregistre un CPT
case_studyavecshow_in_restactivé (Gutenberg + API REST + builders). - On ajoute une taxonomie hiérarchique “Secteurs” (
sector) et une taxonomie “Tags projet” (project_tag). - On ajoute une métabox “Détails” (client, URL du projet, budget, date) avec sauvegarde sécurisée.
- On améliore l’UX admin : colonnes, tri, filtres.
- On teste proprement : permaliens, capacités, autosave, révisions, REST.
Quand utiliser cette solution
- Vous avez un type de contenu récurrent (portfolio, événements, recettes, témoignages, offres d’emploi) avec des attributs structurés.
- Vous voulez un back-office propre, sans détourner les “Articles”.
- Vous avez besoin que ce contenu soit accessible via l’éditeur de blocs et l’API REST (Elementor/Divi/Avada s’appuient souvent dessus).
- Vous voulez contrôler la sécurité et la qualité des données (sanitization/escaping) plutôt que de stocker du JSON brut dans un champ.
- Vous prévoyez de faire des requêtes (WP_Query) et des filtres (tax_query/meta_query) de façon fiable.
Quand ne PAS utiliser cette solution
- Un simple modèle de page suffit : si vous avez 3 pages statiques “Étude de cas”, un CPT est du surcoût.
- Vous n’avez pas besoin de requêtes : si le contenu n’est jamais listé/filtré, une page + blocs peut faire le travail.
- Vous dépendez d’un plugin métier : e-commerce (WooCommerce), événements (The Events Calendar), LMS… réutilisez leurs post types, sinon vous doublez les concepts.
- Vous pensez “CPT = SEO automatique” : non. Le SEO dépend surtout des templates, des données et de l’indexation. Un CPT mal configuré peut même créer du contenu dupliqué (archives, taxonomies, etc.).
- Vous voulez un schéma de données complexe (relations fortes, contraintes, requêtes SQL avancées) : regardez plutôt des tables custom + UI dédiée. Les post meta deviennent vite un goulot.
Prérequis / avant de commencer
Contexte : WordPress 6.9.4 (avril 2026), PHP 8.1+. Le code ci-dessous cible ces versions.
- Travaillez sur un environnement de staging et faites une sauvegarde (fichiers + base).
- Activez
WP_DEBUGetWP_DEBUG_LOGsur staging pour attraper les erreurs PHP (parenthèse oubliée, hook mal orthographié, etc.). - Évitez de coller ce code dans un thème parent. Utilisez un plugin ou un thème enfant. J’ai souvent vu des CPT “disparaître” lors d’un changement de thème.
- Outils utiles :
- Query Monitor (pour voir les requêtes et hooks),
- WP-CLI (pour flush des permaliens et tester),
- un plugin de snippets seulement si vous savez le désactiver en cas d’erreur fatale.
Docs officielles utiles :
- register_post_type()
- register_taxonomy()
- Custom Meta Boxes (plugin handbook)
- add_meta_box()
- Filtres de sanitization PHP (filter_var)
L’approche naïve (et pourquoi l’éviter)
Le snippet que je vois le plus souvent : un register_post_type() minimal dans functions.php, des métas sauvées sans nonce, et un flush_rewrite_rules() appelé à chaque page.
<?php
// ❌ Exemple volontairement mauvais : ne copiez pas.
add_action('init', function () {
register_post_type('case_study', [
'public' => true,
'label' => 'Études de cas',
]);
// ❌ Très mauvais : flush à chaque chargement => perf catastrophique.
flush_rewrite_rules();
});
add_action('save_post', function ($post_id) {
// ❌ Pas de nonce, pas de permission, pas de sanitization.
update_post_meta($post_id, 'client', $_POST['client']);
});
Pourquoi c’est un problème :
- Sécurité : sans nonce + vérification de capacité, n’importe quel flux d’édition (ou un plugin compromis) peut injecter des données.
- Autosave / révisions :
save_postse déclenche souvent. Sans garde-fous, vous écrasez des données. - Performance :
flush_rewrite_rules()est coûteux. Le faire surinitpeut ralentir tout le site. - Maintenabilité : un CPT dans un thème = contenu couplé au design. Le jour où vous changez de thème, l’admin devient incohérente.
La bonne approche — tutoriel pas à pas
Étape 1 — Créer un mini-plugin (recommandé)
Créez un dossier wp-content/plugins/bpcab-case-studies, puis un fichier bpcab-case-studies.php. Activez-le dans l’admin.
Étape 2 — Enregistrer le CPT et les taxonomies sur init
On enregistre tout sur init avec une priorité standard. On active show_in_rest pour Gutenberg, l’API REST et la compatibilité builders.
On définit aussi un rewrite explicite. Dans mon expérience, c’est ce qui réduit le plus les surprises quand un site a déjà des pages aux slugs proches.
Étape 3 — Ajouter une métabox sécurisée
On utilise add_meta_box() sur add_meta_boxes, on rend un formulaire avec un nonce, puis on sauvegarde sur save_post_case_study (hook spécifique au post type). Ça évite d’exécuter du code inutile sur tous les types.
Étape 4 — Nettoyer et valider les entrées
On sanitise selon le type :
- texte :
sanitize_text_field(), - URL :
esc_url_raw(), - montant : conversion stricte (float) + bornes,
- date : validation via
DateTimeImmutable(PHP 8.1).
Étape 5 — Améliorer l’admin (colonnes, tri, filtres)
Ce n’est pas “bonus”. Un CPT sans colonnes utiles devient vite pénible dès que vous avez 50 entrées. On ajoute :
- une colonne “Client”,
- une colonne “Secteur”,
- une colonne “Budget”,
- un tri sur “Budget”.
Étape 6 — Flusher les permaliens au bon moment
On flush à l’activation du plugin (et à la désactivation), pas sur chaque requête. C’est la différence entre “ça marche” et “ça tue le TTFB”.
Code complet
Copiez-collez ce fichier tel quel dans wp-content/plugins/bpcab-case-studies/bpcab-case-studies.php, puis activez le plugin. Le code est complet et fonctionnel sur WordPress 6.9.4+ et PHP 8.1+.
<?php
/**
* Plugin Name: BPCAB - Études de cas (CPT + Taxonomies + Métaboxes)
* Description: Ajoute un Custom Post Type "Études de cas" avec taxonomies et métaboxes sécurisées.
* Version: 1.0.0
* Requires at least: 6.9
* Requires PHP: 8.1
* Author: Votre Nom
* License: GPL-2.0-or-later
*/
declare(strict_types=1);
if (!defined('ABSPATH')) {
exit;
}
final class BPCAB_Case_Studies {
public const POST_TYPE = 'case_study';
public const TAX_SECTOR = 'sector';
public const TAX_PROJECT_TAG = 'project_tag';
// Clés meta (préfixées pour éviter les collisions).
public const META_CLIENT = '_bpcab_client';
public const META_PROJECT_URL = '_bpcab_project_url';
public const META_BUDGET = '_bpcab_budget';
public const META_DELIVERED_ON = '_bpcab_delivered_on'; // Format: YYYY-MM-DD
public static function init(): void {
add_action('init', [__CLASS__, 'register_cpt_and_taxonomies']);
add_action('add_meta_boxes', [__CLASS__, 'register_metaboxes']);
add_action('save_post_' . self::POST_TYPE, [__CLASS__, 'save_metaboxes'], 10, 2);
add_filter('manage_' . self::POST_TYPE . '_posts_columns', [__CLASS__, 'admin_columns']);
add_action('manage_' . self::POST_TYPE . '_posts_custom_column', [__CLASS__, 'admin_column_content'], 10, 2);
add_filter('manage_edit-' . self::POST_TYPE . '_sortable_columns', [__CLASS__, 'admin_sortable_columns']);
add_action('pre_get_posts', [__CLASS__, 'admin_sorting_query']);
register_activation_hook(__FILE__, [__CLASS__, 'activate']);
register_deactivation_hook(__FILE__, [__CLASS__, 'deactivate']);
}
public static function activate(): void {
// Enregistrer d'abord, puis flusher.
self::register_cpt_and_taxonomies();
flush_rewrite_rules();
}
public static function deactivate(): void {
// Flush pour retirer les règles de réécriture.
flush_rewrite_rules();
}
public static function register_cpt_and_taxonomies(): void {
$labels = [
'name' => 'Études de cas',
'singular_name' => 'Étude de cas',
'menu_name' => 'Études de cas',
'add_new' => 'Ajouter',
'add_new_item' => 'Ajouter une étude de cas',
'edit_item' => 'Modifier l’étude de cas',
'new_item' => 'Nouvelle étude de cas',
'view_item' => 'Voir l’étude de cas',
'view_items' => 'Voir les études de cas',
'search_items' => 'Rechercher des études de cas',
'not_found' => 'Aucune étude de cas trouvée',
'not_found_in_trash' => 'Aucune étude de cas dans la corbeille',
'all_items' => 'Toutes les études de cas',
'archives' => 'Archives des études de cas',
'attributes' => 'Attributs',
'insert_into_item' => 'Insérer dans l’étude de cas',
'uploaded_to_this_item' => 'Téléversé pour cette étude de cas',
'featured_image' => 'Image mise en avant',
'set_featured_image' => 'Définir l’image mise en avant',
'remove_featured_image' => 'Retirer l’image mise en avant',
'use_featured_image' => 'Utiliser comme image mise en avant',
'filter_items_list' => 'Filtrer la liste',
'items_list_navigation' => 'Navigation de liste',
'items_list' => 'Liste des études de cas',
];
$args = [
'labels' => $labels,
'public' => true,
'show_in_rest' => true, // Gutenberg + REST + builders
'menu_position' => 20,
'menu_icon' => 'dashicons-portfolio',
'supports' => ['title', 'editor', 'thumbnail', 'excerpt', 'revisions', 'author'],
'has_archive' => true,
'rewrite' => [
'slug' => 'etudes-de-cas',
'with_front' => false,
],
'query_var' => true,
'show_ui' => true,
'show_in_menu' => true,
'capability_type' => 'post',
'map_meta_cap' => true,
'hierarchical' => false,
];
register_post_type(self::POST_TYPE, $args);
// Taxonomie hiérarchique : Secteurs.
register_taxonomy(self::TAX_SECTOR, [self::POST_TYPE], [
'labels' => [
'name' => 'Secteurs',
'singular_name' => 'Secteur',
'search_items' => 'Rechercher des secteurs',
'all_items' => 'Tous les secteurs',
'parent_item' => 'Secteur parent',
'parent_item_colon' => 'Secteur parent :',
'edit_item' => 'Modifier le secteur',
'update_item' => 'Mettre à jour le secteur',
'add_new_item' => 'Ajouter un secteur',
'new_item_name' => 'Nom du nouveau secteur',
'menu_name' => 'Secteurs',
],
'public' => true,
'show_ui' => true,
'show_in_rest' => true,
'hierarchical' => true,
'rewrite' => [
'slug' => 'secteur',
'with_front' => false,
],
]);
// Taxonomie non hiérarchique : tags projet.
register_taxonomy(self::TAX_PROJECT_TAG, [self::POST_TYPE], [
'labels' => [
'name' => 'Tags projet',
'singular_name' => 'Tag projet',
'search_items' => 'Rechercher des tags',
'popular_items' => 'Tags populaires',
'all_items' => 'Tous les tags',
'edit_item' => 'Modifier le tag',
'update_item' => 'Mettre à jour le tag',
'add_new_item' => 'Ajouter un tag',
'new_item_name' => 'Nom du nouveau tag',
'separate_items_with_commas' => 'Séparez les tags par des virgules',
'add_or_remove_items' => 'Ajouter ou retirer des tags',
'choose_from_most_used' => 'Choisir parmi les plus utilisés',
'menu_name' => 'Tags projet',
],
'public' => true,
'show_ui' => true,
'show_in_rest' => true,
'hierarchical' => false,
'rewrite' => [
'slug' => 'tag-projet',
'with_front' => false,
],
]);
}
public static function register_metaboxes(): void {
add_meta_box(
'bpcab_case_study_details',
'Détails de l’étude de cas',
[__CLASS__, 'render_metabox_details'],
self::POST_TYPE,
'normal',
'default'
);
}
public static function render_metabox_details(WP_Post $post): void {
// Nonce pour sécuriser la sauvegarde.
wp_nonce_field('bpcab_case_study_save', 'bpcab_case_study_nonce');
$client = (string) get_post_meta($post->ID, self::META_CLIENT, true);
$project_url = (string) get_post_meta($post->ID, self::META_PROJECT_URL, true);
$budget = (string) get_post_meta($post->ID, self::META_BUDGET, true);
$delivered_on = (string) get_post_meta($post->ID, self::META_DELIVERED_ON, true);
?>
<div class="bpcab-metabox">
<p>
<label for="bpcab_client"><strong>Client</strong></label><br>
<input type="text" id="bpcab_client" name="bpcab_client" value="<?php echo esc_attr($client); ?>" class="regular-text">
</p>
<p>
<label for="bpcab_project_url"><strong>URL du projet</strong></label><br>
<input type="url" id="bpcab_project_url" name="bpcab_project_url" value="<?php echo esc_attr($project_url); ?>" class="regular-text" placeholder="https://exemple.com">
</p>
<p>
<label for="bpcab_budget"><strong>Budget (EUR)</strong></label><br>
<input type="number" step="0.01" min="0" id="bpcab_budget" name="bpcab_budget" value="<?php echo esc_attr($budget); ?>" class="small-text">
<span class="description">Stocké en nombre (décimal) dans la meta.</span>
</p>
<p>
<label for="bpcab_delivered_on"><strong>Date de livraison</strong></label><br>
<input type="date" id="bpcab_delivered_on" name="bpcab_delivered_on" value="<?php echo esc_attr($delivered_on); ?>">
<span class="description">Format YYYY-MM-DD.</span>
</p>
</div>
<?php
}
public static function save_metaboxes(int $post_id, WP_Post $post): void {
// 1) Nonce présent ?
if (!isset($_POST['bpcab_case_study_nonce'])) {
return;
}
// 2) Nonce valide ?
if (!wp_verify_nonce((string) $_POST['bpcab_case_study_nonce'], 'bpcab_case_study_save')) {
return;
}
// 3) Autosave / révision ?
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
return;
}
if (wp_is_post_revision($post_id)) {
return;
}
// 4) Bon type de post ?
if ($post->post_type !== self::POST_TYPE) {
return;
}
// 5) Permissions : capacité d'éditer ce post.
if (!current_user_can('edit_post', $post_id)) {
return;
}
// Lecture + sanitization.
$client = isset($_POST['bpcab_client']) ? sanitize_text_field((string) $_POST['bpcab_client']) : '';
$project_url = isset($_POST['bpcab_project_url']) ? esc_url_raw((string) $_POST['bpcab_project_url']) : '';
$budget_raw = isset($_POST['bpcab_budget']) ? (string) $_POST['bpcab_budget'] : '';
$delivered_on_raw = isset($_POST['bpcab_delivered_on']) ? (string) $_POST['bpcab_delivered_on'] : '';
// Budget : normalisation stricte (point décimal).
$budget = '';
if ($budget_raw !== '') {
// Remplace la virgule par un point si l'utilisateur saisit "1200,50".
$budget_normalized = str_replace(',', '.', $budget_raw);
$budget_float = filter_var($budget_normalized, FILTER_VALIDATE_FLOAT);
if ($budget_float !== false && $budget_float >= 0) {
// Stockage en string formaté pour éviter les surprises d'affichage.
$budget = number_format((float) $budget_float, 2, '.', '');
}
}
// Date : validation YYYY-MM-DD via DateTimeImmutable.
$delivered_on = '';
if ($delivered_on_raw !== '') {
$dt = DateTimeImmutable::createFromFormat('Y-m-d', $delivered_on_raw);
$errors = DateTimeImmutable::getLastErrors();
if ($dt instanceof DateTimeImmutable && empty($errors['warning_count']) && empty($errors['error_count'])) {
$delivered_on = $dt->format('Y-m-d');
}
}
// Écriture meta : supprime si vide (base plus propre).
self::update_or_delete_meta($post_id, self::META_CLIENT, $client);
self::update_or_delete_meta($post_id, self::META_PROJECT_URL, $project_url);
self::update_or_delete_meta($post_id, self::META_BUDGET, $budget);
self::update_or_delete_meta($post_id, self::META_DELIVERED_ON, $delivered_on);
}
private static function update_or_delete_meta(int $post_id, string $meta_key, string $value): void {
if ($value === '') {
delete_post_meta($post_id, $meta_key);
return;
}
update_post_meta($post_id, $meta_key, $value);
}
public static function admin_columns(array $columns): array {
// On insère des colonnes après le titre.
$new = [];
foreach ($columns as $key => $label) {
$new[$key] = $label;
if ($key === 'title') {
$new['bpcab_client'] = 'Client';
$new['bpcab_sector'] = 'Secteur';
$new['bpcab_budget'] = 'Budget';
}
}
return $new;
}
public static function admin_column_content(string $column, int $post_id): void {
if ($column === 'bpcab_client') {
$client = (string) get_post_meta($post_id, self::META_CLIENT, true);
echo $client !== '' ? esc_html($client) : '—';
return;
}
if ($column === 'bpcab_budget') {
$budget = (string) get_post_meta($post_id, self::META_BUDGET, true);
echo $budget !== '' ? esc_html($budget . ' €') : '—';
return;
}
if ($column === 'bpcab_sector') {
$terms = get_the_terms($post_id, self::TAX_SECTOR);
if (is_wp_error($terms) || empty($terms)) {
echo '—';
return;
}
$names = array_map(static fn($t) => $t->name, $terms);
echo esc_html(implode(', ', $names));
return;
}
}
public static function admin_sortable_columns(array $columns): array {
// Tri sur budget (meta).
$columns['bpcab_budget'] = 'bpcab_budget';
return $columns;
}
public static function admin_sorting_query(WP_Query $query): void {
if (!is_admin() || !$query->is_main_query()) {
return;
}
$screen = function_exists('get_current_screen') ? get_current_screen() : null;
if (!$screen || $screen->post_type !== self::POST_TYPE) {
return;
}
$orderby = $query->get('orderby');
if ($orderby !== 'bpcab_budget') {
return;
}
// Tri numérique sur meta.
$query->set('meta_key', self::META_BUDGET);
$query->set('orderby', 'meta_value_num');
}
}
BPCAB_Case_Studies::init();
Explication du code
Pourquoi un plugin plutôt que functions.php
Le CPT représente une structure de contenu. Le thème représente l’affichage. Mélanger les deux vous expose à un classique : “on a changé de thème, tout a disparu”. Le contenu est toujours en base, mais l’admin n’affiche plus le menu, les taxonomies et les métaboxes. J’ai vu ça sur des refontes Avada et Divi plus souvent que je ne voudrais.
Enregistrement CPT + taxonomies
register_post_type() et register_taxonomy() sont appelés sur init. C’est le bon moment : WordPress connaît déjà les bases, mais on est assez tôt pour que la réécriture d’URL et le REST soient cohérents.
- show_in_rest : indispensable en 2026 si vous utilisez Gutenberg, des patterns, ou des builders qui consomment l’API REST.
- rewrite : le slug explicite évite des collisions. Sans ça, vous finissez avec des URLs “case_study” peu lisibles, ou pire, un conflit avec une page existante.
- supports : vous choisissez ce que l’éditeur affiche. Gardez-le minimal : plus vous activez, plus vous ouvrez de portes (et de confusion).
Métabox : rendu + sauvegarde
Le rendu échappe les valeurs avec esc_attr(). La sauvegarde :
- vérifie le nonce (
wp_verify_nonce), - ignore autosave et révisions,
- vérifie la capacité
edit_post, - sanitise par type (texte, URL, float, date),
- supprime les metas vides (base plus propre, requêtes plus simples).
Le hook
save_post_case_studyest un détail qui change tout : vous évitez d’exécuter ce code sur les pages, les articles, les médias… Sur un site avec beaucoup d’éditions, ça se mesure.
Colonnes admin et tri
Les colonnes sont ajoutées via manage_{post_type}_posts_columns et remplies via manage_{post_type}_posts_custom_column. Le tri par budget passe par :
- déclaration de la colonne “sortable”,
- modification de la requête admin via
pre_get_posts, - tri numérique avec
meta_value_num.
Attention : le tri meta sur de gros volumes peut coûter cher (indexation inexistante sur postmeta). Si vous avez des milliers d’entrées et que vous triez souvent, envisagez un stockage différent (ou au minimum, limitez ce tri aux rôles qui en ont besoin).
Flush des permaliens
Le flush est fait à l’activation/désactivation du plugin. C’est le seul endroit acceptable dans la majorité des cas. Si vous flushez sur init, vous forcez WordPress à recalculer des règles coûteuses à chaque requête.
Référence utile : flush_rewrite_rules().
Variantes et cas d’usage
Variante 1 — Capacités dédiées (rôles “éditeur de portfolio”)
Si vous voulez que certains rôles gèrent les études de cas sans toucher aux articles, créez des capacités dédiées via capability_type et capabilities. C’est plus long, mais propre.
Approche : utilisez capability_type => ['case_study','case_studies'] et mappez les caps. Ensuite, ajoutez les caps aux rôles à l’activation.
<?php
// Exemple partiel : à intégrer dans la classe si vous activez cette variante.
// Note : code volontairement court, il faut compléter la liste de capacités selon vos besoins.
$args['capability_type'] = ['case_study', 'case_studies'];
$args['map_meta_cap'] = true;
$args['capabilities'] = [
'edit_post' => 'edit_case_study',
'read_post' => 'read_case_study',
'delete_post' => 'delete_case_study',
'edit_posts' => 'edit_case_studies',
'edit_others_posts' => 'edit_others_case_studies',
'publish_posts' => 'publish_case_studies',
'read_private_posts' => 'read_private_case_studies',
];
Je le fais surtout sur des sites multi-auteurs. Sinon, vous finissez avec des “éditeurs” qui peuvent publier des articles alors que vous ne vouliez que des études de cas.
Variante 2 — Validation plus stricte et messages d’erreur
WordPress ne fournit pas un système “natif” de validation de métaboxes avec messages UI. Une technique réaliste : stocker des erreurs transitoires (transient) et les afficher dans admin_notices après sauvegarde. Utile si la date est obligatoire ou si l’URL doit être sur un domaine précis.
Variante 3 — Exposition REST des metas (pour headless / builders)
Si vous voulez que vos champs apparaissent proprement dans l’API REST, enregistrez-les avec register_post_meta() (type, single, auth_callback, show_in_rest). Ça évite le “meta brut” non typé.
Docs : register_post_meta().
<?php
add_action('init', function () {
register_post_meta('case_study', '_bpcab_budget', [
'type' => 'number',
'single' => true,
'show_in_rest' => true,
'auth_callback' => static function () {
return current_user_can('edit_posts');
},
]);
});
Point d’attention : si vous exposez des metas en REST, pensez “fuite de données”. Un budget peut être sensible. Réglez auth_callback selon votre besoin.
Compatibilité Divi 5 / Elementor / Avada
Avec show_in_rest activé, vous partez déjà bien pour WordPress 6.9.4 et les builders modernes.
Divi 5
- Divi 5 peut utiliser des templates dynamiques. Votre CPT apparaîtra dans les listes de contenus si le builder s’appuie sur les types publics.
- Pour afficher les metas, le chemin le plus robuste reste :
- soit un module qui lit les champs via une requête WordPress,
- soit un shortcode simple (si votre stack Divi le prévoit).
Si vous créez un shortcode, échappez toujours la sortie (XSS) et limitez les attributs.
Elementor
- Elementor détecte généralement les CPT publics et peut construire des templates “Single” et “Archive”.
- Les champs meta : selon votre config, Elementor peut lire des metas via “Dynamic Tags”. Si vous voulez une intégration propre, enregistrez les metas avec
register_post_meta()+show_in_rest(variante ci-dessus).
Avada (Fusion Builder)
- Avada gère bien les CPT publics pour les archives et singles.
- J’ai souvent vu un piège : des slugs qui entrent en conflit avec des “fusion slugs” ou des pages existantes. Si une URL d’archive renvoie 404, régénérez les permaliens et vérifiez qu’aucune page n’utilise
/etudes-de-cas/.
Vérifications après mise en place
- Dans l’admin, vous avez un menu “Études de cas”. Créez une entrée et publiez-la.
- Ajoutez un “Secteur” et un “Tag projet”, puis associez-les.
- Vérifiez l’URL d’archive :
/etudes-de-cas/(selon votre configuration de permaliens). - Vérifiez une URL single :
/etudes-de-cas/mon-etude/. - Dans la liste d’admin, vérifiez :
- affichage des colonnes Client/Secteur/Budget,
- tri sur Budget (cliquez l’en-tête de colonne).
- Test autosave : éditez une étude, attendez l’autosauvegarde, rechargez. Les metas ne doivent pas se vider.
- Test REST (si vous utilisez l’API) : ouvrez
/wp-json/wp/v2/case_study(auth selon votre site). Le CPT doit être exposé.
Tableau de diagnostic rapide
| Symptôme | Cause probable | Vérification | Solution |
|---|---|---|---|
| Archive /etudes-de-cas/ en 404 | Règles de réécriture non flushées, conflit de slug | Réglages > Permaliens, vérifier une page au même slug | Régénérez les permaliens, changez le slug rewrite si conflit |
| Les champs de métabox ne se sauvegardent pas | Nonce absent/invalide, permissions, hook incorrect | Console réseau + vérifier source HTML, logs debug | Vérifiez wp_nonce_field, hook save_post_case_study, capacité edit_post |
| Budget trié “comme du texte” (100 avant 20) | Tri sur meta_value au lieu de meta_value_num |
Inspecter pre_get_posts |
Utiliser meta_value_num et stocker un nombre normalisé |
| Le CPT n’apparaît pas dans Elementor/Divi | public ou show_in_rest désactivé |
Inspecter args du CPT | Activer show_in_rest, vérifier la visibilité |
| Erreur fatale après collage du code | Parenthèse/point-virgule manquant, PHP trop vieux | Lire wp-content/debug.log |
Corriger la syntaxe, vérifier PHP 8.1+ |
Si ça ne marche pas
- Vérifiez où vous avez collé le code : si vous l’avez mis dans
functions.phpd’un thème parent, déplacez-le dans un plugin ou un thème enfant. Beaucoup de “ça marche pas” viennent juste de là. - Regardez les logs : activez
WP_DEBUG_LOGet ouvrezwp-content/debug.log. Une parenthèse oubliée se repère en 10 secondes. - Désactivez temporairement les plugins de cache : j’ai déjà vu des caches agressifs masquer des changements de rewrite. Videz aussi le cache navigateur.
- Régénérez les permaliens : Réglages > Permaliens > Enregistrer. Ou via WP-CLI :
wp rewrite flush --hard - Vérifiez les slugs : si vous avez une page “etudes-de-cas”, elle peut entrer en conflit avec l’archive.
- Vérifiez le hook de sauvegarde :
save_post_case_study(passave_postgénérique si vous l’avez modifié). - Vérifiez les permissions : connectez-vous en admin pour tester. Un rôle “éditeur” personnalisé peut ne pas avoir
edit_postselon votre mapping. - Testez sans builder : passez sur un thème par défaut (sur staging) pour isoler un conflit Avada/Divi/Elementor.
Pièges et erreurs courantes
| Erreur | Cause | Solution |
|---|---|---|
| Le CPT disparaît après changement de thème | CPT défini dans functions.php du thème |
Mettre le CPT dans un plugin (comme ici) ou au minimum dans un mu-plugin |
| “404 Not Found” sur l’archive | Permaliens non flushés, conflit de slug | Réenregistrer les permaliens, changer rewrite.slug, vérifier les pages existantes |
| Les metas se vident aléatoirement | Autosave/révision non filtrés, logique de sauvegarde trop agressive | Garder les garde-fous DOING_AUTOSAVE + wp_is_post_revision |
| “Sorry, you are not allowed to edit this item.” | Capacités mal configurées (variante capabilities incomplète) | Revoir capability_type/capabilities, ajouter les caps aux rôles à l’activation |
| Conflit avec un plugin de snippets | Le snippet se charge trop tôt/trop tard, ou est dupliqué | Désactiver le snippet, passer sur un plugin versionné, éviter les doublons d’enregistrement |
| “Cannot redeclare class …” | Vous avez collé le code deux fois (plugin + theme, ou deux plugins) | Ne garder qu’une seule source, renommer le plugin si besoin |
| Tri budget incohérent | Budget stocké avec virgule, ou tri textuel | Normaliser (point décimal), trier via meta_value_num |
| Les champs n’apparaissent pas en REST | Métas non enregistrées via register_post_meta |
Utiliser la variante REST si nécessaire, avec auth_callback |
| Erreur “Allowed memory size exhausted” lors d’un flush répété | flush_rewrite_rules() appelé trop souvent |
Flush uniquement à l’activation/désactivation, jamais sur init |
Conseils sécurité, performance et maintenance
- Sécurité (XSS/CSRF) :
- Nonce obligatoire dans les métaboxes, vérifié à la sauvegarde.
- Échappement systématique à l’affichage :
esc_attrdans les inputs,esc_htmldans les colonnes. - Si vous exposez des metas en REST, protégez-les avec
auth_callback. Un budget ou un client peut être sensible.
- Performance :
- Évitez les
meta_querycomplexes sur gros volumes. Le tri sur meta dans l’admin est pratique, mais peut coûter cher. - Ne flushez jamais les permaliens en runtime.
- Préférez le hook spécifique
save_post_{post_type}pour limiter l’exécution.
- Évitez les
- Maintenance :
- Versionnez ce plugin (Git). Le jour où un collègue modifie un label ou un slug, vous voulez un historique.
- Si vous changez
rewrite.slugen production, prévoyez des redirections 301 (SEO). - Gardez vos meta keys préfixées. Les collisions avec des plugins “fields” arrivent plus vite qu’on ne le croit.
Référence utile sur les métadonnées : Metadata (Plugin Handbook). Pour les permaliens et rewrite : add_rewrite_rule() (utile si vous allez plus loin).
Ressources
- register_post_type() — Référence
- register_taxonomy() — Référence
- add_meta_box() — Référence
- Custom Meta Boxes — Plugin Handbook
- register_post_meta() — Exposer des metas en REST
- wordpress-develop (code source WordPress)
- WordPress Core Trac (tickets et historique)
- PHP DateTimeImmutable
- PHP filter_var()
FAQ
Pourquoi mon archive CPT renvoie 404 juste après activation ?
Parce que les règles de réécriture n’ont pas été recalculées (ou qu’un cache vous sert une ancienne réponse). Réenregistrez les permaliens, videz le cache, et vérifiez qu’aucune page n’utilise le même slug.
Dois-je utiliser ACF/SCF/Meta Box plugin au lieu de coder des métaboxes ?
Si vous avez beaucoup de champs et que l’équipe n’est pas à l’aise avec le code, un plugin de champs peut être plus rentable. Quand vous voulez un contrôle strict (validation, perfs, exposition REST, sécurité), coder les métaboxes reste très solide.
Pourquoi utiliser save_post_case_study plutôt que save_post ?
Pour limiter l’exécution au CPT et éviter des effets de bord sur les pages/articles. Sur des sites avec beaucoup d’éditions (ou des imports), c’est une différence réelle.
Mes champs ne se sauvegardent pas avec Elementor/Divi, c’est normal ?
Ça dépend de ce que vous éditez. Les métaboxes sont dans l’écran d’édition WordPress du CPT. Si vous éditez un template builder, vous n’êtes pas sur le post case_study. Testez d’abord dans l’éditeur natif de l’entrée “Étude de cas”.
Puis-je rendre certains champs obligatoires ?
Oui, mais WordPress ne fournit pas une validation UI native pour les métaboxes. La méthode propre consiste à valider à la sauvegarde, stocker un message temporaire, et afficher une notice admin. Évitez de “bloquer” brutalement sans feedback, sinon vos utilisateurs vont penser que WordPress bug.
Pourquoi supprimer la meta quand le champ est vide ?
Ça évite d’avoir des lignes inutiles dans wp_postmeta. Pour les requêtes, c’est aussi plus simple de distinguer “absent” et “présent mais vide”.
Comment afficher ces champs dans le front (thème) ?
Utilisez get_post_meta(get_the_ID(), '_bpcab_client', true) puis échappez à l’affichage : esc_html() (texte) ou esc_url() (URL). Ne faites pas confiance à la base : un admin peut coller n’importe quoi.
Est-ce que je peux changer le slug /etudes-de-cas/ plus tard ?
Oui, mais prévoyez des redirections 301 pour ne pas perdre le référencement et les backlinks. Changez le rewrite.slug, flushez les permaliens, puis mettez en place les redirections (plugin ou serveur).
Pourquoi mon tri par budget est lent ?
Parce que wp_postmeta n’est pas optimisée pour trier massivement. Limitez l’usage du tri, réduisez le volume, ou migrez le budget vers une table dédiée si c’est une donnée centrale.
Que faire si j’obtiens une erreur fatale après avoir activé le plugin ?
Repassez en FTP/SSH, renommez le dossier du plugin pour le désactiver, puis lisez debug.log. Les causes les plus fréquentes : point-virgule manquant, code collé deux fois, ou PHP trop ancien (vérifiez PHP 8.1+).