
Bienvenido a esta nueva sección donde empezaremos el curso online de diseño de plugins WordPress . De 0 a avanzado, paso a paso con mini retos que iremos añadiendo cada vez con mayor dificultad. Que lo disfrutes!
LECCIÓN 1 – Tu primer plugin en 5 minutos 🏁
Objetivo: Que veas que crear un plugin es mucho más sencillo de lo que parece.
1.1 – ¿Qué es un plugin?
Un plugin de WordPress es básicamente un archivo PHP (o varios) que “le dice” a WordPress que ejecute código extra.
- No necesitas cambiar el núcleo de WordPress.
- Puedes activar/desactivar sin romper la web.
- Puede ser desde algo muy simple hasta algo enorme como WooCommerce.
1.2 – Crea la carpeta de tu primer plugin
- Ve a la carpeta donde está instalado WordPress →
wp-content/plugins/ - Crea una carpeta llamada
hola-mundo. - Dentro de esa carpeta, crea un archivo llamado
hola-mundo.php.
1.3 – El código más simple posible
Abre hola-mundo.php y escribe esto:
<?php
/*
Plugin Name: Hola Mundo
Description: Mi primer plugin que muestra un mensaje al final de la página.
Version: 1.0
Author: Tu Nombre
*/
// Acción: ejecuta esto justo antes de cerrar la etiqueta </body>
add_action('wp_footer', function(){
echo '<p style="text-align:center;color:blue;font-weight:bold;">
Hola Mundo desde mi primer plugin 🎉
</p>';
});
1.4 – Actívalo
- Ve a Plugins en tu panel de WordPress.
- Verás «Hola Mundo» en la lista.
- Haz clic en Activar.
- Abre tu sitio y desplázate hasta el final: verás el mensaje.
✅ Logro desbloqueado: ¡Ya tienes tu primer plugin funcional!
No importa que sea simple: ya dominas lo básico.
💡 Mini reto para ti:
Cambia el mensaje para que diga algo como
«He creado mi primer plugin y funciona 😎»
y cambia el color a rojo.
Perfecto, seguimos con la LECCIÓN 2 – Shortcodes
Esta parte es muy divertida porque aprenderás a poner contenido de tu plugin en cualquier parte de WordPress sin tocar plantillas, solo usando un “código corto” en el editor.
LECCIÓN 2 – Usando Shortcodes para mostrar contenido donde quieras ✨
Objetivo: Crear un shortcode y usarlo en páginas, entradas o widgets.
2.1 – ¿Qué es un shortcode?
Un shortcode es como una palabra mágica que escribes entre corchetes en el editor, por ejemplo:
[fecha_hoy]
y WordPress lo reemplaza por el contenido que tú le digas en tu plugin.
2.2 – Creando un shortcode
Vamos a modificar tu plugin Hola Mundo para añadir uno.
Abre tu archivo hola-mundo.php y añade esto debajo de lo que ya tienes:
// Shortcode: [saludo]
add_shortcode('saludo', function(){
return '<p style="color:green;font-size:20px;">¡Hola! Este es mi primer shortcode 😎</p>';
});
2.3 – Cómo usarlo
- Ve a una página o entrada de WordPress.
- Escribe:
[saludo] - Guarda y visualiza la página.
Vas a ver el texto que pusimos en el shortcode, con el estilo verde y grande.
2.4 – Ejemplo más útil: mostrar la fecha actual
Añade otro shortcode:
// Shortcode: [fecha_hoy]
add_shortcode('fecha_hoy', function(){
return date('d/m/Y');
});
Ahora escribe:
Hoy es [fecha_hoy]
y WordPress lo cambiará por la fecha actual automáticamente.
✅ Logro desbloqueado: Ahora puedes mostrar cualquier cosa en cualquier parte de WordPress usando un simple código entre corchetes.
💡 Mini reto:
Crea un shortcode que muestre el texto «Visítanos pronto» en azul y centrado.
Genial 🚀
Vamos con la LECCIÓN 3 – Menú y página en el panel de administración.
Aquí aprenderás a que tu plugin aparezca en el menú lateral del admin y tenga su propia página, como hacen los plugins profesionales.
LECCIÓN 3 – Creando un menú en el administrador 🛠️
Objetivo: Añadir una página de opciones básica a tu plugin.
3.1 – ¿Cómo funciona?
WordPress tiene la función add_menu_page() que te permite añadir un enlace en el menú lateral del admin y una página para mostrar contenido.
3.2 – Código básico
Abre tu archivo hola-mundo.php y añade esto al final:
// Añadir un menú en el panel de administración
add_action('admin_menu', function(){
add_menu_page(
'Mi Plugin', // Título de la página
'Mi Plugin', // Texto del menú
'manage_options', // Permisos requeridos
'mi-plugin', // Slug único
'mi_plugin_pagina', // Función que mostrará el contenido
'dashicons-smiley', // Icono del menú (Dashicons)
20 // Posición en el menú
);
});
// Contenido de la página del plugin
function mi_plugin_pagina(){
echo '<div class="wrap">';
echo '<h1>Bienvenido a mi plugin 🎉</h1>';
echo '<p>Aquí podrás configurar opciones y ver información.</p>';
echo '</div>';
}
3.3 – Activar y probar
- Guarda el archivo.
- Recarga el panel de administración.
- Verás un nuevo menú llamado “Mi Plugin” con un icono.
- Haz clic y verás tu nueva página personalizada.
3.4 – Personalización
- Cambia el icono usando la lista oficial de Dashicons:
👉 https://developer.wordpress.org/resource/dashicons/ - Cambia el texto y posición para adaptarlo a tu plugin.
✅ Logro desbloqueado: Ahora tu plugin tiene su propio espacio en el admin, como los grandes.
💡 Mini reto:
Haz que en la página de tu plugin aparezca un mensaje dinámico que diga:
«Hola [nombre del usuario logueado]»
Pista: usa wp_get_current_user().
Perfecto 🔥
Vamos con la LECCIÓN 4 – Guardar opciones en la base de datos.
Esto es lo que va a hacer que tu plugin sea realmente útil, porque así el usuario podrá configurar cosas y que se guarden incluso si apaga el ordenador.
LECCIÓN 4 – Guardar y cargar opciones 💾
Objetivo: Aprender a crear un formulario en la página del plugin para guardar datos en la base de datos y luego mostrarlos.
4.1 – Concepto clave: Opciones de WordPress
WordPress tiene funciones para guardar información sin tener que crear tablas nuevas:
update_option('nombre', $valor)→ Guarda o actualiza un valor.get_option('nombre')→ Recupera un valor guardado.delete_option('nombre')→ Elimina una opción.
4.2 – Añadir un formulario de configuración
Vamos a modificar la función mi_plugin_pagina() de la sección anterior para que tenga un formulario que guarde un mensaje personalizado.
Reemplaza la función anterior por:
function mi_plugin_pagina(){
// Si el formulario ha sido enviado
if(isset($_POST['mi_mensaje'])){
// Seguridad: verificar nonce
if(check_admin_referer('guardar_mi_mensaje')){
$mensaje = sanitize_text_field($_POST['mi_mensaje']);
update_option('mi_mensaje', $mensaje);
echo '<div class="updated"><p>Mensaje guardado ✅</p></div>';
}
}
// Recuperar valor guardado (o vacío si no existe)
$mensaje_guardado = get_option('mi_mensaje', '');
echo '<div class="wrap">';
echo '<h1>Configuración de Mi Plugin</h1>';
echo '<form method="post">';
// Campo de seguridad
wp_nonce_field('guardar_mi_mensaje');
echo '<label for="mi_mensaje">Mensaje personalizado:</label><br>';
echo '<input type="text" name="mi_mensaje" value="' . esc_attr($mensaje_guardado) . '" style="width:300px;"><br><br>';
echo '<input type="submit" value="Guardar" class="button button-primary">';
echo '</form>';
echo '</div>';
}
4.3 – Cómo probarlo
- Ve a tu plugin en el panel de administración.
- Escribe un mensaje y pulsa Guardar.
- Actualiza la página para comprobar que el mensaje sigue ahí (¡está en la base de datos!).
4.4 – Usar ese valor en la web
Ahora vamos a mostrar ese mensaje en cualquier parte usando un shortcode.
Añade esto a tu plugin:
add_shortcode('mensaje_personalizado', function(){
return esc_html( get_option('mi_mensaje', 'No hay mensaje configurado.') );
});
Ahora, en cualquier página/entrada escribe:
[mensaje_personalizado]
y saldrá el mensaje que guardaste en el panel.
✅ Logro desbloqueado: Tu plugin ahora guarda información, la recuerda y la muestra donde quieras.
💡 Mini reto:
Haz que el plugin guarde también un color elegido por el usuario (con <input type="color">) y que el shortcode muestre el mensaje con ese color.
¡Vamos! 🚀
LECCIÓN 5 – AJAX en tu plugin (sin recargar la página) ⚡
Objetivo: Hacer que tu plugin responda a clics del usuario y devuelva datos desde PHP sin recargar.
Vamos a crear un shortcode con un botón que, al hacer clic, pide por AJAX una cita aleatoria al servidor y la muestra.
5.1 – Estructura mínima del plugin
Crea (si no la tienes) esta estructura:
mi-plugin/
mi-plugin.php
assets/
app.js
5.2 – PHP: shortcode + carga del JS + datos seguros
En mi-plugin.php añade:
<?php
/*
Plugin Name: Mi Plugin AJAX
Description: Demostración de AJAX con shortcode.
Version: 1.0
Author: Tu Nombre
*/
// 1) Registrar el script (solo una vez)
add_action('wp_enqueue_scripts', function(){
wp_register_script(
'mi-plugin-app',
plugin_dir_url(__FILE__) . 'assets/app.js',
['jquery'], // puedes quitar 'jquery' si no lo usas
'1.0',
true
);
});
// 2) Shortcode que pinta el botón y el contenedor
add_shortcode('cita_aleatoria', function(){
// Asegura que el JS esté cargado en las páginas donde uses el shortcode
wp_enqueue_script('mi-plugin-app');
// Pasar datos al JS (URL de AJAX y nonce)
wp_localize_script('mi-plugin-app', 'MiPlugin', [
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('mi_plugin_nonce')
]);
// HTML del shortcode
ob_start(); ?>
<div class="mi-plugin-citas">
<button class="mi-plugin-boton">Dame una cita</button>
<p class="mi-plugin-resultado" style="margin-top:10px;"></p>
</div>
<?php
return ob_get_clean();
});
// 3) Endpoint AJAX (para usuarios no logueados y logueados)
add_action('wp_ajax_nopriv_mi_plugin_cita', 'mi_plugin_cita');
add_action('wp_ajax_mi_plugin_cita', 'mi_plugin_cita');
function mi_plugin_cita(){
// Seguridad: validar nonce (debe llamarse 'nonce' en el POST/GET)
check_ajax_referer('mi_plugin_nonce', 'nonce');
$citas = [
'El éxito es la suma de pequeños esfuerzos repetidos día tras día.',
'La mejor manera de predecir el futuro es crearlo.',
'Hazlo simple, pero significativo.',
'La práctica hace al maestro (y a los plugins también).'
];
$texto = $citas[ array_rand($citas) ];
wp_send_json_success(['texto' => $texto]); // Devuelve JSON { success:true, data:{texto:"..."} }
}
5.3 – JavaScript (frontend): assets/app.js
Crea el archivo assets/app.js con:
// Esperar al DOM
document.addEventListener('click', async (e) => {
const btn = e.target.closest('.mi-plugin-boton');
if (!btn) return;
const wrapper = btn.closest('.mi-plugin-citas');
const salida = wrapper.querySelector('.mi-plugin-resultado');
salida.textContent = 'Cargando...';
try {
const form = new FormData();
form.append('action', 'mi_plugin_cita'); // Debe coincidir con wp_ajax_*
form.append('nonce', MiPlugin.nonce); // Mismo nombre que en check_ajax_referer
const res = await fetch(MiPlugin.ajaxUrl, {
method: 'POST',
body: form,
credentials: 'same-origin'
});
const json = await res.json();
if (json.success) {
salida.textContent = json.data.texto;
} else {
salida.textContent = 'Ups, algo salió mal.';
}
} catch (err) {
salida.textContent = 'Error de conexión.';
}
});
5.4 – Usarlo en una página
En el editor escribe:
[cita_aleatoria]
Guarda y prueba el botón. Cada clic debe mostrar una cita distinta sin recargar.
5.5 – ¿Qué acabas de aprender?
wp_enqueue_script+wp_localize_scriptpara pasarajaxUrlynonceal JS.- Rutas AJAX con
admin-ajax.php:wp_ajax_nopriv_*→ visitanteswp_ajax_*→ usuarios logueados
- Responder en JSON con
wp_send_json_success(). check_ajax_referer()para validar el nonce (seguridad).
5.6 – Solución de problemas (rápido)
- ¿No funciona el botón? Abre la consola del navegador (F12) y mira errores.
- ¿
-1como respuesta? Falló elnonce→ revisa que el nombre del campo sea'nonce'y que el action coincida. - ¿404 en
admin-ajax.php? Revisa que el sitio cargue correctamente elajaxUrl(no uses caché agresiva para usuarios logueados).
Mini reto 💪
- Añade un loader (por ejemplo, “⏳”) mientras carga y desactiva el botón.
- Permite varias instancias del shortcode en la misma página (el JS actual ya lo soporta porque usa
closest). - (Nivel pro) Crea una ruta REST con
register_rest_route()y llama confetch('/wp-json/tu-namespace/v1/cita').
¡A tope! 🔒
LECCIÓN 6 – Seguridad y buenas prácticas
Objetivo: Que tu plugin sea sólido, seguro y “WordPress-friendly”.
Te voy a enseñar las 4 defensas básicas: sanitizar → validar → escapar → autorizar, más nonces y SQL seguro.
6.1 – Flujo mental de seguridad (regla de oro)
- Sanitiza lo que llega del usuario (limpia).
- Valida que tiene el formato esperado (comprueba).
- Autoriza: ¿el usuario tiene permiso para hacer esto?
- Escapa al imprimir (protege la salida).
Recuerda: entradas se sanitizan/validan, salidas se escapan.
6.2 – Sanitización de entradas (PHP)
// Texto corto
$nombre = isset($_POST['nombre']) ? sanitize_text_field($_POST['nombre']) : '';
// Texto largo (permitiendo algunos tags)
$bio_raw = isset($_POST['bio']) ? wp_kses_post($_POST['bio']) : '';
// Email / URL
$email = isset($_POST['email']) ? sanitize_email($_POST['email']) : '';
$url = isset($_POST['url']) ? esc_url_raw($_POST['url']) : '';
// Números
$edad = isset($_POST['edad']) ? absint($_POST['edad']) : 0;
6.3 – Validación (reglas de negocio)
if ($edad < 18 || $edad > 120) {
wp_die('Edad inválida'); // o muestra un error amigable en tu UI
}
if (!is_email($email)) {
wp_die('Email inválido');
}
6.4 – Autorización (roles/capabilities)
Siempre comprueba antes de guardar o hacer acciones sensibles:
if (!current_user_can('manage_options')) {
wp_die('No tienes permisos suficientes.');
}
Para cosas de edición de contenido puedes usar capacidades específicas (ej.: edit_posts, publish_posts, edit_user, etc.).
6.5 – Escape en la salida (HTML)
Cuando imprimas, escapa según el contexto:
echo esc_html( $texto ); // texto plano
echo esc_attr( $valor_attr ); // atributos HTML
echo esc_url( $url ); // URLs
printf('<a href="%s">%s</a>', esc_url($url), esc_html($titulo));
6.6 – Nonces (protección CSRF)
Para formularios en el admin (o acciones AJAX) usa nonces:
Form (PHP):
// Dentro del <form>
wp_nonce_field('mi_accion_guardado'); // name=_wpnonce
Procesado (PHP):
if (!isset($_POST['_wpnonce']) || !wp_verify_nonce($_POST['_wpnonce'], 'mi_accion_guardado')) {
wp_die('Nonce inválido'); // evita CSRF
}
AJAX (recuerda la Lección 5):
check_ajax_referer('mi_plugin_nonce', 'nonce'); // 'nonce' es el nombre del campo enviado por JS
6.7 – SQL seguro con $wpdb->prepare()
Nunca interpoles variables directamente en SQL:
global $wpdb;
// ❌ MAL
// $wpdb->query("INSERT INTO $tabla (nombre) VALUES ('{$nombre}')");
// ✅ BIEN
$wpdb->query(
$wpdb->prepare(
"INSERT INTO {$wpdb->prefix}mi_tabla (nombre, edad) VALUES (%s, %d)",
$nombre,
$edad
)
);
Placeholders comunes: %s (string), %d (int), %f (float), %l (lista con prepare avanzado).
6.8 – Carga segura de scripts y estilos
- Usa
wp_enqueue_script/wp_enqueue_style. - Carga solo donde hace falta (por ejemplo, en la página de tu plugin):
add_action('admin_enqueue_scripts', function($hook){
if ($hook !== 'toplevel_page_mi-plugin') return; // slug de tu página
wp_enqueue_style('mi-plugin-admin', plugin_dir_url(__FILE__).'assets/admin.css', [], '1.0');
wp_enqueue_script('mi-plugin-admin', plugin_dir_url(__FILE__).'assets/admin.js', [], '1.0', true);
});
6.9 – Internacionalización (i18n)
Prepara el plugin para traducirse:
// en el init del plugin
add_action('plugins_loaded', function(){
load_plugin_textdomain('mi-plugin', false, dirname(plugin_basename(__FILE__)).'/languages');
});
// Al imprimir textos
echo esc_html__( 'Mensaje guardado', 'mi-plugin' );
_e( 'Guardar', 'mi-plugin' ); // imprime directamente (no recomendado si necesitas escapar)
6.10 – Encabezado y desinstalación limpia
- Incluye metadatos correctos en el header del plugin (ya lo haces).
- Implementa uninstall.php para limpiar opciones si el usuario desinstala:
uninstall.php (en la raíz del plugin):
<?php
if (!defined('WP_UNINSTALL_PLUGIN')) exit;
// Borra opciones creadas por tu plugin
delete_option('mi_mensaje');
delete_option('mi_otro_ajuste');
Ejemplo integrado (guardar con seguridad)
function mi_plugin_guardar(){
// 1) Permisos
if (!current_user_can('manage_options')) wp_die('Sin permisos');
// 2) Nonce
if (!isset($_POST['_wpnonce']) || !wp_verify_nonce($_POST['_wpnonce'], 'mi_accion_guardado')) {
wp_die('Nonce inválido');
}
// 3) Sanitizar
$titulo = isset($_POST['titulo']) ? sanitize_text_field($_POST['titulo']) : '';
// 4) Validar (ejemplo simple)
if ($titulo === '') {
wp_die('El título es obligatorio');
}
// 5) Guardar
update_option('mi_titulo', $titulo);
// 6) Redirigir con éxito
wp_safe_redirect( admin_url('admin.php?page=mi-plugin&guardado=1') );
exit;
}
Mini retos 🧩
- Añade a tu formulario un campo URL y valida con
esc_url_rawal guardar yesc_urlal mostrar. - Cambia tu endpoint AJAX para que solo usuarios logueados con
edit_postspuedan usarlo (si es necesario). - Implementa
uninstall.phpque borre todas las opciones de tu plugin.
✅ Logro desbloqueado: Tu plugin ahora sigue las mejores prácticas y es mucho más difícil de romper o vulnerar.
¡Vamos allá! 📚
LECCIÓN 7 – Custom Post Types (CPT) + Metaboxes
Objetivo: Crear un tipo de contenido propio (por ejemplo, “Citas”) con sus campos, gestionarlo en el admin y mostrarlo en la web.
7.1 – ¿Qué es un CPT?
Un Custom Post Type es como “otro tipo de post” además de Entradas/Páginas. Te permite tener tu propio menú en el admin, con su editor, categorías personalizadas, etc. Ejemplos: “Citas”, “Cursos”, “Eventos”, “Testimonios”.
7.2 – Registrar el CPT “Citas”
Añade esto en tu plugin:
// Registrar CPT: Citas
add_action('init', function () {
$labels = [
'name' => 'Citas',
'singular_name' => 'Cita',
'menu_name' => 'Citas',
'name_admin_bar' => 'Cita',
'add_new' => 'Añadir nueva',
'add_new_item' => 'Añadir nueva cita',
'edit_item' => 'Editar cita',
'new_item' => 'Nueva cita',
'view_item' => 'Ver cita',
'search_items' => 'Buscar citas',
'not_found' => 'No se encontraron citas',
'not_found_in_trash' => 'No hay citas en la papelera',
'all_items' => 'Todas las citas',
];
$args = [
'labels' => $labels,
'public' => true, // visible en front y admin
'show_in_rest' => true, // editor de bloques y REST API
'menu_icon' => 'dashicons-format-quote',
'supports' => ['title', 'editor', 'thumbnail'], // lo que quieres en el editor
'has_archive' => true, // /citas/
'rewrite' => ['slug' => 'citas'],
'capability_type' => 'post', // o define capacidades propias (ver 7.9)
];
register_post_type('cita', $args);
});
Prueba: Recarga el admin: verás Citas en el menú. Crea 2–3 citas con título y contenido.
7.3 – Taxonomía opcional: “Autores de cita”
// Taxonomía: Autor de Cita (jerárquica = como categorías)
add_action('init', function () {
register_taxonomy('autor_cita', 'cita', [
'label' => 'Autores',
'public' => true,
'hierarchical' => true,
'show_in_rest' => true,
'rewrite' => ['slug' => 'autor-cita'],
]);
});
Ahora puedes asignar autores a cada cita.
7.4 – Metabox: campos personalizados (fuente y enlace)
Vamos a añadir un metabox con dos campos: fuente (texto) y enlace (URL).
// Añadir metabox
add_action('add_meta_boxes', function () {
add_meta_box(
'cita_detalles',
'Detalles de la cita',
'cita_metabox_html',
'cita',
'normal',
'default'
);
});
function cita_metabox_html($post) {
// Nonce
wp_nonce_field('guardar_cita_detalles', 'cita_detalles_nonce');
$fuente = get_post_meta($post->ID, '_cita_fuente', true);
$url = get_post_meta($post->ID, '_cita_url', true);
echo '<p><label>Fuente (autor/obra):</label><br>';
echo '<input type="text" name="cita_fuente" value="' . esc_attr($fuente) . '" style="width:100%"></p>';
echo '<p><label>Enlace (opcional):</label><br>';
echo '<input type="url" name="cita_url" value="' . esc_attr($url) . '" style="width:100%"></p>';
}
// Guardar metadatos
add_action('save_post_cita', function ($post_id) {
// 1) Comprobaciones de seguridad
if (!isset($_POST['cita_detalles_nonce']) || !wp_verify_nonce($_POST['cita_detalles_nonce'], 'guardar_cita_detalles')) return;
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;
if (!current_user_can('edit_post', $post_id)) return;
// 2) Sanitizar datos
$fuente = isset($_POST['cita_fuente']) ? sanitize_text_field($_POST['cita_fuente']) : '';
$url = isset($_POST['cita_url']) ? esc_url_raw($_POST['cita_url']) : '';
// 3) Guardar
update_post_meta($post_id, '_cita_fuente', $fuente);
update_post_meta($post_id, '_cita_url', $url);
});
7.5 – Mostrar tus citas con un shortcode
Mostraremos una cita aleatoria con su fuente y enlace si existe.
add_shortcode('cita_random', function ($atts) {
$atts = shortcode_atts([
'autor' => '', // filtrar por autor_cita (slug)
'limit' => 1, // cuántas mostrar
], $atts);
$args = [
'post_type' => 'cita',
'posts_per_page' => intval($atts['limit']),
'orderby' => 'rand',
'tax_query' => [],
];
if ($atts['autor'] !== '') {
$args['tax_query'][] = [
'taxonomy' => 'autor_cita',
'field' => 'slug',
'terms' => $atts['autor'],
];
}
$q = new WP_Query($args);
if (!$q->have_posts()) return '<em>No hay citas disponibles.</em>';
ob_start();
echo '<div class="citas-lista">';
while ($q->have_posts()) { $q->the_post();
$fuente = get_post_meta(get_the_ID(), '_cita_fuente', true);
$url = get_post_meta(get_the_ID(), '_cita_url', true);
echo '<blockquote class="cita-item">';
echo wp_kses_post( wpautop(get_the_content()) );
echo '<footer style="margin-top:6px;">— ' . esc_html($fuente ? $fuente : get_the_title());
if ($url) {
printf(' · <a href="%s" target="_blank" rel="noopener">Enlace</a>', esc_url($url));
}
echo '</footer>';
echo '</blockquote>';
}
echo '</div>';
wp_reset_postdata();
return ob_get_clean();
});
Uso:
[cita_random]
[cita_random limit=»3″]
[cita_random autor=»gabriel-garcia-marquez»]
7.6 – Columnas personalizadas en la lista del admin (calidad de vida)
Añade columnas para ver la Fuente y el Enlace:
// Añadir columnas
add_filter('manage_cita_posts_columns', function ($cols) {
$cols['cita_fuente'] = 'Fuente';
$cols['cita_url'] = 'Enlace';
return $cols;
});
// Rellenar columnas
add_action('manage_cita_posts_custom_column', function ($col, $post_id) {
if ($col === 'cita_fuente') {
echo esc_html(get_post_meta($post_id, '_cita_fuente', true));
}
if ($col === 'cita_url') {
$url = get_post_meta($post_id, '_cita_url', true);
if ($url) printf('<a href="%s" target="_blank">Abrir</a>', esc_url($url));
}
}, 10, 2);
// Hacer ordenable por título (ejemplo simple)
add_filter('manage_edit-cita_sortable_columns', function ($cols) {
$cols['title'] = 'title';
return $cols;
});
7.7 – Plantillas de tema (opcional)
Si quieres controlar la salida en archivos de tema:
- Archivo de archivo:
archive-cita.php - Archivo individual:
single-cita.php
En el tema hijo, crea single-cita.php y usa the_content(), y muestra los metadatos (_cita_fuente, _cita_url) con get_post_meta().
7.8 – Exponer en la REST API (ya activo)
Como show_in_rest => true, el CPT aparece en /wp-json/wp/v2/cita.
Puedes consumir las citas desde JS externo o una SPA.
Tip: filtra por taxonomía con parámetros ?autor_cita=XX (depende del registro/soporte).
7.9 – Capacidades personalizadas (más control)
Si quieres aislar permisos del CPT:
add_action('init', function () {
register_post_type('cita', [
'labels' => ['name' => 'Citas', 'singular_name' => 'Cita'],
'public' => true,
'show_in_rest' => true,
'map_meta_cap' => true,
'capability_type' => ['cita', 'citas'], // singular, plural
'supports' => ['title','editor'],
]);
});
Luego asigna capacidades a un rol con add_role() o add_cap() (ej.: a editor):
add_action('init', function () {
$role = get_role('editor');
if ($role) {
foreach ([
'read_cita', 'read_private_citas',
'edit_cita', 'edit_citas', 'edit_others_citas',
'publish_citas', 'delete_cita', 'delete_citas', 'delete_others_citas'
] as $cap) {
$role->add_cap($cap);
}
}
});
7.10 – Extras útiles
- Imagen destacada como avatar del autor: activa
thumbnail(ya está en supports) y muéstrala en la web conget_the_post_thumbnail(). - Paginación en shortcode: añade
pageda la query y usapaginate_links()si haces una salida tipo listado. - Búsqueda por metadatos: usa
meta_queryenWP_Querypara filtrar porfuenteo por si hayurl.
Mini retos 🎯
- Añade un campo “Año” (número) a la cita y muéstralo tras la fuente.
- Crea un shortcode
[citas_list autor="slug" per_page="5"]con paginación. - Haz que, si hay imagen destacada, se muestre como comillas estilizadas a la izquierda de la cita.
- (Pro) Crea una ruta REST propia
tu-namespace/v1/cita/randomque devuelva una cita aleatoria confuenteyurl.
✅ Logro desbloqueado: Ya dominas CPT + metaboxes + shortcode para contenidos a medida.
¡Seguimos! 🧱
LECCIÓN 8 – Estructura profesional (carpetas, clases, autoload)
Objetivo: Pasar de “un solo archivo” a un plugin ordenado, escalable y mantenible.
8.1 – Estructura de carpetas recomendada
mi-plugin/
├─ mi-plugin.php ← bootstrap (entrada del plugin)
├─ uninstall.php ← limpieza al desinstalar
├─ readme.txt
├─ assets/
│ ├─ css/ admin.css front.css
│ └─ js/ admin.js front.js
├─ languages/ ← .po/.mo
└─ includes/
├─ Autoloader.php
├─ Plugin.php ← núcleo (inyecta/arranca módulos)
├─ Admin/
│ └─ Menu.php ← páginas del admin
├─ Front/
│ └─ Shortcodes.php ← shortcodes y plantilla
├─ Core/
│ ├─ Assets.php ← enqueue de scripts/estilos
│ └─ Hooks.php ← registro centralizado de hooks
└─ Data/
└─ Options.php ← manejo de opciones
Puedes cambiar nombres, pero mantén separación por áreas (Admin/Front/Core/Data).
8.2 – Archivo principal (bootstrap): mi-plugin.php
- Define constantes (rutas), activa carga de textos, registra activación/desactivación.
- Arranca la clase principal
Plugin.
<?php
/*
Plugin Name: Mi Plugin Pro
Description: Estructura profesional con clases y autoload.
Version: 1.0.0
Author: Tu Nombre
Text Domain: mi-plugin
Domain Path: /languages
*/
if (!defined('ABSPATH')) exit;
// 1) Constantes
define('MI_PLUGIN_FILE', __FILE__);
define('MI_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('MI_PLUGIN_URL', plugin_dir_url(__FILE__));
// 2) Autoloader simple (puedes usar Composer si prefieres)
require_once MI_PLUGIN_DIR . 'includes/Autoloader.php';
MiPlugin\Includes\Autoloader::register();
// 3) Cargar traducciones
add_action('plugins_loaded', function(){
load_plugin_textdomain('mi-plugin', false, dirname(plugin_basename(__FILE__)).'/languages');
});
// 4) Activación/Desactivación
register_activation_hook(__FILE__, function(){
// Ejemplo: crear opción y flush de reglas si registras CPTs
add_option('mi_plugin_version', '1.0.0');
flush_rewrite_rules();
});
register_deactivation_hook(__FILE__, function(){
flush_rewrite_rules();
});
// 5) Arrancar el plugin
add_action('plugins_loaded', function(){
(new MiPlugin\Includes\Plugin())->boot();
});
8.3 – Autoload (sin Composer): includes/Autoloader.php
<?php
namespace MiPlugin\Includes;
final class Autoloader {
public static function register(){
spl_autoload_register([__CLASS__, 'autoload']);
}
private static function autoload($class){
// Cargar solo nuestro namespace
if (strpos($class, 'MiPlugin\\') !== 0) return;
$path = str_replace('MiPlugin\\', '', $class);
$path = str_replace('\\', DIRECTORY_SEPARATOR, $path);
$file = MI_PLUGIN_DIR . 'includes/' . $path . '.php';
if (is_readable($file)) require $file;
}
}
Si prefieres Composer, define autoload PSR-4 en
composer.json:
{
"autoload": { "psr-4": { "MiPlugin\\": "includes/" } }
}
y ejecuta composer dump-autoload.
8.4 – Núcleo orquestador: includes/Plugin.php
Esta clase inyecta y arranca módulos (Admin, Front, etc.).
<?php
namespace MiPlugin\Includes;
use MiPlugin\Includes\Core\Assets;
use MiPlugin\Includes\Core\Hooks;
use MiPlugin\Includes\Admin\Menu;
use MiPlugin\Includes\Front\Shortcodes;
use MiPlugin\Includes\Data\Options;
final class Plugin {
private Hooks $hooks;
private Assets $assets;
private Menu $menu;
private Shortcodes $shortcodes;
private Options $options;
public function __construct(){
$this->hooks = new Hooks();
$this->assets = new Assets($this->hooks);
$this->menu = new Menu($this->hooks);
$this->shortcodes = new Shortcodes($this->hooks);
$this->options = new Options();
}
public function boot(): void {
$this->assets->register();
$this->menu->register($this->options);
$this->shortcodes->register($this->options);
}
}
Usamos una clase
Hookspara registrar acciones/filtros de forma centralizada: hace el testeo y lectura del código más limpia.
8.5 – Registro central de hooks: includes/Core/Hooks.php
<?php
namespace MiPlugin\Includes\Core;
final class Hooks {
public function action(string $tag, callable $cb, int $priority=10, int $args=1): void {
add_action($tag, $cb, $priority, $args);
}
public function filter(string $tag, callable $cb, int $priority=10, int $args=1): void {
add_filter($tag, $cb, $priority, $args);
}
}
8.6 – Assets encolados correctamente: includes/Core/Assets.php
<?php
namespace MiPlugin\Includes\Core;
final class Assets {
public function __construct(private Hooks $hooks){}
public function register(): void {
// Front
$this->hooks->action('wp_enqueue_scripts', function(){
wp_enqueue_style('mi-plugin-front', MI_PLUGIN_URL.'assets/css/front.css', [], '1.0.0');
wp_enqueue_script('mi-plugin-front', MI_PLUGIN_URL.'assets/js/front.js', [], '1.0.0', true);
});
// Admin (solo en nuestra página)
$this->hooks->action('admin_enqueue_scripts', function($hook){
if ($hook !== 'toplevel_page_mi-plugin') return;
wp_enqueue_style('mi-plugin-admin', MI_PLUGIN_URL.'assets/css/admin.css', [], '1.0.0');
wp_enqueue_script('mi-plugin-admin', MI_PLUGIN_URL.'assets/js/admin.js', [], '1.0.0', true);
}, 10, 1);
}
}
8.7 – Página de admin modular: includes/Admin/Menu.php
<?php
namespace MiPlugin\Includes\Admin;
use MiPlugin\Includes\Core\Hooks;
use MiPlugin\Includes\Data\Options;
final class Menu {
public function __construct(private Hooks $hooks){}
public function register(Options $options): void {
$this->hooks->action('admin_menu', function() use ($options){
add_menu_page(
__('Mi Plugin', 'mi-plugin'),
__('Mi Plugin', 'mi-plugin'),
'manage_options',
'mi-plugin',
function() use ($options){ $this->render_page($options); },
'dashicons-admin-generic',
25
);
});
}
private function render_page(Options $options): void {
// Guardado simple
if (isset($_POST['mi_title']) && check_admin_referer('mi_plugin_save')) {
$options->set('title', sanitize_text_field($_POST['mi_title']));
echo '<div class="updated"><p>'.esc_html__('Guardado', 'mi-plugin').'</p></div>';
}
$title = $options->get('title', 'Título por defecto');
echo '<div class="wrap"><h1>Mi Plugin</h1>';
echo '<form method="post">';
wp_nonce_field('mi_plugin_save');
echo '<label>Título:</label><br>';
echo '<input name="mi_title" value="'.esc_attr($title).'" class="regular-text">';
echo '<p><button class="button button-primary">'.esc_html__('Guardar', 'mi-plugin').'</button></p>';
echo '</form></div>';
}
}
8.8 – Shortcodes desacoplados: includes/Front/Shortcodes.php
<?php
namespace MiPlugin\Includes\Front;
use MiPlugin\Includes\Core\Hooks;
use MiPlugin\Includes\Data\Options;
final class Shortcodes {
public function __construct(private Hooks $hooks){}
public function register(Options $options): void {
$this->hooks->action('init', function() use ($options){
add_shortcode('mi_title', function() use ($options){
return '<h2>'.esc_html($options->get('title', 'Hola')).'</h2>';
});
});
}
}
8.9 – Capa de datos para opciones: includes/Data/Options.php
<?php
namespace MiPlugin\Includes\Data;
final class Options {
private string $prefix = 'mi_plugin_';
public function get(string $key, $default = ''){
return get_option($this->prefix.$key, $default);
}
public function set(string $key, $value): void {
update_option($this->prefix.$key, $value);
}
public function delete(string $key): void {
delete_option($this->prefix.$key);
}
}
8.10 – Consejos de arquitectura
- Inyección de dependencias (como hicimos): evita singletons, facilita tests.
- Una clase = una responsabilidad (SRP).
- Hooks en un solo lugar por clase. Evita
add_actionsuelto por todo el plugin. - Prefijos/Namespaces: evita colisiones (
MiPlugin\...ymi_plugin_). - Tipos/strict: Si puedes, añade
declare(strict_types=1);al inicio de tus clases. - Limpieza:
uninstall.phpborra las opciones con el prefijomi_plugin_.
Mini retos 🎯
- Mueve tu código actual a esta estructura sin romper compatibilidad.
- Crea una clase
Rest.phpenCore/que registre un endpoint/mi-plugin/v1/pingy devuelva{ ok: true }. - Añade una capa
Data/Repository.phpque devuelva Citas (CPT) y úsala desdeShortcodes(separar “datos” de “presentación”). - Implementa pruebas unitarias de una clase simple (p. ej.
Options) conphpunit(opcional).
✅ Logro desbloqueado: Tu plugin ahora tiene arquitectura de verdad: modular, ampliable y lista para crecer.
Si has llegado hasta aqui, felicidades! Ya empiezas a ser un pro en esto de crear plugins para WordPress. Este curso online de diseño de plugins WordPress todavia no ha acabado. Practica un poco y nos vemos en la continuación del curso donde tocaremos muchos mas temas. Adelante!
⚠️ Atención. El Curso continua AQUI:
Aprender a Programar Plugins WordPress 👈 LECCIONES 9 a 15
No te lo pierdas!
.