
Hola amigas y amigos. Hoy continuamos con el curso de aprender a programar plugins WordPress. Este curso es totalmente gratuito y ya está iniciado, puedes ver las lecciones 1 hasta la 8 desde aquí:
Curso online diseño de plugins Wordpress 👈LECCIONES 1 hasta la 8
Vamos a continuar desde donde lo dejamos:
¡Vamos con la última parte pro! 🌍⚙️
LECCIÓN 9 – Internacionalización, empaquetado de assets y preparación para publicar
Objetivo: Dejar tu plugin listo para todo el mundo y para subirlo a WordPress.org o venderlo.
9.1 – Internacionalización (i18n)
Esto permite que tu plugin se traduzca fácilmente a cualquier idioma.
1) Preparar tu plugin para traducirse
- En el encabezado de
mi-plugin.phppon:
Text Domain: mi-plugin
Domain Path: /languages
2) Al imprimir textos:
Usa funciones de traducción con tu dominio:
__('Hola mundo', 'mi-plugin'); // Devuelve texto
_e('Guardar cambios', 'mi-plugin'); // Imprime directamente
esc_html__('Guardado', 'mi-plugin'); // Devuelve escapado
3) Cargar el dominio de texto:
add_action('plugins_loaded', function(){
load_plugin_textdomain('mi-plugin', false, dirname(plugin_basename(__FILE__)).'/languages');
});
4) Generar el archivo .pot:
En terminal:
wp i18n make-pot . languages/mi-plugin.pot
(Necesita WP-CLI y paquete wp-cli/i18n)
9.2 – Empaquetado de assets (npm + webpack/vite)
Esto es para JS/CSS modernos (ES6+, SCSS, minificación, etc.).
1) Estructura sugerida:
assets/src/js/...
assets/src/scss/...
assets/dist/js/...
assets/dist/css/...
2) Ejemplo con Vite:
- Instala:
npm init -y
npm install vite --save-dev
package.json:
"scripts": {
"dev": "vite",
"build": "vite build"
}
vite.config.js:
export default {
build: {
outDir: 'assets/dist',
rollupOptions: {
input: {
front: 'assets/src/js/front.js',
admin: 'assets/src/js/admin.js'
}
}
}
}
- Encola en PHP:
wp_enqueue_script('mi-plugin-front', MI_PLUGIN_URL.'assets/dist/front.js', [], '1.0', true);
9.3 – Control de versión
- Guarda la versión actual del plugin en una opción (
mi_plugin_version). - En activación, comprueba si la versión cambió → aplica migraciones.
- Ejemplo:
if (get_option('mi_plugin_version') !== '1.2.0') {
// código de migración
update_option('mi_plugin_version', '1.2.0');
}
9.4 – Preparar para WordPress.org
1) Estructura final:
mi-plugin/
assets/
includes/
languages/
readme.txt
uninstall.php
mi-plugin.php
2) readme.txt con formato oficial:
- Encabezado: Nombre, descripción, tags, requisitos.
- Secciones: Description, Installation, FAQ, Screenshots, Changelog.
3) Validar con el Plugin Check:
- Instala plugin «Plugin Check» o usa
wp plugin check.
4) Subir:
- Necesitas cuenta en WordPress.org.
- Pide acceso SVN y sube usando:
svn co https://plugins.svn.wordpress.org/mi-plugin/
9.5 – Preparar para venta (CodeCanyon o tu web)
- Incluye sistema de licencias (opcional).
- Minifica código JS/CSS.
- Ofrece documentación PDF/HTML.
9.6 – Checklist final ✅
- Text domain en todos los textos.
- Código documentado y comentado.
- No hay avisos en debug (
WP_DEBUGactivado). - Escapado y sanitización correctos.
- Desinstalación limpia (
uninstall.php). - Versionado claro y changelog.
💡 Mini retos finales:
- Añade soporte de traducción a todo tu plugin.
- Configura Vite para compilar SCSS a CSS minificado.
- Prepara un
readme.txtque cumpla el estándar de WordPress.org. - Sube tu primer plugin gratuito al repositorio oficial.
✅ Logro final desbloqueado:
Has pasado de cero a crear un plugin modular, seguro, traducible, con CPTs, AJAX, assets modernos, y listo para publicar o vender.
Aprender a programar plugins para WordPress modo experto⚙️
Perfecto, vamos a meternos en nivel experto, donde ya no hablamos solo de “hacer un plugin” sino de arquitectura avanzada, rendimiento, integración con APIs externas y patrones de diseño.
Te propongo que la LECCIÓN 10 sea sobre integraciones avanzadas con la REST API y APIs externas y luego vamos escalando hacia patrones de arquitectura, pruebas automáticas y optimización.
LECCIÓN 10 – Integraciones avanzadas con la REST API y APIs externas 🌐⚡
Objetivo: Aprender a crear endpoints propios en la REST API de WordPress y consumir APIs externas de forma segura y óptima.
10.1 – ¿Por qué usar la REST API?
- Permite que tu plugin se comunique con apps externas, SPA (React, Vue), móviles, etc.
- Puedes exponer datos de WordPress o procesar datos que vienen de fuera.
- También puedes consumir APIs externas y devolver resultados filtrados.
10.2 – Crear un endpoint propio
Ejemplo: /wp-json/mi-plugin/v1/cita/random
add_action('rest_api_init', function(){
register_rest_route('mi-plugin/v1', '/cita/random', [
'methods' => 'GET',
'callback' => function(){
$q = new WP_Query([
'post_type' => 'cita',
'posts_per_page' => 1,
'orderby' => 'rand'
]);
if (!$q->have_posts()) {
return new WP_Error('no_citas', 'No hay citas', ['status' => 404]);
}
$q->the_post();
$fuente = get_post_meta(get_the_ID(), '_cita_fuente', true);
$url = get_post_meta(get_the_ID(), '_cita_url', true);
return [
'texto' => get_the_content(),
'fuente' => $fuente,
'url' => $url
];
},
'permission_callback' => '__return_true' // Público, o agrega tu validación
]);
});
Ahora puedes probar en:
https://tusitio.com/wp-json/mi-plugin/v1/cita/random
10.3 – Consumir APIs externas
Ejemplo: obtener datos de una API pública (Chuck Norris Jokes):
$response = wp_remote_get('https://api.chucknorris.io/jokes/random');
if (is_wp_error($response)) {
return 'Error al conectar con la API';
}
$data = json_decode(wp_remote_retrieve_body($response), true);
echo esc_html($data['value']);
Tip: Usa
wp_remote_get/wp_remote_posten lugar defile_get_contents()para respetar las configuraciones y seguridad de WP.
10.4 – Seguridad en la REST API
- Endpoints públicos: valida entradas con
sanitize_text_field,absint, etc. - Endpoints privados: usa
permission_callbackconcurrent_user_can()o tokens. - Evita devolver datos sensibles (usuarios, emails, etc.) sin autorización.
10.5 – Integración de APIs externas en Shortcodes o Bloques
Puedes combinar la API externa con un shortcode que muestre datos en la web.
Ejemplo:
add_shortcode('chiste_random', function(){
$res = wp_remote_get('https://api.chucknorris.io/jokes/random');
if (is_wp_error($res)) return 'Error';
$data = json_decode(wp_remote_retrieve_body($res), true);
return '<p>'.esc_html($data['value']).'</p>';
});
10.6 – Mini retos avanzados
- Crea un endpoint
/mi-plugin/v1/cita/por-autor/{slug}que devuelva solo las citas de ese autor. - Haz un shortcode que muestre la temperatura de tu ciudad usando una API de clima (ej. OpenWeatherMap) y guarde en caché el resultado con
set_transient()para evitar exceso de llamadas. - Expón un endpoint protegido que permita crear una “Cita” vía POST desde una app externa, validando un token secreto.
✅ Logro desbloqueado: Ya dominas la creación de endpoints y consumo de APIs en WordPress, abriendo la puerta a integraciones con apps y servicios externos.
Perfecto, vamos a entrar en terreno de arquitectura profesional de plugins, donde lo que importa no es solo que funcione, sino que sea mantenible, escalable y testeable como lo haría un equipo grande.
LECCIÓN 11 – Patrones de arquitectura para plugins grandes 🏗️
Objetivo: Aprender cómo organizar tu código usando patrones de diseño y estructuras que usan plugins como WooCommerce, Yoast o Elementor.
11.1 – Problema de los plugins grandes
- Crecen y se llenan de
add_action()y funciones por todos lados. - Se vuelve difícil localizar dónde está cada cosa.
- Añadir o cambiar funciones rompe otras partes.
- No hay separación clara entre datos, lógica y presentación.
Solución: Patrones de diseño + organización modular.
11.2 – Patrones clave para plugins
Te muestro los más útiles en WordPress:
1) Service Container (Inyección de dependencias)
Permite centralizar la creación de clases y compartir instancias.
Ejemplo básico:
class Container {
private $services = [];
public function set($name, callable $callback) {
$this->services[$name] = $callback;
}
public function get($name) {
return $this->services[$name]($this);
}
}
// Uso
$container = new Container();
$container->set('options', fn() => new Options());
$container->set('shortcodes', fn($c) => new Shortcodes($c->get('options')));
$shortcodes = $container->get('shortcodes');
En plugins grandes, esto evita repetir new Clase() en mil sitios y permite cambiar dependencias fácilmente.
2) Repository Pattern
Aísla la lógica de acceso a datos.
Ejemplo para un CPT “cita”:
class CitaRepository {
public function getRandom() {
$q = new WP_Query([
'post_type' => 'cita',
'posts_per_page' => 1,
'orderby' => 'rand'
]);
return $q->have_posts() ? $q->posts[0] : null;
}
}
Ventaja: Si mañana cambias de CPT a tabla personalizada, solo modificas este archivo.
3) Factory Pattern
Crea objetos complejos centralmente.
Ejemplo: Crear bloques de Gutenberg preconfigurados:
class BlockFactory {
public function createQuoteBlock($quote, $author) {
return "<blockquote>{$quote}<footer>{$author}</footer></blockquote>";
}
}
4) Observer Pattern (Eventos)
WordPress ya es un “Observer gigante” con add_action y add_filter.
Puedes usarlo internamente para que tus módulos se comuniquen sin depender directamente unos de otros.
Ejemplo simple:
class EventManager {
private $listeners = [];
public function listen($event, callable $callback) {
$this->listeners[$event][] = $callback;
}
public function dispatch($event, $payload = null) {
foreach ($this->listeners[$event] ?? [] as $listener) {
$listener($payload);
}
}
}
5) Strategy Pattern
Permite cambiar “estrategias” en tiempo de ejecución.
Ejemplo: diferentes formas de mostrar una cita (texto simple, con imagen, en slider).
interface CitaRender {
public function render($cita);
}
class TextoPlano implements CitaRender {
public function render($cita) {
return esc_html($cita->post_content);
}
}
class ConImagen implements CitaRender {
public function render($cita) {
return '<div class="cita-img">'.get_the_post_thumbnail($cita).'<p>'.$cita->post_content.'</p></div>';
}
}
11.3 – Carpeta orientada a dominio
En lugar de tener carpetas solo por “tipo” (Admin, Front…), también puedes organizar por módulo funcional:
modules/
Citas/
CitaRepository.php
CitaController.php
CitaShortcodes.php
Usuarios/
UserProfile.php
UserDashboard.php
Esto facilita aislar cada funcionalidad y moverla a otro plugin si hace falta.
11.4 – Testing
Cuando tienes clases limpias y con dependencias inyectadas, puedes hacer tests unitarios fácilmente:
class CitaRepositoryTest extends WP_UnitTestCase {
public function testGetRandomReturnsNullIfNoCitas() {
$repo = new CitaRepository();
$this->assertNull($repo->getRandom());
}
}
Con WP-CLI:
wp scaffold plugin-tests mi-pluginphpunit
11.5 – Mini retos
- Reorganiza tu plugin actual usando un Container para centralizar instancias.
- Crea un Repository para manejar todos los accesos a tu CPT.
- Aplica Strategy Pattern para que tu shortcode
[cita_random]pueda mostrar la cita en varios formatos según un parámetro (formato="imagen"). - Añade tests unitarios para tu Repository.
✅ Logro desbloqueado: Ahora sabes cómo organizar un plugin como un pro, usando patrones de diseño que hacen que tu código sea mantenible incluso con 50.000 líneas.
En la LECCIÓN 12 entraremos en rendimiento y escalabilidad: consultas optimizadas, transients, object cache, lazy loading, y cómo evitar que tu plugin ralentice la web.
Perfecto, vamos a por nivel ultra-pro ⚡.
Aquí vamos a ver cómo hacer que tu plugin sea rápido, eficiente y escalable incluso en sitios grandes con miles de visitas.
LECCIÓN 12 – Rendimiento y escalabilidad 🚀
Objetivo: Evitar que tu plugin consuma más recursos de los necesarios y optimizar la carga.
12.1 – Problemas comunes de rendimiento en plugins
- Consultas a la base de datos innecesarias o no optimizadas.
- No usar caché y recalcular siempre datos.
- Cargar scripts/estilos en todas las páginas, aunque no se usen.
- Hacer demasiadas peticiones externas (APIs) sin caching.
- No limitar resultados (ej.: traer 1000 posts cuando solo muestras 5).
12.2 – Optimizar consultas
Ejemplo malo ❌:
$q = new WP_Query([
'post_type' => 'cita',
'posts_per_page' => -1 // carga todas las citas
]);
Ejemplo bueno ✅:
$q = new WP_Query([
'post_type' => 'cita',
'posts_per_page' => 5,
'no_found_rows' => true, // no calcula paginación si no la necesitas
'fields' => 'ids' // solo IDs si no necesitas todo el post
]);
Tip:
no_found_rows => truepuede ahorrar mucho tiempo en consultas grandes.
12.3 – Usar Transients para caché temporal
Sirve para guardar datos calculados o API calls durante X tiempo.
function mi_plugin_get_cita_random() {
$cita = get_transient('mi_plugin_cita_random');
if (false === $cita) {
$q = new WP_Query([
'post_type' => 'cita',
'posts_per_page' => 1,
'orderby' => 'rand',
'no_found_rows' => true
]);
if ($q->have_posts()) {
$q->the_post();
$cita = [
'texto' => get_the_content(),
'fuente' => get_post_meta(get_the_ID(), '_cita_fuente', true)
];
} else {
$cita = null;
}
wp_reset_postdata();
set_transient('mi_plugin_cita_random', $cita, HOUR_IN_SECONDS);
}
return $cita;
}
Así, solo haces la query una vez cada hora.
12.4 – Object Cache
Si tu servidor tiene Redis o Memcached, wp_cache_set() y wp_cache_get() son más rápidos que los transients.
Ejemplo:
$cita = wp_cache_get('cita_random', 'mi-plugin');
if (false === $cita) {
// ... consulta aquí
wp_cache_set('cita_random', $cita, 'mi-plugin', 3600);
}
12.5 – Carga condicional de scripts y estilos
No cargues en todo el sitio, solo donde se use.
add_action('wp_enqueue_scripts', function(){
if (!is_singular()) return; // solo en posts/páginas
if (!has_shortcode(get_post()->post_content, 'cita_random')) return;
wp_enqueue_style('mi-plugin-front', MI_PLUGIN_URL.'assets/css/front.css', [], '1.0.0');
});
12.6 – Lazy Loading y diferir cargas
- Usa
loading="lazy"en imágenes. - Para JS no crítico, usa
wp_enqueue_script(..., true)para pie de página odefer. - Plugins grandes usan
import()dinámicos en JS moderno.
12.7 – Evitar bucles con muchas queries
Ejemplo malo ❌:
$citas = get_posts(['post_type' => 'cita']);
foreach ($citas as $cita) {
$meta = get_post_meta($cita->ID, '_cita_fuente', true); // consulta por cada post
}
Ejemplo bueno ✅:
$citas = get_posts([
'post_type' => 'cita',
'meta_key' => '_cita_fuente',
'posts_per_page' => 20
]);
O usar update_post_meta_cache() para precargar todos los metadatos.
12.8 – Monitorizar rendimiento
- Usa Query Monitor (plugin) para ver queries lentas.
- Activa
SAVEQUERIESpara registrar todas las queries (solo en desarrollo). - Mide tiempos con
microtime(true)antes y después.
12.9 – Mini retos
- Optimiza tu shortcode
[cita_random]para que use transients y no_found_rows. - Haz que tu plugin cargue solo sus scripts si la página contiene un shortcode suyo.
- Si usas APIs externas, añade caché de mínimo 30 minutos para las respuestas.
- Usa
fields => 'ids'en WP_Query y carga metadatos en un solo paso.
✅ Logro desbloqueado: Ahora sabes cómo mantener tu plugin rápido y ligero incluso en webs enormes.
Muy pronto, en la LECCIÓN 13 te enseño cómo crear tablas personalizadas en la base de datos con dbDelta() para cuando el CPT ya no es suficiente y necesitas control total sobre tus datos.
¡Seguimos subiendo el nivel! 🧩
LECCIÓN 13 – Tablas personalizadas en la base de datos (dbDelta, CRUD, migraciones)
Objetivo: Cuando un CPT/metadata se queda corto (consultas complejas, informes, grandes volúmenes), creamos tablas propias con índices y migraciones seguras.
13.1 – ¿Cuándo usar tablas personalizadas?
- Necesitas consultas agregadas y filtros complejos (reporting).
- Guardas muchos registros por día (logs, analítica, colas).
- Requieres índices específicos para rendimiento.
- Datos no encajan bien en posts/meta.
Regla práctica: empieza con CPT + meta. Si duele rendimiento o modelado → tabla propia.
13.2 – Crear la tabla con dbDelta()
En el hook de activación define el esquema. dbDelta() crea/actualiza la tabla si cambió el SQL.
register_activation_hook(__FILE__, 'mi_plugin_activate');
function mi_plugin_activate(){
global $wpdb;
$table = $wpdb->prefix . 'citas_log'; // ejemplo
$charset_collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE $table (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
cita_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED DEFAULT 0,
visto_en VARCHAR(191) NOT NULL, -- dónde se mostró (slug, shortcode, etc.)
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY idx_cita (cita_id),
KEY idx_user (user_id),
KEY idx_created (created_at)
) $charset_collate;";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta($sql);
// Control de versión de esquema
add_option('mi_plugin_db_version', '1.0.0');
}
Puntos clave para dbDelta:
- Cada columna e índice en línea separada, coma al final excepto última.
- Usa
PRIMARY KEY,KEY ... (columna). - Longitud
VARCHAR(191)para compatibilidad con índices en utf8mb4. get_charset_collate()aseguraCHARSETyCOLLATEcorrectos.
13.3 – Migraciones (actualizaciones de esquema)
Si cambias el esquema, aumenta mi_plugin_db_version y ejecuta migración en plugins_loaded.
add_action('plugins_loaded', function(){
$current = get_option('mi_plugin_db_version', '1.0.0');
if (version_compare($current, '1.1.0', '<')) {
mi_plugin_migrate_110();
update_option('mi_plugin_db_version', '1.1.0');
}
});
function mi_plugin_migrate_110(){
global $wpdb;
$table = $wpdb->prefix.'citas_log';
$charset_collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE $table (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
cita_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED DEFAULT 0,
visto_en VARCHAR(191) NOT NULL,
created_at DATETIME NOT NULL,
source VARCHAR(50) DEFAULT NULL, -- nueva columna
PRIMARY KEY (id),
KEY idx_cita (cita_id),
KEY idx_user (user_id),
KEY idx_created (created_at),
KEY idx_source (source)
) $charset_collate;";
require_once ABSPATH.'wp-admin/includes/upgrade.php';
dbDelta($sql); // añade la columna e índice si faltan
}
Evita
ALTER TABLEmanual cuandodbDelta()puede deducir los cambios.
13.4 – Capa de acceso a datos (CRUD con $wpdb + prepare)
Crea una clase para encapsular accesos (Repository).
class CitasLogRepository {
private string $table;
public function __construct(){
global $wpdb;
$this->table = $wpdb->prefix . 'citas_log';
}
public function insert(int $cita_id, int $user_id, string $visto_en, string $source=null): int {
global $wpdb;
$wpdb->insert($this->table, [
'cita_id' => $cita_id,
'user_id' => $user_id,
'visto_en' => $visto_en,
'created_at' => current_time('mysql'),
'source' => $source,
], ['%d','%d','%s','%s','%s']);
return (int) $wpdb->insert_id;
}
public function get_by_cita(int $cita_id, int $limit=20, int $offset=0): array {
global $wpdb;
$sql = $wpdb->prepare(
"SELECT * FROM {$this->table}
WHERE cita_id = %d
ORDER BY created_at DESC
LIMIT %d OFFSET %d",
$cita_id, $limit, $offset
);
return $wpdb->get_results($sql, ARRAY_A);
}
public function count_by_source(string $source): int {
global $wpdb;
$sql = $wpdb->prepare(
"SELECT COUNT(*) FROM {$this->table} WHERE source = %s",
$source
);
return (int) $wpdb->get_var($sql);
}
public function delete_older_than(string $date): int {
global $wpdb;
$sql = $wpdb->prepare(
"DELETE FROM {$this->table} WHERE created_at < %s",
$date
);
$wpdb->query($sql);
return (int) $wpdb->rows_affected;
}
}
Buenas prácticas:
- Siempre
prepare()con%d,%s,%f. - Usa
current_time('mysql')(respeta zona/ajustes WP). - Devuelve tipos claros (int/array).
13.5 – Registrar eventos (ej. cuando se muestra una cita)
Integra el repo en tu shortcode o AJAX:
add_shortcode('cita_random', function(){
$cita = mi_plugin_get_cita_random(); // como en secciones previas (cacheada)
if (!$cita) return '<em>Sin citas</em>';
// Registrar log
$repo = new CitasLogRepository();
$repo->insert( get_the_ID(), get_current_user_id(), 'shortcode', 'front' );
return '<blockquote>'.wp_kses_post($cita['texto']).'</blockquote>';
});
13.6 – Listado en el admin con WP_List_Table (básico)
Para crear una tabla en el admin (paginada/ordenable):
- Crea una página de admin (ya sabes hacerlo).
- Dentro, instancia una clase que extienda
WP_List_Table.
if ( ! class_exists('WP_List_Table') ) {
require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
}
class CitasLogTable extends WP_List_Table {
private CitasLogRepository $repo;
public function __construct(){
parent::__construct(['singular'=>'registro','plural'=>'registros','ajax'=>false]);
$this->repo = new CitasLogRepository();
}
public function get_columns(){
return [
'id' => 'ID',
'cita_id' => 'Cita',
'user_id' => 'Usuario',
'visto_en' => 'Visto en',
'source' => 'Origen',
'created_at' => 'Fecha'
];
}
public function prepare_items(){
$per_page = 20;
$paged = max(1, (int)($_GET['paged'] ?? 1));
$offset = ($paged - 1) * $per_page;
$items = $this->repo->get_by_cita( (int)($_GET['cita_id'] ?? 0), $per_page, $offset );
$this->items = $items;
// set_pagination_args necesita total_items; puedes añadir un método count_total()
$this->set_pagination_args([
'total_items' => 200, // TODO: real total
'per_page' => $per_page
]);
}
public function column_default($item, $column_name){
return esc_html( $item[$column_name] ?? '' );
}
}
13.7 – Rendimiento: índices, tipos y mantenimiento
- Índices: crea índices para columnas por las que filtras/ordenas (ej.
created_at,cita_id,source). - Usa tipos enteros para claves/contadores; evita
TEXTsi no hace falta. - Paginar siempre en vistas.
- Limpia datos antiguos con un cron (ver 13.8).
FOREIGN KEY: WordPress no impone FKs por compatibilidad; si tu hosting usa InnoDB puedes añadirlos, pero asume que no siempre estarán (es opcional).
13.8 – Cron para tareas de limpieza/agregación
Programa borrados o agregados diarios:
register_activation_hook(__FILE__, function(){
if (!wp_next_scheduled('mi_plugin_cron_diario')) {
wp_schedule_event(time(), 'daily', 'mi_plugin_cron_diario');
}
});
register_deactivation_hook(__FILE__, function(){
wp_clear_scheduled_hook('mi_plugin_cron_diario');
});
add_action('mi_plugin_cron_diario', function(){
// Borrar registros de hace > 90 días
(new CitasLogRepository())->delete_older_than( gmdate('Y-m-d H:i:s', strtotime('-90 days')) );
});
13.9 – Exponer informes por REST (solo para admins)
add_action('rest_api_init', function(){
register_rest_route('mi-plugin/v1', '/stats/source', [
'methods' => 'GET',
'permission_callback' => function(){ return current_user_can('manage_options'); },
'callback' => function(){
global $wpdb;
$table = $wpdb->prefix.'citas_log';
$rows = $wpdb->get_results("SELECT source, COUNT(*) as total FROM $table GROUP BY source", ARRAY_A);
return $rows ?: [];
}
]);
});
13.10 – Desinstalación limpia
En uninstall.php borra opciones y (opcional) la tabla (pregúntate si el usuario espera conservar datos):
<?php
if (!defined('WP_UNINSTALL_PLUGIN')) exit;
global $wpdb;
$table = $wpdb->prefix.'citas_log';
// Opción 1: conservar datos (recomendado en producción)
// delete_option('mi_plugin_db_version');
// Opción 2: borrar todo (¡peligroso!)
// $wpdb->query("DROP TABLE IF EXISTS $table");
// delete_option('mi_plugin_db_version');
Mini retos 🎯
- Añade un método
count_total()en el repositorio y úsalo enWP_List_Tablepara paginar correctamente. - Crea un índice compuesto
(cita_id, created_at)y mide mejoras en una consulta filtrada y ordenada. - Implementa un endpoint REST
/stats/rango?desde=YYYY-MM-DD&hasta=YYYY-MM-DDcon validación yprepare(). - Añade una página de informes en el admin con un gráfico (Chart.js) consumiendo tu endpoint
/stats/source. - (Pro) Implementa batch processing: un comando WP-CLI que recorra registros y calcule métricas agregadas.
✅ Logro desbloqueado: Ya sabes modelar datos con tablas personalizadas, migrarlos con dbDelta, exponerlos por REST, administrarlos en el panel y mantener rendimiento.
En la próxima LECCIÓN 14 veremos – Bloques de Gutenberg avanzados (build con React/Vite, atributos dinámicos, useSelect/useDispatch) y luego en la LECCIÓN 15 veremos – Licenciamiento, updates remotos, y arquitectura de add-ons
¡Hecho! Vamos con bloques a nivel pro 🤓
LECCIÓN 14 – Bloques de Gutenberg avanzados (React, atributos, controles, datos, dinámicos)
Objetivo: Crear bloques modernos con React, con paneles de configuración, estados dinámicos, datos desde WP y renderizado en PHP cuando conviene.
14.1 – Setup rápido del entorno
Opción A: @wordpress/scripts (simple)
npm init -y
npm install @wordpress/scripts @wordpress/blocks @wordpress/i18n @wordpress/element @wordpress/block-editor @wordpress/components @wordpress/data --save-dev
package.json:
"scripts": {
"start": "wp-scripts start",
"build": "wp-scripts build"
}
Estructura:
mi-plugin/
mi-plugin.php
blocks/
cita-destacada/
block.json
edit.js
save.js
style.css
editor.css
14.2 – Tu primer bloque “Cita destacada” (con atributos y controles)
block.json
{
"apiVersion": 2,
"name": "mi-plugin/cita-destacada",
"title": "Cita destacada",
"category": "text",
"icon": "format-quote",
"description": "Una cita con autor y estilo personalizable.",
"attributes": {
"texto": { "type": "string", "source": "html", "selector": "blockquote" },
"autor": { "type": "string", "default": "" },
"align": { "type": "string", "default": "center" },
"color": { "type": "string", "default": "#00a0d2" }
},
"supports": {
"html": false,
"align": ["left", "center", "right"],
"spacing": { "margin": true, "padding": true }
},
"editorScript": "file:./index.js",
"style": "file:./style.css",
"editorStyle": "file:./editor.css"
}
index.js (re-exporta edit/save)
import Edit from './edit';
import save from './save';
import { registerBlockType } from '@wordpress/blocks';
registerBlockType('mi-plugin/cita-destacada', {
edit: Edit,
save
});
edit.js (controles + RichText + Inspector)
import { __ } from '@wordpress/i18n';
import { RichText, InspectorControls, BlockControls, AlignmentToolbar, useBlockProps } from '@wordpress/block-editor';
import { PanelBody, TextControl, ColorPicker } from '@wordpress/components';
export default function Edit({ attributes, setAttributes }) {
const { texto, autor, align, color } = attributes;
const blockProps = useBlockProps({
className: 'mi-cita',
style: { borderLeft: `4px solid ${color}`, textAlign: align }
});
return (
<>
<BlockControls>
<AlignmentToolbar
value={align}
onChange={(v) => setAttributes({ align: v })}
/>
</BlockControls>
<InspectorControls>
<PanelBody title={__('Apariencia', 'mi-plugin')} initialOpen>
<ColorPicker
color={color}
onChangeComplete={(c) => setAttributes({ color: c.hex })}
/>
<TextControl
label={__('Autor', 'mi-plugin')}
value={autor}
onChange={(v) => setAttributes({ autor: v })}
placeholder={__('Ej: Gabriel García Márquez', 'mi-plugin')}
/>
</PanelBody>
</InspectorControls>
<div {...blockProps}>
<RichText
tagName="blockquote"
value={texto}
onChange={(v) => setAttributes({ texto: v })}
placeholder={__('Escribe la cita…', 'mi-plugin')}
/>
{autor && <p style={{ opacity: 0.8, marginTop: 6 }}>— {autor}</p>}
</div>
</>
);
}
save.js (salida estática en HTML)
import { useBlockProps, RichText } from '@wordpress/block-editor';
export default function save({ attributes }) {
const { texto, autor, align, color } = attributes;
const blockProps = useBlockProps.save({
className: 'mi-cita',
style: { borderLeft: `4px solid ${color}`, textAlign: align }
});
return (
<div {...blockProps}>
<RichText.Content tagName="blockquote" value={texto} />
{autor && <p>— {autor}</p>}
</div>
);
}
style.css (front)
.mi-cita { padding: 12px 16px; background: #111; color: #fff; }
.mi-cita blockquote { margin: 0; font-size: 1.25rem; line-height: 1.5; }
editor.css (solo editor)
.mi-cita { outline: 1px dashed rgba(255,255,255,.15); }
En tu mi-plugin.php asegúrate de registrar assets del bloque si no usas block.json con handles globales. Con block.json + @wordpress/scripts no necesitas mucho más: wp-scripts detecta y crea el bundle index.js.
Build y probar
npm run build
Activa el plugin, añade el bloque “Cita destacada” en el editor y juega con los controles.
14.3 – Bloque dinámico (renderizado PHP)
Cuando el contenido depende del tiempo real (consultas, permisos, datos externos), usa render_callback.
block.json (dinámico)
{
"apiVersion": 2,
"name": "mi-plugin/cita-random",
"title": "Cita aleatoria (dinámica)",
"category": "widgets",
"icon": "randomize",
"attributes": {
"autor": { "type": "string", "default": "" },
"mostrarFuente": { "type": "boolean", "default": true }
},
"editorScript": "file:./index.js",
"render": "file:./render.php"
}
render.php
<?php
$autor = isset($attributes['autor']) ? sanitize_title($attributes['autor']) : '';
$mostrar = ! empty($attributes['mostrarFuente']);
$args = [
'post_type' => 'cita',
'posts_per_page' => 1,
'orderby' => 'rand',
'no_found_rows' => true
];
if ($autor) {
$args['tax_query'] = [[
'taxonomy' => 'autor_cita',
'field' => 'slug',
'terms' => $autor
]];
}
$q = new WP_Query($args);
if (!$q->have_posts()) return '<em>No hay citas</em>';
$q->the_post();
$fuente = get_post_meta(get_the_ID(), '_cita_fuente', true);
$out = '<div class="mi-cita-dinamica"><blockquote>'.wp_kses_post(get_the_content()).'</blockquote>';
if ($mostrar && $fuente) $out .= '<p>— '.esc_html($fuente).'</p>';
$out .= '</div>';
wp_reset_postdata();
echo $out;
index.js (solo UI del editor: paneles/controles, no guarda HTML)
import { __ } from '@wordpress/i18n';
import { InspectorControls, useBlockProps } from '@wordpress/block-editor';
import { PanelBody, TextControl, ToggleControl } from '@wordpress/components';
export default function Edit({ attributes, setAttributes }) {
const { autor, mostrarFuente } = attributes;
const props = useBlockProps({ className: 'mi-cita-dinamica' });
return (
<>
<InspectorControls>
<PanelBody title={__('Opciones', 'mi-plugin')} initialOpen>
<TextControl
label={__('Autor (slug)', 'mi-plugin')}
value={autor}
onChange={(v) => setAttributes({ autor: v })}
/>
<ToggleControl
label={__('Mostrar fuente', 'mi-plugin')}
checked={!!mostrarFuente}
onChange={(v) => setAttributes({ mostrarFuente: v })}
/>
</PanelBody>
</InspectorControls>
<div {...props}>
<em>{__('Vista previa generada por PHP al guardar/visualizar', 'mi-plugin')}</em>
</div>
</>
);
}
Ventajas: No “horneas” HTML en el contenido: siempre se muestra lo último.
14.4 – InnerBlocks y bloques compuestos
Para crear layouts o contenedores donde el usuario inserta otros bloques:
import { __ } from '@wordpress/i18n';
import { InnerBlocks, useBlockProps } from '@wordpress/block-editor';
export default function Edit() {
const props = useBlockProps({ className: 'mi-hero' });
return (
<section {...props}>
<InnerBlocks
allowedBlocks={['core/heading','core/paragraph','core/buttons']}
template={[
['core/heading', { level: 2, placeholder: 'Título del hero' }],
['core/paragraph', { placeholder: 'Subtítulo o descripción...' }],
['core/buttons']
]}
templateLock={false}
/>
</section>
);
}
export function save() {
const props = useBlockProps.save({ className: 'mi-hero' });
return (
<section {...props}>
<InnerBlocks.Content />
</section>
);
}
14.5 – useSelect / useDispatch (datos en vivo desde WordPress)
Ejemplo: selector de Citas (CPT) dentro del editor.
import { useSelect } from '@wordpress/data';
import apiFetch from '@wordpress/api-fetch';
import { SelectControl, Button } from '@wordpress/components';
export default function CitaPicker({ attributes, setAttributes }) {
const { citaId } = attributes;
const posts = useSelect( (select) => {
return select('core').getEntityRecords('postType', 'cita', { per_page: 20 }) || [];
}, [] );
return (
<div>
<SelectControl
label="Elige una cita"
value={citaId}
options={[{ label:'—', value:'' }].concat(
posts.map(p => ({ label: p.title.rendered, value: p.id }))
)}
onChange={(v) => setAttributes({ citaId: parseInt(v) || '' })}
/>
<Button
variant="secondary"
onClick={async () => {
if (!citaId) return;
const data = await apiFetch({ path: `/wp/v2/cita/${citaId}` });
alert(`Contenido: ${data.content.rendered.replace(/<[^>]+>/g,'')}`);
}}
>
Previsualizar
</Button>
</div>
);
}
Nota: para
apiFetchen el editor, WordPress ya gestiona el nonce.
14.6 – Buenas prácticas (bloques)
block.json: define todo ahí; WP puede auto-registrar assets.- Atributos minimalistas: guarda lo necesario; lo demás, calcúlalo en render.
- Server-side cuando toque: datos dinámicos →
render.php. - Accesibilidad: usa elementos semánticos (
blockquote,figure,aria-*). - Estilos scoped: usa clases únicas (prefijo del plugin).
- Performance: evita dependencias pesadas; split de código si es grande.
- i18n: envuelve textos con
__()y fijatextdomain.
14.7 – Mini retos
- Convierte “Cita destacada” en bloque compuesto: título, cita (RichText) y botón opcional (link).
- Crea un bloque dinámico que liste 5 citas recientes con paginación usando atributos (
perPage,autor). - Añade un control de color de fondo (PanelColorSettings) y un control tipográfico (FontSizePicker).
- Implementa un pattern (patrón reutilizable) que inserte tu bloque con una plantilla predefinida.
- (Pro) Carga datos de una REST API propia desde el editor y muestra un skeleton mientras carga.
✅ Logro desbloqueado: Sabes crear bloques modernos con React, controles en el inspector, consumo de datos del editor, bloques dinámicos renderizados en PHP y layouts con InnerBlocks.
¿Listo para la LECCIÓN 15 – Licenciamiento, actualizaciones remotas y arquitectura de add-ons cuando quieras? Muy pronto la veremos. De momento trabaja con los ejemplos que te he propuesto y luego ya podrás pasar a la LECCION 15
Recuerda…
Curso online diseño de plugins Wordpress 👈Para repasar las LECCIONES 1 hasta la 8 en donde se explican los primeros pasos para crear tu propio plugin optimizado.
.