Creare un plugin multilingue per WordPress non richiede necessariamente migliaia di righe di codice. Un singolo file PHP di poco più di 500 righe può bastare per realizzare una soluzione essenziale, con contenuti separati per lingua, URL riconoscibili, collegamenti tra le traduzioni e indicazioni SEO per Google.

La parte più difficile, però, non è scrivere le funzioni PHP. È definire l'architettura prima di cominciare: come associare le traduzioni dello stesso contenuto, come costruire gli URL, come evitare che WordPress mostri articoli nella lingua sbagliata e come comunicare ai motori di ricerca quali pagine sono equivalenti.

In questa guida realizziamo un plugin multilingue con caratteristiche precise:

  • Un post per lingua, collegato alle altre versioni tramite un identificativo condiviso.
  • URL con prefisso linguistico, come /en/hello-world/, per le lingue diverse da quella predefinita.
  • Tag hreflang reciproci, generati a partire dalle traduzioni effettivamente pubblicate.
  • Selettore della lingua, che porta alla traduzione della pagina corrente quando esiste, invece di rimandare sempre alla home.
  • Creazione rapida delle traduzioni, attraverso una bozza generata dall'area amministrativa.
  • Punto d'integrazione per la traduzione automatica, senza legare il plugin a uno specifico servizio esterno.

Il codice è stato eseguito su WordPress 7.1 con PHP 8.2 e database SQLite, utilizzando WP-CLI, richieste curl e uno script di test. L'obiettivo è costruire una base funzionante e verificabile, non reinventare tutte le funzionalità di Polylang o WPML.

La distinzione è importante: un plugin personalizzato può essere una buona soluzione per un progetto con requisiti circoscritti, ma non diventa automaticamente un prodotto completo solo perché funziona nell'ambiente di prova. Vedremo sia le funzionalità implementate sia i limiti che devono essere considerati prima di utilizzarlo in produzione.

Internazionalizzazione e traduzione dei contenuti: due problemi diversi

Prima di scrivere codice è necessario distinguere due attività che in WordPress vengono spesso confuse. Tradurre le stringhe dell'interfaccia di un plugin non equivale a tradurre gli articoli pubblicati sul sito: cambiano i dati coinvolti, gli strumenti utilizzati e il modo in cui i contenuti vengono presentati ai visitatori.

Internazionalizzazione dell'interfaccia

L'internazionalizzazione, indicata comunemente con la sigla i18n, consiste nel predisporre il codice affinché le stringhe dell'interfaccia possano essere tradotte. Nel nostro caso riguarda testi come «Lingua del contenuto», «Traduzioni», «Modifica» e «Crea traduzione».

WordPress mette a disposizione funzioni come __(), esc_html__() e load_plugin_textdomain(), oltre agli strumenti per generare i cataloghi di traduzione .pot, .po e .mo.

Questi strumenti non intervengono sugli articoli salvati nel database. Servono a tradurre le stringhe che il plugin mostra nell'amministrazione o nell'interfaccia pubblica.

Traduzione dei contenuti editoriali

La traduzione degli articoli richiede invece un modello dati. WordPress deve sapere che un articolo italiano e la sua versione inglese rappresentano lo stesso contenuto, pur essendo due post distinti.

AspettoInternazionalizzazioneTraduzione dei contenuti
Cosa viene tradottoStringhe dell'interfacciaArticoli, pagine e altri contenuti
Dove sono conservati i datiCodice e cataloghi linguisticiDatabase di WordPress
Strumenti principali__(), load_plugin_textdomain()Meta del post e collegamenti tra traduzioni
DestinatariAmministratori e utenti dell'interfacciaVisitatori del sito

Le due funzionalità convivono nello stesso plugin, ma devono restare concettualmente separate. La prima rende traducibile l'interfaccia; la seconda organizza i contenuti e determina come vengono raggiunti, mostrati e indicizzati.

Architettura di un sito WordPress multilingua: le scelte iniziali

Il modello dati influenza quasi tutte le funzioni successive. Se il collegamento tra le traduzioni è progettato male, diventa più difficile generare hreflang corretti, costruire un selettore affidabile e impedire che una pagina venga mostrata nella lingua sbagliata.

Per questa implementazione scegliamo un approccio semplice: ogni lingua ha il proprio post, mentre un identificativo condiviso permette di ricostruire il gruppo delle traduzioni.

Collegare le traduzioni con i meta del post

Esistono diversi modi per associare contenuti equivalenti. Una soluzione consiste nell'utilizzare una tassonomia dedicata alla lingua, un approccio adottato anche da plugin multilingue esistenti. Un'altra possibilità è utilizzare i metadati del post.

Qui utilizziamo due meta:

  • _ml_lang: contiene il codice della lingua, per esempio it oppure en.
  • _ml_group: contiene un identificativo condiviso da tutte le traduzioni dello stesso contenuto.

Il gruppo è indipendente dalla lingua. Un articolo italiano e la sua traduzione inglese hanno valori _ml_lang diversi, ma condividono lo stesso _ml_group.

Per un progetto limitato ad articoli e pagine, questa struttura è relativamente semplice da interrogare e mantenere. Non offre però, da sola, un sistema completo per tradurre tassonomie, menu, archivi o altri tipi di contenuto.

Scegliere una struttura URL riconoscibile

La lingua può essere rappresentata in diversi modi: mediante un parametro di query, un sottodominio oppure un prefisso nel percorso.

Un URL come https://example.com/articolo/?lang=en è tecnicamente possibile, ma rende meno immediata la distinzione tra le versioni. Un sottodominio come en.example.com è un'alternativa valida, ma introduce ulteriori aspetti di configurazione, tra cui DNS e certificati.

In questa guida utilizziamo un prefisso per le lingue non predefinite:

  • Italiano: /ciao-mondo/
  • Inglese: /en/hello-world/

La lingua predefinita non ha prefisso. Questa scelta rende gli URL leggibili e permette di distinguere le versioni linguistiche direttamente dal percorso.

Mantenere un post indipendente per ogni lingua

Ogni traduzione è un post WordPress autonomo, con titolo, slug, contenuto e metadati SEO propri. Non inseriamo tutte le traduzioni in un unico campo del post originale.

Questa separazione permette di pubblicare le versioni in momenti diversi e di assegnare a ciascuna un URL specifico. È inoltre possibile modificare il contenuto tradotto senza intervenire sull'originale.

Il collegamento tra i post rimane affidato a _ml_group. Non basta, infatti, che due articoli abbiano titoli simili o slug diversi per considerarli traduzioni: il plugin deve disporre di un'associazione esplicita e verificabile.

Preparare WordPress in locale con WP-CLI e SQLite

Per sviluppare il plugin conviene partire da un ambiente isolato, nel quale sia possibile attivare e disattivare il codice, creare contenuti di prova e controllare le risposte HTTP senza intervenire su un sito in produzione.

Gli esempi seguenti utilizzano WP-CLI e SQLite. Il database non è il punto centrale della guida: serve semplicemente un'installazione WordPress funzionante sulla quale verificare il comportamento del plugin.

Installare WordPress e configurare i permalink

Scarichiamo WordPress in italiano e prepariamo la configurazione iniziale:

wp core download --locale=it_IT

wp config create \
  --dbname=wp \
  --dbuser=wp \
  --dbpass=wp \
  --skip-check

Per utilizzare SQLite occorre installare il plugin SQLite Database Integration, predisporre il relativo file db.php in wp-content e configurarlo secondo le istruzioni della versione utilizzata. I passaggi precisi possono cambiare tra le versioni del componente.

Completata la configurazione del database, installiamo WordPress e un tema di prova:

wp core install \
  --url=http://localhost:8080 \
  --title="Lab" \
  --admin_user=admin \
  --admin_password=admin \
  --admin_email=tu@example.com

wp theme install twentytwentyfive --activate
wp rewrite structure '/%postname%/'
php -S localhost:8080

L'indirizzo email e le credenziali sono valori di esempio: non vanno riutilizzati in un'installazione esposta pubblicamente.

I permalink basati sul nome del post sono necessari per gli URL mostrati nella guida. Una volta completata l'installazione, creiamo la directory wp-content/plugins/multi-language/ e al suo interno il file multi-language.php.

Intestazione del plugin e configurazione delle lingue

WordPress riconosce un plugin attraverso le informazioni dichiarate nell'intestazione del file PHP. Nello stesso punto definiamo le costanti che identificano i metadati utilizzati per lingua e gruppo.

Il controllo su ABSPATH impedisce l'esecuzione diretta del file al di fuori del contesto di WordPress.

Dichiarare il plugin e i meta

Inseriamo nel file multi-language.php il seguente codice:

<?php
/**
 * Plugin Name:       Multi-Language
 * Description:       Multilingual content with one post per language, prefixed URLs (/en/), hreflang and a language switcher.
 * Version:           2.0.0
 * Requires at least: 6.0
 * Requires PHP:      7.4
 * Author:            Luca Terribili
 * License:           GPL-2.0-or-later
 * Text Domain:       multi-language
 * Domain Path:       /languages
 */

defined( 'ABSPATH' ) || exit;

const ML_META_LANG  = '_ml_lang';
const ML_META_GROUP = '_ml_group';

Il codice è volutamente essenziale: non contiene ancora la logica delle traduzioni, ma stabilisce i nomi che verranno utilizzati in tutto il plugin.

Centralizzare la configurazione linguistica

L'elenco delle lingue viene restituito da una funzione dedicata e passa attraverso il filtro ml_languages. In questo modo è possibile modificare la configurazione senza dover riscrivere il file principale.

function ml_languages(): array {
	return apply_filters(
		'ml_languages',
		array(
			'it' => array(
				'name'   => 'Italiano',
				'locale' => 'it-IT',
			),
			'en' => array(
				'name'   => 'English',
				'locale' => 'en-US',
			),
		)
	);
}

function ml_default_language(): string {
	return (string) apply_filters( 'ml_default_language', 'it' );
}

function ml_is_valid_language( $code ): bool {
	return is_string( $code ) && isset( ml_languages()[ $code ] );
}

function ml_current_language(): string {
	global $ml_current_lang;
	return $ml_current_lang ?? ml_default_language();
}

/** I contenuti senza meta appartengono alla lingua predefinita. */
function ml_post_language( $post_id ): string {
	$lang = get_post_meta( (int) $post_id, ML_META_LANG, true );
	return ml_is_valid_language( $lang ) ? $lang : ml_default_language();
}

La funzione ml_post_language() gestisce un caso importante: i contenuti già presenti nel sito prima dell'attivazione del plugin non hanno necessariamente il meta _ml_lang.

In assenza di quel valore, il plugin considera il contenuto appartenente alla lingua predefinita. È una scelta utile per la compatibilità con gli articoli esistenti, perché evita di richiedere la modifica manuale di ogni post prima di poter utilizzare il plugin.

La funzione ml_current_language(), invece, restituisce la lingua individuata per la richiesta corrente. Se non è stato rilevato alcun prefisso, viene utilizzata la lingua predefinita.

Internazionalizzare le stringhe e registrare i metadati

Definita la configurazione, possiamo predisporre le stringhe dell'interfaccia e registrare i due metadati utilizzati per le traduzioni.

La registrazione dei meta documenta il tipo di dato e le operazioni di sanificazione previste. È importante non confondere questa operazione con il controllo dei permessi: validare un valore e stabilire chi può modificarlo sono due responsabilità differenti.

Caricare il catalogo delle traduzioni

WordPress carica il dominio linguistico del plugin attraverso load_plugin_textdomain(). Le stringhe utilizzate nel codice possono quindi essere raccolte con WP-CLI e tradotte nei cataloghi appropriati.

add_action( 'init', 'ml_load_textdomain' );

function ml_load_textdomain(): void {
	load_plugin_textdomain(
		'multi-language',
		false,
		dirname( plugin_basename( __FILE__ ) ) . '/languages'
	);
}

Registrare i meta per articoli e pagine

I metadati vengono registrati per i tipi di contenuto post e page. Il codice lingua viene controllato tramite ml_sanitize_language(), mentre l'identificativo del gruppo viene normalizzato con sanitize_key().

add_action( 'init', 'ml_register_meta' );

function ml_register_meta(): void {
	foreach ( array( 'post', 'page' ) as $post_type ) {
		register_post_meta(
			$post_type,
			ML_META_LANG,
			array(
				'type'              => 'string',
				'single'            => true,
				'sanitize_callback' => 'ml_sanitize_language',
			)
		);

		register_post_meta(
			$post_type,
			ML_META_GROUP,
			array(
				'type'              => 'string',
				'single'            => true,
				'sanitize_callback' => 'sanitize_key',
			)
		);
	}
}

function ml_sanitize_language( $value ): string {
	return ml_is_valid_language( $value ) ? $value : ml_default_language();
}

Il callback di sanificazione impedisce di conservare codici lingua non riconosciuti attraverso i percorsi di aggiornamento dei metadati che lo applicano. La funzione, tuttavia, non sostituisce i controlli di autorizzazione necessari quando si salvano i dati provenienti da un form.

I meta iniziano con un carattere di sottolineatura e non vengono esposti automaticamente tramite REST in assenza della configurazione necessaria. Se in futuro si decidesse di renderli disponibili attraverso le API, bisognerebbe definire esplicitamente le condizioni di accesso e di scrittura.

Generare i file di traduzione con WP-CLI

Per estrarre le stringhe del plugin in un catalogo di traduzione, possiamo utilizzare WP-CLI:

wp i18n make-pot . languages/multi-language.pot --slug=multi-language

# Dopo aver creato e tradotto il file .po:
wp i18n make-mo languages/

Il file .pot contiene le stringhe individuate nel codice. Da questo modello si può creare un file .po per ciascuna lingua e generare il relativo .mo.

Se il profilo amministrativo è impostato sull'italiano e i cataloghi sono configurati correttamente, la meta box potrà mostrare le stringhe tradotte anziché quelle originali in inglese.

Gestire gli URL con prefisso linguistico

Questa è una delle parti più delicate del plugin. WordPress non interpreta automaticamente /en/ come un prefisso linguistico: senza una gestione specifica, il percorso può essere interpretato come parte della normale struttura dei permalink.

La soluzione adottata consiste nell'individuare il prefisso all'inizio della richiesta, memorizzare la lingua corrente e rimuovere il segmento prima che WordPress risolva l'URL. È una tecnica che richiede verifiche attente perché il percorso della richiesta può essere rappresentato da diverse variabili del server.

Rilevare il prefisso e impostare la lingua corrente

La funzione seguente confronta il percorso richiesto con l'elenco delle lingue configurate. Quando trova un prefisso valido, lo rimuove dal percorso e memorizza la lingua in una variabile globale.

add_action( 'plugins_loaded', 'ml_detect_language', 1 );

function ml_detect_language(): void {
	global $ml_current_lang;

	$ml_current_lang = ml_default_language();

	if ( is_admin() || empty( $_SERVER['REQUEST_URI'] ) ) {
		return;
	}

	$other = array_diff(
		array_keys( ml_languages() ),
		array( ml_default_language() )
	);

	if ( ! $other ) {
		return;
	}

	$base = untrailingslashit(
		(string) wp_parse_url( get_option( 'home' ), PHP_URL_PATH )
	);

	$path = (string) wp_parse_url( $_SERVER['REQUEST_URI'], PHP_URL_PATH );
	$rest = (string) substr( $path, strlen( $base ) );

	if ( ! preg_match(
		'#^/(' . implode( '|', array_map( 'preg_quote', $other ) ) . ')(?=/|$)#',
		$rest,
		$match
	) ) {
		return;
	}

	$ml_current_lang = $match[1];

	$stripped = (string) substr( $rest, strlen( $match[0] ) );

	$query = isset( $_SERVER['QUERY_STRING'] ) && '' !== $_SERVER['QUERY_STRING']
		? '?' . $_SERVER['QUERY_STRING']
		: '';

	$_SERVER['REQUEST_URI'] =
		$base . ( '' === $stripped ? '/' : $stripped ) . $query;

	foreach ( array( 'PATH_INFO', 'ORIG_PATH_INFO', 'PHP_SELF' ) as $key ) {
		if ( isset( $_SERVER[ $key ] ) ) {
			$_SERVER[ $key ] = (string) preg_replace(
				'#/' . preg_quote( $match[1], '#' ) . '(?=/|$)#',
				'',
				$_SERVER[ $key ],
				1
			);
		}
	}
}

Il codice conserva la query string originale, così una richiesta contenente parametri non li perde durante la rimozione del prefisso. Il controllo sul segmento linguistico evita inoltre di interpretare come prefisso un testo più lungo che comincia con le stesse lettere.

Perché bisogna considerare le variabili del server

WordPress ricava il percorso da interpretare dalle informazioni disponibili nella richiesta HTTP. A seconda dell'ambiente, possono entrare in gioco REQUEST_URI, PATH_INFO e altre variabili.

Modificare soltanto REQUEST_URI può non essere sufficiente se il server valorizza PATH_INFO e WordPress utilizza quest'ultimo durante la risoluzione del percorso. Per questo l'implementazione interviene anche sulle altre variabili quando sono presenti.

Questa parte è legata al comportamento dell'ambiente di esecuzione: il fatto che funzioni con il server integrato di PHP non dimostra automaticamente che si comporti nello stesso modo con Apache o Nginx. La configurazione effettiva deve essere verificata con richieste reali sul server di destinazione.

È altrettanto importante verificare il percorso di base dell'installazione. Il codice tiene conto del percorso configurato in home, ma un'installazione in sottocartella introduce casi aggiuntivi che richiedono test specifici.

Generare i permalink con il prefisso corretto

Una volta riconosciuta la lingua della richiesta, bisogna assicurarsi che i link generati da WordPress puntino alla versione corretta del contenuto.

La regola scelta è semplice: il permalink dipende dalla lingua del post, non dalla lingua della pagina dalla quale viene generato. Un articolo inglese deve quindi avere un URL con /en/ anche quando viene richiamato da un contesto amministrativo o da una pagina italiana.

Aggiungere il prefisso agli URL

La funzione ml_url_with_lang() aggiunge il prefisso quando l'URL appartiene al sito e la lingua non è quella predefinita. I filtri successivi applicano questa regola ai permalink di articoli e pagine.

/** Aggiunge il prefisso linguistico a un URL interno del sito. */
function ml_url_with_lang( string $url, string $lang ): string {
	$home = untrailingslashit( get_option( 'home' ) );

	if (
		ml_default_language() === $lang ||
		0 !== strpos( $url, $home )
	) {
		return $url;
	}

	return $home . '/' . $lang . substr( $url, strlen( $home ) );
}

function ml_home_url( string $lang ): string {
	return ml_url_with_lang( home_url( '/' ), $lang );
}

add_filter( 'post_link', 'ml_filter_post_link', 10, 2 );

function ml_filter_post_link( string $url, WP_Post $post ): string {
	return ml_url_with_lang( $url, ml_post_language( $post->ID ) );
}

add_filter( 'page_link', 'ml_filter_page_link', 10, 2 );

function ml_filter_page_link( string $url, $post_id ): string {
	return ml_url_with_lang( $url, ml_post_language( $post_id ) );
}

Il controllo sul dominio evita di modificare URL esterni. La funzione presuppone, però, che il permalink di partenza sia compatibile con la struttura attesa e che l'URL base configurato in WordPress sia quello corretto.

In produzione è quindi necessario verificare che home e gli URL canonici utilizzino il dominio pubblico e HTTPS. Un ambiente di sviluppo può utilizzare localhost, ma quell'indirizzo non deve finire nei metadati SEO del sito online.

Gestire i redirect canonici e gli URL nella lingua sbagliata

WordPress dispone di un sistema di redirect canonici che cerca di ricondurre gli URL a una forma considerata corretta. Quando si introduce un prefisso linguistico gestito dal plugin, questo comportamento può entrare in conflitto con la logica personalizzata.

È utile separare due problemi: evitare redirect indesiderati sulle richieste con prefisso e gestire esplicitamente gli URL che appartengono a una traduzione diversa da quella richiesta.

Evitare i redirect canonici indesiderati

Per le lingue non predefinite, il plugin disattiva il redirect canonico restituito dal core:

add_filter( 'redirect_canonical', 'ml_skip_canonical_redirect' );

function ml_skip_canonical_redirect( $redirect_url ) {
	return ml_default_language() === ml_current_language()
		? $redirect_url
		: false;
}

Questa scelta impedisce al meccanismo canonico di interferire con gli URL linguistici già riconosciuti. Non costituisce, però, una strategia generale di canonicalizzazione: il comportamento va verificato insieme ai permalink generati, ai redirect del server e ai canonical emessi dal tema o dai plugin SEO.

Filtrare i contenuti della query principale

Il prefisso linguistico deve influenzare anche i risultati restituiti da WordPress. Senza un filtro, la home o gli archivi potrebbero mostrare articoli di lingue diverse.

La funzione ml_language_meta_query() costruisce la condizione da applicare. Per le lingue diverse da quella predefinita viene cercato il meta corrispondente; per la lingua predefinita vengono inclusi anche i contenuti precedenti al plugin, che potrebbero non avere ancora un meta linguistico.

/** Condizione di meta_query che limita i risultati a una lingua. */
function ml_language_meta_query( string $lang ): array {
	$by_lang = array(
		'key'   => ML_META_LANG,
		'value' => $lang,
	);

	if ( ml_default_language() !== $lang ) {
		return array( $by_lang );
	}

	return array(
		'relation' => 'OR',
		$by_lang,
		array(
			'key'     => ML_META_LANG,
			'compare' => 'NOT EXISTS',
		),
	);
}

/** Applica il filtro linguistico preservando le condizioni già presenti. */
function ml_limit_query_to_language( array $args, string $lang ): array {
	$condition = ml_language_meta_query( $lang );
	$existing  = $args['meta_query'] ?? array();

	$args['meta_query'] = $existing
		? array( 'relation' => 'AND', $existing, $condition )
		: $condition;

	return $args;
}

add_action( 'pre_get_posts', 'ml_filter_main_query' );

function ml_filter_main_query( WP_Query $query ): void {
	if ( is_admin() || ! $query->is_main_query() ) {
		return;
	}

	$args = ml_limit_query_to_language(
		array(
			'meta_query' => $query->get( 'meta_query' ) ?: array(),
		),
		ml_current_language()
	);

	$query->set( 'meta_query', $args['meta_query'] );
}

La funzione interviene sulla query principale del frontend. Può quindi limitare i risultati della home, degli archivi e delle ricerche, ma non gestisce automaticamente ogni query secondaria presente nel tema.

Il filtro linguistico, inoltre, non crea da solo un redirect quando un contenuto viene richiesto con la lingua sbagliata. Se /en/ciao-mondo/ punta a un articolo italiano, il risultato atteso è un contenuto non trovato nella lingua inglese. Per trasformare quel caso in un redirect occorre una logica aggiuntiva.

Reindirizzare esplicitamente gli URL con lingua errata

Affidarsi al meccanismo di individuazione automatica degli URL del core non garantisce un comportamento coerente in tutte le direzioni. Per rendere esplicita la regola, possiamo cercare un contenuto pubblicato con lo stesso slug e verificare che appartenga a un'altra lingua.

add_action( 'template_redirect', 'ml_redirect_wrong_language', 9 );

function ml_redirect_wrong_language(): void {
	if ( ! is_404() ) {
		return;
	}

	$slug = get_query_var( 'name' )
		?: wp_basename( (string) get_query_var( 'pagename' ) );

	if ( '' === $slug ) {
		return;
	}

	$found = get_posts(
		array(
			'name'        => $slug,
			'post_type'   => array( 'post', 'page' ),
			'post_status' => 'publish',
			'numberposts' => 1,
		)
	);

	if (
		$found &&
		ml_post_language( $found[0]->ID ) !== ml_current_language()
	) {
		wp_safe_redirect( get_permalink( $found[0] ), 301 );
		exit;
	}
}

Questo frammento mostra il principio, ma non è una soluzione universale per tutti i permalink. La ricerca basata sul solo slug può essere ambigua se esistono contenuti diversi con lo stesso nome, se vengono utilizzati permalink gerarchici o se più tipi di contenuto condividono la stessa struttura URL.

Prima di considerarlo pronto per la produzione, bisogna verificare la risoluzione di articoli e pagine, la gestione delle gerarchie e l'eventuale presenza di slug duplicati. Il codice va inoltre provato per evitare redirect ripetuti o destinazioni non coerenti con la lingua effettiva del contenuto.

Filtrare anche le query secondarie dei temi a blocchi

Il filtro pre_get_posts non copre tutte le query generate dal frontend. Un tema a blocchi può utilizzare il blocco Query Loop per mostrare elenchi di articoli aggiuntivi, indipendenti dalla query principale.

Se queste query non vengono filtrate, una pagina inglese potrebbe mostrare suggerimenti o articoli italiani, anche se il contenuto principale è correttamente localizzato.

Applicare il filtro al blocco Query Loop

WordPress espone il filtro query_loop_block_query_vars, che permette di modificare gli argomenti delle query generate dal blocco:

add_filter( 'query_loop_block_query_vars', 'ml_filter_query_loop_block' );

function ml_filter_query_loop_block( array $query ): array {
	return ml_limit_query_to_language(
		$query,
		ml_current_language()
	);
}

Riutilizzare ml_limit_query_to_language() evita di duplicare la logica e mantiene coerenti i criteri utilizzati dalla query principale e dal Query Loop.

Rimangono comunque possibili altre query secondarie: widget classici, blocchi diversi dal Query Loop e codice personalizzato del tema. Questi casi devono essere verificati separatamente. Un plugin che filtra una query non può presumere che tutto il sito utilizzi lo stesso percorso per recuperare i contenuti.

Collegare le traduzioni con un identificativo condiviso

Ora che gli URL e le query rispettano la lingua corrente, serve recuperare le traduzioni di un determinato contenuto. È qui che entra in gioco _ml_group, il valore condiviso tra le diverse versioni linguistiche.

L'identificativo viene generato quando necessario e permette al plugin di cercare il post equivalente in una lingua specifica. Le stesse informazioni vengono utilizzate nell'area amministrativa, nei tag hreflang e nel selettore della lingua.

Recuperare l'ID di una traduzione

La funzione ml_get_translation_id() cerca un post appartenente allo stesso gruppo e alla lingua richiesta. Il parametro $published_only permette di distinguere tra la ricerca di una traduzione esistente e quella di una versione già pubblicata.

/** ID della traduzione di $post_id nella lingua $lang, oppure 0. */
function ml_get_translation_id(
	int $post_id,
	string $lang,
	bool $published_only = false
): int {
	$group = get_post_meta( $post_id, ML_META_GROUP, true );

	if ( ! $group ) {
		return 0;
	}

	$ids = get_posts(
		array(
			'post_type'   => get_post_type( $post_id ),
			'post_status' => $published_only
				? 'publish'
				: array( 'publish', 'draft', 'pending', 'future', 'private' ),
			'numberposts' => 1,
			'fields'      => 'ids',
			'meta_query'  => array(
				array(
					'key'   => ML_META_GROUP,
					'value' => $group,
				),
				array(
					'key'   => ML_META_LANG,
					'value' => $lang,
				),
			),
		)
	);

	return $ids ? (int) $ids[0] : 0;
}

La funzione restituisce zero quando non trova una traduzione. Questo permette alle altre parti del plugin di distinguere facilmente tra un contenuto collegato e una versione ancora da creare.

Il codice presume che il gruppo sia coerente: idealmente, ogni gruppo deve avere al massimo un post per lingua. Se un altro plugin duplica i metadati senza generare un nuovo identificativo, questa condizione può venire meno e la funzione potrebbe restituire un risultato non deterministico.

Recuperare gli URL delle traduzioni pubblicate

La funzione successiva costruisce una mappa composta dal codice della lingua e dall'URL della versione pubblicata. Le bozze non vengono incluse, perché non devono comparire nei collegamenti SEO pubblici né nel selettore destinato ai visitatori.

/** Traduzioni pubblicate: codice lingua => URL. */
function ml_get_published_translations( int $post_id ): array {
	$urls = array();

	foreach ( array_keys( ml_languages() ) as $code ) {
		$id = ml_post_language( $post_id ) === $code
			? $post_id
			: ml_get_translation_id( $post_id, $code, true );

		if ( $id && 'publish' === get_post_status( $id ) ) {
			$urls[ $code ] = get_permalink( $id );
		}
	}

	return $urls;
}

Questa funzione diventa il punto di riferimento per due funzionalità SEO importanti: i tag hreflang e il selettore della lingua. Entrambi utilizzano gli stessi collegamenti, riducendo il rischio che il selettore punti a una traduzione mentre i metadati SEO ne dichiarano un'altra.

Creare la gestione delle traduzioni nell'area amministrativa

Un plugin multilingue deve semplificare il lavoro editoriale, non limitarsi a modificare gli URL pubblici. L'amministratore deve poter riconoscere la lingua di un articolo, trovare le traduzioni esistenti e creare una nuova versione senza cercare manualmente il contenuto originale.

A questo scopo aggiungiamo una meta box nell'editor di articoli e pagine. La stessa interfaccia consente di modificare la lingua e di aprire o creare le versioni collegate.

Aggiungere la meta box della lingua

Registriamo la meta box sui tipi di contenuto post e page. Il form contiene un selettore linguistico e un elenco delle traduzioni associate al post corrente.

add_action( 'add_meta_boxes', 'ml_add_meta_box' );

function ml_add_meta_box(): void {
	add_meta_box(
		'ml_language',
		__( 'Language', 'multi-language' ),
		'ml_render_meta_box',
		array( 'post', 'page' ),
		'side',
		'high'
	);
}

function ml_render_meta_box( WP_Post $post ): void {
	wp_nonce_field( 'ml_save_post', 'ml_nonce' );

	$current = ml_post_language( $post->ID );

	echo '<p><label for="ml_lang" class="screen-reader-text">'
		. esc_html__( 'Content language', 'multi-language' )
		. '</label>';

	echo '<select name="ml_lang" id="ml_lang">';

	foreach ( ml_languages() as $code => $language ) {
		printf(
			'<option value="%s"%s>%s</option>',
			esc_attr( $code ),
			selected( $current, $code, false ),
			esc_html( $language['name'] )
		);
	}

	echo '</select></p>';

	echo '<p><strong>'
		. esc_html__( 'Translations', 'multi-language' )
		. '</strong></p><ul>';

	foreach ( ml_languages() as $code => $language ) {
		if ( $code === $current ) {
			continue;
		}

		$translation_id = ml_get_translation_id( $post->ID, $code );

		if ( $translation_id ) {
			printf(
				'<li>%s: %s</li>',
				esc_html( $language['name'] ),
				esc_url( get_edit_post_link( $translation_id ) ),
				esc_html__( 'Edit', 'multi-language' )
			);
			continue;
		}

		$url = wp_nonce_url(
			add_query_arg(
				array(
					'action' => 'ml_create_translation',
					'post'   => $post->ID,
					'lang'   => $code,
				),
				admin_url( 'admin-post.php' )
			),
			'ml_create_translation_' . $post->ID . '_' . $code
		);

		printf(
			'<li>%s: %s</li>',
			esc_html( $language['name'] ),
			esc_url( $url ),
			esc_html__( 'Create translation', 'multi-language' )
		);
	}

	echo '</ul>';
}

Il codice applica le funzioni di escaping appropriate ai valori inseriti nell'HTML. Il nonce del form serve a verificare la provenienza della richiesta, mentre il nonce associato al collegamento di creazione include l'ID del post e il codice della lingua.

Questi controlli sono importanti perché l'area amministrativa non deve fidarsi ciecamente dei dati ricevuti dal browser. La verifica dei permessi viene comunque eseguita anche durante il salvataggio e la creazione della traduzione.

Salvare la lingua con controlli di sicurezza

Il salvataggio avviene attraverso l'hook save_post. La funzione deve ignorare autosalvataggi e revisioni, verificare il nonce, controllare i permessi dell'utente e accettare esclusivamente codici lingua presenti nella configurazione.

Un ulteriore controllo impedisce di assegnare a un post una lingua già utilizzata da un'altra traduzione appartenente allo stesso gruppo.

add_action( 'save_post', 'ml_save_meta_box', 10, 2 );

function ml_save_meta_box( int $post_id, WP_Post $post ): void {
	if (
		! isset( $_POST['ml_nonce'] ) ||
		! wp_verify_nonce(
			sanitize_text_field( wp_unslash( $_POST['ml_nonce'] ) ),
			'ml_save_post'
		)
	) {
		return;
	}

	if (
		( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) ||
		wp_is_post_revision( $post_id )
	) {
		return;
	}

	if ( ! current_user_can( 'edit_post', $post_id ) ) {
		return;
	}

	$lang = isset( $_POST['ml_lang'] )
		? sanitize_key( wp_unslash( $_POST['ml_lang'] ) )
		: '';

	if ( ! ml_is_valid_language( $lang ) ) {
		return;
	}

	$existing = ml_get_translation_id( $post_id, $lang );

	if ( $existing && $existing !== $post_id ) {
		return;
	}

	update_post_meta( $post_id, ML_META_LANG, $lang );
}

I controlli svolgono compiti differenti:

  • Il nonce verifica che la richiesta provenga dal form previsto.
  • Il controllo su autosave e revisioni evita di modificare i meta durante operazioni automatiche o revisioni.
  • current_user_can() verifica che l'utente abbia il permesso di modificare il post.
  • La whitelist delle lingue impedisce di salvare codici non configurati.
  • Il controllo sul gruppo evita di assegnare la stessa lingua a due traduzioni diverse.

Il codice si concentra sul salvataggio della lingua e non modifica direttamente titolo, contenuto o stato del post. Questa separazione riduce gli effetti collaterali e rende più semplice verificare la logica.

Creare una bozza della traduzione con un clic

Quando una traduzione non esiste, il collegamento presente nella meta box invia la richiesta ad admin-post.php. Il gestore verifica il nonce e i permessi, crea un identificativo di gruppo se manca e inserisce un nuovo post in stato di bozza.

La bozza conserva il tipo di contenuto e il contenuto di partenza, ma riceve il codice lingua richiesto. L'amministratore può quindi tradurre il testo e pubblicare la nuova versione quando è pronta.

add_action( 'admin_post_ml_create_translation', 'ml_handle_create_translation' );

function ml_handle_create_translation(): void {
	$source_id = isset( $_GET['post'] )
		? absint( $_GET['post'] )
		: 0;

	$lang = isset( $_GET['lang'] )
		? sanitize_key( wp_unslash( $_GET['lang'] ) )
		: '';

	check_admin_referer(
		'ml_create_translation_' . $source_id . '_' . $lang
	);

	$source = get_post( $source_id );

	if (
		! $source ||
		! current_user_can( 'edit_post', $source_id ) ||
		! ml_is_valid_language( $lang )
	) {
		wp_die(
			esc_html__( 'Invalid request.', 'multi-language' ),
			'',
			array( 'response' => 400 )
		);
	}

	if ( ml_get_translation_id( $source_id, $lang ) ) {
		wp_die(
			esc_html__( 'This translation already exists.', 'multi-language' ),
			'',
			array( 'response' => 409 )
		);
	}

	$source_lang = ml_post_language( $source_id );
	$group       = get_post_meta( $source_id, ML_META_GROUP, true );

	if ( ! $group ) {
		$group = sanitize_key( wp_generate_uuid4() );
		update_post_meta( $source_id, ML_META_GROUP, $group );
	}

	update_post_meta( $source_id, ML_META_LANG, $source_lang );

	$new_id = wp_insert_post(
		array(
			'post_type'    => $source->post_type,
			'post_status'  => 'draft',
			'post_author'  => get_current_user_id(),
			'post_title'   => $source->post_title,
			'post_content' => apply_filters(
				'ml_translate_content',
				$source->post_content,
				$source_lang,
				$lang
			),
			'meta_input'   => array(
				ML_META_LANG  => $lang,
				ML_META_GROUP => $group,
			),
		),
		true
	);

	if ( is_wp_error( $new_id ) ) {
		wp_die( esc_html( $new_id->get_error_message() ) );
	}

	wp_safe_redirect( get_edit_post_link( $new_id, 'raw' ) );
	exit;
}

La chiamata a wp_insert_post() restituisce un nuovo ID oppure un oggetto WP_Error, gestito separatamente. In caso di successo l'amministratore viene portato direttamente all'editor della nuova bozza.

Una traduzione creata in questo modo non viene pubblicata automaticamente e non compare nei tag hreflang. Il comportamento è intenzionale: la presenza di un contenuto nel database non significa che sia pronto per essere mostrato ai visitatori.

Integrare un servizio di traduzione automatica

Il plugin non dipende da un provider specifico. Questa scelta evita di inserire nel codice principale credenziali, richieste HTTP e logiche legate a un singolo servizio.

La funzione di creazione della bozza passa il contenuto attraverso il filtro ml_translate_content. Senza altri componenti collegati, il filtro lascia invariato il testo; un'integrazione esterna può invece restituire la traduzione.

Utilizzare il filtro come punto d'integrazione

Un'implementazione minimale può essere strutturata così:

add_filter(
	'ml_translate_content',
	function ( $content, $from, $to ) {
		return my_provider_translate( $content, $from, $to );
	},
	10,
	3
);

La funzione my_provider_translate() è un segnaposto: deve essere implementata nel codice che integra il provider scelto. Questo esempio mostra il punto di collegamento, non una traduzione automatica già funzionante.

Il vantaggio è che il sistema di associazione tra le traduzioni rimane indipendente dal servizio utilizzato. È possibile integrare un'API commerciale, un modello linguistico o un sistema interno senza modificare la gestione dei permalink e dei metadati.

Preservare il markup e verificare il risultato

I contenuti di WordPress possono contenere blocchi Gutenberg, commenti strutturali, shortcode e markup HTML. Inviare il testo a un traduttore senza considerare questi elementi può danneggiare il contenuto o alterare la struttura della pagina.

Un'integrazione reale deve quindi preservare la sintassi dei blocchi, gestire gli errori del provider, rispettare i limiti dell'API e impedire che un problema di rete interrompa la creazione della bozza.

La traduzione automatica dovrebbe inoltre essere revisionata prima della pubblicazione. Il fatto che una traduzione sia grammaticalmente plausibile non garantisce che sia corretta dal punto di vista tecnico, editoriale o commerciale.

Aggiungere la colonna della lingua nell'elenco dei contenuti

La meta box permette di vedere la lingua mentre si modifica un post, ma nell'elenco generale dell'amministrazione può essere utile identificare rapidamente gli articoli italiani e inglesi.

Aggiungiamo quindi una colonna «Language» agli elenchi di articoli e pagine. La colonna mostra il codice della lingua in maiuscolo, senza immagini o risorse esterne.

foreach (
	array( 'manage_posts_columns', 'manage_pages_columns' ) as $ml_hook
) {
	add_filter( $ml_hook, 'ml_add_language_column' );
}

function ml_add_language_column( array $columns ): array {
	$columns['ml_language'] = __( 'Language', 'multi-language' );
	return $columns;
}

add_action(
	'manage_posts_custom_column',
	'ml_render_language_column',
	10,
	2
);

add_action(
	'manage_pages_custom_column',
	'ml_render_language_column',
	10,
	2
);

function ml_render_language_column( string $column, int $post_id ): void {
	if ( 'ml_language' === $column ) {
		echo esc_html( strtoupper( ml_post_language( $post_id ) ) );
	}
}

La colonna riutilizza ml_post_language(), quindi applica la stessa regola utilizzata dal resto del plugin: un contenuto privo di metadati linguistici viene considerato appartenente alla lingua predefinita.

In questo modo l'amministrazione non mostra informazioni contraddittorie rispetto ai permalink o al selettore delle traduzioni.

Generare hreflang reciproci e attributo lang per la SEO

La gestione dei contenuti e degli URL deve riflettersi anche nell'HTML pubblicato. I tag hreflang aiutano i motori di ricerca a comprendere la relazione tra versioni linguistiche equivalenti, mentre l'attributo lang dell'elemento HTML dichiara la lingua principale della pagina.

Per essere coerenti, questi elementi devono essere generati a partire dalle traduzioni effettivamente collegate e pubblicate. Inserire URL statici o dichiarare una versione ancora in bozza produrrebbe informazioni che non corrispondono allo stato reale del sito.

Impostare la lingua dell'elemento HTML

Il filtro language_attributes permette di sostituire l'attributo lang restituito da WordPress con il locale associato alla lingua corrente.

add_filter( 'language_attributes', 'ml_filter_language_attributes' );

function ml_filter_language_attributes( string $output ): string {
	$language = ml_languages()[ ml_current_language() ] ?? null;

	return $language
		? (string) preg_replace(
			'/lang="[^"]*"/',
			'lang="' . esc_attr( $language['locale'] ) . '"',
			$output
		)
		: $output;
}

Nel nostro esempio, una richiesta inglese produce lang="en-US", mentre una richiesta italiana produce lang="it-IT". Questo attributo descrive la lingua della pagina, ma non sostituisce la traduzione dell'interfaccia del tema, che richiede una gestione separata.

Stampare i tag hreflang delle traduzioni pubblicate

Per ogni versione pubblicata generiamo un collegamento rel="alternate" con il codice della lingua e il relativo URL. Ogni pagina elenca se stessa e le altre versioni disponibili, in modo che i collegamenti siano reciproci.

add_action( 'wp_head', 'ml_print_hreflang' );

function ml_print_hreflang(): void {
	if ( ! is_singular() ) {
		return;
	}

	$urls = ml_get_published_translations(
		get_queried_object_id()
	);

	if ( count( $urls ) < 2 ) {
		return;
	}

	foreach ( $urls as $code => $url ) {
		printf(
			'<link rel="alternate" hreflang="%s" href="%s" />' . "\n",
			esc_attr( $code ),
			esc_url( $url )
		);
	}

	if ( isset( $urls[ ml_default_language() ] ) ) {
		printf(
			'<link rel="alternate" hreflang="x-default" href="%s" />' . "\n",
			esc_url( $urls[ ml_default_language() ] )
		);
	}
}

Se esiste una sola versione pubblicata, la funzione non stampa hreflang perché non ci sono traduzioni da collegare. Se invece esistono due o più versioni, ognuna viene inclusa nell'elenco.

Il codice utilizza get_permalink() per ottenere gli URL e applica le funzioni di escaping prima di inserirli nell'HTML. Il comportamento effettivo deve comunque essere verificato insieme al tema e agli eventuali plugin SEO, per evitare che vengano generati canonical o hreflang duplicati.

Distinguere canonical e hreflang

I due elementi hanno scopi differenti. Il canonical indica l'URL preferito per una determinata pagina, mentre hreflang mette in relazione versioni linguistiche equivalenti.

Per esempio, la versione italiana e quella inglese dovrebbero avere ciascuna il proprio canonical, se sono pagine autonome destinate all'indicizzazione. I tag hreflang le collegano tra loro, senza sostituire i canonical.

L'implementazione mostrata genera gli hreflang, ma non contiene una funzione che scrive direttamente il canonical. Se il tema o un plugin SEO già gestisce questo elemento, bisogna verificare che riconosca i permalink modificati dal plugin e non produca URL errati.

Creare un selettore di lingua che rispetti la pagina corrente

Un selettore linguistico utile non deve limitarsi a cambiare il codice della lingua o a riportare il visitatore alla home. Quando si legge un articolo, dovrebbe condurre alla traduzione dello stesso contenuto, se disponibile.

Se invece ci si trova in una pagina non singola, come la home, il selettore può proporre le home delle diverse lingue. Questa distinzione permette di evitare link verso traduzioni inesistenti.

Recuperare le voci del selettore

La funzione ml_get_switcher_items() utilizza gli URL delle traduzioni pubblicate per costruire l'elenco delle lingue disponibili. Sulle pagine singole mostra soltanto le versioni esistenti; negli altri contesti utilizza la home della lingua.

/** Voci del selettore linguistico. */
function ml_get_switcher_items(): array {
	$items   = array();
	$current = ml_current_language();

	$urls = is_singular()
		? ml_get_published_translations( get_queried_object_id() )
		: array();

	foreach ( ml_languages() as $code => $language ) {
		$url = is_singular()
			? ( $urls[ $code ] ?? '' )
			: ml_home_url( $code );

		if ( '' === $url ) {
			continue;
		}

		$items[] = array(
			'code'    => $code,
			'name'    => $language['name'],
			'url'     => $url,
			'current' => $code === $current,
		);
	}

	return $items;
}

La funzione non genera link a traduzioni in bozza, perché utilizza esclusivamente l'elenco delle versioni pubblicate. La lingua corrente viene invece contrassegnata per permettere all'interfaccia di mostrarla senza creare un collegamento inutile verso la stessa pagina.

Generare il markup accessibile

Il selettore produce una struttura HTML con un elemento nav, una lista e link che indicano la lingua di destinazione. La lingua attiva viene mostrata come testo con aria-current, così non è necessario rendere cliccabile anche la voce corrente.

function ml_language_switcher(): string {
	$items = ml_get_switcher_items();

	if ( count( $items ) < 2 ) {
		return '';
	}

	$html = '<nav class="ml-switcher" aria-label="'
		. esc_attr__( 'Language', 'multi-language' )
		. '"><ul>';

	foreach ( $items as $item ) {
		if ( $item['current'] ) {
			$html .= '<li><span aria-current="true">'
				. esc_html( $item['name'] )
				. '</span></li>';
		} else {
			$html .= sprintf(
				'<li>%s</li>',
				esc_url( $item['url'] ),
				esc_attr( $item['code'] ),
				esc_attr( $item['code'] ),
				esc_html( $item['name'] )
			);
		}
	}

	return $html . '</ul></nav>';
}

add_shortcode( 'ml_switcher', 'ml_language_switcher' );

add_action( 'wp_footer', 'ml_print_switcher' );

function ml_print_switcher(): void {
	if ( apply_filters( 'ml_auto_switcher', true ) ) {
		echo ml_language_switcher(); // phpcs:ignore WordPress.Security.EscapeOutput -- HTML già escapato nella funzione.
	}
}

Il selettore può essere inserito con lo shortcode [ml_switcher], richiamato dal template attraverso ml_language_switcher() oppure mostrato automaticamente nel footer.

Per disattivare l'inserimento automatico si può utilizzare il filtro:

add_filter( 'ml_auto_switcher', '__return_false' );

L'inserimento automatico nel footer è una scelta di comodità per la demo. In un progetto reale è preferibile valutare dove posizionare il selettore e come integrarlo con il menu e il layout del tema.

Verificare il comportamento del sito multilingua

Un plugin che modifica permalink e query non dovrebbe essere considerato funzionante soltanto perché l'editor mostra correttamente le lingue. È necessario controllare le risposte HTTP, la risoluzione dei contenuti, i redirect e i metadati SEO prodotti nel frontend.

Per il test si può creare un articolo italiano con slug ciao-mondo, usare la meta box per creare la traduzione, modificare il contenuto inglese e pubblicarlo con slug hello-world.

Controllare gli URL principali

La tabella seguente riassume il comportamento atteso durante i test:

RichiestaRisultato atteso
/ciao-mondo/200, articolo italiano
/en/hello-world/200, articolo inglese
/en/ciao-mondo/301 verso la versione italiana, se la logica di redirect la identifica senza ambiguità
/hello-world/301 verso /en/hello-world/, se la logica di redirect copre anche questa direzione
/en/inesistente/404
/en/200, home filtrata sulla lingua inglese

È importante distinguere i risultati desiderati da quelli garantiti dal solo codice. Il filtro della query principale può escludere un articolo appartenente alla lingua sbagliata, ma non realizza da solo tutti i redirect della tabella. La logica di redirect deve essere testata separatamente, soprattutto nei casi con slug duplicati o permalink gerarchici.

Le richieste possono essere eseguite con curl, per esempio:

curl -sS -o /dev/null -w '%{http_code} %{url_effective}\n' \
  http://localhost:8080/ciao-mondo/

curl -sS -o /dev/null -w '%{http_code} %{url_effective}\n' \
  http://localhost:8080/en/hello-world/

curl -sS -L -o /dev/null -w '%{http_code} %{url_effective}\n' \
  http://localhost:8080/en/ciao-mondo/

L'opzione -L segue i redirect. Per verificare anche la catena intermedia è utile aggiungere -I o -v, in modo da controllare gli header HTTP restituiti dal server.

Controllare canonical e hreflang nell'HTML

Nel sorgente della versione inglese, l'obiettivo è ottenere un elemento HTML con lingua inglese, il canonical della versione inglese e i collegamenti alle traduzioni pubblicate.

In un ambiente locale gli URL potrebbero apparire così:

<html lang="en-US">
<link rel="canonical" href="http://localhost:8080/en/hello-world/" />
<link rel="alternate" hreflang="it" href="http://localhost:8080/ciao-mondo/" />
<link rel="alternate" hreflang="en" href="http://localhost:8080/en/hello-world/" />
<link rel="alternate" hreflang="x-default" href="http://localhost:8080/ciao-mondo/" />

Il canonical mostrato è un esempio del risultato atteso, non viene generato dal frammento PHP dedicato agli hreflang. Per ottenerlo, il tema o il plugin SEO deve produrre l'URL corretto.

La versione italiana dovrebbe contenere un insieme equivalente di hreflang, con i collegamenti reciproci alle stesse pagine. In produzione, gli URL devono utilizzare il dominio pubblico e HTTPS: lasciare localhost:8080 nel codice HTML del sito online sarebbe un errore di configurazione.

Testare sicurezza e casi limite

I test funzionali non bastano per un plugin che modifica metadati e richieste amministrative. Occorre verificare anche che un utente senza permessi non possa cambiare la lingua, che una richiesta senza nonce venga ignorata e che una traduzione già esistente non venga duplicata.

Uno script di test con WP-CLI può simulare richieste valide e non valide, controllando il valore dei metadati prima e dopo ogni operazione.

Tra i casi da verificare rientrano:

  • salvataggio senza nonce;
  • nonce associato a un'azione differente;
  • utente privo del permesso di modifica;
  • codice lingua non configurato;
  • assegnazione di una lingua già presente nel gruppo;
  • contenuto privo di metadati linguistici;
  • traduzione esistente soltanto in bozza;
  • meta non esposto attraverso la REST API;
  • query secondarie del blocco Query Loop.

Questi test non sono un dettaglio accessorio. Un sistema può mostrare correttamente le pagine principali e fallire quando un utente modifica un articolo, quando una traduzione è ancora in bozza o quando un componente del tema genera una query separata.

I risultati devono essere verificati sull'installazione reale del plugin. Non è sufficiente dichiarare che un caso è coperto: lo script deve controllare la risposta o lo stato atteso e segnalare il fallimento quando il comportamento differisce.

Limiti del plugin e quando scegliere Polylang o WPML

Il plugin realizzato in questa guida gestisce un insieme circoscritto di funzionalità: articoli e pagine separati per lingua, collegamenti tra traduzioni, prefissi URL, hreflang, selettore e creazione di bozze.

Non è un sistema multilingue completo. Prima di adottarlo in produzione bisogna confrontare i requisiti del sito con ciò che è effettivamente implementato e testato.

Funzionalità non comprese

La versione descritta presenta questi limiti:

  • Menu, widget, categorie e tag: le tassonomie restano condivise e non sono disponibili traduzioni autonome.
  • Archivi e ricerca con prefisso linguistico: la query principale può essere filtrata, ma non viene implementato un sistema completo di URL linguistici per tutti gli archivi e le tassonomie.
  • Sitemap XML e metadati SEO: non vengono generate sitemap separate per lingua e non è inclusa la traduzione dei titoli SEO o delle description.
  • Interfaccia del tema: modificare lang nell'HTML non traduce automaticamente le stringhe del tema o di altri plugin.
  • Installazioni in sottocartella: il codice considera il percorso di home, ma questo scenario necessita di test dedicati.
  • Server Apache e Nginx: i test descritti sono stati eseguiti con il server integrato di PHP; il comportamento delle variabili della richiesta deve essere verificato sugli altri ambienti.
  • Widget classici e query personalizzate: possono continuare a mostrare contenuti di lingue diverse se non utilizzano gli hook intercettati dal plugin.
  • Gruppi incoerenti: se un altro plugin duplica _ml_group e _ml_lang, possono esistere più contenuti con la stessa lingua nello stesso gruppo.

L'elenco non significa che queste funzionalità siano impossibili da aggiungere. Significa che richiedono ulteriore codice, test e manutenzione, e che non devono essere considerate già risolte dalla sola gestione dei permalink.

Quando conviene utilizzare un plugin multilingue esistente

Una soluzione personalizzata è sensata quando il progetto ha pochi tipi di contenuto, un numero limitato di lingue e requisiti ben definiti. Permette di mantenere il controllo sul codice e di evitare funzionalità che non servono.

Se invece il sito richiede tassonomie tradotte, menu complessi, integrazione con plugin SEO, supporto per molti tipi di contenuto e una gestione editoriale completa, Polylang o WPML rappresentano punti di partenza più adatti.

Non è corretto affermare che il plugin personalizzato sia necessariamente più leggero senza aver misurato tempi di risposta, query SQL e consumo di risorse in condizioni comparabili. Le dimensioni del sorgente, da sole, non dimostrano un vantaggio prestazionale.

La scelta dovrebbe quindi dipendere dai requisiti e dai risultati delle verifiche, non soltanto dal numero di righe di codice.

Conclusioni: un modello semplice, ma da verificare prima della produzione

Un plugin multilingue di base si fonda su tre decisioni architetturali: come collegare le traduzioni, come distinguere le versioni attraverso gli URL e come dichiarare ai motori di ricerca la relazione tra pagine equivalenti.

In questa implementazione, ogni lingua ha il proprio post, le versioni sono collegate da un identificativo condiviso e le lingue diverse da quella predefinita utilizzano un prefisso nel percorso. Da questa struttura derivano il filtro delle query, la gestione delle traduzioni nell'area amministrativa, i tag hreflang e il selettore linguistico.

La creazione della bozza e il filtro per la traduzione automatica completano il flusso editoriale senza vincolare il plugin a uno specifico provider.

Il sorgente è organizzato in un unico file PHP, ma la semplicità strutturale non elimina la necessità di test. Prima della pubblicazione occorre verificare i permalink, i redirect, le query del tema, i canonical, gli hreflang e i controlli di autorizzazione sul server effettivo.

Un plugin di questo tipo può essere una base didattica utile o una soluzione adatta a un progetto circoscritto. Per trasformarlo in un sistema multilingue completo, invece, servono una gestione più ampia dei contenuti, test di compatibilità e una strategia di manutenzione nel tempo.