Le problème / Le besoin
Si vous avez déjà ouvert un plugin “maison” écrit il y a 2 ans et que vous avez eu envie de le supprimer plutôt que de le maintenir, vous avez déjà rencontré le vrai problème : tout est entremêlé. Hooks, SQL, HTML, AJAX, permissions… dans le même fichier.
Sur WordPress 6.9.4 (avril 2026) et PHP 8.1+, ça ne pardonne plus : le code “spaghetti” rend les régressions quasi inévitables, complique les tests, et augmente les risques de failles (nonce oubliés, sanitization bancale, capacités mal vérifiées).
Objectif : vous donner une architecture de plugin inspirée MVC, mais adaptée à WordPress (donc pas un framework MVC plaqué). À la fin, vous saurez structurer un plugin en services OOP, avec un mini-container, des contrôleurs (admin + REST), des modèles (accès données), et des vues (templates), sans casser les conventions WordPress.
Résumé rapide
- On code un plugin complet “Notes” : gestion d’un Custom Post Type, écran admin, métadonnées, et endpoint REST.
- Architecture inspirée MVC : Controllers (hooks + actions), Models (repository + validation), Views (templates PHP), et un “Kernel” (bootstrap).
- OOP moderne : namespaces, autoload PSR-4 via Composer, injection de dépendances simple, classes immuables quand possible.
- Sécurité : capabilities, nonces, sanitization/escaping, REST permissions callbacks.
- Compatibilité WordPress 6.9.4+ : utilisation des APIs core (CPT, meta, REST) et respect des timings de hooks.
- Variantes : intégration page builders (shortcode + widget Elementor + module Divi 5), et option “sans Composer”.
Quand utiliser cette solution
- Vous maintenez un plugin sur plusieurs sites (ou un site à gros trafic) et vous voulez réduire le coût de maintenance.
- Vous avez des fonctionnalités “produit” (admin + front + API) et vous voulez isoler les responsabilités.
- Vous devez faire cohabiter plusieurs intégrations (REST, WP-CLI, cron, webhooks) sans créer une forêt de fichiers procéduraux.
- Vous travaillez en équipe et vous voulez des points d’entrée clairs (controllers) et des tests plus simples (models/services).
Quand ne PAS utiliser cette solution
- Vous avez un micro-snippet de 30 lignes. Un fichier procédural propre suffit, ou un mini-plugin “mu-plugin”.
- Vous livrez un plugin jetable pour une campagne de 2 semaines. Le surcoût d’architecture ne sera pas amorti.
- Votre besoin est 100% “contenu” (mise en page, blocs) : un bloc Gutenberg, un pattern, ou un CPT + ACF peut suffire.
- Vous ne contrôlez pas l’hébergement et PHP 8.1+ n’est pas garanti. L’OOP moderne devient vite une source d’erreurs fatales.
Alternative fréquente : partir sur un framework complet (Laravel, Symfony) et “brancher WordPress”. Je l’ai vu fonctionner, mais rarement sans friction (auth, routing, lifecycle WP). Ici, on garde WordPress comme runtime, et on structure proprement dedans.
Prérequis / avant de commencer
- WordPress : 6.9.4 (ou plus récent).
- PHP : 8.1 minimum (8.2/8.3 recommandé si votre hébergeur suit).
- Environnement : un staging ou un site local (LocalWP, Docker, ou WP-ENV).
- Outils : Composer (recommandé), et idéalement WP-CLI.
- Sauvegarde : base + wp-content avant d’activer le plugin sur un site existant.
Côté docs officielles, gardez ces pages ouvertes :
- Plugin Developer Handbook
- register_post_type()
- REST API Handbook
- register_post_meta()
- PHP OOP (php.net)
Précaution sécurité : tout ce qui touche l’admin, REST, ou AJAX doit vérifier capabilities + nonce (quand applicable) + sanitization. Les failles que je vois le plus en audit : endpoints REST “publics par accident” et metas enregistrées sans validation.
L’approche naïve (et pourquoi l’éviter)
Le pattern classique : un seul fichier plugin, des hooks anonymes, du HTML echo, et des accès directs à $wpdb sans abstraction. Ça marche… jusqu’au premier besoin “simple” : ajouter une validation, un second écran, ou une API.
<?php
/**
* Plugin Name: Notes (naïf)
*/
add_action('init', function () {
register_post_type('note', [
'label' => 'Notes',
'public' => false,
'show_ui' => true,
'supports' => ['title', 'editor'],
]);
});
add_action('add_meta_boxes', function () {
add_meta_box('note_meta', 'Meta', function ($post) {
// Problème : pas de nonce, pas d’escaping, logique et vue mélangées
$priority = get_post_meta($post->ID, '_note_priority', true);
echo '<label>Priorité</label>';
echo '<input name="note_priority" value="' . $priority . '" />';
}, 'note');
});
add_action('save_post_note', function ($post_id) {
// Problème : pas de check autosave/revision, pas de capability, pas de sanitization
if (isset($_POST['note_priority'])) {
update_post_meta($post_id, '_note_priority', $_POST['note_priority']);
}
});
Ce qui casse en pratique :
- Sécurité : absence de nonce et de capability check sur
save_post. - Qualité : pas de séparation des responsabilités, impossible de tester sans WordPress.
- Évolutivité : dès que vous ajoutez un endpoint REST, vous dupliquez validation + permissions.
- Maintenance : vous finissez avec 800 lignes dans un fichier et des hooks partout.
La bonne approche — tutoriel pas à pas
Principe : MVC “à la WordPress”
WordPress n’est pas un framework MVC. Le cycle de vie est piloté par des hooks, pas par un routeur central. La version “propre” consiste à :
- Utiliser des Controllers pour enregistrer hooks, écrans admin, endpoints REST.
- Utiliser des Models (ou “Repositories/Services”) pour la logique métier et l’accès aux données.
- Utiliser des Views (templates PHP) pour rendre l’HTML, avec escaping strict.
- Utiliser un Kernel (bootstrap) qui instancie et enregistre les services.
Étape 1 — Arborescence du plugin
Créez wp-content/plugins/bpcab-notes-mvc/ :
bpcab-notes-mvc/
├─ bpcab-notes-mvc.php
├─ composer.json
├─ src/
│ ├─ Plugin.php
│ ├─ Container.php
│ ├─ Contracts/
│ │ └─ ServiceProviderInterface.php
│ ├─ Providers/
│ │ ├─ CoreProvider.php
│ │ └─ NotesProvider.php
│ ├─ Notes/
│ │ ├─ Model/
│ │ │ ├─ Note.php
│ │ │ └─ NoteRepository.php
│ │ ├─ Controller/
│ │ │ ├─ AdminController.php
│ │ │ ├─ MetaBoxController.php
│ │ │ └─ RestController.php
│ │ └─ View/
│ │ └─ metabox-note.php
└─ vendor/ (généré)
Étape 2 — Composer + autoload PSR-4
Dans un plugin, l’autoload PSR-4 vous évite les require_once en cascade. Sur des sites pro, c’est vite indispensable.
{
"name": "bpcab/notes-mvc",
"type": "wordpress-plugin",
"require": {
"php": ">=8.1"
},
"autoload": {
"psr-4": {
"Bpcab\NotesMvc\": "src/"
}
}
}
Puis :
cd wp-content/plugins/bpcab-notes-mvc
composer install
composer dump-autoload
Étape 3 — Fichier principal du plugin (bootstrap)
Le fichier racine doit rester minimal : déclarations, chargement autoload, instanciation du Kernel, hooks d’activation/désactivation.
<?php
/**
* Plugin Name: BPCAB Notes MVC
* Description: Exemple d’architecture inspirée MVC + OOP pour WordPress 6.9.4+.
* Version: 1.0.0
* Requires at least: 6.9
* Requires PHP: 8.1
* Author: BPCAB
*/
declare(strict_types=1);
if (!defined('ABSPATH')) {
exit;
}
$autoload = __DIR__ . '/vendor/autoload.php';
if (file_exists($autoload)) {
require_once $autoload;
} else {
// Sécurité : si Composer n’est pas installé, on évite un fatal incompréhensible.
add_action('admin_notices', function () {
echo '<div class="notice notice-error"><p>';
echo esc_html__('BPCAB Notes MVC : exécutez "composer install" dans le dossier du plugin.', 'bpcab-notes-mvc');
echo '</p></div>';
});
return;
}
use BpcabNotesMvcPlugin;
register_activation_hook(__FILE__, [Plugin::class, 'activate']);
register_deactivation_hook(__FILE__, [Plugin::class, 'deactivate']);
add_action('plugins_loaded', static function () {
// plugins_loaded : WP a chargé les plugins, traductions possibles, mais pas encore init.
(new Plugin(__FILE__))->boot();
});
Étape 4 — Container minimal + Providers
Je vois souvent des plugins qui “font du DI” mais finissent avec un container trop intelligent. Ici, on vise un container simple : un tableau de factories, instanciation lazy, et injection manuelle.
Étape 5 — Modèle + Repository
Le modèle Note représente les données (id, titre, contenu, priorité) et le repository centralise l’accès (lecture/écriture) avec validation.
Étape 6 — Controllers (admin, metabox, REST)
Les controllers enregistrent les hooks et délèguent la logique au repository. Le rendu HTML est dans une view.
Étape 7 — View de la metabox
La view est un fichier PHP “bête” : pas de logique métier, uniquement affichage et escaping.
Code complet
bpcab-notes-mvc.php
<?php
/**
* Plugin Name: BPCAB Notes MVC
* Description: Exemple d’architecture inspirée MVC + OOP pour WordPress 6.9.4+.
* Version: 1.0.0
* Requires at least: 6.9
* Requires PHP: 8.1
*/
declare(strict_types=1);
if (!defined('ABSPATH')) {
exit;
}
$autoload = __DIR__ . '/vendor/autoload.php';
if (file_exists($autoload)) {
require_once $autoload;
} else {
add_action('admin_notices', function () {
echo '<div class="notice notice-error"><p>';
echo esc_html__('BPCAB Notes MVC : exécutez "composer install" dans le dossier du plugin.', 'bpcab-notes-mvc');
echo '</p></div>';
});
return;
}
use BpcabNotesMvcPlugin;
register_activation_hook(__FILE__, [Plugin::class, 'activate']);
register_deactivation_hook(__FILE__, [Plugin::class, 'deactivate']);
add_action('plugins_loaded', static function () {
(new Plugin(__FILE__))->boot();
});
src/Plugin.php
<?php
declare(strict_types=1);
namespace BpcabNotesMvc;
use BpcabNotesMvcContractsServiceProviderInterface;
use BpcabNotesMvcProvidersCoreProvider;
use BpcabNotesMvcProvidersNotesProvider;
final class Plugin
{
private string $pluginFile;
private Container $container;
public function __construct(string $pluginFile)
{
$this->pluginFile = $pluginFile;
$this->container = new Container();
}
public function boot(): void
{
// Enregistrement des providers (composition racine).
$this->register(new CoreProvider($this->pluginFile));
$this->register(new NotesProvider());
// Démarrage des services “bootables”.
$this->container->boot();
}
private function register(ServiceProviderInterface $provider): void
{
$provider->register($this->container);
}
public static function activate(): void
{
// Activation : enregistrer CPT + flush des permaliens si nécessaire.
// Attention : ne pas flusher à chaque page load, uniquement ici.
if (!function_exists('flush_rewrite_rules')) {
return;
}
// On enregistre le CPT “à la main” ici via un callback minimaliste.
add_action('init', static function () {
register_post_type('bpcab_note', [
'label' => 'Notes',
'public' => false,
'show_ui' => true,
'show_in_menu' => true,
'supports' => ['title', 'editor'],
'show_in_rest' => true,
]);
});
// init doit être exécuté pour que WP connaisse le CPT avant flush.
do_action('init');
flush_rewrite_rules(false);
}
public static function deactivate(): void
{
// Désactivation : flush rewrite rules (si vous avez des routes).
if (function_exists('flush_rewrite_rules')) {
flush_rewrite_rules(false);
}
}
}
src/Container.php
<?php
declare(strict_types=1);
namespace BpcabNotesMvc;
use RuntimeException;
final class Container
{
/**
* @var array<string, callable(self): mixed>
*/
private array $factories = [];
/**
* @var array<string, mixed>
*/
private array $instances = [];
/**
* @var list<callable(self): void>
*/
private array $bootCallbacks = [];
public function set(string $id, callable $factory): void
{
$this->factories[$id] = $factory;
}
public function get(string $id): mixed
{
if (array_key_exists($id, $this->instances)) {
return $this->instances[$id];
}
if (!isset($this->factories[$id])) {
throw new RuntimeException(sprintf('Service introuvable: %s', $id));
}
$this->instances[$id] = ($this->factories[$id])($this);
return $this->instances[$id];
}
/**
* Permet d’enregistrer des callbacks à exécuter après l’enregistrement des services.
* Pratique pour brancher les hooks WordPress sans tout déclencher trop tôt.
*/
public function bootable(callable $callback): void
{
$this->bootCallbacks[] = $callback;
}
public function boot(): void
{
foreach ($this->bootCallbacks as $callback) {
$callback($this);
}
}
}
src/Contracts/ServiceProviderInterface.php
<?php
declare(strict_types=1);
namespace BpcabNotesMvcContracts;
use BpcabNotesMvcContainer;
interface ServiceProviderInterface
{
public function register(Container $container): void;
}
src/Providers/CoreProvider.php
<?php
declare(strict_types=1);
namespace BpcabNotesMvcProviders;
use BpcabNotesMvcContainer;
use BpcabNotesMvcContractsServiceProviderInterface;
final class CoreProvider implements ServiceProviderInterface
{
public const SERVICE_PLUGIN_FILE = 'core.plugin_file';
public const SERVICE_PLUGIN_URL = 'core.plugin_url';
public const SERVICE_PLUGIN_PATH = 'core.plugin_path';
public function __construct(private readonly string $pluginFile)
{
}
public function register(Container $container): void
{
$pluginFile = $this->pluginFile;
$container->set(self::SERVICE_PLUGIN_FILE, static fn () => $pluginFile);
$container->set(self::SERVICE_PLUGIN_URL, static function (Container $c): string {
$file = $c->get(self::SERVICE_PLUGIN_FILE);
return plugin_dir_url($file);
});
$container->set(self::SERVICE_PLUGIN_PATH, static function (Container $c): string {
$file = $c->get(self::SERVICE_PLUGIN_FILE);
return plugin_dir_path($file);
});
}
}
src/Providers/NotesProvider.php
<?php
declare(strict_types=1);
namespace BpcabNotesMvcProviders;
use BpcabNotesMvcContainer;
use BpcabNotesMvcContractsServiceProviderInterface;
use BpcabNotesMvcNotesControllerAdminController;
use BpcabNotesMvcNotesControllerMetaBoxController;
use BpcabNotesMvcNotesControllerRestController;
use BpcabNotesMvcNotesModelNoteRepository;
final class NotesProvider implements ServiceProviderInterface
{
public const SERVICE_REPO = 'notes.repo';
public const SERVICE_ADMIN_CTRL = 'notes.controller.admin';
public const SERVICE_METABOX_CTRL = 'notes.controller.metabox';
public const SERVICE_REST_CTRL = 'notes.controller.rest';
public function register(Container $container): void
{
$container->set(self::SERVICE_REPO, static fn () => new NoteRepository());
$container->set(self::SERVICE_ADMIN_CTRL, static function (Container $c) {
return new AdminController($c->get(self::SERVICE_REPO));
});
$container->set(self::SERVICE_METABOX_CTRL, static function (Container $c) {
return new MetaBoxController($c->get(self::SERVICE_REPO));
});
$container->set(self::SERVICE_REST_CTRL, static function (Container $c) {
return new RestController($c->get(self::SERVICE_REPO));
});
// Boot : on branche réellement les hooks WP ici.
$container->bootable(static function (Container $c): void {
$c->get(self::SERVICE_ADMIN_CTRL)->hooks();
$c->get(self::SERVICE_METABOX_CTRL)->hooks();
$c->get(self::SERVICE_REST_CTRL)->hooks();
});
}
}
src/Notes/Model/Note.php
<?php
declare(strict_types=1);
namespace BpcabNotesMvcNotesModel;
final class Note
{
public function __construct(
public readonly int $id,
public readonly string $title,
public readonly string $content,
public readonly int $priority
) {
}
}
src/Notes/Model/NoteRepository.php
<?php
declare(strict_types=1);
namespace BpcabNotesMvcNotesModel;
use WP_Post;
use WP_Error;
final class NoteRepository
{
public const POST_TYPE = 'bpcab_note';
public const META_PRIORITY = '_bpcab_note_priority';
public function registerPostType(): void
{
register_post_type(self::POST_TYPE, [
'label' => 'Notes',
'public' => false,
'show_ui' => true,
'show_in_menu' => true,
'supports' => ['title', 'editor'],
'show_in_rest' => true,
'menu_icon' => 'dashicons-welcome-write-blog',
]);
}
public function registerMeta(): void
{
// Meta déclarée : utile pour REST et cohérence de schéma.
register_post_meta(self::POST_TYPE, self::META_PRIORITY, [
'type' => 'integer',
'single' => true,
'default' => 0,
'show_in_rest' => true,
'sanitize_callback' => static function ($value): int {
// Sanitization stricte côté serveur.
return max(0, (int) $value);
},
'auth_callback' => static function (): bool {
// REST + admin : seules les personnes pouvant éditer des notes.
return current_user_can('edit_posts');
},
]);
}
public function toNote(WP_Post $post): Note
{
$priority = (int) get_post_meta($post->ID, self::META_PRIORITY, true);
return new Note(
$post->ID,
(string) get_the_title($post),
(string) $post->post_content,
max(0, $priority)
);
}
/**
* Validation métier simple.
* Retourne WP_Error si invalide (pratique pour REST et admin).
*/
public function validatePriority(mixed $priority): int|WP_Error
{
if ($priority === null || $priority === '') {
return 0;
}
if (!is_numeric($priority)) {
return new WP_Error('bpcab_invalid_priority', 'La priorité doit être un nombre.');
}
$p = (int) $priority;
if ($p < 0 || $p > 10) {
return new WP_Error('bpcab_invalid_priority_range', 'La priorité doit être entre 0 et 10.');
}
return $p;
}
public function updatePriority(int $postId, int $priority): void
{
update_post_meta($postId, self::META_PRIORITY, $priority);
}
}
src/Notes/Controller/AdminController.php
<?php
declare(strict_types=1);
namespace BpcabNotesMvcNotesController;
use BpcabNotesMvcNotesModelNoteRepository;
final class AdminController
{
public function __construct(private readonly NoteRepository $repo)
{
}
public function hooks(): void
{
add_action('init', [$this, 'register']);
add_action('init', [$this, 'registerMeta']);
}
public function register(): void
{
$this->repo->registerPostType();
}
public function registerMeta(): void
{
$this->repo->registerMeta();
}
}
src/Notes/Controller/MetaBoxController.php
<?php
declare(strict_types=1);
namespace BpcabNotesMvcNotesController;
use BpcabNotesMvcNotesModelNoteRepository;
use WP_Post;
final class MetaBoxController
{
private const NONCE_ACTION = 'bpcab_note_save_metabox';
private const NONCE_NAME = 'bpcab_note_nonce';
public function __construct(private readonly NoteRepository $repo)
{
}
public function hooks(): void
{
add_action('add_meta_boxes', [$this, 'registerMetaBox']);
add_action('save_post_' . NoteRepository::POST_TYPE, [$this, 'saveMetaBox'], 10, 2);
}
public function registerMetaBox(): void
{
add_meta_box(
'bpcab_note_meta',
'Paramètres de la note',
[$this, 'renderMetaBox'],
NoteRepository::POST_TYPE,
'side',
'default'
);
}
public function renderMetaBox(WP_Post $post): void
{
$priority = (int) get_post_meta($post->ID, NoteRepository::META_PRIORITY, true);
// Nonce : protège contre CSRF.
wp_nonce_field(self::NONCE_ACTION, self::NONCE_NAME);
$viewFile = plugin_dir_path(__DIR__) . '../View/metabox-note.php';
// Edge case : si le chemin est faux (copier/coller), on évite un warning.
if (!file_exists($viewFile)) {
echo '<p>' . esc_html__('Vue introuvable (metabox-note.php).', 'bpcab-notes-mvc') . '</p>';
return;
}
/** @var int $priority */
require $viewFile;
}
public function saveMetaBox(int $postId, WP_Post $post): void
{
// Autosave / révisions : on ne touche pas aux metas.
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
return;
}
if (wp_is_post_revision($postId) || wp_is_post_autosave($postId)) {
return;
}
// Capability : l’utilisateur doit pouvoir éditer ce post.
if (!current_user_can('edit_post', $postId)) {
return;
}
// Nonce : si absent, on ignore (évite de casser des imports).
if (!isset($_POST[self::NONCE_NAME]) || !wp_verify_nonce((string) $_POST[self::NONCE_NAME], self::NONCE_ACTION)) {
return;
}
// Sanitization + validation métier.
$raw = $_POST['bpcab_note_priority'] ?? null;
$validated = $this->repo->validatePriority($raw);
if (is_wp_error($validated)) {
// En admin, je préfère ne pas bloquer l’enregistrement du post.
// Mais on pourrait ajouter un admin_notice via transient si besoin.
return;
}
$this->repo->updatePriority($postId, $validated);
}
}
src/Notes/View/metabox-note.php
<?php
/**
* Vue : metabox priorité.
*
* Variables disponibles :
* @var int $priority
*/
declare(strict_types=1);
?>
<p>
<label for="bpcab_note_priority"><strong><?php echo esc_html__('Priorité (0 à 10)', 'bpcab-notes-mvc'); ?></strong></label>
</p>
<p>
<input
type="number"
id="bpcab_note_priority"
name="bpcab_note_priority"
min="0"
max="10"
step="1"
value="<?php echo esc_attr((string) $priority); ?>"
style="width: 100%;"
/>
</p>
src/Notes/Controller/RestController.php
<?php
declare(strict_types=1);
namespace BpcabNotesMvcNotesController;
use BpcabNotesMvcNotesModelNoteRepository;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
final class RestController
{
private const NS = 'bpcab-notes/v1';
public function __construct(private readonly NoteRepository $repo)
{
}
public function hooks(): void
{
add_action('rest_api_init', [$this, 'registerRoutes']);
}
public function registerRoutes(): void
{
register_rest_route(self::NS, '/note/(?P<id>d+)/priority', [
'methods' => 'POST',
'callback' => [$this, 'updatePriority'],
'permission_callback' => [$this, 'canUpdatePriority'],
'args' => [
'id' => [
'type' => 'integer',
'required' => true,
],
'priority' => [
'type' => 'integer',
'required' => true,
],
],
]);
}
public function canUpdatePriority(WP_REST_Request $request): bool
{
$id = (int) $request->get_param('id');
return $id > 0 && current_user_can('edit_post', $id);
}
public function updatePriority(WP_REST_Request $request): WP_REST_Response|WP_Error
{
$id = (int) $request->get_param('id');
$priority = $this->repo->validatePriority($request->get_param('priority'));
if (is_wp_error($priority)) {
return $priority;
}
$post = get_post($id);
if (!$post || $post->post_type !== NoteRepository::POST_TYPE) {
return new WP_Error('bpcab_note_not_found', 'Note introuvable.', ['status' => 404]);
}
$this->repo->updatePriority($id, $priority);
return new WP_REST_Response([
'id' => $id,
'priority' => $priority,
], 200);
}
}
Explication du code
Vue d’ensemble : qui fait quoi
- Plugin : point d’entrée, activation/désactivation, boot du container.
- Container : instanciation lazy des services, et exécution d’un “boot phase” pour brancher les hooks.
- Providers : enregistrent les services. C’est votre “composition root”.
- Repository : encapsule CPT, meta, validation, et opérations.
- Controllers : branchent les hooks WordPress et appellent le repository.
- View : rend l’HTML de la metabox avec escaping.
Pourquoi ce n’est pas un MVC “pur”
Dans un MVC classique, le routeur appelle un controller, qui choisit une view. Dans WordPress, les routes sont : des hooks (init, save_post), des écrans admin, ou la REST API. Le controller devient donc un “registrar” de callbacks, plus qu’un objet appelé directement.
Le point clé : on garde la logique métier hors des callbacks WordPress. Les callbacks deviennent des adaptateurs.
Hooks et timing : ce qui se passe en coulisses
- plugins_loaded : on boot le plugin, on enregistre les services.
- init : enregistrement du CPT et des metas. Si vous le faites trop tôt (avant
init), vous aurez des comportements bizarres selon les plugins. - add_meta_boxes : déclaration de la metabox.
- save_post_{post_type} : sauvegarde sécurisée.
- rest_api_init : enregistrement des routes REST.
Docs : Hook init et Hook rest_api_init.
Sanitization vs validation vs escaping
- Sanitization : rendre une entrée “propre” (ex: cast int, clamp).
- Validation : vérifier que la valeur respecte votre règle métier (0..10).
- Escaping : sécuriser la sortie HTML (ex:
esc_attr,esc_html).
Dans ce code :
- Metabox : validation via
validatePriority()+ sortie viaesc_attr(). - REST : validation identique, et contrôle d’accès via
permission_callback. - Meta enregistrée :
sanitize_callbackcomme garde-fou supplémentaire.
Activation : le flush des permaliens
J’ai souvent vu des plugins flusher les rewrite rules à chaque chargement (catastrophique en perf). Ici, on flushe uniquement sur activation/désactivation via register_activation_hook. Référence : register_activation_hook().
Notes sur la fiabilité (edge cases)
- Si Composer n’est pas installé, on affiche une notice au lieu d’un fatal.
- Sur
save_post, on ignore autosaves et révisions. - On ne casse pas l’enregistrement du post si la priorité est invalide (choix produit). Variante possible : bloquer et afficher une erreur.
Variantes et cas d’usage
Variante 1 — Sans Composer (autoload manuel)
Si vous devez livrer un zip à un client qui refuse Composer, vous pouvez embarquer vendor/ dans le zip (solution la plus simple) ou écrire un autoloader minimal. Je préfère embarquer vendor/ : moins de magie, moins de bugs.
Si vous insistez sur un autoload minimal, faites-le proprement et testez chaque classe. Mais attention : un autoloader custom mal écrit est une source classique de Class not found.
Variante 2 — Ajouter une couche “Service” (cas métier plus complexe)
Quand le repository commence à gonfler (notifications, logs, webhooks), créez un service métier :
- NoteService : règles métier (priorité, transitions d’état).
- NoteRepository : uniquement I/O WordPress (post/meta).
Le controller ne connaît que le service. Ça réduit la surface de changement.
Variante 3 — WP-CLI et cron dans la même architecture
Le même container peut exposer :
- Un CliController branché sur
WP_CLI::add_command()(quand WP-CLI est présent). - Un CronController qui planifie et exécute des tâches.
Avantage : mêmes services, mêmes validations, zéro duplication.
Compatibilité Divi 5 / Elementor / Avada
Votre architecture MVC/OOP ne doit pas empêcher l’intégration page builder. Le piège courant : mettre la logique dans un shortcode “monolithique” qui contourne votre repository. Ici, vous exposez une API stable : un service qui retourne des données, et des adaptateurs (shortcode/widget/module) qui rendent.
Base commune : un shortcode propre
Ajoutez un controller “FrontController” (non inclus dans le code complet pour rester focalisé), ou plus simple : un hook dans un controller existant. Exemple direct (à placer dans un nouveau controller) :
<?php
// Exemple : controller front minimal (à créer) qui expose un shortcode.
add_shortcode('bpcab_note_priority', function ($atts) {
$atts = shortcode_atts(['id' => 0], $atts, 'bpcab_note_priority');
$id = (int) $atts['id'];
if ($id <= 0) {
return '';
}
$priority = (int) get_post_meta($id, BpcabNotesMvcNotesModelNoteRepository::META_PRIORITY, true);
return esc_html((string) $priority);
});
Elementor (widget custom)
Elementor permet d’enregistrer des widgets qui appellent votre repository/service. Le point d’attention : ne chargez le code Elementor que si le plugin est actif, et accrochez-vous au bon hook Elementor (leur API évolue). Dans un plugin pro, je mets toujours l’intégration dans un provider séparé “ElementorProvider”.
Divi 5 (module custom)
Divi 5 a une architecture plus moderne que les anciens modules Divi 4, mais vous restez sur la même idée : un adaptateur UI qui appelle vos services. J’ai souvent vu des modules Divi qui requêtent directement la DB : c’est exactement ce que votre repository doit éviter.
Avada (Fusion Builder)
Avada expose des éléments/shortcodes Fusion. Là aussi, exposez un shortcode stable (comme ci-dessus), puis mappez-le dans Fusion. Vous évitez de lier votre cœur métier à l’API d’un builder.
Vérifications après mise en place
- Dans l’admin, vous voyez un menu “Notes” (CPT
bpcab_note). - En éditant une note, la metabox “Paramètres de la note” apparaît, et la priorité se sauvegarde.
- En REST, testez la route :
# Remplacez https://example.test et utilisez un cookie de session admin (ou un token application password).
curl -X POST "https://example.test/wp-json/bpcab-notes/v1/note/123/priority"
-H "Content-Type: application/json"
-d '{"priority": 7}'
Si vous utilisez des Application Passwords, référez-vous à la doc : REST API authentication.
Tableau de diagnostic rapide
| Symptôme | Cause probable | Vérification | Solution |
|---|---|---|---|
| Le CPT “Notes” n’apparaît pas | Hook init non exécuté / plugin non activé |
Vérifiez “Extensions” + logs PHP | Activez le plugin, vérifiez que plugins_loaded boot bien le Kernel |
| Metabox absente | Mauvais post type ciblé ou hook non branché | Confirmez que le post est bpcab_note |
Vérifiez add_meta_boxes et NoteRepository::POST_TYPE |
| La priorité ne se sauvegarde pas | Nonce/capability/autosave bloque | Inspectez $_POST et le nonce dans le HTML |
Vérifiez wp_verify_nonce, current_user_can, et testez hors autosave |
| REST renvoie 401/403 | Permission callback refuse | Vérifiez utilisateur connecté + capacité edit_post |
Authentifiez-vous, ou ajustez la stratégie de permissions |
| REST renvoie 404 | ID incorrect ou post type différent | Testez get_post(123) en console |
Utilisez un ID de note valide |
Si ça ne marche pas
- Vérifiez la version PHP. Une erreur classique : le site tourne encore en PHP 7.4, et vous prenez un fatal sur
readonlyou les types union. Corrigez côté hébergeur. - Vérifiez l’autoload : si
vendor/autoload.phpmanque, vous verrez la notice admin. Lancezcomposer install. - Activez WP_DEBUG sur staging :
define('WP_DEBUG', true); define('WP_DEBUG_LOG', true); - Contrôlez les hooks : j’ai souvent vu le code collé dans
functions.phpau lieu d’un plugin. Ici, c’est un plugin : il doit être danswp-content/plugins/et activé. - Cache : si vous testez une UI ou un endpoint derrière un cache agressif (plugin, proxy), videz le cache plugin + navigateur. Sur REST, testez avec
curlpour isoler. - Permaliens : si vous avez ajouté des routes et que ça ne répond pas, allez dans Réglages > Permaliens et sauvegardez (ou réactivez le plugin) pour forcer un flush.
Pièges et erreurs courantes
| Erreur | Cause | Solution |
|---|---|---|
Fatal error: Class "BpcabNotesMvcPlugin" not found |
Composer non installé, autoload absent, ou namespace différent | Lancez composer install, vérifiez composer.json PSR-4, puis composer dump-autoload |
| Écran blanc après activation | Erreur de syntaxe (point-virgule manquant) ou PHP trop ancien | Consultez wp-content/debug.log, corrigez la syntaxe, passez en PHP 8.1+ |
| La metabox s’affiche mais ne sauvegarde jamais | Nonce manquant, ou hook save_post incorrect |
Vérifiez le name du nonce et que vous utilisez save_post_bpcab_note |
Vous utilisez un hook inadapté (ex: CPT déclaré sur plugins_loaded) |
Timing : WordPress attend CPT/meta sur init |
Déclarez CPT et meta sur init (comme dans AdminController) |
| Conflit avec un plugin de snippets | Le snippet est chargé trop tôt/trop tard, ou doublon de déclaration | Évitez d’exécuter ce type d’architecture via un plugin de snippets. Utilisez un vrai plugin |
REST renvoie rest_no_route |
Route non enregistrée (hook rest_api_init non branché) ou cache |
Vérifiez que RestController->hooks() est bien appelé, videz le cache, testez /wp-json/ |
| Erreur “Call to undefined function register_post_meta()” | Code exécuté trop tôt (avant chargement complet) ou environnement WordPress incomplet | Assurez-vous d’exécuter registerMeta() sur init et dans WordPress, pas en script isolé |
| Copier le code au mauvais endroit | Collé dans functions.php ou dans un mauvais dossier |
Respectez l’arborescence plugin, activez-le via l’admin |
Conseils sécurité, performance et maintenance
Sécurité
- Admin : nonce +
current_user_can('edit_post', $postId)sursave_post. - REST :
permission_callbackobligatoire. Évitez les endpoints “publics” qui modifient des données. - Sorties HTML :
esc_html,esc_attr, et pas d’HTML non filtré venant de l’utilisateur.
Référence : WordPress Security APIs.
Performance
- Container lazy : les services ne sont instanciés que si nécessaires.
- Activation flush : pas de flush en runtime.
- Meta :
register_post_metaclarifie le schéma et évite des surprises côté REST.
Maintenance
- Providers : un provider par domaine (Core, Notes, REST, Builders). Vous isolez les intégrations.
- Contrats : commencez simple. Ajoutez des interfaces quand vous avez 2 implémentations (pas avant).
- Compat future : surveillez les changements core (REST, meta, editor). Les tickets Trac utiles à suivre sont souvent tagués “REST API” et “Meta”. Point d’entrée : core.trac.wordpress.org.
À propos de “MVC” et Gutenberg
Si votre plugin expose des blocs, vous aurez une autre séparation : le bloc (JS) devient une “view” interactive, et votre REST devient le “model gateway”. L’architecture présentée ici s’y prête bien : vous avez déjà un repository et un endpoint sécurisé.
Référence blocs : Block Editor Handbook et code source : wordpress-develop (GitHub).
Comment tester ce code proprement
- Tests manuels :
- Créer une note, changer la priorité, recharger l’éditeur, vérifier la persistance.
- Tester un autosave (attendre l’autosave) puis sauvegarder : la priorité doit rester correcte.
- Tester un utilisateur “Éditeur” vs “Abonné” : l’abonné ne doit pas pouvoir modifier via REST.
- Tests REST :
- Testez une priorité invalide (
-1,999,"abc") et vérifiez la réponseWP_Error.
- Testez une priorité invalide (
- Tests de régression :
- Activez/désactivez le plugin, vérifiez que rien ne casse côté permaliens.
- Changez de thème (Divi/Avada) : l’admin doit rester stable.
Ressources
- Plugin Developer Handbook
- register_post_type()
- register_post_meta()
- REST API Handbook
- Security APIs
- WordPress Core Trac
- Dépôt GitHub wordpress-develop
- Constructeurs et injection (PHP.net)
FAQ
Est-ce que je dois absolument utiliser MVC dans un plugin WordPress ?
Non. Le bon critère, c’est la complexité et la durée de vie. Si votre plugin vit 3 ans, avec REST + admin + intégrations, une séparation type MVC (adaptée hooks) vous fera gagner du temps.
Pourquoi un container alors que WordPress n’en a pas ?
Parce que WordPress a un système d’événements (hooks), pas un système de composition. Le container vous donne un point central pour instancier vos services, et évite les singletons globaux.
Pourquoi ne pas utiliser un singleton pour chaque classe ?
Parce que ça rend les dépendances implicites et les tests pénibles. Un container simple + injection explicite est plus lisible, et vous pouvez remplacer un service sans réécrire tout le plugin.
Où mettre les templates (views) ?
Dans un dossier View/ interne au plugin, et vous les incluez depuis les controllers. Gardez-les “stupides” : affichage uniquement, pas de requêtes.
Comment gérer la traduction (i18n) dans cette architecture ?
Ajoutez un service “i18n” dans un provider Core qui appelle load_plugin_textdomain sur init ou plugins_loaded. Évitez de charger les traductions trop tôt.
Comment éviter les conflits de noms (CPT/meta/REST namespace) ?
Préfixez tout : bpcab_note, _bpcab_note_priority, namespace REST bpcab-notes/v1. Les collisions arrivent vite sur des sites qui empilent les plugins.
Est-ce compatible multisite ?
Oui pour ce qui est montré (CPT/meta). Pour l’activation réseau, adaptez la méthode activate() si vous devez exécuter des migrations site par site.
Pourquoi utiliser save_post_{post_type} plutôt que save_post ?
Parce que vous réduisez le bruit : votre callback ne s’exécute que pour votre CPT. C’est plus rapide et limite les effets de bord.
Est-ce que cette structure marche avec des blocs Gutenberg ?
Oui. Le bloc (JS) consomme votre REST (ou des données localisées), et votre repository reste la source de vérité côté serveur.
Je vois souvent des tutos avec admin-ajax.php. Pourquoi REST ici ?
Pour des opérations CRUD, REST est plus clair, mieux outillé (permissions, schémas), et plus simple à consommer côté JS moderne. AJAX admin reste utile pour des cas spécifiques, mais je le réserve quand REST est surdimensionné.