Les métadonnées sont le mécanisme le plus utilisé et le plus mal maîtrisé de WordPress. La fonction update_post_meta() est tellement simple d’usage qu’elle invite à l’excès : des extensions stockent des tableaux de plusieurs centaines d’éléments dans une seule clé de métadonnée, puis s’étonnent que les listes d’articles de l’administration deviennent lentes après quelques mois d’utilisation.
Cet article revient sur le fonctionnement réel des tables de métadonnées, sur l’API moderne register_post_meta() introduite en 2017 mais encore trop peu utilisée, et sur les limites à connaître avant qu’une extension ne devienne un problème de performance pour le site qui l’héberge.
Comment WordPress stocke réellement une métadonnée
Les tables wp_postmeta, wp_usermeta, wp_termmeta et wp_commentmeta partagent la même structure : un identifiant, l’identifiant de l’objet parent, une clé et une valeur. Toute valeur qui n’est pas un scalaire (tableau, objet) est automatiquement sérialisée avec maybe_serialize() avant l’enregistrement, et désérialisée à la lecture.
update_post_meta( $post_id, 'acme_options', [
'couleur' => 'bleu',
'taille' => 'M',
'variants' => [ 'rouge', 'vert', 'jaune' ],
] );
$options = get_post_meta( $post_id, 'acme_options', true );
// $options['couleur'] === 'bleu'
Ce confort a un coût : une valeur sérialisée est stockée comme une seule chaîne de caractères opaque pour MySQL. Impossible d’écrire une requête SQL qui filtre sur couleur à l’intérieur de ce tableau sans désérialiser côté PHP l’ensemble des lignes candidates. Pour toute donnée qui doit être filtrable ou triable, mieux vaut une clé de métadonnée dédiée par valeur simple plutôt qu’un tableau imbriqué.
// Préférable si "couleur" doit être filtrable :
update_post_meta( $post_id, 'acme_couleur', 'bleu' );
update_post_meta( $post_id, 'acme_taille', 'M' );
register_post_meta : la déclaration explicite
Depuis WordPress 4.9.8, register_post_meta() permet de déclarer une métadonnée avec son type, sa visibilité dans l’API REST et sa fonction de sanitisation, plutôt que de la manipuler de façon implicite.
add_action( 'init', function () {
register_post_meta( 'produit', 'acme_reference', [
'type' => 'string',
'single' => true,
'show_in_rest' => true,
'sanitize_callback' => 'sanitize_text_field',
'auth_callback' => function () {
return current_user_can( 'edit_posts' );
},
] );
} );
show_in_rest expose automatiquement la métadonnée dans l’objet retourné par l’API REST, sous meta.acme_reference, sans écrire de contrôleur personnalisé. L’auth_callback contrôle qui a le droit de la modifier via cette même API : un oubli ici rend la métadonnée modifiable par n’importe quel utilisateur authentifié disposant d’un accès en écriture minimal, ce qui est rarement l’intention recherchée.

Le piège classique de meta_query
Filtrer une liste d’articles par métadonnée via WP_Query et l’argument meta_query fonctionne bien à petite échelle, mais se dégrade fortement au-delà de quelques dizaines de milliers d’entrées, car la table wp_postmeta n’est indexée efficacement que sur la clé, pas sur la valeur.
$query = new WP_Query( [
'post_type' => 'produit',
'meta_query' => [
[
'key' => 'acme_stock',
'value' => 0,
'compare' => '>',
'type' => 'NUMERIC',
],
],
] );
Cette requête génère une jointure SQL sur wp_postmeta qui doit convertir chaque valeur en nombre à la volée, sans pouvoir s’appuyer sur un index numérique. Sur un catalogue de dix mille produits, le temps de réponse peut passer de quelques millisecondes à plusieurs centaines. Deux solutions existent : mettre en cache le résultat via un objet cache (wp_cache_set) quand la donnée change peu, ou migrer vers une table SQL personnalisée dès que le volume et la fréquence de filtrage le justifient.
Créer une table dédiée quand c’est justifié
global $wpdb;
$wpdb->query( "
CREATE TABLE {$wpdb->prefix}acme_stock (
post_id BIGINT UNSIGNED NOT NULL,
quantite INT NOT NULL DEFAULT 0,
PRIMARY KEY (post_id),
KEY quantite (quantite)
) {$wpdb->get_charset_collate()}
" );
Une table dédiée avec un index sur la colonne filtrée transforme une requête de plusieurs centaines de millisecondes en une requête de quelques millisecondes. Ce chantier ne se justifie pas systématiquement : il devient pertinent au-delà de quelques dizaines de milliers d’objets filtrés régulièrement, pas avant.
User meta et term meta : les mêmes règles
Les fonctions update_user_meta(), get_user_meta(), update_term_meta() et get_term_meta() suivent une logique strictement identique. register_meta() couvre d’ailleurs les quatre types d’objets avec un seul appel générique :
register_meta( 'term', 'acme_couleur_theme', [
'type' => 'string',
'single' => true,
'show_in_rest' => true,
] );
Un piège spécifique au term meta : la table wp_termmeta n’existe que depuis WordPress 4.4 (2015). Toute extension encore compatible avec des versions antérieures (ce qui devient rarissime en 2021) doit prévoir une table de repli, mais ce cas est aujourd’hui purement académique.
La règle qu’on applique systématiquement : une métadonnée qui sert uniquement à l’affichage reste dans postmeta sans complication ; une métadonnée qui sert à filtrer, trier ou agréger des milliers d’enregistrements mérite sa propre table dès la conception, pas en urgence six mois plus tard.
En résumé
register_post_meta() et ses équivalents apportent structure, sécurité et intégration REST à un mécanisme trop souvent utilisé de façon implicite. Le vrai arbitrage à faire dès la conception d’une extension porte sur le volume : au-delà de quelques dizaines de milliers d’objets à filtrer régulièrement, la table de métadonnées générique montre ses limites et une table SQL dédiée devient le choix le plus sain.