Curso online diseño plugins WordPress ( Gratis y Paso a Paso)

curso online diseño plugins wordpress

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

  1. Ve a la carpeta donde está instalado WordPress → wp-content/plugins/
  2. Crea una carpeta llamada hola-mundo.
  3. 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

  1. Ve a Plugins en tu panel de WordPress.
  2. Verás «Hola Mundo» en la lista.
  3. Haz clic en Activar.
  4. 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

  1. Ve a una página o entrada de WordPress.
  2. Escribe: [saludo]
  3. 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

  1. Guarda el archivo.
  2. Recarga el panel de administración.
  3. Verás un nuevo menú llamado “Mi Plugin” con un icono.
  4. Haz clic y verás tu nueva página personalizada.

3.4 – Personalización


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

  1. Ve a tu plugin en el panel de administración.
  2. Escribe un mensaje y pulsa Guardar.
  3. 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_script para pasar ajaxUrl y nonce al JS.
  • Rutas AJAX con admin-ajax.php:
    • wp_ajax_nopriv_* → visitantes
    • wp_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.
  • ¿-1 como respuesta? Falló el nonce → revisa que el nombre del campo sea 'nonce' y que el action coincida.
  • ¿404 en admin-ajax.php? Revisa que el sitio cargue correctamente el ajaxUrl (no uses caché agresiva para usuarios logueados).

Mini reto 💪

  1. Añade un loader (por ejemplo, “⏳”) mientras carga y desactiva el botón.
  2. Permite varias instancias del shortcode en la misma página (el JS actual ya lo soporta porque usa closest).
  3. (Nivel pro) Crea una ruta REST con register_rest_route() y llama con fetch('/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)

  1. Sanitiza lo que llega del usuario (limpia).
  2. Valida que tiene el formato esperado (comprueba).
  3. Autoriza: ¿el usuario tiene permiso para hacer esto?
  4. 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 🧩

  1. Añade a tu formulario un campo URL y valida con esc_url_raw al guardar y esc_url al mostrar.
  2. Cambia tu endpoint AJAX para que solo usuarios logueados con edit_posts puedan usarlo (si es necesario).
  3. Implementa uninstall.php que 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 con get_the_post_thumbnail().
  • Paginación en shortcode: añade paged a la query y usa paginate_links() si haces una salida tipo listado.
  • Búsqueda por metadatos: usa meta_query en WP_Query para filtrar por fuente o por si hay url.

Mini retos 🎯

  1. Añade un campo “Año” (número) a la cita y muéstralo tras la fuente.
  2. Crea un shortcode [citas_list autor="slug" per_page="5"] con paginación.
  3. Haz que, si hay imagen destacada, se muestre como comillas estilizadas a la izquierda de la cita.
  4. (Pro) Crea una ruta REST propia tu-namespace/v1/cita/random que devuelva una cita aleatoria con fuente y url.

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 Hooks para 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_action suelto por todo el plugin.
  • Prefijos/Namespaces: evita colisiones (MiPlugin\... y mi_plugin_).
  • Tipos/strict: Si puedes, añade declare(strict_types=1); al inicio de tus clases.
  • Limpieza: uninstall.php borra las opciones con el prefijo mi_plugin_.

Mini retos 🎯

  1. Mueve tu código actual a esta estructura sin romper compatibilidad.
  2. Crea una clase Rest.php en Core/ que registre un endpoint /mi-plugin/v1/ping y devuelva { ok: true }.
  3. Añade una capa Data/Repository.php que devuelva Citas (CPT) y úsala desde Shortcodes (separar “datos” de “presentación”).
  4. Implementa pruebas unitarias de una clase simple (p. ej. Options) con phpunit (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!

.

Scroll al inicio