Si votre client vous demande “un bloc Elementor qui affiche un encart auteur avec un lien vers ses derniers articles”, vous avez deux choix : bricoler un shortcode, ou créer un vrai widget Elementor, proprement configurable et maintenable. Le shortcode marche… jusqu’au jour où vous devez ajouter un contrôle de style, gérer un fallback, ou éviter de charger du CSS partout.

Le problème / Le besoin

Vous voulez un widget Elementor personnalisé, réutilisable, qui s’insère dans l’éditeur comme n’importe quel widget natif. Et surtout : vous voulez qu’il soit configurable (contenu + styles), sécurisé (sanitization/escaping), et qu’il n’impacte pas les performances du site.

Ce guide s’adresse à des utilisateurs intermédiaires (à l’aise avec PHP et les hooks) qui travaillent sur WordPress 6.9.4 (avril 2026) et PHP 8.1+. À la fin, vous saurez :

  • Créer un mini-plugin qui enregistre un widget via l’API Widget d’Elementor.
  • Ajouter des contrôles (texte, URL, sélecteur d’utilisateur, nombre d’articles, bascule d’options).
  • Générer un rendu HTML sûr (escaping) et robuste (fallbacks).
  • Charger CSS/JS uniquement quand le widget est utilisé.

Résumé rapide

  • On crée un plugin “mu” ou classique qui s’accroche à elementor/widgets/register.
  • On définit une classe de widget qui étend ElementorWidget_Base.
  • On ajoute des contrôles via Controls_Manager (contenu + style).
  • On rend le widget avec render() (front) et content_template() (aperçu éditeur, optionnel).
  • On enregistre des assets (CSS/JS) et on les charge via get_style_depends()/get_script_depends().

Quand utiliser cette solution

  • Vous avez un bloc spécifique au projet (ex : “Encart auteur”, “CTA maison”, “Produits mis en avant”) que vos éditeurs doivent pouvoir configurer visuellement.
  • Vous devez exposer des contrôles de style Elementor (typographie, couleurs, espacements) sans écrire une usine à gaz de classes CSS.
  • Vous voulez éviter les shortcodes “magiques” qui cassent la mise en page quand on change de thème ou de builder.
  • Vous maintenez plusieurs sites : un widget packagé en plugin est plus simple à versionner et déployer.

Quand ne PAS utiliser cette solution

  • Vous voulez juste insérer un petit bout de HTML statique : utilisez un widget “HTML” ou un modèle Elementor.
  • Vous avez besoin d’un rendu 100% dynamique côté serveur mais sans UI complexe : un shortcode peut suffire (et être consommé dans Elementor via widget “Shortcode”).
  • Vous cherchez un composant réutilisable cross-builders : privilégiez un bloc Gutenberg (Block Editor) et/ou un pattern. Elementor est un écosystème spécifique.
  • Vous n’avez pas la main sur le code (site client verrouillé) : un plugin “snippets” peut dépanner, mais c’est rarement propre pour des classes/chargements d’assets.

Prérequis / avant de commencer

Avant de toucher au code :

  • Travaillez sur un environnement de staging/local (LocalWP, DevKinsta, Docker…).
  • Sauvegardez la base et les fichiers (au minimum wp-content).
  • Vérifiez les versions : WordPress 6.9.4, PHP 8.1+, Elementor à jour.
  • Activez WP_DEBUG et WP_DEBUG_LOG sur staging pour voir les erreurs.

Rappels utiles :

Précaution sécurité : un widget Elementor peut afficher des données issues de la base (utilisateurs, posts). Si vous échappez mal les sorties, vous ouvrez la porte à des XSS stockées. J’ai déjà vu ce scénario sur des sites multi-auteurs où un “display_name” mal filtré finissait injecté dans un attribut HTML.

L’approche naïve (et pourquoi l’éviter)

Ce que je vois souvent : un shortcode qui récupère des options via $_GET ou des attributs non filtrés, puis imprime du HTML brut. Exemple typique (à ne pas utiliser) :

<?php
// ❌ Exemple volontairement mauvais : pas de sanitization, pas d'escaping, requête non bornée.
add_shortcode('author_box', function($atts) {
    $atts = shortcode_atts([
        'user' => 1,
        'count' => 5,
        'title' => 'Auteur'
    ], $atts);

    $user = get_user_by('id', $atts['user']);
    echo '<div class="author-box">';
    echo '<h3>' . $atts['title'] . '</h3>';
    echo '<p>' . $user->display_name . '</p>';
    echo '</div>';
});

Problèmes concrets :

  • Sécurité : $atts['title'] et $user->display_name sortent sans esc_html().
  • Perf : pas de cache, pas de bornes strictes, et vous risquez de charger des requêtes répétées dans une page Elementor.
  • UX : dans Elementor, l’éditeur ne voit pas des contrôles natifs (pas de typographie/couleurs sans CSS custom).
  • Maintenance : vous finissez avec 12 shortcodes, chacun avec sa logique et son CSS global.

La bonne approche — tutoriel pas à pas

Étape 1 — Créer un plugin minimal

Créez un dossier : wp-content/plugins/bpcab-elementor-widgets

Créez le fichier principal : wp-content/plugins/bpcab-elementor-widgets/bpcab-elementor-widgets.php

Étape 2 — Vérifier qu’Elementor est chargé (et au bon moment)

Le piège classique : enregistrer le widget trop tôt (ex : sur init) et obtenir une erreur du type Class ‘ElementorWidget_Base’ not found. On s’accroche aux hooks Elementor dédiés.

Étape 3 — Déclarer un widget “Encart Auteur + derniers posts”

On va coder un widget qui :

  • Choisit un utilisateur (auteur) via un contrôle.
  • Affiche son avatar + nom + bio (optionnel).
  • Liste ses derniers articles (nombre configurable).
  • Expose des options de style (couleurs, typographie, espacement).

Étape 4 — Charger CSS/JS seulement si nécessaire

Elementor permet de déclarer des dépendances via get_style_depends() et get_script_depends(). Dans mon expérience, c’est un gros gain : vous évitez un fichier CSS global chargé sur toutes les pages.

Étape 5 — Ajouter des contrôles de style Elementor

On utilisera les “selectors” d’Elementor pour générer du CSS scoped au widget. Ça évite de coder des dizaines de classes et ça réduit les conflits avec Avada/Divi.

Étape 6 — Rendu sécurisé et robuste

Points clés :

  • Sanitization côté réglages : absint, sanitize_text_field, esc_url_raw si besoin.
  • Escaping côté sortie : esc_html, esc_attr, esc_url.
  • Fallbacks : auteur introuvable, pas d’articles, bio vide.

Code complet

Copiez-collez tel quel. Ce plugin enregistre 1 widget Elementor. Il est volontairement compact mais complet et prêt à tester.

1) Fichier principal du plugin

<?php
/**
 * Plugin Name: BPCAB - Widgets Elementor (Exemple)
 * Description: Exemple pédagogique : widget Elementor personnalisé (encart auteur + derniers articles).
 * Version: 1.0.0
 * Requires at least: 6.9
 * Requires PHP: 8.1
 * Author: BPCAB
 *
 * Sécurité : ce plugin est un exemple. Testez en staging avant production.
 */

declare(strict_types=1);

if (!defined('ABSPATH')) {
	exit;
}

final class BPCAB_Elementor_Widgets_Plugin {

	private const MIN_PHP = '8.1';

	public static function init(): void {
		add_action('plugins_loaded', [__CLASS__, 'bootstrap']);
	}

	public static function bootstrap(): void {
		// Vérif PHP (utile si le site est downgradé par erreur).
		if (version_compare(PHP_VERSION, self::MIN_PHP, '<')) {
			add_action('admin_notices', [__CLASS__, 'notice_php_version']);
			return;
		}

		// Ne rien faire si Elementor n'est pas actif.
		if (!did_action('elementor/loaded')) {
			add_action('admin_notices', [__CLASS__, 'notice_elementor_missing']);
			return;
		}

		// Enregistrer le widget au bon hook.
		add_action('elementor/widgets/register', [__CLASS__, 'register_widgets']);

		// Enregistrer les assets (CSS/JS) utilisables par les widgets.
		add_action('wp_enqueue_scripts', [__CLASS__, 'register_front_assets']);
	}

	public static function register_front_assets(): void {
		$ver = '1.0.0';

		wp_register_style(
			'bpcab-author-box',
			plugins_url('assets/author-box.css', __FILE__),
			[],
			$ver
		);

		// JS optionnel : ici on ne fait rien de critique, mais c'est prêt si vous en avez besoin.
		wp_register_script(
			'bpcab-author-box',
			plugins_url('assets/author-box.js', __FILE__),
			[],
			$ver,
			true
		);
	}

	public static function register_widgets($widgets_manager): void {
		// Charger la classe du widget.
		require_once __DIR__ . '/widgets/class-bpcab-author-box-widget.php';

		// Elementor 3.x+ : register() existe sur le manager.
		$widgets_manager->register(new BPCAB_Author_Box_Widget());
	}

	public static function notice_elementor_missing(): void {
		if (!current_user_can('activate_plugins')) {
			return;
		}

		$plugin_page = admin_url('plugins.php');

		echo '<div class="notice notice-warning"><p>';
		echo esc_html__('BPCAB - Widgets Elementor : Elementor n’est pas actif. Activez Elementor pour charger le widget.', 'bpcab');
		echo ' ';
		echo '<a href="' . esc_url($plugin_page) . '">' . esc_html__('Aller aux extensions', 'bpcab') . '</a>';
		echo '</p></div>';
	}

	public static function notice_php_version(): void {
		if (!current_user_can('manage_options')) {
			return;
		}

		echo '<div class="notice notice-error"><p>';
		echo esc_html__('BPCAB - Widgets Elementor : PHP 8.1+ est requis.', 'bpcab');
		echo '</p></div>';
	}
}

BPCAB_Elementor_Widgets_Plugin::init();

2) Classe du widget

Créez : wp-content/plugins/bpcab-elementor-widgets/widgets/class-bpcab-author-box-widget.php

<?php
declare(strict_types=1);

if (!defined('ABSPATH')) {
	exit;
}

use ElementorWidget_Base;
use ElementorControls_Manager;
use ElementorGroup_Control_Typography;
use ElementorGroup_Control_Border;
use ElementorGroup_Control_Box_Shadow;

final class BPCAB_Author_Box_Widget extends Widget_Base {

	public function get_name(): string {
		return 'bpcab_author_box';
	}

	public function get_title(): string {
		return esc_html__('Encart Auteur (BPCAB)', 'bpcab');
	}

	public function get_icon(): string {
		// Icône Elementor (dashicons-like). Vous pouvez la changer.
		return 'eicon-user-circle-o';
	}

	public function get_categories(): array {
		// Catégorie standard. Vous pouvez créer votre propre catégorie si besoin.
		return ['general'];
	}

	public function get_keywords(): array {
		return ['author', 'auteur', 'bio', 'posts', 'bpcab'];
	}

	public function get_style_depends(): array {
		return ['bpcab-author-box'];
	}

	public function get_script_depends(): array {
		return ['bpcab-author-box'];
	}

	protected function register_controls(): void {

		// SECTION : Contenu
		$this->start_controls_section(
			'section_content',
			[
				'label' => esc_html__('Contenu', 'bpcab'),
				'tab' => Controls_Manager::TAB_CONTENT,
			]
		);

		$this->add_control(
			'title',
			[
				'label' => esc_html__('Titre', 'bpcab'),
				'type' => Controls_Manager::TEXT,
				'default' => esc_html__('À propos de l’auteur', 'bpcab'),
				'placeholder' => esc_html__('Ex : À propos de Marie', 'bpcab'),
				'label_block' => true,
			]
		);

		// Contrôle simple : ID utilisateur (numérique).
		// Variante plus avancée : select2 alimenté en AJAX (hors scope), ou select statique.
		$this->add_control(
			'user_id',
			[
				'label' => esc_html__('ID utilisateur (auteur)', 'bpcab'),
				'type' => Controls_Manager::NUMBER,
				'min' => 1,
				'step' => 1,
				'default' => (int) get_current_user_id(),
				'description' => esc_html__('Astuce : récupérez l’ID dans Utilisateurs > Tous les utilisateurs.', 'bpcab'),
			]
		);

		$this->add_control(
			'show_bio',
			[
				'label' => esc_html__('Afficher la bio', 'bpcab'),
				'type' => Controls_Manager::SWITCHER,
				'label_on' => esc_html__('Oui', 'bpcab'),
				'label_off' => esc_html__('Non', 'bpcab'),
				'return_value' => 'yes',
				'default' => 'yes',
			]
		);

		$this->add_control(
			'show_posts',
			[
				'label' => esc_html__('Afficher les derniers articles', 'bpcab'),
				'type' => Controls_Manager::SWITCHER,
				'label_on' => esc_html__('Oui', 'bpcab'),
				'label_off' => esc_html__('Non', 'bpcab'),
				'return_value' => 'yes',
				'default' => 'yes',
			]
		);

		$this->add_control(
			'posts_count',
			[
				'label' => esc_html__('Nombre d’articles', 'bpcab'),
				'type' => Controls_Manager::NUMBER,
				'min' => 1,
				'max' => 12,
				'step' => 1,
				'default' => 3,
				'condition' => [
					'show_posts' => 'yes',
				],
			]
		);

		$this->add_control(
			'profile_url',
			[
				'label' => esc_html__('URL du profil (optionnel)', 'bpcab'),
				'type' => Controls_Manager::URL,
				'placeholder' => 'https://',
				'show_external' => true,
				'description' => esc_html__('Si vide, le nom n’est pas cliquable.', 'bpcab'),
			]
		);

		$this->end_controls_section();

		// SECTION : Style (encart)
		$this->start_controls_section(
			'section_style_box',
			[
				'label' => esc_html__('Style : Encart', 'bpcab'),
				'tab' => Controls_Manager::TAB_STYLE,
			]
		);

		$this->add_control(
			'box_bg',
			[
				'label' => esc_html__('Couleur de fond', 'bpcab'),
				'type' => Controls_Manager::COLOR,
				'default' => '',
				'selectors' => [
					'{{WRAPPER}} .bpcab-author-box' => 'background-color: {{VALUE}};',
				],
			]
		);

		$this->add_responsive_control(
			'box_padding',
			[
				'label' => esc_html__('Padding', 'bpcab'),
				'type' => Controls_Manager::DIMENSIONS,
				'size_units' => ['px', 'em', 'rem', '%'],
				'selectors' => [
					'{{WRAPPER}} .bpcab-author-box' => 'padding: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};',
				],
			]
		);

		$this->add_group_control(
			Group_Control_Border::get_type(),
			[
				'name' => 'box_border',
				'selector' => '{{WRAPPER}} .bpcab-author-box',
			]
		);

		$this->add_group_control(
			Group_Control_Box_Shadow::get_type(),
			[
				'name' => 'box_shadow',
				'selector' => '{{WRAPPER}} .bpcab-author-box',
			]
		);

		$this->end_controls_section();

		// SECTION : Style (typos)
		$this->start_controls_section(
			'section_style_text',
			[
				'label' => esc_html__('Style : Texte', 'bpcab'),
				'tab' => Controls_Manager::TAB_STYLE,
			]
		);

		$this->add_control(
			'title_color',
			[
				'label' => esc_html__('Couleur du titre', 'bpcab'),
				'type' => Controls_Manager::COLOR,
				'selectors' => [
					'{{WRAPPER}} .bpcab-author-box__title' => 'color: {{VALUE}};',
				],
			]
		);

		$this->add_group_control(
			Group_Control_Typography::get_type(),
			[
				'name' => 'title_typography',
				'selector' => '{{WRAPPER}} .bpcab-author-box__title',
			]
		);

		$this->add_control(
			'name_color',
			[
				'label' => esc_html__('Couleur du nom', 'bpcab'),
				'type' => Controls_Manager::COLOR,
				'selectors' => [
					'{{WRAPPER}} .bpcab-author-box__name' => 'color: {{VALUE}};',
				],
			]
		);

		$this->add_group_control(
			Group_Control_Typography::get_type(),
			[
				'name' => 'name_typography',
				'selector' => '{{WRAPPER}} .bpcab-author-box__name',
			]
		);

		$this->add_control(
			'bio_color',
			[
				'label' => esc_html__('Couleur de la bio', 'bpcab'),
				'type' => Controls_Manager::COLOR,
				'selectors' => [
					'{{WRAPPER}} .bpcab-author-box__bio' => 'color: {{VALUE}};',
				],
				'condition' => [
					'show_bio' => 'yes',
				],
			]
		);

		$this->end_controls_section();
	}

	protected function render(): void {
		$settings = $this->get_settings_for_display();

		$title = isset($settings['title']) ? sanitize_text_field((string) $settings['title']) : '';
		$user_id = isset($settings['user_id']) ? absint($settings['user_id']) : 0;

		$show_bio = (!empty($settings['show_bio']) && $settings['show_bio'] === 'yes');
		$show_posts = (!empty($settings['show_posts']) && $settings['show_posts'] === 'yes');

		$posts_count = isset($settings['posts_count']) ? absint($settings['posts_count']) : 3;
		$posts_count = max(1, min(12, $posts_count));

		$user = $user_id ? get_user_by('id', $user_id) : false;

		echo '<div class="bpcab-author-box">';

		if ($title !== '') {
			echo '<div class="bpcab-author-box__title">' . esc_html($title) . '</div>';
		}

		if (!$user instanceof WP_User) {
			// Fallback propre : évite une box vide.
			echo '<p>' . esc_html__('Auteur introuvable (vérifiez l’ID utilisateur).', 'bpcab') . '</p>';
			echo '</div>';
			return;
		}

		$display_name = (string) $user->display_name;
		$description = (string) get_user_meta((int) $user->ID, 'description', true);

		$avatar = get_avatar((int) $user->ID, 96, '', $display_name, [
			'class' => 'bpcab-author-box__avatar',
		]);

		// URL de profil optionnelle (Elementor URL control).
		$profile_url = '';
		$profile_is_external = false;
		$profile_nofollow = false;

		if (!empty($settings['profile_url']) && is_array($settings['profile_url'])) {
			$profile_url = !empty($settings['profile_url']['url']) ? esc_url($settings['profile_url']['url']) : '';
			$profile_is_external = !empty($settings['profile_url']['is_external']);
			$profile_nofollow = !empty($settings['profile_url']['nofollow']);
		}

		echo '<div class="bpcab-author-box__header">';
		echo '<div class="bpcab-author-box__avatar-wrap">' . $avatar . '</div>';
		echo '<div class="bpcab-author-box__meta">';

		$name_html = '<span class="bpcab-author-box__name">' . esc_html($display_name) . '</span>';

		if ($profile_url) {
			$rel = [];
			if ($profile_is_external) {
				// target blank sans noopener = vulnérable.
				// Elementor gère souvent ça côté UI, mais on le force côté rendu.
				$rel[] = 'noopener';
			}
			if ($profile_nofollow) {
				$rel[] = 'nofollow';
			}

			$target = $profile_is_external ? ' target="_blank"' : '';
			$rel_attr = !empty($rel) ? ' rel="' . esc_attr(implode(' ', array_unique($rel))) . '"' : '';

			$name_html = '<a class="bpcab-author-box__name bpcab-author-box__name--link" href="' . esc_url($profile_url) . '"' . $target . $rel_attr . '>' . esc_html($display_name) . '</a>';
		}

		echo $name_html;

		if ($show_bio && $description !== '') {
			// Bio : autoriser un sous-ensemble HTML ? Ici on reste strict : texte simple.
			echo '<div class="bpcab-author-box__bio">' . esc_html($description) . '</div>';
		}

		echo '</div>'; // meta
		echo '</div>'; // header

		if ($show_posts) {
			$posts = get_posts([
				'post_type' => 'post',
				'post_status' => 'publish',
				'author' => (int) $user->ID,
				'numberposts' => $posts_count,
				'no_found_rows' => true,
				'ignore_sticky_posts' => true,
				'suppress_filters' => false,
			]);

			echo '<div class="bpcab-author-box__posts">';
			if (!empty($posts)) {
				echo '<ul class="bpcab-author-box__posts-list">';
				foreach ($posts as $post) {
					$permalink = get_permalink($post);
					$post_title = get_the_title($post);

					echo '<li class="bpcab-author-box__posts-item">';
					echo '<a class="bpcab-author-box__posts-link" href="' . esc_url($permalink) . '">' . esc_html($post_title) . '</a>';
					echo '</li>';
				}
				echo '</ul>';
			} else {
				echo '<p class="bpcab-author-box__empty">' . esc_html__('Aucun article récent.', 'bpcab') . '</p>';
			}
			echo '</div>';
		}

		echo '</div>'; // box
	}

	// Optionnel : aperçu dans l’éditeur (JS template). On le laisse simple.
	// Si vous ne le faites pas, Elementor affichera un rendu serveur en preview (souvent suffisant).
	protected function content_template(): void {}
}

3) CSS du widget

Créez : wp-content/plugins/bpcab-elementor-widgets/assets/author-box.css

.bpcab-author-box{
	display:block;
	border-radius:12px;
	background:#fff;
}

.bpcab-author-box__title{
	font-weight:700;
	margin:0 0 12px 0;
}

.bpcab-author-box__header{
	display:flex;
	gap:12px;
	align-items:flex-start;
}

.bpcab-author-box__avatar{
	border-radius:999px;
	display:block;
}

.bpcab-author-box__meta{
	display:block;
	min-width:0;
}

.bpcab-author-box__name{
	display:inline-block;
	font-weight:700;
	text-decoration:none;
}

.bpcab-author-box__bio{
	margin-top:6px;
	opacity:.9;
}

.bpcab-author-box__posts{
	margin-top:14px;
}

.bpcab-author-box__posts-list{
	margin:0;
	padding-left:18px;
}

.bpcab-author-box__posts-item{
	margin:6px 0;
}

4) JS (optionnel)

Créez : wp-content/plugins/bpcab-elementor-widgets/assets/author-box.js

/* Fichier volontairement vide.
   Gardez-le si vous prévoyez d'ajouter des interactions.
   Sinon, supprimez get_script_depends() et l'enregistrement du script. */

Explication du code

Ce qui se passe en coulisses (version simple)

Le plugin attend qu’Elementor soit chargé. Ensuite, il enregistre un widget. Elementor liste ce widget dans l’éditeur, et quand vous le déposez dans une page, Elementor :

  • affiche vos contrôles (contenu + style),
  • stocke les réglages dans le JSON de la page (post meta),
  • appelle render() pour produire le HTML côté front.

Pourquoi ces hooks et pas d’autres

  • plugins_loaded : bon moment pour vérifier l’environnement et la présence d’Elementor.
  • did_action('elementor/loaded') : évite d’appeler des classes Elementor avant leur autoload.
  • elementor/widgets/register : hook prévu pour enregistrer des widgets. C’est celui qui évite les “Class not found”.
  • wp_register_style : on prépare les assets, mais on ne les enfile pas globalement.

Sanitization vs escaping (les erreurs que je vois le plus)

Deux règles :

  • Sanitization : quand vous normalisez une valeur (ex : absint pour un ID, sanitize_text_field pour un titre).
  • Escaping : quand vous imprimez dans le HTML (ex : esc_html, esc_url, esc_attr).

Dans le widget, on sanitise les réglages au moment du rendu, puis on échappe systématiquement au moment d’afficher. Oui, Elementor stocke déjà des valeurs “propres” la plupart du temps, mais ne vous reposez pas dessus : un import JSON, un copier-coller, ou un plugin tiers peut injecter des chaînes inattendues.

Chargement conditionnel des assets

get_style_depends() retourne un handle de style enregistré. Elementor ne chargera ce style que si le widget est présent sur la page. C’est un pattern simple qui évite le CSS “site-wide”.

Requête des derniers articles

On utilise get_posts() avec :

  • no_found_rows : pas de pagination, donc pas besoin de compter.
  • ignore_sticky_posts : évite des résultats surprenants.
  • numberposts borné à 12 : garde un widget “léger”.

Alternative possible : WP_Query si vous avez besoin de plus de contrôle, mais ici get_posts() suffit et reste lisible.

Variantes et cas d’usage

Variante 1 — Sélecteur d’auteur plus ergonomique (liste déroulante)

Le contrôle “ID utilisateur” est pratique pour un exemple, mais vos éditeurs vont vous maudire. Une variante simple : construire une liste d’options à partir des utilisateurs (attention aux sites avec des milliers d’utilisateurs).

Dans register_controls(), remplacez le contrôle user_id par :

<?php
// ✅ Variante : select (attention : coûteux si beaucoup d'utilisateurs).
$users = get_users([
	'fields' => ['ID', 'display_name'],
	'number' => 200, // borne volontaire
	'orderby' => 'display_name',
	'order' => 'ASC',
]);

$options = [];
foreach ($users as $u) {
	$options[(string) $u->ID] = $u->display_name . ' (#' . $u->ID . ')';
}

$this->add_control(
	'user_id',
	[
		'label' => esc_html__('Auteur', 'bpcab'),
		'type' => Controls_Manager::SELECT,
		'options' => $options,
		'default' => (string) get_current_user_id(),
	]
);

Edge case : sur un site membership avec 50 000 comptes, ce SELECT devient inutilisable. Dans ce cas, passez sur un contrôle AJAX (Select2) ou imposez un champ “ID” avec une aide UI (ou une recherche via REST).

Variante 2 — Afficher un CPT (ex : “portfolio”) au lieu des articles

Changez simplement 'post_type' => 'post' en 'post_type' => 'portfolio' (ou un tableau). Pensez à rendre le post type configurable via un SELECT si vous en avez plusieurs.

Variante 3 — Mise en cache légère (transient) par auteur

Si votre widget est utilisé 10 fois sur une page (ça arrive sur des landing pages “team”), vous pouvez cacher la liste des posts. Exemple simplifié :

<?php
$cache_key = 'bpcab_ab_posts_' . (int) $user->ID . '_' . (int) $posts_count;
$posts = get_transient($cache_key);

if ($posts === false) {
	$posts = get_posts([
		'post_type' => 'post',
		'post_status' => 'publish',
		'author' => (int) $user->ID,
		'numberposts' => $posts_count,
		'no_found_rows' => true,
		'ignore_sticky_posts' => true,
	]);

	// Cache 10 minutes.
	set_transient($cache_key, $posts, 10 * MINUTE_IN_SECONDS);
}

Attention : si vous publiez souvent, le cache peut retarder l’affichage. Sur un site à fort trafic, vous préférerez un cache objet persistant (Redis) et des invalidations (hook save_post) plutôt qu’un TTL fixe.

Compatibilité Divi 5 / Elementor / Avada

Elementor (éditeur et front)

  • Le widget apparaît dans la catégorie “Général”.
  • Les styles sont scoped via {{WRAPPER}}, ce qui limite les collisions CSS.
  • Si vous utilisez le cache Elementor / génération CSS, videz-le après modifications.

Divi 5

Divi n’utilise pas l’API Widget d’Elementor. Votre widget ne sera pas disponible dans Divi Builder.

Approche réaliste si vous devez supporter Divi 5 aussi :

  • Exposez un shortcode “compat” qui réutilise la même logique PHP (fonction partagée), puis insérez-le via un module Code/Shortcode Divi.
  • Ou créez un module Divi 5 dédié (plus propre, plus long).

Avada (Fusion Builder)

Même logique : Avada ne consomme pas les widgets Elementor. Pour Avada :

  • Shortcode compatible (Fusion Builder sait les intégrer).
  • Ou élément Fusion custom si vous avez besoin d’une UI native Avada.

Conseil “multi-builder” que j’applique souvent

Gardez la logique métier dans une classe PHP indépendante (ex : BPCAB_Author_Box_Renderer) et faites des adaptateurs :

  • Widget Elementor → appelle le renderer.
  • Shortcode → appelle le renderer.
  • Bloc Gutenberg dynamique → appelle le renderer.

Vous évitez de dupliquer la logique de requête, les fallbacks, et l’escaping.

Vérifications après mise en place

  1. Activez le plugin dans Extensions.
  2. Ouvrez une page avec Elementor.
  3. Recherchez “Encart Auteur (BPCAB)”.
  4. Déposez le widget, entrez un ID utilisateur valide.
  5. Vérifiez côté front :
    • avatar affiché,
    • nom affiché (cliquable si URL fournie),
    • bio affichée si activée,
    • liste d’articles affichée et limitée.
  6. Testez les contrôles de style : couleur de fond, typographie du titre, padding.

Si vous avez un système de cache (plugin de cache, Cloudflare, cache serveur), purgez-le. J’ai souvent vu des développeurs croire que “le widget ne se charge pas”, alors que le front servait une page HTML cachée avant activation.

Si ça ne marche pas

Checklist rapide (dans l’ordre)

  1. Erreur 500 après activation : regardez wp-content/debug.log (ou les logs serveur).
  2. Widget invisible dans Elementor : Elementor est-il actif ? Le hook elementor/widgets/register est-il atteint ?
  3. Class not found : vous avez probablement chargé la classe trop tôt, ou le chemin require_once est faux.
  4. CSS non appliqué : le fichier est-il au bon chemin ? Le handle bpcab-author-box est-il bien enregistré ? Videz le cache Elementor.
  5. Rien ne s’affiche : testez un ID utilisateur existant, puis désactivez “Afficher les derniers articles” pour isoler la requête.

Tableau de diagnostic

Symptôme Cause probable Vérification Solution
Widget absent dans la liste Elementor Hook non exécuté / Elementor non chargé Regardez si did_action('elementor/loaded') est vrai (log), vérifiez Extensions Activez Elementor, évitez init, utilisez elementor/widgets/register
Erreur “Class ‘ElementorWidget_Base’ not found” Classe chargée trop tôt Stack trace dans debug.log Déplacez l’enregistrement sur elementor/widgets/register et gardez le require_once dedans
CSS non chargé Handle non enregistré ou mauvais chemin Onglet Network, cherchez author-box.css Corrigez plugins_url(), vérifiez get_style_depends(), purgez cache
Nom auteur affiché mais pas l’avatar Gravatar bloqué / configuration avatar Réglages > Discussion > Avatars, ou restrictions réseau Autorisez Gravatar ou utilisez un avatar local (plugin) / fallback
Liste d’articles vide Auteur sans posts publiés / mauvais post_type Vérifiez l’auteur des posts et le statut “Publié” Changez l’ID, publiez un post, ajustez post_type
Modifications non visibles Cache navigateur / cache page / cache Elementor CSS Test en navigation privée + purge cache plugin + regen CSS Purger caches, régénérer fichiers CSS Elementor si activé

Pièges et erreurs courantes

Erreur Cause Solution
Copier le code dans functions.php du thème Mauvais endroit : le thème peut changer, et l’ordre de chargement peut casser Elementor Utilisez un plugin dédié (comme ici) ou un MU-plugin
Oublier un point-virgule dans la classe du widget Erreur PHP fatale Activez WP_DEBUG_LOG, relisez la ligne indiquée, utilisez un IDE
Utiliser add_action('init', ...) pour enregistrer le widget Elementor pas encore chargé Utilisez elementor/widgets/register et testez did_action('elementor/loaded')
CSS/JS “introuvable” Mauvais chemin dans plugins_url() ou fichier non créé Vérifiez l’arborescence et le nom exact des fichiers
Conflit de styles avec le thème (Avada/Divi) CSS trop générique (ex : .title) Préfixez vos classes (bpcab-) et utilisez {{WRAPPER}} dans les selectors Elementor
Erreur après mise à jour PHP Typage strict + code ancien incompatible Gardez PHP 8.1+ et corrigez les warnings/TypeError (logs)
Tester directement en production Risque d’écran blanc Staging + déploiement versionné + rollback
Confusion entre sanitization et escaping Données “propres” supposées Sanitisez les entrées, échappez les sorties, systématiquement

Conseils sécurité, performance et maintenance

  • Sécurité XSS : échappez chaque sortie selon le contexte (esc_html texte, esc_url URL, esc_attr attribut). Référence : developer.wordpress.org/apis/security/escaping
  • Permissions : ici, pas de formulaire front, donc pas de nonce. Si vous ajoutez des actions (ex : bouton “suivre l’auteur”), utilisez wp_nonce_field et vérifiez current_user_can.
  • Performance : limitez le nombre de posts, activez un cache si le widget est répété. Sur gros sites, évitez get_users() sans limites.
  • Compatibilité : gardez vos classes et handles préfixés. C’est la meilleure défense contre les collisions de noms avec d’autres add-ons Elementor.
  • Maintenance : versionnez le plugin (Git), et mettez un changelog. Évitez les snippets “collés” dans un plugin de snippets : j’ai vu des mises à jour casser des classes autoloadées parce que le plugin de snippets changeait l’ordre d’exécution.
  • SEO : ce widget n’ajoute pas de contenu caché, donc pas de piège SEO. Faites attention si vous ajoutez du contenu conditionnel via JS uniquement.

Ressources

FAQ

Pourquoi créer un plugin plutôt que mettre le code dans le thème enfant ?

Parce qu’un widget Elementor est une fonctionnalité. Si vous changez de thème (ou si Avada/Divi est remplacé), vous voulez garder le widget. Et vous évitez aussi des surprises d’ordre de chargement.

Est-ce que ce widget fonctionne si Elementor Pro n’est pas installé ?

Oui. Le code utilise l’API widget d’Elementor (version gratuite). Elementor Pro apporte d’autres fonctionnalités (Theme Builder, etc.), mais ce widget n’en dépend pas.

Pourquoi ne pas utiliser un shortcode dans un widget “Shortcode” Elementor ?

Vous perdez la plupart des contrôles de style natifs, et vous finissez par injecter du CSS global. Pour un composant récurrent, un widget est plus propre.

Comment ajouter une catégorie “BPCAB” dans Elementor au lieu de “Général” ?

Vous pouvez enregistrer une catégorie Elementor dédiée via les hooks de catégories (selon version Elementor). Je le fais quand j’ai 5+ widgets, sinon je reste sur “Général” pour éviter de fragmenter l’UI.

Pourquoi get_settings_for_display() et pas get_settings() ?

get_settings_for_display() renvoie les valeurs prêtes pour l’affichage (avec certaines transformations internes Elementor). C’est généralement le bon choix dans render().

Comment éviter la saisie d’un ID utilisateur (pas user-friendly) ?

Utilisez un SELECT borné (200 utilisateurs max) ou implémentez un contrôle AJAX. Sur les sites à gros volume, un champ ID + documentation interne est parfois la solution la plus stable.

Pourquoi mon CSS ne se met pas à jour après modification ?

Cache. Videz :

  • cache navigateur,
  • cache de votre plugin de cache,
  • et si vous utilisez la génération CSS d’Elementor, forcez la régénération (selon votre configuration).

Peut-on utiliser ce widget dans un template Elementor Theme Builder (single post) ?

Oui. C’est même un bon cas d’usage : un encart auteur en bas d’article. Dans ce cas, améliorez la logique pour prendre l’auteur du post courant si user_id est vide (variante simple à ajouter).

Comment adapter le widget pour afficher l’auteur du post courant automatiquement ?

Dans render(), si $user_id vaut 0, récupérez get_post_field('post_author', get_the_ID()) quand vous êtes dans une boucle/template. Faites attention aux pages statiques sans contexte de post.

Est-ce que ce code est compatible PHP 8.2/8.3/8.4 ?

Oui en principe (syntaxe moderne, typage strict). Le point de vigilance, c’est surtout la compatibilité Elementor et les warnings dépréciés venant d’autres plugins.

Comment tester proprement sans casser le site ?

Staging, logs activés, et une méthode simple : activez le plugin, déposez le widget sur une page de test, vérifiez le front, puis testez un cas “auteur introuvable” (ID inexistant) pour valider les fallbacks.