Avance 2 min de lecture · 306 mots

WordPress Multisite : Architecture et Gestion à Grande Échelle

Estimated reading time: 2 minutes

Introduction : Multisite pour les Applications d’Entreprise

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.

Architecture Fondamentale de Multisite

Structure de Base de Données

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


  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; }
}

Scalabilité et Performance

Cache Distribué pour Multisite


  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());
    }
}

Optimisation des Requêtes Cross-Site


  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";
}

Gestion Centralisée des Plugins et Thèmes


  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,
        ];
    }
}

Monitoring et Analytics Multisite


  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;
    }
}

Conclusion

WordPress Multisite en 2025 offre une architecture robuste pour gérer des réseaux de sites à grande échelle. Les pratiques essentielles incluent :

Une remarque, un retour ?

Cet article est vivant — corrections, contre-arguments et retours de production sont les bienvenus. Trois canaux, choisissez celui qui vous convient.