Si vous avez déjà ajouté une option “vite fait” dans wp_options puis passé une heure à comprendre pourquoi elle ne se sauvegarde pas (ou pire, pourquoi n’importe qui peut la modifier), vous avez rencontré le mur classique : une page d’options sans Settings API.
Le problème / Le besoin
Vous voulez une vraie page d’options WordPress : un écran dans l’admin, des champs bien rangés, des valeurs validées, un stockage propre, et surtout un flux standard (capabilities, nonce, messages de succès/erreur) qui survit aux mises à jour.
La Settings API est faite pour ça, mais elle est souvent mal utilisée. J’ai souvent vu des pages d’options qui “marchent” en apparence… jusqu’au jour où un champ checkbox ne se décoche jamais, où un textarea casse la mise en page, ou où un plugin de cache admin empêche l’affichage du message “Réglages enregistrés”.
À la fin, vous saurez créer un petit plugin (compatible WordPress 6.9.4+ et PHP 8.1+) qui :
- ajoute une page d’options dans “Réglages” ;
- enregistre un groupe d’options dans
wp_optionssous forme de tableau unique ; - valide/sanitize côté serveur ;
- affiche des messages d’erreur par champ ;
- reste maintenable (et testable).
Résumé rapide
- On crée un plugin minimal (pas un snippet collé dans le thème) avec une classe
BPCAB_Settings_Page. - On stocke toutes les valeurs dans une seule option :
bpcab_settings(tableau), au lieu de 12 options séparées. - On déclare :
add_options_page(),register_setting(),add_settings_section(),add_settings_field(). - On fait la validation avec le callback
sanitize_callbackderegister_setting()+add_settings_error(). - On utilise
settings_fields(),do_settings_sections(),submit_button()pour un formulaire standard WordPress. - On ajoute une variante “advanced” : export/import JSON sécurisé (nonce + capability) pour migrer les réglages.
Quand utiliser cette solution
- Vous développez un plugin (ou un mu-plugin) qui a des réglages stables et durables.
- Vous voulez que WordPress gère le nonce, le flux de sauvegarde et les messages d’erreur.
- Vous avez besoin de validation stricte (URLs, emails, listes blanches, entiers bornés).
- Vous voulez éviter les formulaires “maison” qui appellent
update_option()n’importe comment. - Vous travaillez sur des sites avec plusieurs admins : vous devez contrôler qui peut modifier quoi.
Quand ne PAS utiliser cette solution
- Vous stockez des données volumineuses ou très fréquentes (logs, métriques).
wp_optionsn’est pas une base de données analytique. - Vous avez besoin de réglages par utilisateur : utilisez plutôt
get_user_meta()/update_user_meta(). - Vous construisez une UI React complexe dans l’admin : vous pouvez utiliser l’API REST + un écran custom. La Settings API reste utile pour l’enregistrement, mais l’UI peut être décorrélée.
- Vous devez gérer des réglages par site en multisite : certaines options doivent aller dans
wp_sitemetaviaget_site_option()/update_site_option()(et une page dans le Network Admin).
Prérequis / avant de commencer
En 2026, je pars sur :
- WordPress 6.9.4 (ou plus récent)
- PHP 8.1+ (idéalement 8.2/8.3 en prod si votre hébergeur suit)
Avant de toucher à l’admin :
- Travaillez sur un environnement de staging, ou au minimum faites une sauvegarde (fichiers + base).
- Activez
WP_DEBUGetWP_DEBUG_LOGsur staging pour voir les warnings (les erreurs de type “Undefined array key” sont fréquentes sur des champs mal initialisés). - Évitez de coller ce code dans
functions.php. Un thème (même enfant) se change. Un plugin, non.
Docs officielles utiles (vous y reviendrez pendant le dev) :
- Settings API (Plugins Handbook)
- register_setting()
- add_settings_field()
- add_settings_error()
- PHP filter_var() / filtres sanitize
L’approche naïve (et pourquoi l’éviter)
Le pattern que je vois le plus : une page admin avec un formulaire, puis un if ( isset($_POST['...']) ) update_option(...). Ça “fonctionne” jusqu’à ce que ça casse.
Exemple typique (à éviter)
<?php
// À ÉVITER : exemple volontairement naïf.
add_action('admin_menu', function () {
add_options_page('Réglages', 'Réglages', 'manage_options', 'naif', function () {
if (isset($_POST['site_color'])) {
// Pas de nonce, pas de capability check, pas de sanitization.
update_option('site_color', $_POST['site_color']);
echo '<div class="updated"><p>Enregistré.</p></div>';
}
?>
<form method="post">
<input type="text" name="site_color" value="<?php echo get_option('site_color'); ?>">
<button>Sauvegarder</button>
</form>
<?php
});
});
Pourquoi ça pose problème
- Sécurité : pas de nonce, donc vulnérable aux attaques CSRF. Un admin connecté peut être piégé par une page externe.
- Permissions : le capability check est implicite via
add_options_page(), mais beaucoup de gens finissent par mettre le formulaire ailleurs (ou réutiliser le callback) sans vérifiercurrent_user_can(). - Validation : aucune. Une couleur attendue devient du HTML/JS stocké en base (XSS stockée si ré-affichée).
- UX : les messages “updated” ne suivent pas le standard WordPress (et se cassent avec certains écrans/redirects).
- Maintenance : vous réinventez la roue : gestion des erreurs par champ, valeurs par défaut, etc.
La bonne approche — tutoriel pas à pas
On va créer un plugin autonome qui ajoute une page d’options complète. L’idée clé : une option unique (tableau) + un sanitize callback qui valide tout.
Étape 1 — Créer le plugin
Créez le fichier : wp-content/plugins/bpcab-settings-api/bpcab-settings-api.php.
Étape 2 — Déclarer une option unique et des valeurs par défaut
Dans mon expérience, stocker chaque champ séparément finit en spaghetti (surtout quand vous devez exporter/importer). Ici : bpcab_settings contiendra tout.
Étape 3 — Ajouter la page dans “Réglages”
On utilise add_options_page(). Capability : manage_options (classique) — ajustez si vous avez un rôle custom.
Étape 4 — Déclarer Settings API (settings/sections/fields)
Le hook à utiliser : admin_init pour register_setting() et les champs. Beaucoup se trompent et font ça dans admin_menu : ça marche “souvent”, mais vous finissez avec des champs qui n’apparaissent pas sur certains écrans ou des callbacks appelés au mauvais moment.
Étape 5 — Écrire les callbacks de champs (escaping strict)
Règle simple : sanitize à l’entrée, escape à la sortie. Pour un input : esc_attr(). Pour un textarea : esc_textarea(). Pour une URL affichée : esc_url().
Étape 6 — Valider côté serveur (sanitize_callback)
Le callback reçoit les valeurs soumises. On :
- fusionne avec les valeurs existantes (utile si un champ n’est pas présent dans POST, typiquement une checkbox) ;
- applique des règles strictes (whitelist, bornes, types) ;
- déclare des erreurs par champ avec
add_settings_error().
Étape 7 — Rendre la page (formulaire standard)
Le trio :
settings_fields( 'bpcab_settings_group' )(nonce + hidden fields)do_settings_sections( 'bpcab-settings' )(sections + champs)submit_button()
Étape 8 — (Optionnel) Export/Import JSON
Sur des sites d’agence, j’ai souvent besoin de répliquer des réglages entre staging et production. On ajoute un import/export simple, sécurisé (capability + nonce), sans dépendances.
Code complet
Copiez-collez ce fichier tel quel dans wp-content/plugins/bpcab-settings-api/bpcab-settings-api.php, puis activez le plugin.
<?php
/**
* Plugin Name: BPCAB - Page d'options (Settings API)
* Description: Exemple complet de page d'options via la Settings API (WP 6.9.4+, PHP 8.1+).
* Version: 1.0.0
* Author: BPCAB
* Requires at least: 6.9
* Requires PHP: 8.1
*/
if (!defined('ABSPATH')) {
exit;
}
final class BPCAB_Settings_Page {
private const OPTION_NAME = 'bpcab_settings';
private const OPTION_GROUP = 'bpcab_settings_group';
private const PAGE_SLUG = 'bpcab-settings';
public static function boot(): void {
$instance = new self();
add_action('admin_menu', [$instance, 'register_menu']);
add_action('admin_init', [$instance, 'register_settings']);
// Handler import/export (admin-post.php) : actions dédiées.
add_action('admin_post_bpcab_export_settings', [$instance, 'handle_export']);
add_action('admin_post_bpcab_import_settings', [$instance, 'handle_import']);
}
/**
* Valeurs par défaut : évite les "Undefined array key" et stabilise l'UI.
*/
private function defaults(): array {
return [
'enabled' => 0,
'brand_color' => '#2271b1',
'contact_email' => '',
'tracking_url' => '',
'posts_per_block' => 6,
'custom_css' => '',
];
}
/**
* Récupère les réglages, fusionnés avec les defaults.
*/
public function get_settings(): array {
$raw = get_option(self::OPTION_NAME, []);
if (!is_array($raw)) {
$raw = [];
}
return array_merge($this->defaults(), $raw);
}
public function register_menu(): void {
add_options_page(
'Réglages du site',
'Réglages du site',
'manage_options',
self::PAGE_SLUG,
[$this, 'render_page']
);
}
public function register_settings(): void {
register_setting(
self::OPTION_GROUP,
self::OPTION_NAME,
[
'type' => 'array',
'sanitize_callback' => [$this, 'sanitize_settings'],
'default' => $this->defaults(),
// show_in_rest : à activer seulement si vous voulez exposer ces réglages via REST.
// 'show_in_rest' => false,
]
);
// Section principale.
add_settings_section(
'bpcab_main_section',
'Réglages généraux',
function (): void {
echo '<p>Réglages utilisés par le thème ou vos modules. Validation stricte côté serveur.</p>';
},
self::PAGE_SLUG
);
// Champ: activation.
add_settings_field(
'enabled',
'Activer la fonctionnalité',
[$this, 'field_enabled'],
self::PAGE_SLUG,
'bpcab_main_section',
[
'label_for' => 'bpcab_enabled',
]
);
// Champ: couleur.
add_settings_field(
'brand_color',
'Couleur de marque',
[$this, 'field_brand_color'],
self::PAGE_SLUG,
'bpcab_main_section',
[
'label_for' => 'bpcab_brand_color',
]
);
// Champ: email.
add_settings_field(
'contact_email',
'Email de contact',
[$this, 'field_contact_email'],
self::PAGE_SLUG,
'bpcab_main_section',
[
'label_for' => 'bpcab_contact_email',
]
);
// Champ: URL tracking.
add_settings_field(
'tracking_url',
'URL de tracking (optionnel)',
[$this, 'field_tracking_url'],
self::PAGE_SLUG,
'bpcab_main_section',
[
'label_for' => 'bpcab_tracking_url',
]
);
// Champ: entier borné.
add_settings_field(
'posts_per_block',
'Articles par bloc',
[$this, 'field_posts_per_block'],
self::PAGE_SLUG,
'bpcab_main_section',
[
'label_for' => 'bpcab_posts_per_block',
]
);
// Champ: CSS custom (textarea).
add_settings_field(
'custom_css',
'CSS personnalisé',
[$this, 'field_custom_css'],
self::PAGE_SLUG,
'bpcab_main_section',
[
'label_for' => 'bpcab_custom_css',
]
);
// Section import/export.
add_settings_section(
'bpcab_tools_section',
'Outils',
function (): void {
echo '<p>Export/import JSON des réglages. Utile pour migrer entre environnements.</p>';
},
self::PAGE_SLUG
);
add_settings_field(
'tools_export',
'Exporter',
[$this, 'field_export'],
self::PAGE_SLUG,
'bpcab_tools_section'
);
add_settings_field(
'tools_import',
'Importer',
[$this, 'field_import'],
self::PAGE_SLUG,
'bpcab_tools_section'
);
}
/**
* Sanitize callback global.
* @param mixed $input Valeurs soumises.
*/
public function sanitize_settings($input): array {
$current = $this->get_settings();
$output = $current;
if (!is_array($input)) {
add_settings_error(self::OPTION_NAME, 'bpcab_invalid_payload', 'Données invalides envoyées.', 'error');
return $output;
}
// Checkbox: si absente du POST, elle doit être considérée comme décochée.
$output['enabled'] = isset($input['enabled']) ? 1 : 0;
// Couleur hex : whitelist stricte.
$brand_color = isset($input['brand_color']) ? (string) $input['brand_color'] : '';
$brand_color = trim($brand_color);
if ($brand_color === '') {
$output['brand_color'] = $this->defaults()['brand_color'];
} elseif (preg_match('/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/', $brand_color)) {
$output['brand_color'] = $brand_color;
} else {
add_settings_error(self::OPTION_NAME, 'bpcab_brand_color_invalid', 'Couleur de marque invalide. Format attendu : #RRGGBB.', 'error');
}
// Email : sanitize + validation.
$email = isset($input['contact_email']) ? sanitize_email((string) $input['contact_email']) : '';
if ($email !== '' && !is_email($email)) {
add_settings_error(self::OPTION_NAME, 'bpcab_email_invalid', 'Email de contact invalide.', 'error');
} else {
$output['contact_email'] = $email;
}
// URL (optionnelle). On accepte vide, sinon URL valide.
$url = isset($input['tracking_url']) ? esc_url_raw((string) $input['tracking_url']) : '';
if ($url !== '' && filter_var($url, FILTER_VALIDATE_URL) === false) {
add_settings_error(self::OPTION_NAME, 'bpcab_url_invalid', 'URL de tracking invalide.', 'error');
} else {
$output['tracking_url'] = $url;
}
// Entier borné (1..24).
$ppb = isset($input['posts_per_block']) ? (int) $input['posts_per_block'] : (int) $this->defaults()['posts_per_block'];
if ($ppb < 1 || $ppb > 24) {
add_settings_error(self::OPTION_NAME, 'bpcab_ppb_invalid', 'Articles par bloc : valeur attendue entre 1 et 24.', 'error');
} else {
$output['posts_per_block'] = $ppb;
}
// CSS : on garde du texte, pas de HTML. (Vous pouvez renforcer selon votre usage.)
$css = isset($input['custom_css']) ? (string) $input['custom_css'] : '';
$css = wp_strip_all_tags($css, true);
// Évite des payloads énormes en base.
$css = mb_substr($css, 0, 20000);
$output['custom_css'] = $css;
return $output;
}
/* ==========================
* RENDER FIELDS
* ========================== */
public function field_enabled(): void {
$s = $this->get_settings();
$name = self::OPTION_NAME . '[enabled]';
$id = 'bpcab_enabled';
echo '<label>';
printf(
'<input type="checkbox" id="%s" name="%s" value="1" %s /> Actif',
esc_attr($id),
esc_attr($name),
checked(1, (int) $s['enabled'], false)
);
echo '</label>';
echo '<p class="description">Désactivez pour couper la fonctionnalité sans perdre les réglages.</p>';
}
public function field_brand_color(): void {
$s = $this->get_settings();
$name = self::OPTION_NAME . '[brand_color]';
$id = 'bpcab_brand_color';
printf(
'<input type="text" class="regular-text" id="%s" name="%s" value="%s" placeholder="#2271b1" />',
esc_attr($id),
esc_attr($name),
esc_attr((string) $s['brand_color'])
);
echo '<p class="description">Hex uniquement (ex: #2271b1).</p>';
}
public function field_contact_email(): void {
$s = $this->get_settings();
$name = self::OPTION_NAME . '[contact_email]';
$id = 'bpcab_contact_email';
printf(
'<input type="email" class="regular-text" id="%s" name="%s" value="%s" />',
esc_attr($id),
esc_attr($name),
esc_attr((string) $s['contact_email'])
);
echo '<p class="description">Utilisé pour les notifications (optionnel).</p>';
}
public function field_tracking_url(): void {
$s = $this->get_settings();
$name = self::OPTION_NAME . '[tracking_url]';
$id = 'bpcab_tracking_url';
printf(
'<input type="url" class="regular-text" id="%s" name="%s" value="%s" placeholder="https://example.com/pixel" />',
esc_attr($id),
esc_attr($name),
esc_attr((string) $s['tracking_url'])
);
echo '<p class="description">Laissez vide si vous n’utilisez pas de pixel/endpoint.</p>';
}
public function field_posts_per_block(): void {
$s = $this->get_settings();
$name = self::OPTION_NAME . '[posts_per_block]';
$id = 'bpcab_posts_per_block';
printf(
'<input type="number" min="1" max="24" step="1" class="small-text" id="%s" name="%s" value="%s" />',
esc_attr($id),
esc_attr($name),
esc_attr((string) (int) $s['posts_per_block'])
);
echo '<p class="description">Borné pour éviter des requêtes trop lourdes.</p>';
}
public function field_custom_css(): void {
$s = $this->get_settings();
$name = self::OPTION_NAME . '[custom_css]';
$id = 'bpcab_custom_css';
printf(
'<textarea id="%s" name="%s" rows="8" class="large-text code" placeholder="/* CSS */">%s</textarea>',
esc_attr($id),
esc_attr($name),
esc_textarea((string) $s['custom_css'])
);
echo '<p class="description">Stocké en base. Pour de gros styles, préférez un fichier et un enqueue.</p>';
}
public function field_export(): void {
if (!current_user_can('manage_options')) {
return;
}
$url = wp_nonce_url(
admin_url('admin-post.php?action=bpcab_export_settings'),
'bpcab_export_settings',
'_wpnonce'
);
printf('<a class="button button-secondary" href="%s">Télécharger le JSON</a>', esc_url($url));
echo '<p class="description">Export des réglages actuels au format JSON.</p>';
}
public function field_import(): void {
if (!current_user_can('manage_options')) {
return;
}
$action = esc_url(admin_url('admin-post.php'));
$nonce = wp_create_nonce('bpcab_import_settings');
echo '<form method="post" action="' . $action . '" enctype="multipart/form-data" style="margin-top:6px;">';
echo '<input type="hidden" name="action" value="bpcab_import_settings" />';
echo '<input type="hidden" name="_wpnonce" value="' . esc_attr($nonce) . '" />';
echo '<input type="file" name="bpcab_settings_file" accept="application/json" /> ';
echo '<input type="submit" class="button button-secondary" value="Importer" />';
echo '</form>';
echo '<p class="description">Import : remplace les réglages. Faites-le sur staging d’abord.</p>';
}
/* ==========================
* PAGE RENDER
* ========================== */
public function render_page(): void {
if (!current_user_can('manage_options')) {
wp_die('Accès refusé.');
}
echo '<div class="wrap">';
echo '<h1>Réglages du site</h1>';
// Affiche les messages Settings API (succès/erreurs).
settings_errors(self::OPTION_NAME);
echo '<form method="post" action="options.php">';
settings_fields(self::OPTION_GROUP);
do_settings_sections(self::PAGE_SLUG);
submit_button('Enregistrer les réglages');
echo '</form>';
echo '</div>';
}
/* ==========================
* IMPORT / EXPORT
* ========================== */
public function handle_export(): void {
if (!current_user_can('manage_options')) {
wp_die('Accès refusé.');
}
check_admin_referer('bpcab_export_settings');
$settings = $this->get_settings();
$json = wp_json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if (!is_string($json)) {
wp_die('Impossible de générer le JSON.');
}
nocache_headers();
header('Content-Type: application/json; charset=' . get_option('blog_charset'));
header('Content-Disposition: attachment; filename="bpcab-settings-' . gmdate('Y-m-d') . '.json"');
echo $json;
exit;
}
public function handle_import(): void {
if (!current_user_can('manage_options')) {
wp_die('Accès refusé.');
}
check_admin_referer('bpcab_import_settings');
if (empty($_FILES['bpcab_settings_file']['tmp_name'])) {
$this->redirect_with_notice('Fichier manquant.', 'error');
}
$tmp = (string) $_FILES['bpcab_settings_file']['tmp_name'];
$contents = file_get_contents($tmp);
if (!is_string($contents) || $contents === '') {
$this->redirect_with_notice('Fichier vide ou illisible.', 'error');
}
$data = json_decode($contents, true);
if (!is_array($data)) {
$this->redirect_with_notice('JSON invalide.', 'error');
}
// On repasse par le sanitize callback pour appliquer les règles + éviter les surprises.
$sanitized = $this->sanitize_settings($data);
update_option(self::OPTION_NAME, $sanitized, false);
$this->redirect_with_notice('Import terminé. Vérifiez les réglages.', 'success');
}
private function redirect_with_notice(string $message, string $type = 'success'): void {
// On stocke un message via settings_errors : ici, on utilise un transient court.
set_transient('bpcab_admin_notice', ['message' => $message, 'type' => $type], 30);
wp_safe_redirect(
add_query_arg(['page' => self::PAGE_SLUG], admin_url('options-general.php'))
);
exit;
}
}
BPCAB_Settings_Page::boot();
// Affichage d'une notice simple après import (transient).
add_action('admin_notices', function (): void {
if (!is_admin() || !current_user_can('manage_options')) {
return;
}
$notice = get_transient('bpcab_admin_notice');
if (!is_array($notice) || empty($notice['message'])) {
return;
}
delete_transient('bpcab_admin_notice');
$type = ($notice['type'] === 'error') ? 'notice notice-error' : 'notice notice-success';
echo '<div class="' . esc_attr($type) . '"><p>' . esc_html((string) $notice['message']) . '</p></div>';
});
Explication du code
Pourquoi une option tableau plutôt que 6 options séparées
Une option tableau (bpcab_settings) simplifie :
- l’export/import ;
- les defaults (un seul endroit) ;
- les migrations (vous versionnez la structure) ;
- les lectures (un seul
get_option()au lieu de 6).
Le trade-off : si vous autoloadiez cette option et qu’elle devient énorme, vous polluez la mémoire à chaque requête. Ici on reste sur des valeurs petites. Notez que update_option(..., false) dans l’import force autoload = no.
Hooks utilisés (et pourquoi)
admin_menu: pour enregistrer le menu. Logique, stable.admin_init: pourregister_setting()et la déclaration des champs/sections. C’est là que WordPress s’attend à voir ces appels.admin_post_*: pour l’import/export. C’est la voie standard pour traiter des formulaires/actions admin horsoptions.php.
Le flux de sauvegarde Settings API
Quand vous soumettez le formulaire :
settings_fields()injecte les champs cachés, dont le nonce et le nom du groupe.- Le POST va vers
options.php(cœur WP). - WordPress vérifie le nonce et la capability (selon la config du setting).
- Votre
sanitize_callbackest appelé. - WordPress met à jour l’option et redirige vers la page, avec le flag “settings-updated”.
Référence utile : Settings API et settings_fields().
Validation : les détails qui évitent 80% des bugs
- Checkbox : si décochée, elle n’apparaît pas dans
$_POST. D’oùisset($input['enabled']) ? 1 : 0. - Couleur : une regex simple évite les valeurs du type
red; background:url(javascript:...). - Email :
sanitize_email()+is_email(). Référence : is_email(). - URL :
esc_url_raw()nettoie, puisFILTER_VALIDATE_URLvalide. Référence PHP : filter_var(). - Textarea :
wp_strip_all_tags()évite d’enregistrer du HTML. Si vous avez un cas légitime (ex: SVG), traitez-le explicitement, pas “au hasard”.
Messages d’erreur par champ
add_settings_error() empile des erreurs associées à l’option. Puis settings_errors() les affiche. C’est beaucoup plus fiable que d’imprimer des <div class="updated"> “à la main”.
Référence : add_settings_error().
Import/Export : pourquoi passer par admin-post.php
Ça évite de bricoler des endpoints custom, et vous profitez :
- du routing via
admin_post_{action}; - de
check_admin_referer(); - de
wp_safe_redirect().
Et surtout : l’import repasse par sanitize_settings(). Je vois souvent des imports qui font update_option() directement… puis le site se retrouve avec des valeurs hors bornes, et les templates explosent.
Variantes et cas d’usage
Variante 1 — Stocker en autoload “no” dès le départ
Si vos réglages ne sont utilisés que dans l’admin, vous pouvez forcer autoload = no lors de la première création. WordPress décide l’autoload au moment où l’option est ajoutée.
Approche simple : à l’activation du plugin, ajoutez l’option si absente.
<?php
register_activation_hook(__FILE__, function (): void {
if (get_option('bpcab_settings', null) === null) {
add_option('bpcab_settings', [
'enabled' => 0,
'brand_color' => '#2271b1',
'contact_email' => '',
'tracking_url' => '',
'posts_per_block' => 6,
'custom_css' => '',
], '', false); // false => autoload = no
}
});
Piège classique : appeler add_option() alors que l’option existe déjà ne change pas l’autoload. Il faut la supprimer/recréer (à éviter en prod) ou gérer autrement.
Variante 2 — Ajouter une migration de schéma (versioning)
Quand vous ajoutez un nouveau champ, vous voulez éviter des “Undefined index” dans votre code front. Ajoutez une clé schema_version et migrez si besoin.
<?php
// Exemple de principe (à intégrer proprement dans la classe).
$settings = get_option('bpcab_settings', []);
$ver = isset($settings['schema_version']) ? (int) $settings['schema_version'] : 1;
if ($ver < 2) {
$settings['new_field'] = 'default';
$settings['schema_version'] = 2;
update_option('bpcab_settings', $settings, false);
}
Variante 3 — Exposer l’option via REST (avec prudence)
Si vous avez un écran React ou un builder qui consomme des réglages via REST, vous pouvez activer show_in_rest dans register_setting(). Mais réfléchissez à :
- quels rôles peuvent lire/écrire ;
- si des données sensibles sont dedans (emails internes, tokens) ;
- si vous devez implémenter un contrôle plus fin (endpoints custom).
Référence : register_setting().
Compatibilité Divi 5 / Elementor / Avada
La Settings API est côté admin, donc compatible “par défaut” avec Divi 5, Elementor et Avada. Le vrai sujet, c’est : comment consommer ces réglages dans vos modules/widgets sans créer de couplage fragile.
Divi 5 (modules)
Divi 5 a sa propre architecture de modules, mais côté PHP vous pouvez lire l’option n’importe où (template, shortcode, module). Le point à surveiller : Divi met souvent en cache certains rendus (selon configuration), donc après modification des options, testez en vidant le cache Divi.
Exemple d’utilisation (dans un module custom ou un shortcode) :
<?php
function bpcab_get_brand_color(): string {
$s = get_option('bpcab_settings', []);
$color = is_array($s) && isset($s['brand_color']) ? (string) $s['brand_color'] : '#2271b1';
return preg_match('/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/', $color) ? $color : '#2271b1';
}
Elementor (widgets)
Avec Elementor, le piège fréquent est de “dupliquer” un réglage dans le widget, puis de ne plus savoir quelle valeur fait foi. Je recommande : réglages globaux dans votre option, réglages de widget uniquement pour du spécifique.
Si vous injectez du CSS global, faites-le via wp_add_inline_style() sur un handle fiable (celui de votre plugin), pas via echo dans le header.
Avada (Fusion Builder)
Avada a aussi ses systèmes d’options, mais rien ne vous empêche d’avoir vos propres réglages plugin. Faites attention aux optimisations Avada (minification/combinaison) : si vous ajoutez du CSS inline, vérifiez qu’il sort bien sur le front et qu’il n’est pas différé de façon inattendue.
Vérifications après mise en place
- Activez le plugin, allez dans Réglages → Réglages du site.
- Changez la couleur en une valeur invalide (ex:
blue) : vous devez voir une erreur, et la valeur ne doit pas être enregistrée. - Décochez “Actif”, enregistrez, rechargez : la checkbox doit rester décochée (test clé).
- Testez l’export : un fichier JSON doit se télécharger.
- Testez l’import sur staging avec ce JSON : les réglages doivent être identiques après import.
Tableau diagnostic (symptômes réels)
| Symptôme | Cause probable | Vérification | Solution |
|---|---|---|---|
| La page d’options est vide (juste le titre) | do_settings_sections() appelé avec un mauvais slug |
Comparer le slug utilisé dans add_options_page() et dans add_settings_section()/do_settings_sections() |
Utiliser exactement bpcab-settings partout |
| Les champs s’affichent mais rien ne se sauvegarde | settings_fields() absent ou mauvais group |
Voir si le HTML contient option_page et un nonce |
Mettre settings_fields('bpcab_settings_group') |
| La checkbox ne se décoche jamais | Vous ne gérez pas le cas “absent du POST” | Inspecter $_POST (ou log) : la clé n’existe pas quand décoché |
Dans sanitize : isset(...) ? 1 : 0 |
| Message “Réglages enregistrés” absent | Cache admin / redirection cassée / pas de settings_errors() |
Désactiver cache, vérifier la présence de settings_errors() |
Afficher settings_errors() et vider les caches |
| Erreur 403 sur export/import | Nonce invalide ou capability insuffisante | Vérifier l’URL export (nonce), vérifier rôle utilisateur | Régénérer la page, vérifier manage_options |
Si ça ne marche pas
- Vérifiez où vous avez collé le code. Si vous l’avez mis dans un plugin de snippets, un “;” manquant peut casser l’admin entière. Un plugin dédié est plus stable.
- Regardez
wp-content/debug.log. Les erreurs typiques : parenthèse manquante, “Cannot redeclare class”, “Undefined array key”. - Confirmez PHP 8.1+. Sur certains hébergements, l’admin tourne encore sur une version inférieure si un pool FPM est mal configuré.
- Contrôlez les hooks.
register_setting()doit être suradmin_init. Si vous l’avez mis surinit, ça peut marcher, mais vous allez courir après des effets de bord. - Désactivez temporairement les plugins d’optimisation/caching admin. J’ai déjà vu des plugins “security” bloquer
admin-post.phppour des actions non listées. - Testez avec un thème par défaut. Rare, mais certains thèmes injectent des styles/scripts admin qui cassent la mise en page des tables de réglages.
- Permaliens : normalement non lié, mais si vous avez des redirections admin bizarres, regénérez les permaliens et vérifiez les règles de sécurité serveur.
Pièges et erreurs courantes
| Erreur | Cause | Solution |
|---|---|---|
Copier le code dans functions.php puis perdre la page après changement de thème |
Le code dépend du thème | Mettre le code dans un plugin (comme ici) ou un mu-plugin |
Fatal error: Cannot redeclare class ... |
Le fichier est chargé deux fois (plugin dupliqué, snippet + plugin) | Supprimer le doublon, vérifier le dossier plugins |
| Les valeurs reviennent aux defaults après sauvegarde | Sanitize callback retourne un tableau incomplet ou écrase tout | Fusionner avec les valeurs courantes et ne modifier que les clés attendues |
settings_fields() ne “fait rien” |
Mauvais nom de groupe | Le group doit correspondre au 1er paramètre de register_setting() |
| “Réglages enregistrés” mais option inchangée | Option name incorrect dans les champs (name="...") |
Respecter bpcab_settings[field_key] |
| CSS/JS admin non chargé | Mauvais hook d’enqueue (ex: wp_enqueue_scripts au lieu de admin_enqueue_scripts) |
Utiliser admin_enqueue_scripts + vérifier $hook_suffix |
| Import ne marche pas (redirection sans message) | Nonce invalide ou upload bloqué | Vérifier check_admin_referer(), la taille max upload PHP, et les règles WAF |
Code d’un ancien tutoriel : register_setting sans sanitize, XSS stockée |
Pas de validation/escaping | Sanitize à l’entrée + escape à la sortie, systématiquement |
Conseils sécurité, performance et maintenance
- CSRF : ne postez pas vers un handler custom sans nonce. Ici,
options.phpgère le nonce viasettings_fields(), et import/export ont leurs nonces dédiés. - Capabilities :
manage_optionsest correct pour des réglages globaux. Pour des sites éditoriaux, vous pouvez créer une capability dédiée (rôle custom) si nécessaire. - Ne stockez pas de secrets en clair. Tokens API : préférez des constantes côté serveur (wp-config) ou un stockage chiffré (et encore, c’est un sujet à part).
- Autoload : surveillez
wp_options. Une option autoload énorme ralentit toutes les requêtes. Référence utile : Options API. - Maintenance : ajoutez une migration de schéma quand vous changez la structure. C’est ce qui évite les bugs “fantômes” après update plugin.
- Perf admin : évitez de faire des requêtes lourdes dans les callbacks de champs. Les callbacks sont appelés à chaque chargement de page.
Ressources
- Settings API — Plugins Handbook
- register_setting()
- add_settings_section()
- add_settings_field()
- add_settings_error()
- settings_errors()
- Options API
- Miroir GitHub du core WordPress (wordpress-develop)
- WordPress Core Trac (tickets et historique)
- PHP: filter_var()
FAQ
Pourquoi mon champ checkbox ne se sauvegarde pas correctement ?
Parce qu’une checkbox décochée n’est pas envoyée dans le POST. Dans le sanitize callback, vous devez traiter “absent = 0”. C’est la source n°1 des réglages “impossibles à désactiver”.
Est-ce que je dois utiliser une option par champ ?
Pas obligatoire. Pour 5–30 réglages, une option tableau est souvent plus simple. Pour des réglages très indépendants (et consommés séparément), plusieurs options peuvent être OK, mais vous perdez en cohérence (defaults, export, migration).
Où placer ce code : thème enfant, plugin de snippets, mu-plugin ?
Pour une page d’options, je recommande un plugin dédié (comme ici). Un mu-plugin est utile si vous voulez l’imposer sur tous les sites d’un réseau ou éviter la désactivation accidentelle.
Comment ajouter un champ select avec liste blanche ?
Ajoutez un add_settings_field() et, dans le sanitize callback, vérifiez que la valeur est dans un tableau autorisé (in_array($value, $allowed, true)). N’enregistrez jamais une valeur arbitraire.
Pourquoi utiliser settings_errors() au lieu d’un echo “updated” ?
Parce que WordPress gère des redirections après sauvegarde. Les messages “faits main” se perdent facilement. add_settings_error() + settings_errors() survivent au flux standard.
Mon import JSON “réussit” mais certaines valeurs ne changent pas
Si une valeur est invalide, le sanitize callback la refuse (et conserve l’ancienne). C’est voulu. Sur staging, mettez temporairement un log dans sanitize_settings() pour voir quelles clés sont rejetées.
Puis-je afficher ces réglages sur le front ?
Oui. Mais échappez toujours : esc_html(), esc_attr(), esc_url() selon le contexte. Évitez d’injecter du CSS brut sans contrôle.
Comment tester proprement sans casser la prod ?
Faites un staging, activez WP_DEBUG_LOG, testez les cas invalides (email faux, URL invalide, entier hors bornes), puis exportez/importez. Ensuite seulement, déployez en prod et revérifiez avec un compte admin non-super-admin (si multisite).
Ça marche en multisite ?
Tel quel, c’est une option par site (blog) via wp_options. Si vous voulez un réglage global réseau, il faut une page dans le Network Admin et utiliser get_site_option()/update_site_option().
Est-ce compatible avec WordPress 6.9.4 et PHP 8.1 ?
Oui. Le code utilise des APIs stables (Settings API, Options API, admin-post) et des types compatibles PHP 8.1. Si vous copiez un vieux tutoriel qui utilise des patterns obsolètes, vous verrez vite des warnings PHP (notamment sur les tableaux).