Skip to content
EVE Milano Consulenza SEO

EveMilano Logo White EveMilano Logo White

I mu-plugin (must-use plugins) sono file PHP che WordPress include a ogni richiesta, prima di qualsiasi plugin e del tema, senza passare dall’attivazione. Detto così sembrano una variante comoda dei plugin. In realtà seguono regole di caricamento proprie, e alcune hanno conseguenze pratiche che la documentazione non racconta: l’ordine reale non è quello che mostra il pannello, la recovery mode non li protegge, il sistema delle dipendenze fra plugin non li vede.

Questa guida parte dal sorgente di WordPress 7.1 e da test eseguiti il 17 settembre 2026 su un’installazione WordPress 7.1 con PHP 8.2 creata con WordPress Playground. Ogni snippet pubblicato qui è stato eseguito prima della pubblicazione, e dove un test ha smentito un’aspettativa lo trovi scritto.

Cosa sono i mu-plugin e cosa non sono

Un mu-plugin è un file .php che si trova direttamente dentro wp-content/mu-plugins/. WordPress lo carica per il solo fatto che è lì: niente pulsante “Attiva”, niente “Disattiva”, niente aggiornamenti da wordpress.org. Per spegnerlo si rinomina o si cancella il file.

La cartella non esiste di default: va creata. Il percorso è definito da tre costanti in wp-includes/default-constants.php, tutte ridefinibili in wp-config.php:

  • WPMU_PLUGIN_DIR: percorso assoluto, di default WP_CONTENT_DIR . '/mu-plugins'
  • WPMU_PLUGIN_URL: URL corrispondente, usato per gli asset
  • MUPLUGINDIR: percorso relativo ad ABSPATH, mantenuto solo per retrocompatibilità

Il prefisso WPMU viene da WordPress MU, dove la cartella esisteva da anni. Secondo il manuale ufficiale il codice che gestisce mu-plugins/ è entrato nel core con il changeset 10737 del marzo 2009, dieci mesi prima della fusione del codice MU: da allora i mu-plugin funzionano su qualsiasi installazione, multisite o no. Lo stesso manuale chiama “Must-Use” un backronym, un acronimo ricostruito a posteriori.

Il confronto con le alternative serve a capire quando ha senso usarli. Nella tabella Tema sta per il codice nel functions.php del tema, Drop-in per i file con nome fisso descritti subito dopo:

PluginMu-pluginDrop-inTema
Cartellaplugins/mu-plugins/wp-content/tema attivo
Attivazionemanualeautomaticaautomatica o con costantecon il tema
Si spegne dal pannellononocambiando tema
Aggiorna­mentinonocon il tema
Hook di attivazionemainono
Sotto­cartelleignorateno
Pausa in recovery modenono
Resta al cambio temano

I drop-in sono la cosa con cui i mu-plugin vengono confusi più spesso. Non sono plugin: sono file con un nome preciso che sostituiscono un componente del core. In WordPress 7.1 l’elenco sta in _get_dropins(): advanced-cache.php, db.php, db-error.php, install.php, maintenance.php, object-cache.php, php-error.php, fatal-error-handler.php, più sunrise.php, blog-deleted.php, blog-inactive.php e blog-suspended.php solo su multisite. Su questo sito, per esempio, wp-content/mu-plugins/ non esiste mentre wp-content/object-cache.php sì: è il drop-in della cache a oggetti con Memcached, non un mu-plugin.

Come WordPress 7.1 carica i mu-plugin

Tutto parte da wp_get_mu_plugins(), in wp-includes/load.php. Questo è il codice di WordPress 7.1, senza modifiche:

function wp_get_mu_plugins() {
	$mu_plugins = array();

	if ( ! is_dir( WPMU_PLUGIN_DIR ) ) {
		return $mu_plugins;
	}

	$dh = opendir( WPMU_PLUGIN_DIR );
	if ( ! $dh ) {
		return $mu_plugins;
	}

	while ( ( $plugin = readdir( $dh ) ) !== false ) {
		if ( str_ends_with( $plugin, '.php' ) ) {
			$mu_plugins[] = WPMU_PLUGIN_DIR . '/' . $plugin;
		}
	}

	closedir( $dh );
	sort( $mu_plugins );
	return $mu_plugins;
}

Da queste venti righe discendono quasi tutte le regole pratiche:

  • Solo il primo livello. readdir() non scende nelle sottocartelle: mu-plugins/mio-plugin/mio-plugin.php non viene mai incluso.
  • Qualsiasi nome che finisce in .php. Compresi index.php e i file che iniziano con un punto, come .cache.php, che ls senza -a non mostra. Nel test entrambi sono stati caricati. Il caso reale più banale è documentato nel ticket Trac #46982: un Mac che modifica un file via SMB crea il file di metadati ._my-mu-plugin.php, che WordPress include come se fosse un plugin. Il ticket è ancora aperto.
  • Nessun altro criterio. Non serve un header, non c’è una lista di file autorizzati: basta che il file sia leggibile ed esista nella cartella.
  • Ordine deciso da sort(). Il confronto è fra stringhe, byte per byte: se ne parla più avanti.

Il ciclo che li include sta in wp-settings.php:

// Load must-use plugins.
foreach ( wp_get_mu_plugins() as $mu_plugin ) {
	$_wp_plugin_file = $mu_plugin;
	include_once $mu_plugin;
	$mu_plugin = $_wp_plugin_file; // Avoid stomping of the $mu_plugin variable in a plugin.

	/**
	 * Fires once a single must-use plugin has loaded.
	 * @since 5.1.0
	 * @param string $mu_plugin Full path to the plugin's main file.
	 */
	do_action( 'mu_plugin_loaded', $mu_plugin );
}
unset( $mu_plugin, $_wp_plugin_file );

Due dettagli meritano attenzione. Il commento Avoid stomping of the $mu_plugin variable in a plugin dice che i file vengono inclusi nello stesso scope del ciclo: una variabile dichiarata al primo livello di un mu-plugin finisce lì e può sovrascriverne un’altra, tanto che il core si protegge salvando il percorso in $_wp_plugin_file. E l’azione mu_plugin_loaded scatta una volta per file, mentre muplugins_loaded (disponibile dalla 2.8.0) scatta solo dopo che sono stati caricati anche i plugin attivati a livello di rete sul multisite.

L’ordine è byte per byte, e il pannello mostra un altro ordine

sort() su stringhe confronta i codici dei caratteri. Le cifre (0 = 0x30) vengono prima delle maiuscole (B = 0x42), che vengono prima dell’underscore (_ = 0x5F), che viene prima delle minuscole (a = 0x61). Nel test ho messo in mu-plugins/ i file 0-loader.php, B-upper.php, _underscore.php, a-lower.php, index.php e my-mu-helper.php: WordPress li ha inclusi esattamente in quest’ordine.

La schermata Plugin › Must-Use invece usa get_mu_plugins() di wp-admin/includes/plugin.php, che rilegge la cartella e ordina per il campo Name dell’header. Lo stesso gruppo di file compare come Alpha, Beta, Mu Underscore, My MU Helper, Zeta Loader: il file che gira per primo appare per ultimo. In più index.php sparisce dall’elenco se pesa 30 byte o meno (è il caso classico del Silence is golden), ma viene incluso lo stesso a ogni richiesta.

Conseguenze operative:

  1. Se un mu-plugin deve girare prima degli altri, lo si dichiara nel nome del file con un prefisso numerico (0-loader.php), non nel nome dell’header.
  2. Maiuscole e minuscole contano: Z.php gira prima di a.php.
  3. Per sapere cosa gira e in che ordine si guarda il filesystem, non il pannello.

Il manuale ufficiale parla di caricamento “in alphabetical order”, ma quello che fa il codice è un ordinamento byte per byte, e il core non lo tratta come un contratto. Nel ticket #17127, chiuso come wontfix, un core developer lo ha scritto senza giri di parole: “The only guarantee you have is that must-use plugins will be loaded before normal plugins.”

Per questo l’ordine conta meno di quanto sembri. Conta per il codice eseguito al caricamento del file (costanti, require, filtri che devono esistere prima che un altro mu-plugin li usi). Per tutto ciò che è agganciato a un hook conta la priorità dell’hook, non il file.

Cosa non esiste ancora quando il file viene incluso

I mu-plugin arrivano presto: dopo i drop-in di cache e database, prima dei plugin, di pluggable.php e del tema. Nel test ogni file ha registrato cosa trovava al momento dell’inclusione: function_exists( 'wp_get_current_user' ) restituisce false e after_setup_theme non è ancora partito.

L’errore tipico è chiamare al primo livello del file una funzione che dipende dall’utente:

<?php
// SBAGLIATO: logica eseguita al caricamento del file.
if ( current_user_can( 'manage_options' ) ) {
	add_filter( 'xmlrpc_enabled', '__return_false' );
}

Su WordPress 7.1 il risultato è un errore fatale su tutto il sito:

Fatal error: Uncaught Error: Call to undefined function wp_get_current_user() in /wordpress/wp-includes/capabilities.php:914
Stack trace:
#0 /wordpress/wp-content/mu-plugins/wrong.php(3): current_user_can('manage_options')
#1 /wordpress/wp-settings.php(508): include_once('/wordpress/wp-c...')

La regola è semplice: al caricamento del file si registrano hook e filtri, la logica gira dentro gli hook. Lo schema corretto è nella sezione sulla struttura di un mu-plugin.

Un dettaglio su cui la documentazione si contraddice: l’Action Reference descrive plugins_loaded come “before pluggable functions are loaded”, mentre la pagina di reference dello stesso hook dice che le funzioni pluggable sono già disponibili. Ha ragione la seconda: in wp-settings.php 7.1 pluggable.php viene richiesto alla riga 612 e plugins_loaded parte alla riga 630. Le funzioni esistono, ma è un’altra cosa rispetto all’utente autenticato: WordPress lo imposta alla riga 766 con $GLOBALS['wp']->init(), che chiama wp_get_current_user(), subito prima di init. Per la logica che dipende dall’utente l’hook giusto resta init.

Limiti e comportamenti che la documentazione non racconta

Gli hook di attivazione non partono mai

Un mu-plugin non viene mai “attivato”, quindi register_activation_hook() e register_deactivation_hook() non fanno nulla. Nel test un mu-plugin con un activation hook che scrive un’opzione non l’ha mai scritta. Anche is_plugin_active() restituisce false, e il docblock di WordPress 7.1 lo dice esplicitamente: “Plugins in the mu-plugins/ folder can’t be “activated,” so this function will return false for those plugins.”

La conseguenza è concreta: un plugin di terze parti copiato in mu-plugins/ perde la sua routine di installazione. Tabelle custom, cron, opzioni di default, flush delle rewrite rules: tutto ciò che il plugin fa all’attivazione non avviene.

Nessun aggiornamento, nessun avviso

Un mu-plugin non ha un canale di aggiornamento. Se è codice tuo, è versionato con il resto del progetto. Se è un plugin pubblico spostato lì per “impedire che lo disattivino”, smette di ricevere correzioni di sicurezza nel momento stesso in cui lo sposti. Nessuna schermata ti avviserà.

La recovery mode non li mette in pausa

Dalla 5.2 WordPress intercetta gli errori fatali e, se l’errore viene da un plugin o da un tema, può metterlo in pausa e inviare all’amministratore un link per entrare in recovery mode. L’attribuzione dell’errore sta in WP_Recovery_Mode::get_extension_for_error(), che in WordPress 7.1 confronta il file dell’errore solo con WP_PLUGIN_DIR e con le cartelle dei temi. WPMU_PLUGIN_DIR non c’è.

L’ho verificato in due modi. Chiamando direttamente wp_recovery_mode()->handle_error() con un file in mu-plugins/, la risposta è invalid_source, “Error not caused by a plugin or theme.”; con un file in plugins/ l’attribuzione va a buon fine. Poi con richieste HTTP reali, provocando lo stesso errore fatale nei due posti:

Errore fatale inURLRisposta
plugin normale/wp-login.php500, “There has been a critical error on this website. Please check your site admin email inbox for instructions.”
mu-plugin/wp-login.php500, “There has been a critical error on this website.”
mu-plugin/wp-admin/500, stesso messaggio generico
mu-plugin/500, stesso messaggio generico

Non è una svista. Il post che nel 2019 ha presentato la funzione, The Improved Fatal Error Protection, lo dichiara: la pausa è pensata per le estensioni che l’utente gestisce dal pannello, “It therefore excludes support for drop-ins and must-use plugins.” Un mu-plugin rotto spegne quindi front-end, login e backend, senza email e senza modalità di recupero. L’unica via d’uscita è il filesystem: rinominare il file via SFTP o SSH e leggere l’errore nel debug log di WordPress. Per questo un mu-plugin va testato in staging come e più di un plugin.

Plugin Dependencies non li vede

Dalla 6.5 un plugin può dichiarare le sue dipendenze nell’header Requires Plugins:. La classe WP_Plugin_Dependencies costruisce l’elenco dei plugin disponibili con get_plugins(), che legge solo wp-content/plugins/. Un mu-plugin, per questo sistema, non esiste.

Nel test ho creato mu-plugins/my-mu-helper.php e un plugin normale con Requires Plugins: my-mu-helper. WP_Plugin_Dependencies::has_unmet_dependencies() restituisce true e activate_plugin() fallisce con plugin_missing_dependencies, anche se il codice richiesto è caricato a ogni richiesta.

È un limite noto. La dev note della 6.5 lo mette per iscritto (“Must-Use plugins as dependencies are not officially supported by WordPress Core at this time”) e il ticket #60692, aperto nel marzo 2024 per permettere a un mu-plugin di soddisfare una dipendenza, è ancora in Future Release. Dalla discussione del ticket precedente, #60504, emerge anche l’asimmetria: un plugin dipendente già attivo al momento dell’aggiornamento resta attivo, uno inattivo non si può più attivare. Se un plugin dipende da codice che vive in un mu-plugin, la dipendenza va gestita nel codice (per esempio con function_exists() o did_action( 'muplugins_loaded' )), non nell’header.

Nessun filtro per escluderli

I plugin normali e quelli di rete passano da un’opzione del database, quindi si possono filtrare: è il meccanismo usato nel caso d’uso sul caricamento selettivo, più avanti. I mu-plugin no, perché wp_get_mu_plugins() legge il filesystem e non espone filtri. Il ticket #64665, aperto a febbraio 2026 per aggiungerne uno, è in attesa di revisione. Nella discussione un core committer ricorda l’unica alternativa attuale, definita nello stesso commento hacky: su multisite, ridefinire WPMU_PLUGIN_DIR verso una directory inesistente dentro sunrise.php. È coerente con il commento che si legge in wp-settings.php prima del caricamento: “Define must-use plugin directory constants, which may be overridden in the sunrise.php drop-in.”

Traduzioni e URL degli asset

Per i plugin normali wp-settings.php registra da solo il percorso delle traduzioni leggendo TextDomain e DomainPath dall’header. Per i mu-plugin no: il percorso va dichiarato con load_muplugin_textdomain( $domain, $mu_plugin_rel_path ), che punta a una sottocartella di WPMU_PLUGIN_DIR.

Gli URL degli asset invece funzionano come ci si aspetta: plugins_url() riconosce i percorsi dentro WPMU_PLUGIN_DIR e usa WPMU_PLUGIN_URL. Nel test plugins_url( 'assets/app.js', __FILE__ ) da un mu-plugin ha restituito un URL che termina in /wp-content/mu-plugins/assets/app.js, e plugin_dir_url() da un file in sottocartella uno che termina correttamente in /wp-content/mu-plugins/sub/.

Struttura consigliata di un mu-plugin

Un mu-plugin ben scritto ha tre caratteristiche: un header leggibile nel pannello, un blocco contro l’accesso diretto, nessuna logica al caricamento del file.

<?php
/**
 * Plugin Name: Site Core
 * Description: Funzionalità del sito indipendenti dal tema.
 * Version:     1.0.0
 * Author:      Giovanni Sacheli
 */

defined( 'ABSPATH' ) || exit;

// I filtri si registrano subito: WordPress li eseguirà più avanti.
add_filter( 'xmlrpc_enabled', '__return_false' );

// Ciò che dipende dall'utente va dentro un hook: quando il file viene
// incluso, wp_get_current_user() non esiste ancora.
add_action( 'init', function () {
	if ( ! current_user_can( 'edit_posts' ) ) {
		add_filter( 'show_admin_bar', '__return_false' );
	}
} );

Verificato su WordPress 7.1: xmlrpc_enabled restituisce false, un amministratore vede la barra di amministrazione (is_admin_bar_showing() = true), un utente con ruolo subscriber no.

L’header non è obbligatorio per il caricamento, ma senza Plugin Name la schermata Must-Use mostra il nome del file e nient’altro. Su un sito che gestiscono più persone, Description è la documentazione minima che qualcuno leggerà.

Un loader per organizzare i mu-plugin in sottocartelle

Quando i mu-plugin diventano più di due o tre, o hanno bisogno di asset e classi, la soluzione standard è un file loader al primo livello che include i moduli dalle sottocartelle:

<?php
/**
 * Plugin Name: MU Loader
 * Description: Carica i moduli mu-plugin organizzati in sottocartelle.
 * Version:     1.0.0
 */

defined( 'ABSPATH' ) || exit;

// Elenco esplicito, non glob(): è il codice a decidere cosa gira, non il filesystem.
$mu_loader_modules = array(
	'seo-guard/seo-guard.php',
	'selective-loading/selective-loading.php',
);

foreach ( $mu_loader_modules as $mu_loader_module ) {
	$mu_loader_file = WPMU_PLUGIN_DIR . '/' . $mu_loader_module;

	if ( is_readable( $mu_loader_file ) ) {
		require_once $mu_loader_file;
	} else {
		// Un modulo mancante non deve sparire in silenzio.
		error_log( 'MU Loader: modulo non trovato: ' . $mu_loader_file );
	}
}

// I mu-plugin vengono inclusi nello scope globale: niente variabili lasciate in giro.
unset( $mu_loader_modules, $mu_loader_module, $mu_loader_file );

Tre scelte non ovvie:

  • Nome 0-loader.php. Il prefisso numerico lo fa girare prima degli altri mu-plugin di primo livello, per l’ordinamento byte per byte visto sopra.
  • Lista esplicita invece di glob(). Con un glob( WPMU_PLUGIN_DIR . '/*/*.php' ) qualsiasi file caricato in una sottocartella verrebbe eseguito. È esattamente il comportamento che rende la cartella appetibile a chi compromette un sito.
  • error_log() sul modulo mancante. Se un deploy dimentica la cartella di un modulo di sicurezza, il sito continua a funzionare e nessuno se ne accorge. Il log almeno lo registra.

I moduli caricati così non compaiono nella schermata Must-Use (compare solo il loader) e non generano mu_plugin_loaded, che il core emette solo per i file di primo livello. Elencarli nella Description del loader è una buona abitudine.

Il manuale ufficiale propone lo stesso schema nella forma più essenziale, un require WPMU_PLUGIN_DIR.'/my-plugin/my-plugin.php';. Due implementazioni diffuse fanno scelte opposte:

  • WordPress VIP non dà accesso a mu-plugins/ e usa una cartella client-mu-plugins/ in cui i moduli in sottocartella vanno caricati “programmatically with a plugin-loader.php file”. La documentazione VIP aggiunge che i plugin di terze parti devono restare in plugins/, “as many of these plugins utilize the activation and deactivation hooks, neither of which are executed for MU plugins.”
  • Roots Bedrock include un autoloader che fa l’opposto: cerca con glob() tutti i plugin nelle sottocartelle e, per rimediare agli hook di attivazione mancanti, ne simula l’esecuzione lanciando do_action( 'activate_' . $plugin_file ) su init. È comodo con Composer (i pacchetti di tipo wordpress-muplugin finiscono lì), ma ha un effetto collaterale segnalato su GitHub: wp plugin list non elenca i mu-plugin caricati dalle sottocartelle, perché legge la cartella come fa il core. Anche la documentazione è ambigua: la pagina dice che i plugin vengono inclusi dopo mu-plugin e plugin standard, l’header del file dice “during mu-plugin loading”.

La lista esplicita è la scelta più rigida delle tre, e per un sito singolo gestito a mano resta quella che consiglio: nessun file entra in esecuzione senza che qualcuno lo abbia scritto nel loader.

Tre casi d’uso che giustificano un mu-plugin

Un mu-plugin ha senso quando il codice deve girare sempre, indipendentemente dal tema e dalle scelte di chi amministra il pannello, e quando deve girare prima dei plugin. I tre casi che seguono rispondono a entrambe le condizioni. Il codice è stato eseguito su WordPress 7.1, i primi due tramite il loader della sezione precedente.

Guardia SEO tra staging e produzione

Il flag Scoraggia i motori di ricerca dall’effettuare l’indicizzazione di questo sito è salvato nell’opzione blog_public, cioè nel database. Chi fa migrazioni WordPress conosce i due incidenti speculari: il database di staging copiato in produzione con il flag attivo, che mette in noindex il sito vero, e lo staging clonato dalla produzione e lasciato indicizzabile. Un mu-plugin sposta la decisione dal database al codice:

<?php
/**
 * Plugin Name: SEO Environment Guard
 * Description: Fuori produzione forza il noindex; in produzione impedisce che "Scoraggia i motori di ricerca" resti attivo.
 * Version:     1.0.0
 */

defined( 'ABSPATH' ) || exit;

// L'unico host su cui il sito può essere indicizzabile.
const SEO_GUARD_PRODUCTION_HOST = 'www.example.com';

/**
 * Produzione solo se lo confermano sia l'ambiente sia l'host.
 * wp_get_environment_type() restituisce 'production' anche quando
 * WP_ENVIRONMENT_TYPE non è definita: da sola non protegge uno staging.
 */
function seo_guard_is_production(): bool {
	return 'production' === wp_get_environment_type()
		&& SEO_GUARD_PRODUCTION_HOST === wp_parse_url( home_url(), PHP_URL_HOST );
}

// Sovrascrive il valore salvato in "Impostazioni > Lettura", qualunque esso sia.
add_filter( 'pre_option_blog_public', function () {
	return seo_guard_is_production() ? '1' : '0';
} );

// Il meta robots copre solo l'HTML: l'header vale anche per feed e risposte non HTML.
add_action( 'send_headers', function () {
	if ( ! seo_guard_is_production() ) {
		header( 'X-Robots-Tag: noindex, nofollow', true );
	}
} );

Il doppio controllo non è ridondante. In WordPress 7.1 wp_get_environment_type() parte da 'production' e cambia valore solo se trova la variabile d’ambiente o la costante WP_ENVIRONMENT_TYPE: uno staging in cui nessuno ha definito la costante risulta produzione a tutti gli effetti. Legare la produzione anche all’host chiude quel buco.

Il test, con blog_public impostato a '0' nel database per simulare un DB migrato da staging. Nella tabella www sta per www.example.com e staging per staging.example.com; la colonna blog_public riporta il valore restituito da get_option(), e in produzione il meta robots è max-image-preview:large, senza noindex:

ScenarioAmbienteHostblog_publicMeta robots
Produzioneproductionwww'1'indicizzabile
Staging configuratostagingwww'0'noindex, nofollow
Staging senza costanteproduction (default)staging'0'noindex, nofollow

Con richieste HTTP reali su un host non di produzione, home e feed rispondono con X-Robots-Tag: noindex, nofollow. Tre limiti da conoscere:

  • La REST API non passa da send_headers. rest_api_loaded() serve la risposta durante parse_request, prima di quell’hook. Le risposte REST hanno comunque X-Robots-Tag: noindex, ma lo aggiunge il core in WP_REST_Server::serve_request(), non il mu-plugin.
  • I file statici non passano da PHP. Immagini e PDF in uploads/ serviti direttamente da nginx o Apache non ricevono l’header: vanno coperti nella configurazione del web server.
  • Non sostituisce l’autenticazione. Il modo corretto di impedire l’accesso a Google a un sito in sviluppo resta l’autenticazione HTTP. Questo mu-plugin è la rete di sicurezza per quando qualcuno la toglie.

Da ricordare anche che il robots.txt virtuale di WordPress in 7.1 non aggiunge più Disallow: / quando il sito non è pubblico: do_robots() passa il valore di blog_public al filtro robots_txt e basta. La protezione passa da meta robots e X-Robots-Tag, non dal robots.txt.

Caricare un plugin solo sulle URL che lo usano

Un mu-plugin gira prima che WordPress legga l’elenco dei plugin attivi, quindi può filtrarlo con option_active_plugins. La tecnica è descritta in una guida di Kinsta ed è quella di Plugin Organizer, che per attivare il caricamento selettivo chiede di spostare un proprio file in mu-plugins/. Un vincolo va tenuto presente: a quel punto la query principale non è ancora stata eseguita, quindi i tag condizionali come is_page() restituiscono sempre false e l’unica informazione affidabile è l’URL della richiesta. L’esempio tiene Contact Form 7 attivo solo dove serve:

<?php
/**
 * Plugin Name: Selective Plugin Loading
 * Description: Non carica Contact Form 7 sulle URL front-end che non contengono un form.
 * Version:     1.0.0
 */

defined( 'ABSPATH' ) || exit;

add_filter( 'option_active_plugins', function ( $plugins ) {
	// Backend, AJAX, cron e WP-CLI vedono sempre la lista completa.
	if ( is_admin() || wp_doing_cron() || ( defined( 'WP_CLI' ) && WP_CLI ) ) {
		return $plugins;
	}

	$path = (string) wp_parse_url( $_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH );

	// CF7 invia i form via REST API: escludere la REST romperebbe l'invio.
	$is_rest = str_starts_with( $path, '/' . rest_get_url_prefix() . '/' ) || isset( $_GET['rest_route'] );

	if ( $is_rest || '/contatti/' === $path ) {
		return $plugins;
	}

	return array_values( array_diff( $plugins, array( 'contact-form-7/wp-contact-form-7.php' ) ) );
} );

Il test con Contact Form 7 installato da wordpress.org, controllando a ogni richiesta se la costante WPCF7_VERSION era definita:

RichiestaContact Form 7 caricato
/no
/feed/no
/contatti/
/wp-json/contact-form-7/v1sì, e la REST risponde con le sue route
/?rest_route=/contact-form-7/v1

Funziona, ma il test ha fatto emergere anche il suo effetto collaterale. Per preparare l’ambiente ho usato lo step activatePlugin della CLI di Playground, che dopo l’attivazione verifica il risultato con una richiesta front-end. La CLI ha risposto “Plugin /wordpress/wp-content/plugins/contact-form-7 could not be activated.”: su quella richiesta il filtro nascondeva il plugin, quindi per la verifica il plugin risultava spento. Qualsiasi codice che legge active_plugins o chiama is_plugin_active() fuori dal backend vede la lista filtrata. Due regole ne discendono:

  1. Mai filtrare in backend, cron e WP-CLI, come fa l’esempio. Se durante una richiesta filtrata qualcosa riscrivesse l’opzione active_plugins, salverebbe la lista ridotta e il plugin risulterebbe disattivato per davvero.
  2. Verificare le dipendenze reali del plugin, non solo le pagine in cui compare. Nel caso di CF7 (verificato sul sorgente della 6.1.7) l’invio è una POST sulla route REST contact-form-7/v1/contact-forms/<id>/feedback: senza l’eccezione sulla REST API, il form si vedrebbe su /contatti/ ma fallirebbe all’invio.

Esporre in REST campi che il tema tiene nascosti

Il terzo caso viene da un lavoro reale. Su un sito cliente costruito su Genesis, senza plugin SEO, title e description venivano dalla SEO integrata del framework. Genesis registra in REST solo i campi di layout: una scrittura via API su _genesis_title rispondeva 200 e non salvava nulla, perché le chiavi che iniziano con underscore sono meta protetti e la REST li scarta se nessun auth_callback li autorizza. Il file che ha risolto il problema, ridotto all’essenziale:

<?php
/**
 * Plugin Name: Genesis SEO meta in REST
 * Description: Espone in REST title e description SEO di Genesis a chi può già modificare il contenuto.
 * Version:     1.0.0
 */

defined( 'ABSPATH' ) || exit;

add_action( 'init', function () {
	// Se il sito smette di usare Genesis, il file diventa inerte.
	if ( 'genesis' !== get_template() ) {
		return;
	}

	$post_types = get_post_types( array( 'public' => true, 'show_in_rest' => true ) );

	foreach ( $post_types as $post_type ) {
		foreach ( array( '_genesis_title', '_genesis_description' ) as $key ) {
			register_post_meta( $post_type, $key, array(
				'single'            => true,
				'type'              => 'string',
				'show_in_rest'      => true,
				'sanitize_callback' => 'sanitize_text_field',
				// Chiavi con underscore = meta protetti: senza auth_callback la REST li scarta.
				'auth_callback'     => function ( $allowed, $meta_key, $object_id ) {
					return current_user_can( 'edit_post', (int) $object_id );
				},
			) );
		}
	}
}, 20 );

Rieseguito su WordPress 7.1 con una richiesta REST interna: risposta 200, _genesis_title salvato e ripulito dai tag HTML, mentre _genesis_keywords (non registrata) viene ignorata con la stessa risposta 200. Perché un mu-plugin e non il functions.php: il codice deve sopravvivere agli aggiornamenti del child theme e non deve poter essere disattivato da chi gestisce i contenuti, perché senza di lui ogni integrazione via API smette di scrivere in silenzio. Lo stesso ragionamento sta dietro alla guida su come tracciare le ChatGPT Ads su WordPress, dove pixel, consenso ed eventi vivono in file dentro mu-plugins/.

Quando un mu-plugin è la scelta sbagliata

  • Plugin di terze parti. Perdono la routine di attivazione e gli aggiornamenti di sicurezza. Se il problema è “non devono disattivarlo”, la soluzione è un ruolo utente senza la capability activate_plugins, non spostare il plugin.
  • Funzionalità che qualcuno deve poter spegnere. Un banner, una funzione stagionale, un’integrazione in prova: se l’unico modo per disattivarli è l’SFTP, il cliente dipende da te per ogni modifica.
  • Codice non testato. Un errore fatale in un mu-plugin ferma tutto il sito, backend compreso, senza recovery mode.
  • Siti che non sono tuoi, senza un accordo esplicito. Un mu-plugin non si vede finché non si apre la scheda giusta e nessuno lo aggiorna. Sui siti dei clienti installo codice solo se il cliente lo chiede, con il file versionato e documentato da una parte che resta a lui. Altrimenti è una dipendenza invisibile di cui si risponde al primo conflitto.
  • Multisite con logiche diverse per sito. Un mu-plugin gira su tutti i siti della rete: la distinzione va fatta nel codice, per esempio con get_current_blog_id().

Sicurezza: la cartella mu-plugins come nascondiglio

Le stesse proprietà che rendono utili i mu-plugin li rendono ideali per nascondere codice malevolo: girano sempre, non si disattivano, non compaiono nell’elenco principale dei plugin e nessuno li aggiorna. Non è un rischio teorico. Negli ultimi diciotto mesi la cartella compare in modo ricorrente nelle analisi dei vendor di sicurezza:

  • Marzo 2025, Sucuri. File redirect.php, index.php e custom-js-loader.php: redirect verso finti aggiornamenti, webshell e iniezione di spam. Bot e utenti privilegiati esclusi dal redirect.
  • Luglio 2025, Sucuri. File wp-index.php: loader che scarica il payload da un URL offuscato in ROT13, lo salva nell’opzione _hdra_core e crea l’amministratore nascosto officialwp.
  • Marzo 2026, Monarx. File session-manager.php e wp-user-query-filter.php: backdoor installata dal functions.php del tema, copia di riserva nel database, amministratore nascosto, spider dei motori di ricerca esclusi.
  • Aprile 2026, Patchstack. File object-cache-helper.php: compromissione della supply chain di Smart Slider 3 Pro. Il malware crea la cartella se non esiste e scrive un file dal nome che imita un componente di cache.
  • Agosto 2026, Wordfence. File class-wp-token-validate.php, class-wp-query-*.php e wp-cache-optimizer.php: login senza credenziali tramite parametro URL, amministratori nascosti dall’elenco utenti, file retrodatati a settembre 2025.

Dai casi emergono tre schemi utili per chi fa audit. I nomi imitano componenti legittimi (cache, sessioni, classi class-wp-*), quindi il nome da solo non assolve nessun file. Il malware si nasconde a chi controlla: Sucuri e Monarx documentano redirect che escludono amministratori loggati e spider dei motori di ricerca, cioè esattamente i due punti di osservazione di un SEO, pannello e Search Console. Infine la persistenza passa dal database: cancellare il file non basta se una copia del codice resta in un’opzione e un altro componente la riscrive. Una nota di metodo: un malware di aprile 2025 descritto da Wordfence viene spesso associato ai mu-plugin da fonti secondarie, ma nell’articolo originale si reinstalla tramite wp-cron.php e i mu-plugin non compaiono. Prima di citare un caso, meglio leggere la fonte.

Alle proprietà di base se ne aggiungono tre, verificate sul core 7.1:

  • La scheda Must-Use si può nascondere. Il filtro show_advanced_plugins, con tipo mustuse, toglie la scheda dalla schermata Plugin. Un file che lo usa nasconde sé stesso e tutti gli altri mu-plugin. Il filtro però non tocca Strumenti › Salute del sito › Info: la sezione dei must-use plugin viene costruita chiamando direttamente get_mu_plugins(), quindi lì l’elenco resta visibile.
  • I file nascosti vengono caricati. Un .wp-cache.php è invisibile a un ls distratto ma per wp_get_mu_plugins() è un file .php come gli altri.
  • La cartella si può spostare. Nel test, definendo WPMU_PLUGIN_DIR verso un’altra directory, WordPress ha smesso di caricare wp-content/mu-plugins/ e ha caricato i file della nuova cartella. Chi ha accesso in scrittura a wp-config.php può far girare mu-plugin da una directory che nessuno va a controllare.

Audit della cartella in cinque comandi

In un SEO audit tecnico di un sito WordPress questo controllo va fatto sempre, perché i casi descritti sopra colpiscono proprio la visibilità organica. I comandi vanno eseguiti nella root dell’installazione, prima di qualsiasi comando WP-CLI (il motivo è subito dopo). Li ho provati su una cartella di esempio con un file legittimo vecchio, un file nascosto con eval( base64_decode( ... ) ), un modulo in sottocartella che usa show_advanced_plugins, una WPMU_PLUGIN_DIR ridefinita in wp-config.php e un file retrodatato di un anno con touch -d, su un server Linux: ogni comando fa emergere il caso per cui è pensato.

# 1. Dove WordPress cerca davvero i mu-plugin: la cartella si può spostare da wp-config.php
grep -nE "WPMU_PLUGIN_(DIR|URL)|MUPLUGINDIR" wp-config.php

# 2. Contenuto completo, file nascosti inclusi (ls senza -a non li mostra)
ls -la wp-content/mu-plugins/

# 3. File PHP cambiati negli ultimi 30 giorni, sottocartelle comprese.
#    ctime e non mtime: mtime si falsifica con touch, ctime no.
find wp-content/mu-plugins/ -type f -name '*.php' -ctime -30 -printf '%CY-%Cm-%Cd %CH:%CM ctime | %TY-%Tm-%Td mtime  %p\n'

# 4. Costrutti tipici del codice offuscato
grep -rnE --include='*.php' 'eval\(|base64_decode\(|gzinflate\(|str_rot13\(|assert\(' wp-content/mu-plugins/

# 5. Codice che nasconde le schede Must-Use e Drop-in dalla schermata Plugin
grep -rn --include='*.php' 'show_advanced_plugins' wp-content/

Qualche nota di lettura:

  • Comando 1. Se restituisce qualcosa, i comandi successivi vanno rilanciati sul percorso indicato lì.
  • Comando 3. Il ctime registra l’ultimo cambio dell’inode, non la data di creazione: si aggiorna anche con un cambio di permessi o un ripristino da backup, quindi un risultato va letto, non condannato. Il punto è che touch non lo può riportare indietro. Nel test il file retrodatato a settembre 2025 sfuggiva a -mtime -30; con -ctime -30 compare, e l’output affianca le due date. Un ctime di oggi accanto a un mtime vecchio di un anno è la firma della tecnica documentata da Wordfence ad agosto 2026. -printf esiste in GNU find (Linux), non nella versione BSD di macOS.
  • Comando 4. Produce falsi positivi su codice legittimo che usa base64_decode(): serve a restringere la lettura, non a emettere un verdetto.

WP-CLI aiuta, ma dopo il filesystem. wp plugin list --status=must-use elenca i mu-plugin e --status=dropin i drop-in. Il parametro globale --skip-plugins, che molti usano per lavorare “a plugin spenti” su un sito compromesso, ha però una nota precisa nella documentazione di WP-CLI: “Note: mu-plugins are still loaded.” Un mu-plugin malevolo gira anche dentro il comando con cui lo stai cercando.

Per un quadro più ampio della messa in sicurezza rimando alla guida alla sicurezza di WordPress, e per lavorare sul server senza pannello alla guida su come fare SEO da terminale.

Gestione, deploy e hosting gestiti

Versionare i propri, riconoscere quelli dell’hosting

I mu-plugin scritti da te vanno nel repository del progetto, come il resto del codice (se serve un ripasso, c’è la guida a Git e GitHub). Il deploy di un mu-plugin è un deploy di codice che gira su ogni richiesta e senza recovery mode: stesso flusso di review e staging di un aggiornamento del tema.

Il problema pratico è che la cartella spesso non è solo tua. Gli hosting gestiti la usano per integrare la piattaforma, e il manuale ufficiale lo riconosce: “For web hosts, mu-plugins are commonly used to add support for host-specific features, especially those where their absence could break the site.” Qualche esempio documentato:

  • WP Engine installa mu-plugin.php, che carica la cartella wpengine-common/ e compare nel pannello come “WP Engine System”. La documentazione precisa: “This plugin cannot be disabled.”
  • Kinsta installa il proprio MU plugin per cache full-page e integrazione CDN, lo aggiorna periodicamente e dichiara che “cannot be removed”. Lo aggiorna però solo sui siti che non usano Composer e non gestiscono in Git l’intera cartella wp-content o mu-plugins/.
  • Pantheon usa un mu-plugin di piattaforma che, tra le altre cose, disattiva gli aggiornamenti automatici perché passino dall’upstream.
  • WordPress VIP carica da mu-plugins/ la propria integrazione con l’infrastruttura (cache, opzioni gestite) e lascia al cliente solo client-mu-plugins/.

Tre conseguenze operative:

  1. Prima di cancellare un mu-plugin sconosciuto, verifica se è dell’hosting. Rimuoverlo può rompere cache o CDN.
  2. Se versioni l’intera cartella in Git, fermi gli aggiornamenti del file dell’hosting, almeno nel caso di Kinsta. Conviene escludere quei file dal repository.
  3. In una migrazione i mu-plugin dell’hosting di partenza non vanno portati. WP Engine pubblica l’elenco dei file da rimuovere quando si sposta un sito altrove: i suoi mu-plugin, più i drop-in advanced-cache.php e object-cache.php. Sull’hosting di destinazione quei file cercano un’infrastruttura che non esiste. È un punto da aggiungere alla checklist di ogni migrazione WordPress, accanto a mappatura degli URL e redirect: le parti che un’assistenza alla migrazione del sito deve coprire e che i tool automatici degli hosting non fanno.

Multisite

Su un’installazione multisite i mu-plugin girano su tutti i siti della rete, e la scheda Must-Use compare solo nel Network Admin: in WP_Plugins_List_Table di WordPress 7.1 la condizione è ! is_multisite() || ( $screen->in_admin( 'network' ) && current_user_can( 'manage_network_plugins' ) ). L’amministratore di un singolo sito non vede i mu-plugin che girano sul suo sito.

Codice in wp-config.php

Un uso meno ovvio: spostare in un mu-plugin il codice che qualcuno ha messo in wp-config.php oltre alle costanti. Prima di require_once ABSPATH . 'wp-settings.php' funzioni come add_action() e add_filter() non esistono ancora, e la documentazione di Pantheon ricorda che il risultato è un errore fatale. Il manuale di WP-CLI dà lo stesso consiglio per un motivo in più: WP-CLI deve poter leggere wp-config.php, e il codice extra glielo impedisce.

Checklist operativa

  1. Crea wp-content/mu-plugins/ solo quando serve, e verifica che WPMU_PLUGIN_DIR non sia ridefinita altrove.
  2. Un file per responsabilità, con Plugin Name e Description compilati.
  3. Prefisso numerico nel nome del file solo se l’ordine conta davvero.
  4. Al caricamento del file solo add_action() e add_filter(), nessuna logica.
  5. Sottocartelle solo tramite un loader con elenco esplicito dei moduli.
  6. Nessun plugin di terze parti spostato in mu-plugins/.
  7. Test in staging prima del deploy: un errore fatale qui non ha recovery mode.
  8. Codice versionato in Git insieme al resto del progetto.
  9. Audit periodico della cartella con i comandi della sezione sicurezza, filesystem e non pannello.
  10. Sui siti dei clienti, nessun mu-plugin senza richiesta esplicita e documentazione consegnata.

Fonti e metodo

Sorgente: file wp-settings.php, wp-includes/load.php, wp-includes/default-constants.php, wp-admin/includes/plugin.php, wp-includes/class-wp-recovery-mode.php, wp-includes/class-wp-plugin-dependencies.php e wp-includes/link-template.php di WordPress 7.1, letti su un’installazione in produzione. Test: WordPress 7.1 con PHP 8.2 su WordPress Playground CLI, 17 settembre 2026, con fixture dedicate per ordine di caricamento, errori fatali, dipendenze, caricamento selettivo (Contact Form 7 da wordpress.org), ambienti e meta REST. Comandi di audit provati con GNU find e grep.

Documentazione e discussioni citate, tutte verificate il 17 settembre 2026:

Articoli correlati

11 min lettura

I tool automatici degli hosting migrano solo file e database, tralasciando URL mapping e redirect. Procedura tecnica manuale per trasferire un sito WordPress su un altro dominio e server gestendo il database MySQL e preservando il traffico organico post-migrazione.
94 mi piace

Autore

Lascia un commento

Il tuo indirizzo email non sarà pubblicato. I campi obbligatori sono contrassegnati *

Ultimi articoli aggiornati

31 min lettura

Come WordPress 7.1 carica i mu-plugin: ordine byte per byte, sottocartelle ignorate, recovery mode che non li mette in pausa, Plugin Dependencies che non li vede. Con snippet eseguiti su WordPress 7.1 per la guardia SEO tra staging e produzione, il caricamento selettivo dei plugin e l'audit di sicurezza della cartella.
0 mi piace
2 min lettura

Filtrare il traffico interno lato server garantisce dati analitici incontaminati su WordPress. Utilizzando costrutti PHP e conditional tags è possibile bloccare il caricamento dello script di Google Analytics per amministratori, editor e specifici ruoli utente loggati.
0 mi piace

Richiedi un preventivo SEO e Google Ads

Porta il tuo sito web al livello successivo con l’esperienza di EVE Milano. La nostra agenzia di Search Marketing ha ricevuto dal 2010 oltre 1.400 richieste di preventivo, un segnale chiaro della fiducia che webmaster, imprenditori e manager ripongono nella nostra specializzazione tecnica e verticale nella SEO e PPC. Se la tua organizzazione cerca competenze specifiche per emergere nei risultati di Google e chatbot AI, noi siamo pronti a fornire quel valore aggiunto. Richiedi un preventivo ora e scopri la differenza tra noi e gli altri.
Richiedi un preventivo

Vuoi ricevere un avviso al mese con le nuove guide pubblicate?

Iscriviti alla newsletter!