GraphQL : Schema design et resolvers optimisés
Introduction GraphQL a révolutionné la façon dont nous concevons des APIs en 2025, avec plus…
WordPress Multisite permet de gérer des centaines, voire des milliers de sites depuis une seule installation. En 2025, cette architecture est devenue la norme pour les organisations gérant des réseaux de sites : franchises, universités, médias, portails régionaux, et plateformes SaaS basées sur WordPress.
Cet article explore l’architecture, les stratégies de scalabilité, et les patterns avancés pour gérer des réseaux Multisite de niveau entreprise avec des performances optimales et une maintenance centralisée.
WordPress Multisite utilise une architecture de tables partagées et isolées.
-- Tables globales (partagées)
wpusers -- Tous les utilisateurs du réseau
wpusermeta -- Métadonnées utilisateurs
wpblogs -- Liste des sites du réseau
wpblogmeta -- Métadonnées des sites
wpsite -- Sites du réseau (pour multi-network)
wpsitemeta -- Métadonnées du réseau
wpregistrationlog -- Log des inscriptions
wpsignups -- Inscriptions en attente
-- Tables par site (isolées)
wp1posts -- Posts du site 1
wp1postmeta -- Métadonnées posts site 1
wp1comments -- Commentaires site 1
wp1commentmeta -- Métadonnées commentaires site 1
wp1terms -- Termes taxonomies site 1
wp1termtaxonomy -- Relations taxonomies site 1
wp1termrelationships -- Relations posts-termes site 1
wp1termmeta -- Métadonnées termes site 1
wp1options -- Options site 1
wp1links -- Liens site 1
-- Site 2, 3, etc. suivent le même pattern avec préfixe wp2, wp3...
Service de gestion des sites Multisite
/
class SiteManager {
private wpdb $wpdb;
public function construct(wpdb $wpdb) {
$this->wpdb = $wpdb;
}
/
Crée un nouveau site dans le réseau
@param array $config Configuration du site
@return Site
@throws SiteCreationException
/
public function createSite(array $config): Site {
$defaults = [
'domain' => '',
'path' => '/',
'title' => '',
'userid' => getcurrentuserid(),
'public' => 1,
'meta' => [],
];
$config = arraymerge($defaults, $config);
// Validation
$this->validateSiteConfig($config);
// Vérifier que le domaine/chemin n'existe pas déjà
if ($this->siteExists($config['domain'], $config['path'])) {
throw new SiteCreationException(
"Site already exists: {$config['domain']}{$config['path']}"
);
}
// Créer le site
$siteId = wpmucreateblog(
$config['domain'],
$config['path'],
$config['title'],
$config['userid'],
$config['meta'],
getcurrentnetworkid()
);
if (iswperror($siteId)) {
throw new SiteCreationException(
"Failed to create site: {$siteId->geterrormessage()}"
);
}
// Appliquer la configuration post-création
$this->configureSite($siteId, $config);
return $this->getSiteById($siteId);
}
/
Récupère un site par son ID
@param int $siteId
@return Site
@throws SiteNotFoundException
/
public function getSiteById(int $siteId): Site {
$blogDetails = getblogdetails($siteId);
if (!$blogDetails) {
throw new SiteNotFoundException("Site not found: {$siteId}");
}
return Site::fromBlogDetails($blogDetails);
}
/
Liste tous les sites avec pagination
@param array $args Arguments de filtrage
@return array
/
public function listSites(array $args = []): array {
$defaults = [
'number' => 100,
'offset' => 0,
'public' => null,
'archived' => 0,
'deleted' => 0,
'spam' => 0,
'orderby' => 'registered',
'order' => 'DESC',
];
$args = wpparseargs($args, $defaults);
$sites = getsites($args);
return arraymap(
fn($site) => Site::fromWpSite($site),
$sites
);
}
/
Met à jour un site
@param int $siteId
@param array $data Données à mettre à jour
@return Site
/
public function updateSite(int $siteId, array $data): Site {
// Basculer vers le site
switchtoblog($siteId);
// Mettre à jour les options
if (isset($data['blogname'])) {
updateoption('blogname', $data['blogname']);
}
if (isset($data['blogdescription'])) {
updateoption('blogdescription', $data['blogdescription']);
}
if (isset($data['siteurl'])) {
updateoption('siteurl', $data['siteurl']);
}
if (isset($data['home'])) {
updateoption('home', $data['home']);
}
// Mettre à jour les métadonnées du site
if (isset($data['meta'])) {
foreach ($data['meta'] as $key => $value) {
updateblogoption($siteId, $key, $value);
}
}
// Restaurer le contexte
restorecurrentblog();
// Mettre à jour la table wpblogs si nécessaire
if (isset($data['public']) || isset($data['archived']) ||
isset($data['deleted']) || isset($data['spam'])) {
$updateData = arrayintersectkey($data, arrayflip([
'public', 'archived', 'deleted', 'spam', 'mature', 'langid'
]));
if (!empty($updateData)) {
$this->wpdb->update(
$this->wpdb->blogs,
$updateData,
['blogid' => $siteId],
arrayfill(0, count($updateData), '%d'),
['%d']
);
}
}
return $this->getSiteById($siteId);
}
/
Supprime un site
@param int $siteId
@param bool $drop Supprimer les tables (true) ou marquer comme deleted (false)
@return bool
/
public function deleteSite(int $siteId, bool $drop = false): bool {
if ($siteId === 1) {
throw new InvalidArgumentException('Cannot delete main site');
}
if ($drop) {
wpmudeleteblog($siteId, true);
} else {
updateblogstatus($siteId, 'deleted', 1);
}
return true;
}
/
Clone un site existant
@param int $sourceSiteId Site source
@param array $targetConfig Configuration du nouveau site
@param array $options Options de clonage
@return Site
/
public function cloneSite(
int $sourceSiteId,
array $targetConfig,
array $options = []
): Site {
$defaults = [
'clonecontent' => true,
'clonemedia' => true,
'cloneplugins' => true,
'clonethemesettings' => true,
'cloneusers' => false,
];
$options = arraymerge($defaults, $options);
// Créer le nouveau site
$newSite = $this->createSite($targetConfig);
if ($options['clonecontent']) {
$this->cloneContent($sourceSiteId, $newSite->getId());
}
if ($options['clonemedia']) {
$this->cloneMedia($sourceSiteId, $newSite->getId());
}
if ($options['cloneplugins']) {
$this->clonePluginSettings($sourceSiteId, $newSite->getId());
}
if ($options['clonethemesettings']) {
$this->cloneThemeSettings($sourceSiteId, $newSite->getId());
}
if ($options['cloneusers']) {
$this->cloneUsers($sourceSiteId, $newSite->getId());
}
return $newSite;
}
/
Valide la configuration d'un site
@throws SiteCreationException
/
private function validateSiteConfig(array $config): void {
if (empty($config['domain'])) {
throw new SiteCreationException('Domain is required');
}
if (empty($config['title'])) {
throw new SiteCreationException('Title is required');
}
// Valider le domaine
if (!pregmatch('/^[a-z0-9]+([-.]{1}[a-z0-9]+).[a-z]{2,}$/i', $config['domain'])) {
throw new SiteCreationException('Invalid domain format');
}
// Valider le chemin
if (!pregmatch('/^/[a-z0-9-/]/?$/i', $config['path'])) {
throw new SiteCreationException('Invalid path format');
}
}
/
Vérifie si un site existe
/
private function siteExists(string $domain, string $path): bool {
return (bool) domainexists($domain, $path);
}
/
Configure un site après création
/
private function configureSite(int $siteId, array $config): void {
switchtoblog($siteId);
// Activer le thème par défaut du réseau
if (isset($config['theme'])) {
switchtheme($config['theme']);
}
// Activer les plugins réseau
if (isset($config['plugins'])) {
foreach ($config['plugins'] as $plugin) {
activateplugin($plugin);
}
}
// Configuration des permaliens
if (isset($config['permalinkstructure'])) {
updateoption('permalinkstructure', $config['permalinkstructure']);
}
// Configuration timezone
if (isset($config['timezone'])) {
updateoption('timezonestring', $config['timezone']);
}
restorecurrentblog();
}
/
Clone le contenu d'un site
/
private function cloneContent(int $sourceId, int $targetId): void {
global $wpdb;
// Tables à cloner
$tables = [
'posts', 'postmeta', 'comments', 'commentmeta',
'terms', 'termtaxonomy', 'termrelationships', 'termmeta'
];
foreach ($tables as $table) {
$sourceTable = $wpdb->getblogprefix($sourceId) . $table;
$targetTable = $wpdb->getblogprefix($targetId) . $table;
// Vider la table cible
$wpdb->query("TRUNCATE TABLE {$targetTable}");
// Copier les données
$wpdb->query("INSERT INTO {$targetTable} SELECT FROM {$sourceTable}");
}
}
/
Clone les médias d'un site
/
private function cloneMedia(int $sourceId, int $targetId): void {
$sourceUploadDir = wpuploaddir();
switchtoblog($sourceId);
$sourceUploadDir = wpuploaddir();
restorecurrentblog();
switchtoblog($targetId);
$targetUploadDir = wpuploaddir();
restorecurrentblog();
// Copier récursivement le dossier uploads
$this->recursiveCopy(
$sourceUploadDir['basedir'],
$targetUploadDir['basedir']
);
}
/
Clone les paramètres de plugins
/
private function clonePluginSettings(int $sourceId, int $targetId): void {
switchtoblog($sourceId);
$activePlugins = getoption('activeplugins', []);
$pluginOptions = [];
// Récupérer toutes les options de plugins
global $wpdb;
$results = $wpdb->getresults(
"SELECT optionname, optionvalue FROM {$wpdb->options}
WHERE optionname NOT LIKE '%'
AND optionname NOT IN ('siteurl', 'home', 'blogname')",
ARRAYA
);
foreach ($results as $row) {
$pluginOptions[$row['optionname']] = maybeunserialize($row['optionvalue']);
}
restorecurrentblog();
// Appliquer au site cible
switchtoblog($targetId);
foreach ($pluginOptions as $key => $value) {
updateoption($key, $value);
}
updateoption('activeplugins', $activePlugins);
restorecurrentblog();
}
/
Clone les paramètres du thème
/
private function cloneThemeSettings(int $sourceId, int $targetId): void {
switchtoblog($sourceId);
$theme = getoption('stylesheet');
$themeMods = getoption('thememods' . $theme);
$widgetSettings = [];
// Récupérer les widgets
$sidebars = wpgetsidebarswidgets();
restorecurrentblog();
switchtoblog($targetId);
// Appliquer le thème
switchtheme($theme);
// Appliquer les mods
if ($themeMods) {
updateoption('thememods' . $theme, $themeMods);
}
// Appliquer les widgets
if ($sidebars) {
wpsetsidebarswidgets($sidebars);
}
restorecurrentblog();
}
/
Clone les utilisateurs
/
private function cloneUsers(int $sourceId, int $targetId): void {
$sourceUsers = getusers([
'blogid' => $sourceId,
'fields' => 'allwithmeta',
]);
foreach ($sourceUsers as $user) {
// Récupérer le rôle sur le site source
switchtoblog($sourceId);
$userRoles = $user->roles;
restorecurrentblog();
// Ajouter au site cible
if (!empty($userRoles)) {
addusertoblog($targetId, $user->ID, $userRoles[0]);
}
}
}
/
Copie récursive de répertoire
/
private function recursiveCopy(string $source, string $dest): void {
if (!fileexists($dest)) {
mkdir($dest, 0755, true);
}
$dir = opendir($source);
while (($file = readdir($dir)) !== false) {
if ($file === '.' || $file === '..') {
continue;
}
$srcPath = $source . '/' . $file;
$destPath = $dest . '/' . $file;
if (isdir($srcPath)) {
$this->recursiveCopy($srcPath, $destPath);
} else {
copy($srcPath, $destPath);
}
}
closedir($dir);
}
}
/
Value Object représentant un site
/
class Site {
private int $id;
private string $domain;
private string $path;
private string $url;
private string $name;
private bool $public;
private bool $archived;
private bool $deleted;
private bool $spam;
public function construct(
int $id,
string $domain,
string $path,
string $url,
string $name,
bool $public = true,
bool $archived = false,
bool $deleted = false,
bool $spam = false
) {
$this->id = $id;
$this->domain = $domain;
$this->path = $path;
$this->url = $url;
$this->name = $name;
$this->public = $public;
$this->archived = $archived;
$this->deleted = $deleted;
$this->spam = $spam;
}
public static function fromBlogDetails($blogDetails): self {
return new self(
(int) $blogDetails->blogid,
$blogDetails->domain,
$blogDetails->path,
getsiteurl($blogDetails->blogid),
$blogDetails->blogname,
(bool) $blogDetails->public,
(bool) $blogDetails->archived,
(bool) $blogDetails->deleted,
(bool) $blogDetails->spam
);
}
public static function fromWpSite(WPSite $site): self {
return new self(
(int) $site->blogid,
$site->domain,
$site->path,
getsiteurl($site->blogid),
getblogoption($site->blogid, 'blogname', ''),
(bool) $site->public,
(bool) $site->archived,
(bool) $site->deleted,
(bool) $site->spam
);
}
public function getId(): int { return $this->id; }
public function getDomain(): string { return $this->domain; }
public function getPath(): string { return $this->path; }
public function getUrl(): string { return $this->url; }
public function getName(): string { return $this->name; }
public function isPublic(): bool { return $this->public; }
public function isArchived(): bool { return $this->archived; }
public function isDeleted(): bool { return $this->deleted; }
public function isSpam(): bool { return $this->spam; }
}
Gestionnaire de cache optimisé pour Multisite
/
class MultisiteCacheManager {
private CacheInterface $cache;
private int $currentBlogId;
public function construct(CacheInterface $cache) {
$this->cache = $cache;
$this->currentBlogId = getcurrentblogid();
}
/
Récupère une valeur du cache avec isolation par site
/
public function get(string $key, ?int $blogId = null): mixed {
$blogId = $blogId ?? $this->currentBlogId;
$cacheKey = $this->buildKey($key, $blogId);
return $this->cache->get($cacheKey);
}
/
Stocke une valeur dans le cache avec isolation par site
/
public function set(string $key, mixed $value, int $ttl = 3600, ?int $blogId = null): bool {
$blogId = $blogId ?? $this->currentBlogId;
$cacheKey = $this->buildKey($key, $blogId);
return $this->cache->set($cacheKey, $value, $ttl);
}
/
Supprime une clé du cache
/
public function delete(string $key, ?int $blogId = null): bool {
$blogId = $blogId ?? $this->currentBlogId;
$cacheKey = $this->buildKey($key, $blogId);
return $this->cache->delete($cacheKey);
}
/
Vide tout le cache d'un site
/
public function flushSite(int $blogId): bool {
// Pattern pour identifier toutes les clés du site
$pattern = "site:{$blogId}:";
// Nécessite une implémentation de cache supportant les patterns (Redis)
if (methodexists($this->cache, 'deletePattern')) {
return $this->cache->deletePattern($pattern);
}
return false;
}
/
Vide le cache de tous les sites
/
public function flushAll(): bool {
return $this->cache->flush();
}
/
Récupère ou génère une valeur cachée
/
public function remember(
string $key,
callable $callback,
int $ttl = 3600,
?int $blogId = null
): mixed {
$value = $this->get($key, $blogId);
if ($value !== false) {
return $value;
}
$value = $callback();
$this->set($key, $value, $ttl, $blogId);
return $value;
}
/
Cache de requêtes globales (partagées entre sites)
/
public function getGlobal(string $key): mixed {
$cacheKey = "global:{$key}";
return $this->cache->get($cacheKey);
}
/
Stockage de cache global
/
public function setGlobal(string $key, mixed $value, int $ttl = 3600): bool {
$cacheKey = "global:{$key}";
return $this->cache->set($cacheKey, $value, $ttl);
}
/
Construit une clé de cache avec isolation par site
/
private function buildKey(string $key, int $blogId): string {
return "site:{$blogId}:{$key}";
}
}
/
Cache pour Object Cache Redis avec Multisite
/
class RedisMultisiteCache implements CacheInterface {
private Redis $redis;
private string $prefix;
public function construct(Redis $redis, string $prefix = 'wp:') {
$this->redis = $redis;
$this->prefix = $prefix;
}
public function get(string $key): mixed {
$value = $this->redis->get($this->prefix . $key);
if ($value === false) {
return false;
}
return unserialize($value);
}
public function set(string $key, mixed $value, int $ttl = 3600): bool {
return $this->redis->setex(
$this->prefix . $key,
$ttl,
serialize($value)
);
}
public function delete(string $key): bool {
return (bool) $this->redis->del($this->prefix . $key);
}
public function flush(): bool {
$keys = $this->redis->keys($this->prefix . '');
if (empty($keys)) {
return true;
}
return (bool) $this->redis->del($keys);
}
/
Supprime les clés correspondant à un pattern
/
public function deletePattern(string $pattern): bool {
$keys = $this->redis->keys($this->prefix . $pattern);
if (empty($keys)) {
return true;
}
return (bool) $this->redis->del($keys);
}
}
/
Invalidation automatique du cache
/
class CacheInvalidation {
private MultisiteCacheManager $cache;
public function construct(MultisiteCacheManager $cache) {
$this->cache = $cache;
$this->registerHooks();
}
private function registerHooks(): void {
// Invalider le cache à la modification de post
addaction('savepost', [$this, 'invalidatePostCache'], 10, 2);
// Invalider à la modification de terme
addaction('editedterm', [$this, 'invalidateTermCache'], 10, 3);
// Invalider à la modification d'option
addaction('updatedoption', [$this, 'invalidateOptionCache'], 10, 3);
// Invalider tout le cache du site lors d'un changement de thème
addaction('switchtheme', [$this, 'invalidateSiteCache']);
}
public function invalidatePostCache(int $postId, WPPost $post): void {
$blogId = getcurrentblogid();
// Invalider le cache du post
$this->cache->delete("post:{$postId}", $blogId);
// Invalider les archives si publié
if ($post->poststatus === 'publish') {
$this->cache->delete('posts:archive', $blogId);
$this->cache->delete("posts:type:{$post->posttype}", $blogId);
}
// Invalider le cache des taxonomies associées
$taxonomies = getobjecttaxonomies($post->posttype);
foreach ($taxonomies as $taxonomy) {
$terms = wpgetpostterms($postId, $taxonomy, ['fields' => 'ids']);
foreach ($terms as $termId) {
$this->cache->delete("term:{$termId}:posts", $blogId);
}
}
}
public function invalidateTermCache(int $termId, int $ttId, string $taxonomy): void {
$blogId = getcurrentblogid();
$this->cache->delete("term:{$termId}", $blogId);
$this->cache->delete("term:{$termId}:posts", $blogId);
$this->cache->delete("taxonomy:{$taxonomy}", $blogId);
}
public function invalidateOptionCache(string $option, mixed $oldValue, mixed $value): void {
$blogId = getcurrentblogid();
$this->cache->delete("option:{$option}", $blogId);
// Invalider tout si option critique
$criticalOptions = ['blogname', 'siteurl', 'home', 'permalinkstructure'];
if (inarray($option, $criticalOptions)) {
$this->cache->flushSite($blogId);
}
}
public function invalidateSiteCache(): void {
$this->cache->flushSite(getcurrentblogid());
}
}
Query Builder pour requêtes multi-sites
/
class CrossSiteQueryBuilder {
private wpdb $wpdb;
private array $siteIds = [];
private string $postType = 'post';
private string $postStatus = 'publish';
private int $limit = 10;
private int $offset = 0;
private array $orderBy = ['postdate' => 'DESC'];
private array $metaQuery = [];
private array $taxQuery = [];
public function construct(wpdb $wpdb) {
$this->wpdb = $wpdb;
}
/
Définit les sites à interroger
/
public function sites(array $siteIds): self {
$this->siteIds = $siteIds;
return $this;
}
/
Définit le type de post
/
public function postType(string $postType): self {
$this->postType = $postType;
return $this;
}
/
Définit le statut
/
public function status(string $status): self {
$this->postStatus = $status;
return $this;
}
/
Définit la limite
/
public function limit(int $limit): self {
$this->limit = $limit;
return $this;
}
/
Définit l'offset
/
public function offset(int $offset): self {
$this->offset = $offset;
return $this;
}
/
Définit le tri
/
public function orderBy(string $field, string $order = 'DESC'): self {
$this->orderBy = [$field => $order];
return $this;
}
/
Ajoute une condition sur les métadonnées
/
public function whereMeta(string $key, mixed $value, string $compare = '='): self {
$this->metaQuery[] = [
'key' => $key,
'value' => $value,
'compare' => $compare,
];
return $this;
}
/
Exécute la requête multi-sites
/
public function get(): array {
if (empty($this->siteIds)) {
$this->siteIds = $this->getAllSiteIds();
}
$results = [];
foreach ($this->siteIds as $siteId) {
$siteResults = $this->getFromSite($siteId);
$results = arraymerge($results, $siteResults);
}
// Tri global
$results = $this->sortResults($results);
// Pagination globale
$results = arrayslice($results, $this->offset, $this->limit);
return $results;
}
/
Exécute une requête optimisée avec UNION
/
public function getOptimized(): array {
if (empty($this->siteIds)) {
$this->siteIds = $this->getAllSiteIds();
}
$queries = [];
$values = [];
foreach ($this->siteIds as $siteId) {
$prefix = $this->wpdb->getblogprefix($siteId);
$query = "
SELECT
{$siteId} as blogid,
p.ID,
p.posttitle,
p.postcontent,
p.postexcerpt,
p.postdate,
p.postauthor
FROM {$prefix}posts p
WHERE p.posttype = %s
AND p.poststatus = %s
";
$values[] = $this->postType;
$values[] = $this->postStatus;
// Ajouter les conditions meta
if (!empty($this->metaQuery)) {
foreach ($this->metaQuery as $index => $meta) {
$alias = "pm{$index}";
$query .= " INNER JOIN {$prefix}postmeta {$alias}
ON p.ID = {$alias}.postid
AND {$alias}.metakey = %s
AND {$alias}.metavalue {$meta['compare']} %s";
$values[] = $meta['key'];
$values[] = $meta['value'];
}
}
$queries[] = $query;
}
// Combiner avec UNION ALL
$unionQuery = implode(' UNION ALL ', $queries);
// Ajouter le tri et la limite
[$orderField, $orderDirection] = each($this->orderBy);
$unionQuery .= " ORDER BY {$orderField} {$orderDirection}";
$unionQuery .= " LIMIT %d OFFSET %d";
$values[] = $this->limit;
$values[] = $this->offset;
// Préparer et exécuter
$preparedQuery = $this->wpdb->prepare($unionQuery, ...$values);
$results = $this->wpdb->getresults($preparedQuery);
return $this->hydrateResults($results);
}
/
Compte le total de résultats
/
public function count(): int {
if (empty($this->siteIds)) {
$this->siteIds = $this->getAllSiteIds();
}
$total = 0;
foreach ($this->siteIds as $siteId) {
switchtoblog($siteId);
$args = [
'posttype' => $this->postType,
'poststatus' => $this->postStatus,
'postsperpage' => -1,
'fields' => 'ids',
];
if (!empty($this->metaQuery)) {
$args['metaquery'] = $this->metaQuery;
}
$query = new WPQuery($args);
$total += $query->foundposts;
restorecurrentblog();
}
return $total;
}
/
Récupère les posts d'un site spécifique
/
private function getFromSite(int $siteId): array {
switchtoblog($siteId);
$args = [
'posttype' => $this->postType,
'poststatus' => $this->postStatus,
'postsperpage' => $this->limit 2, // Buffer pour tri global
'offset' => 0,
];
if (!empty($this->metaQuery)) {
$args['metaquery'] = $this->metaQuery;
}
if (!empty($this->orderBy)) {
$args['orderby'] = arraykeys($this->orderBy)[0];
$args['order'] = arrayvalues($this->orderBy)[0];
}
$query = new WPQuery($args);
$posts = $query->posts;
// Ajouter le blogid à chaque post
foreach ($posts as $post) {
$post->blogid = $siteId;
$post->siteurl = getsiteurl($siteId);
}
restorecurrentblog();
return $posts;
}
/
Trie les résultats combinés
/
private function sortResults(array $results): array {
$field = arraykeys($this->orderBy)[0];
$order = arrayvalues($this->orderBy)[0];
usort($results, function($a, $b) use ($field, $order) {
$comparison = $a->$field <=> $b->$field;
return $order === 'DESC' ? -$comparison : $comparison;
});
return $results;
}
/
Hydrate les résultats en objets WPPost
/
private function hydrateResults(array $results): array {
$hydrated = [];
foreach ($results as $result) {
$post = new WPPost((object) $result);
$post->blogid = $result->blogid;
$hydrated[] = $post;
}
return $hydrated;
}
/
Récupère tous les IDs de sites
/
private function getAllSiteIds(): array {
return getsites([
'fields' => 'ids',
'number' => 10000,
]);
}
}
// Utilisation
$crossSiteQuery = new CrossSiteQueryBuilder($wpdb);
$posts = $crossSiteQuery
->sites([1, 2, 3, 4])
->postType('post')
->status('publish')
->whereMeta('featured', '1')
->orderBy('postdate', 'DESC')
->limit(20)
->getOptimized();
foreach ($posts as $post) {
echo "Site {$post->blogid}: {$post->posttitle}n";
}
Gestionnaire centralisé de plugins
/
class NetworkPluginManager {
/
Active un plugin sur plusieurs sites
/
public function activateOnSites(string $plugin, array $siteIds): array {
$results = [];
foreach ($siteIds as $siteId) {
switchtoblog($siteId);
$result = activateplugin($plugin);
$results[$siteId] = [
'success' => !iswperror($result),
'error' => iswperror($result) ? $result->geterrormessage() : null,
];
restorecurrentblog();
}
return $results;
}
/
Désactive un plugin sur plusieurs sites
/
public function deactivateOnSites(string $plugin, array $siteIds): array {
$results = [];
foreach ($siteIds as $siteId) {
switchtoblog($siteId);
deactivateplugins($plugin);
$results[$siteId] = ['success' => true];
restorecurrentblog();
}
return $results;
}
/
Récupère les plugins actifs sur chaque site
/
public function getActivePluginsBySite(array $siteIds = []): array {
if (empty($siteIds)) {
$siteIds = getsites(['fields' => 'ids']);
}
$report = [];
foreach ($siteIds as $siteId) {
switchtoblog($siteId);
$report[$siteId] = [
'sitename' => getbloginfo('name'),
'activeplugins' => getoption('activeplugins', []),
];
restorecurrentblog();
}
return $report;
}
/
Synchronise la configuration d'un plugin sur tous les sites
/
public function syncPluginSettings(
string $optionName,
mixed $value,
array $siteIds = []
): int {
if (empty($siteIds)) {
$siteIds = getsites(['fields' => 'ids']);
}
$updated = 0;
foreach ($siteIds as $siteId) {
switchtoblog($siteId);
if (updateoption($optionName, $value)) {
$updated++;
}
restorecurrentblog();
}
return $updated;
}
/
Audit de sécurité des plugins
/
public function auditPlugins(): array {
$allSites = getsites(['fields' => 'ids']);
$pluginVersions = [];
$outdatedSites = [];
// Récupérer les versions de tous les sites
foreach ($allSites as $siteId) {
switchtoblog($siteId);
$activePlugins = getoption('activeplugins', []);
foreach ($activePlugins as $plugin) {
$pluginData = getplugindata(WPPLUGINDIR . '/' . $plugin);
if (!isset($pluginVersions[$plugin])) {
$pluginVersions[$plugin] = [];
}
$version = $pluginData['Version'];
if (!isset($pluginVersions[$plugin][$version])) {
$pluginVersions[$plugin][$version] = [];
}
$pluginVersions[$plugin][$version][] = $siteId;
}
restorecurrentblog();
}
// Identifier les sites avec versions obsolètes
foreach ($pluginVersions as $plugin => $versions) {
if (count($versions) > 1) {
$latestVersion = max(arraykeys($versions));
foreach ($versions as $version => $sites) {
if (versioncompare($version, $latestVersion, '<')) {
$outdatedSites[$plugin][$version] = $sites;
}
}
}
}
return [
'pluginversions' => $pluginVersions,
'outdatedsites' => $outdatedSites,
];
}
}
Service d'analytics pour réseau Multisite
/
class NetworkAnalytics {
private wpdb $wpdb;
public function _construct(wpdb $wpdb) {
$this->wpdb = $wpdb;
}
/
Statistiques globales du réseau
/
public function getNetworkStats(): array {
$sites = getsites(['number' => 10000]);
$stats = [
'totalsites' => count($sites),
'publicsites' => 0,
'archivedsites' => 0,
'deletedsites' => 0,
'spamsites' => 0,
'totalposts' => 0,
'totalpages' => 0,
'totalusers' => 0,
'totalcomments' => 0,
'storageusage' => 0,
];
foreach ($sites as $site) {
if ($site->public) $stats['publicsites']++;
if ($site->archived) $stats['archivedsites']++;
if ($site->deleted) $stats['deletedsites']++;
if ($site->spam) $stats['spamsites']++;
switchtoblog($site->blogid);
$stats['totalposts'] += wpcountposts('post')->publish;
$stats['totalpages'] += wpcountposts('page')->publish;
$stats['totalcomments'] += wpcountcomments()->approved;
// Calcul du stockage
$uploadDir = wpuploaddir();
if (fileexists($uploadDir['basedir'])) {
$stats['storageusage'] += $this->getDirectorySize($uploadDir['basedir']);
}
restorecurrentblog();
}
// Utilisateurs uniques
$stats['totalusers'] = count(getusers(['fields' => 'ID']));
// Convertir le stockage en MB
$stats['storageusage'] = round($stats['storageusage'] / 1024 / 1024, 2);
return $stats;
}
/
Top sites par activité
/
public function getTopSitesByActivity(int $limit = 10): array {
$sites = getsites(['number' => 10000]);
$activity = [];
foreach ($sites as $site) {
switchtoblog($site->blogid);
// Calculer un score d'activité
$posts = wpcountposts('post')->publish;
$comments = wpcountcomments()->approved;
$recentPosts = $this->getRecentPostsCount($site->blogid, 30);
$score = ($posts 1) + ($comments 0.5) + ($recentPosts 5);
$activity[] = [
'siteid' => $site->blogid,
'sitename' => getbloginfo('name'),
'siteurl' => getsiteurl(),
'posts' => $posts,
'comments' => $comments,
'recentposts' => $recentPosts,
'activityscore' => $score,
];
restorecurrentblog();
}
// Trier par score
usort($activity, fn($a, $b) => $b['activityscore'] <=> $a['activityscore']);
return arrayslice($activity, 0, $limit);
}
/
Sites inactifs
/
public function getInactiveSites(int $days = 90): array {
$sites = getsites(['number' => 10000]);
$inactive = [];
$threshold = strtotime("-{$days} days");
foreach ($sites as $site) {
$prefix = $this->wpdb->getblogprefix($site->blogid);
$lastPost = $this->wpdb->getvar(
"SELECT postdate FROM {$prefix}posts
WHERE posttype = 'post'
AND poststatus = 'publish'
ORDER BY postdate DESC
LIMIT 1"
);
if (!$lastPost || strtotime($lastPost) < $threshold) {
$inactive[] = [
'siteid' => $site->blogid,
'sitename' => getblogoption($site->blogid, 'blogname'),
'siteurl' => getsiteurl($site->blogid),
'lastpostdate' => $lastPost,
'daysinactive' => $lastPost ?
floor((time() - strtotime($lastPost)) / 86400) :
null,
];
}
}
return $inactive;
}
/
Rapport d'utilisation du stockage
/
public function getStorageReport(): array {
$sites = getsites(['number' => 10000]);
$report = [];
foreach ($sites as $site) {
switchtoblog($site->blogid);
$uploadDir = wpuploaddir();
$size = 0;
if (fileexists($uploadDir['basedir'])) {
$size = $this->getDirectorySize($uploadDir['basedir']);
}
$report[] = [
'siteid' => $site->blogid,
'sitename' => getbloginfo('name'),
'storagemb' => round($size / 1024 / 1024, 2),
];
restorecurrentblog();
}
// Trier par utilisation
usort($report, fn($a, $b) => $b['storagemb'] <=> $a['storagemb']);
return $report;
}
private function getRecentPostsCount(int $siteId, int $days): int {
$prefix = $this->wpdb->getblogprefix($siteId);
return (int) $this->wpdb->getvar($this->wpdb->prepare(
"SELECT COUNT() FROM {$prefix}posts
WHERE posttype = 'post'
AND poststatus = 'publish'
AND postdate > DATESUB(NOW(), INTERVAL %d DAY)",
$days
));
}
private function getDirectorySize(string $path): int {
$size = 0;
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($files as $file) {
if ($file->isFile()) {
$size += $file->getSize();
}
}
return $size;
}
}
WordPress Multisite en 2025 offre une architecture robuste pour gérer des réseaux de sites à grande échelle. Les pratiques essentielles incluent :
Cette approche permet de gérer efficacement des centaines ou milliers de sites avec des performances optimales et une maintenance simplifiée.
Mots-clés : WordPress Multisite, network management, scalability WordPress, multi-tenant architecture, WordPress enterprise, site provisioning, cross-site queries, Multisite performance, network analytics, WordPress SaaS
Meta Description : Guide complet d’architecture WordPress Multisite pour applications d’entreprise. Gestion à grande échelle, performance, cache distribué et code production-ready pour réseaux de milliers de sites.
Cet article est vivant — corrections, contre-arguments et retours de production sont les bienvenus. Trois canaux, choisissez celui qui vous convient.