WordPress è un CMS che ha ormai qualche anno, ma è anche il più utilizzato. C’è il tema del supporto di versioni PHP obsolete e del codice datato, e mancano anche certe prassi di programmazione moderna: l’astrazione di WordPress ne è un esempio.
Sarebbe molto meglio dividere la base di codice del core di WordPress in pacchetti gestiti da Composer. O magari, auto-caricare le classi di WordPress dai percorsi dei file.
Questo articolo vi spiegherà come astrarre il codice di WordPress manualmente e utilizzare le capacità astratte dei plugin di WordPress.
Problemi con l’Integrazione di WordPress e gli Strumenti PHP
A causa della sua architettura datata, a volte sorgono problemi quando integriamo WordPress con strumenti per il codice PHP, come l’analizzatore statico PHPStan, la libreria di test unitari PHPUnit e la libreria di namespace-scoping PHP-Scoper. Per esempio, considerate i seguenti casi:
- In anticipo su WordPress 5.6 con il supporto di PHP 8.0, un rapporto di Yoast descriveva come l’esecuzione di PHPStan sul core di WordPress avrebbe prodotto migliaia di problemi.
- Dato che supporta ancora PHP 5.6, le suite di test di WordPress attualmente supportano PHPUnit solo fino alla versione 7.5, che però ha già raggiunto il suo fine vita.
- Scoprire i plugin di WordPress tramite PHP-Scoper è molto impegnativo.
Il codice di WordPress all’interno dei nostri progetti sarà solo una frazione del totale; il progetto conterrà anche codice aziendale agnostico rispetto al CMS sottostante. Eppure, solo per il fatto di avere del codice WordPress, il progetto potrebbe non integrarsi correttamente con gli strumenti.
Per questo, potrebbe avere senso dividere il progetto in pacchetti, alcuni dei quali contengono codice WordPress e altri hanno solo codice aziendale che usano PHP “vanilla” e nessun codice WordPress. In questo modo, questi ultimi pacchetti non saranno affetti dai problemi descritti sopra ma potranno essere perfettamente integrati con gli strumenti.
Cosa Si Intende per Astrazione del Codice?
L’astrazione del codice rimuove le dipendenze fisse dal codice, producendo pacchetti che interagiscono tra loro tramite contratti. Questi pacchetti possono poi essere aggiunti a diverse applicazioni con diversi stack, massimizzando la loro usabilità. Il risultato dell’astrazione del codice è una codebase disaccoppiato in modo pulito e basato sui seguenti pilastri:
- Prograzione sulle interfacce, non sulle implementazioni.
- Creazione di pacchetti e distribuzione tramite Composer.
- Unione di tutte le parti insieme tramite dependency injection.
Programmare Sulle Interfacce, Non Sulle Implementazioni
Programmare sulle interfacce significa utilizzare contratti per far interagire tra loro frammenti di codice. Un contratto è semplicemente un’interfaccia PHP (o qualsiasi altro linguaggio) che definisce le funzioni disponibili e le loro firme, cioè l’input che ricevono e il loro output.
Un’interfaccia dichiara l’intento della funzionalità senza spiegare come la funzionalità sarà implementata. Accedendo alle funzionalità attraverso le interfacce, la nostra applicazione può contare su pezzi di codice autonomi che realizzano un obiettivo specifico senza sapere, o preoccuparsi, di come lo fanno. In questo modo, l’applicazione non ha bisogno di essere adattata per passare a un altro pezzo di codice che realizza lo stesso obiettivo (provenendo, per esempio, da un fornitore diverso).
Esempio di Contratti
Il seguente codice usa il contratto di Symfony CacheInterface
e il contratto PHP Standard Recommendation (PSR) CacheItemInterface
per implementare la funzionalità di caching:
use Psr\Cache\CacheItemInterface;
use Symfony\Contracts\Cache\CacheInterface;
$value = $cache->get('my_cache_key', function (CacheItemInterface $item) {
$item->expiresAfter(3600);
return 'foobar';
});
$cache
implementa CacheInterface
, che definisce il metodo get
per recuperare un oggetto dalla cache. Accedendo a questa funzionalità attraverso il contratto, l’applicazione può ignorare dove si trova la cache sia essa nella memoria, nel disco, nel database, nella rete o in qualsiasi altro posto. Tuttavia, deve eseguire la funzione.
CacheItemInterface
definisce il metodo expiresAfter
per dichiarare per quanto tempo l’elemento deve essere tenuto nella cache. L’applicazione può invocare questo metodo senza preoccuparsi di cosa sia l’oggetto in cache; si preoccupa solo di quanto tempo debba essere tenuto in cache.
Programmare Sulle Interfacce in WordPress
Poiché stiamo astraendo il codice di WordPress, il risultato sarà che l’applicazione non farà direttamente riferimento al codice di WordPress, ma sempre attraverso un’interfaccia. Per esempio, la funzione get_posts
di WordPress presenta questa firma:
/**
* @param array $args
* @return WP_Post[]|int[] Array of post objects or post IDs.
*/
function get_posts( $args = null )
Invece di invocare direttamente questo metodo, possiamo accedervi tramite il contratto Owner\MyApp\Contracts\PostsAPIInterface
:
namespace Owner\MyApp\Contracts;
interface PostAPIInterface
{
public function get_posts(array $args = null): PostInterface[]|int[];
}
Notate che la funzione get_posts
di WordPress può restituire oggetti della classe WP_Post
, che è specifica di WordPress. Quando si astrae il codice, dobbiamo rimuovere questo tipo di dipendenza fissa. Il metodo get_posts
nel contratto restituisce oggetti del tipo PostInterface
, permettendovi di fare riferimento alla classe WP_Post
senza essere esplicito al riguardo.
La classe PostInterface
dovrà fornire accesso a tutti i metodi e attributi di WP_Post
:
namespace Owner\MyApp\Contracts;
interface PostInterface
{
public function get_ID(): int;
public function get_post_author(): string;
public function get_post_date(): string;
// ...
}
L’esecuzione di questa strategia può cambiare la nostra comprensione del punto in cui WordPress si inserisce nel nostro stack. Invece di pensare a WordPress come all’applicazione stessa (su cui installiamo temi e plugin), possiamo pensarlo semplicemente come un’altra dipendenza all’interno dell’applicazione, sostituibile come qualsiasi altro componente. (Anche se non sostituiremo WordPress nella pratica, è comunque sostituibile da un punto di vista concettuale.)
Creare e Distribuire i Pacchetti
Composer è un gestore di pacchetti per PHP. Permette alle applicazioni PHP di recuperare i pacchetti (cioè pezzi di codice) da un repository e installarli come dipendenze. Per disaccoppiare l’applicazione da WordPress, dobbiamo distribuire il suo codice in pacchetti di due tipi diversi: quelli che contengono codice WordPress e gli altri che contengono la logica di business (cioè nessun codice WordPress).
Infine, aggiungiamo tutti i pacchetti come dipendenze nell’applicazione e li installiamo tramite Composer. Dato che gli strumenti saranno applicati ai pacchetti di codice di business, questi devono contenere la maggior parte del codice dell’applicazione; più alta è la percentuale, meglio è. Far loro gestire circa il 90% del codice complessivo è un buon obiettivo.
Estrarre il Codice WordPress nei Pacchetti
Seguendo l’esempio di prima, i contratti PostAPIInterface
e PostInterface
saranno aggiunti al pacchetto contenente il codice aziendale mentre un altro pacchetto includerà l’implementazione di WordPress di questi contratti. Per soddisfare PostInterface
, creiamo una classe PostWrapper
che recupererà tutti gli attributi da un oggetto WP_Post
:
namespace Owner\MyAppForWP\ContractImplementations;
use Owner\MyApp\Contracts\PostInterface;
use WP_Post;
class PostWrapper implements PostInterface
{
private WP_Post $post;
public function __construct(WP_Post $post)
{
$this->post = $post;
}
public function get_ID(): int
{
return $this->post->ID;
}
public function get_post_author(): string
{
return $this->post->post_author;
}
public function get_post_date(): string
{
return $this->post->post_date;
}
// ...
}
Quando si implementa PostAPI
, poiché il metodo get_posts
restituisce PostInterface[]
, dobbiamo convertire gli oggetti da WP_Post
a PostWrapper
:
namespace Owner\MyAppForWP\ContractImplementations;
use Owner\MyApp\Contracts\PostAPIInterface;
use WP_Post;
class PostAPI implements PostAPIInterface
{
public function get_posts(array $args = null): PostInterface[]|int[]
{
// This var will contain WP_Post[] or int[]
$wpPosts = \get_posts($args);
// Convert WP_Post[] to PostWrapper[]
return array_map(
function (WP_Post|int $post) {
if ($post instanceof WP_Post) {
return new PostWrapper($post);
}
return $post
},
$wpPosts
);
}
}
Usare la Dependency Injection
L’iniezione delle dipendenze è un pattern di progettazione che vi permette di incollare tutte le parti dell’applicazione in modo accoppiato e senza blocchi. Con la dependency injection, l’applicazione accede ai servizi tramite i loro contratti e le implementazioni dei contratti vengono “iniettate” nell’applicazione tramite la configurazione.
Con il semplice cambio della configurazione, possiamo facilmente passare da un fornitore di contratti a un altro. Ci sono diverse librerie di dependency injection tra cui possiamo scegliere. Consigliamo di sceglierne una che aderisca alle raccomandazioni standard di PHP (spesso indicate come “PSR”), così possiamo facilmente sostituire la libreria con un’altra in caso di necessità. Per quanto riguarda l’iniezione delle dipendenze, la libreria deve soddisfare la PSR-11, che fornisce le specifiche per una “interfaccia contenitore”.
Tra le altre, le seguenti librerie sono conformi al PSR-11:
- DependencyInjection di Symfony
- PHP-DI
- Aura.Di
- Container (Dependency Injection)
- DependencyInjection di Yii
Accesso ai Servizi Attraverso il Container di Servizi
La libreria di dependency injection metterà a disposizione un “container di servizi”, che risolve un contratto nella sua corrispondente classe di implementazione. L’applicazione deve fare affidamento sul contenitore di servizi per accedere a tutte le funzionalità. Per esempio, mentre in genere invochiamo direttamente le funzioni di WordPress:
$posts = get_posts();
…con il container di servizi, dobbiamo prima ottenere il servizio che soddisfa PostAPIInterface
ed eseguire la funzionalità attraverso di esso:
use Owner\MyApp\Contracts\PostAPIInterface;
// Obtain the service container, as specified by the library we use
$serviceContainer = ContainerBuilderFactory::getInstance();
// The obtained service will be of class Owner\MyAppForWP\ContractImplementations\PostAPI
$postAPI = $serviceContainer->get(PostAPIInterface::class);
// Now we can invoke the WordPress functionality
$posts = $postAPI->get_posts();
Usare DependencyInjection di Symfony
Il componente DependencyInjection di Symfony è attualmente la libreria di dependency injection più popolare. Vi permette di configurare il contenitore di servizi tramite codice PHP, YAML o XML. Per esempio, per definire che il contratto PostAPIInterface
è soddisfatto tramite la classe PostAPI
, la configurazione in YAML è la seguente:
services:
Owner\MyApp\Contracts\PostAPIInterface:
class: \Owner\MyAppForWP\ContractImplementations\PostAPI
DependencyInjection di Symfony permette anche alle istanze di un servizio di essere automaticamente iniettate (o “autowired”) in qualsiasi altro servizio che dipende da esso. Inoltre, rende facile definire che una classe è un’implementazione del proprio servizio.
Per esempio, considerate la seguente configurazione YAML:
services:
_defaults:
public: true
autowire: true
GraphQLAPI\GraphQLAPI\Registries\UserAuthorizationSchemeRegistryInterface:
class: '\GraphQLAPI\GraphQLAPI\Registries\UserAuthorizationSchemeRegistry'
GraphQLAPI\GraphQLAPI\Security\UserAuthorizationInterface:
class: '\GraphQLAPI\GraphQLAPI\Security\UserAuthorization'
GraphQLAPI\GraphQLAPI\Security\UserAuthorizationSchemes\:
resource: '../src/Security/UserAuthorizationSchemes/*'
Questa configurazione definisce quanto segue:
-
- Il contratto
UserAuthorizationSchemeRegistryInterface
è soddisfatto dalla classeUserAuthorizationSchemeRegistry
- Il contratto
.
- Il contratto
UserAuthorizationInterface
è soddisfatto dalla classeUserAuthorization
. - Tutte le classi sotto la cartella
UserAuthorizationSchemes/
sono un’implementazione di se stesse. - I servizi devono essere iniettati automaticamente l’uno nell’altro
(autowire: true
).
Vediamo come funziona l’autowiring. La classe UserAuthorization
dipende dal servizio con contratto UserAuthorizationSchemeRegistryInterface
:
class UserAuthorization implements UserAuthorizationInterface
{
public function __construct(
protected UserAuthorizationSchemeRegistryInterface $userAuthorizationSchemeRegistry
) {
}
// ...
}
Grazie a autowire: true
, il componente DependencyInjection farà ricevere automaticamente al servizio UserAuthorization
la sua dipendenza richiesta, che è un’istanza di UserAuthorizationSchemeRegistry
.
Quando Passare all’Astrazione
L’astrazione del codice potrebbe consumare tempo e sforzi considerevoli, quindi dovremmo intraprenderla solo quando i suoi benefici superano i costi. Quelli che seguono sono suggerimenti per capire quando conviene astrarre il codice. Potete farlo usando gli snippet di codice in questo articolo o i plugin WordPress astratti suggeriti qui sotto.
Ottenere l’Accesso agli Strumenti
Come menzionato in precedenza, eseguire PHP-Scoper su WordPress è difficile. Disaccoppiando il codice di WordPress in pacchetti distinti, diventa possibile eseguire direttamente un plugin WordPress.
Ridurre le Tempistiche e il Costo degli Strumenti
L’esecuzione di una suite di test PHPUnit richiede più tempo quando deve inizializzare ed eseguire WordPress rispetto a quando non lo fa. Meno tempo può anche tradursi in meno soldi spesi per eseguire i test: GitHub Actions, per esempio, fa pagare i runner ospitati da GitHub in base al tempo di utilizzo.
Non È Necessaria una Rifattorizzazione Pesante
Un progetto esistente può richiedere un pesante refactoring per introdurre l’architettura richiesta (dependency injection, suddivisione del codice in pacchetti, ecc.), e rendere così difficile l’estrazione. Astrarre il codice quando si crea un progetto da zero lo rende molto più gestibile.
Produrre Codice per Piattaforme Multiple
Estraendo il 90% del codice in un pacchetto CMS-agnostico, possiamo produrre una versione della libreria che funziona per un CMS o un framework diverso sostituendo solo il 10% del codice base complessivo.
Migrare a una Piattaforma Diversa
Se dobbiamo migrare un progetto da Drupal a WordPress, da WordPress a Laravel, o qualsiasi altra combinazione, allora solo il 10% del codice deve essere riscritto, e questo è un risparmio significativo.
Le Migliori Pratiche
Mentre progettiamo i contratti per astrarre il nostro codice, ci sono diversi miglioramenti che possiamo applicare alla codebase.
Adesione al PSR-12
Quando definiamo l’interfaccia per accedere ai metodi WordPress, dovremmo aderire al PSR-12. Questa specifica recente mira a ridurre l’attrito cognitivo quando si analizza il codice di diversi autori. Aderire al PSR-12 implica rinominare le funzioni di WordPress.
WordPress nomina le funzioni usando snake_case, mentre PSR-12 usa camelCase. Quindi, la funzione get_posts
diventerà getPosts
:
interface PostAPIInterface
{
public function getPosts(array $args = null): PostInterface[]|int[];
}
…e:
class PostAPI implements PostAPIInterface
{
public function getPosts(array $args = null): PostInterface[]|int[]
{
// This var will contain WP_Post[] or int[]
$wpPosts = \get_posts($args);
// Rest of the code
// ...
}
}
Metodi Split
I metodi nell’interfaccia non hanno bisogno di essere una replica di quelli di WordPress. Possiamo trasformarli quando ha senso. Per esempio, la funzione di WordPress get_user_by($field, $value)
sa come recuperare l’utente dal database tramite il parametro $field
, che accetta i valori "id"
, "ID"
, "slug"
, "email"
o "login"
.
Questo design presenta alcuni problemi:
- Non fallirà in fase di compilazione se passiamo una stringa sbagliata
- Il parametro
$value
deve accettare tutti i tipi diversi per tutte le opzioni, anche se quando si passa"ID"
si aspetta unint
, e quando si passa"email"
può solo ricevere unstring
Possiamo migliorare questa situazione dividendo la funzione in diverse parti:
namespace Owner\MyApp\Contracts;
interface UserAPIInterface
{
public function getUserById(int $id): ?UserInterface;
public function getUserByEmail(string $email): ?UserInterface;
public function getUserBySlug(string $slug): ?UserInterface;
public function getUserByLogin(string $login): ?UserInterface;
}
Il contratto viene risolto per WordPress in questo modo (assumendo che abbiamo creato UserWrapper
e UserInterface
, come spiegato in precedenza):
namespace Owner\MyAppForWP\ContractImplementations;
use Owner\MyApp\Contracts\UserAPIInterface;
class UserAPI implements UserAPIInterface
{
public function getUserById(int $id): ?UserInterface
{
return $this->getUserByProp('id', $id);
}
public function getUserByEmail(string $email): ?UserInterface
{
return $this->getUserByProp('email', $email);
}
public function getUserBySlug(string $slug): ?UserInterface
{
return $this->getUserByProp('slug', $slug);
}
public function getUserByLogin(string $login): ?UserInterface
{
return $this->getUserByProp('login', $login);
}
private function getUserByProp(string $prop, int|string $value): ?UserInterface
{
if ($user = \get_user_by($prop, $value)) {
return new UserWrapper($user);
}
return null;
}
}
Rimuovere i Dettagli di Implementazione dalla Firma della Funzione
Le funzioni in WordPress possono fornire informazioni su come sono implementate nella loro firma. Queste informazioni possono essere rimosse quando si valuta la funzione da una prospettiva astratta. Per esempio, per ottenere il cognome dell’utente in WordPress si chiama la funzione get_the_author_meta
, rendendo esplicito che il cognome di un utente è memorizzato come un valore “meta” (sulla tabella wp_usermeta
):
$userLastname = get_the_author_meta("user_lastname", $user_id);
Non dovete trasmettere questa informazione al contratto. Le interfacce si preoccupano solo del cosa, non del come. Quindi, il contratto può invece avere un metodo:
interface UserAPIInterface
{
public function getUserLastname(UserWrapper $userWrapper): string;
...
}
Aggiungere Tipi Più Rigidi
Alcune funzioni di WordPress possono ricevere parametri in modi diversi, portando all’ambiguità. Per esempio, la funzione add_query_arg
può ricevere una singola chiave e un valore:
$url = add_query_arg('id', 5, $url);
… o un array di chiave => valore
:
$url = add_query_arg(['id' => 5], $url);
La nostra interfaccia può definire un intento più comprensibile dividendo tali funzioni in diverse funzioni separate, ognuna delle quali accetta una combinazione unica di input:
public function addQueryArg(string $key, string $value, string $url);
public function addQueryArgs(array $keyValues, string $url);
Eliminare il Debito Tecnico
La funzione get_posts
di WordPress restituisce non solo “post” ma anche “pagine” o qualsiasi entità di tipo “post personalizzati”, e queste entità non sono intercambiabili. Sia i post che le pagine sono post personalizzati, ma un post personalizzato non è un post né una pagina. Pertanto, l’esecuzione di get_posts
può restituire delle pagine. Questo comportamento è una discrepanza concettuale.
Per correggerlo, get_posts
dovrebbe invece essere chiamato get_customposts
, ma non è mai stato rinominato nel core di WordPress. Questo è un problema comune con la maggior parte dei software di lunga data ed è chiamato “debito tecnico”, cioè un codice che ha dei problemi, ma che non viene mai corretto perché introdurrebbe dei cambiamenti di rottura.
Quando creiamo i nostri contratti, però, abbiamo l’opportunità di evitare questo tipo di debito tecnico. In questo caso, possiamo creare una nuova interfaccia ModelAPIInterface
che può trattare con entità di diversi tipi, e creiamo diversi metodi, ognuno per trattare un tipo diverso:
interface ModelAPIInterface
{
public function getPosts(array $args): array;
public function getPages(array $args): array;
public function getCustomPosts(array $args): array;
}
In questo modo la discrepanza non si verificherà più e vedrete questi risultati:
getPosts
restituisce solo i postgetPages
restituisce solo le paginegetCustomPosts
restituisce sia i post che le pagine
Vantaggi dell’Astrazione del Codice
I principali vantaggi dell’astrazione del codice di un’applicazione sono:
- Gli strumenti che funzionano su pacchetti che contengono solo codice commerciale sono più facili da impostare e richiedono meno tempo (e meno soldi) per funzionare.
- Possiamo usare strumenti che non funzionano con WordPress, come lo scoping di un plugin con PHP-Scoper.
- I pacchetti che produciamo possono essere autonomi per essere usati con facilità in altre applicazioni.
- Migrare un’applicazione su altre piattaforme diventa più facile.
- Possiamo smettere di pensare in termini di WordPress e ragionare in termini di logica di business.
- I contratti descrivono l’intento dell’applicazione, rendendola più comprensibile.
- L’applicazione viene organizzata attraverso pacchetti, creando un’applicazione snella che contiene il minimo indispensabile e migliorandola progressivamente secondo le necessità.
- Possiamo eliminare il debito tecnico.
Problemi con l’Astrazione del Codice
Gli svantaggi di astrarre il codice di un’applicazione sono:
- Inizialmente comporta una notevole quantità di lavoro.
- Il codice diventa più prolisso; si aggiungono ulteriori strati di codice per ottenere lo stesso risultato.
- Potreste finire per produrre dozzine di pacchetti che devono poi essere gestiti e mantenuti.
- Potreste aver bisogno di un monorepo per gestire tutti i pacchetti insieme.
- La dependency injection potrebbe essere eccessiva per applicazioni semplici (rendimenti decrescenti).
- L’astrazione del codice non sarà mai completamente realizzata poiché c’è tipicamente una preferenza generale implicita nell’architettura del CMS.
Opzioni dei Plugin WordPress Astratti
Anche se è generalmente più saggio estrarre il vostro codice in un ambiente locale prima di lavorarci sopra, alcuni plugin di WordPress possono aiutarvi a raggiungere i vostri obiettivi di astrazione. Queste sono le scelte migliori secondo noi.
1. WPide
Prodotto da WebFactory Ltd, il popolare plugin WPide estende notevolmente la funzionalità dell’editor di codice predefinito di WordPress. Funziona come un plugin WordPress astratto e vi permette di visualizzare il vostro codice in situ per visualizzare meglio ciò che necessita di attenzione.
WPide ha anche una funzione di ricerca e sostituzione per individuare rapidamente il codice obsoleto o scaduto e sostituirlo con una versione aggiornata.
Oltre a questo, WPide fornisce un sacco di funzioni extra, tra cui:
- Evidenziazione della sintassi e dei blocchi.
- Backup automatici.
- Creazione di file e cartelle.
- Browser completo dell’alberatura dei file.
- Accesso al filesystem API di WordPress.
2. Ultimate DB Manager
Il plugin Ultimate WP DB Manager di WPHobby vi offre uno strumento veloce per scaricare completamente i vostri database per l’estrazione e il refactoring.
Naturalmente, i plugin di questo tipo non sono necessari per gli utenti Kinsta, poiché Kinsta offre un accesso diretto al database a tutti i clienti. Tuttavia, se non avete sufficiente accesso al database attraverso il vostro fornitore di hosting, Ultimate DB Manager potrebbe essere utile come plugin astratto per WordPress.
3. Il Vostro Plugin Personalizzato per l’Astrazione di WordPress
Alla fine, la scelta migliore per l’astrazione sarà sempre quella di creare il vostro plugin. Può sembrare una grande impresa, ma se avete dei limiti per la gestione diretta dei vostri file principali di WordPress, questo è buon espediente per realizzare l’astrazione.
Farlo ha dei chiari vantaggi:
- Estrae le vostre funzioni dai file del vostro tema.
- Preserva il vostro codice attraverso le modifiche del tema e gli aggiornamenti del database.
Potete imparare come creare il vostro plugin WordPress astratto attraverso il WordPress’ Plugin Developer Handbook.
Riepilogo
È necessario astrarre il codice nelle nostre applicazioni? Come per ogni cosa, non c’è una “risposta giusta” sempre valida, perché le cose cambiano da progetto a progetto. Quei progetti che richiedono un’enorme quantità di tempo di analisi con PHPUnit o PHPStan possono trarne il massimo beneficio, ma lo sforzo necessario per realizzarlo potrebbe non valerne sempre la pena.
In questo articolo avete imparato tutto quello che c’è sapere per iniziare ad astrarre il codice di WordPress.
Avete intenzione di implementare questa strategia nel vostro progetto? Se sì, userete un plugin WordPress astratto? Fatecelo sapere nella sezione dei commenti!
Lascia un commento