Hooks y actions de WordPress explicados con un plugin real: Mis Plugins

Valencia, 18/09/2026, G.B.
Ya he hablado varias veces por aquí de hooks, actions y filters en WordPress, pero siempre viene bien un ejemplo real y completo en vez de fragmentos sueltos. Así que voy a usar mi propio plugin Mis Plugins como caso práctico: es pequeño, no tiene dependencias externas, y toca casi todos los hooks que se usan en el día a día de un plugin.

Qué es un hook

Un hook es un punto del código de WordPress donde puedes «engancharte» para ejecutar tu propia función, sin tocar el núcleo. Hay dos tipos:

  • Actions: ejecutan tu función en un momento concreto (por ejemplo, cuando se activa un plugin, o cuando se carga el panel de administración). No devuelven nada, solo hacen algo.
  • Filters: reciben un valor, tu función lo modifica y lo devuelve. Se usan para transformar datos antes de que WordPress los use.

Mis Plugins usa sobre todo actions, así que empiezo por ahí.

register_activation_hook: al activar el plugin

Este hook se dispara una sola vez, justo cuando activas el plugin desde el panel. Lo uso para crear la tabla propia en la base de datos:

register_activation_hook( __FILE__, 'indaga_mp_activar' );

function indaga_mp_activar() {
    global $wpdb;
    $table_name = indaga_mp_table_name();
    $charset_collate = $wpdb->get_charset_collate();

    require_once ABSPATH . 'wp-admin/includes/upgrade.php';

    $sql = "CREATE TABLE {$table_name} (
        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
        fecha DATE NOT NULL,
        nombre_plugin VARCHAR(191) NOT NULL,
        PRIMARY KEY (id)
    ) {$charset_collate};";

    dbDelta( $sql );
}

La función dbDelta es la forma segura de crear o actualizar tablas propias: compara la estructura que le pasas con la que ya existe y hace los cambios necesarios sin borrar datos.

plugins_loaded: comprobar la versión en cada carga

register_activation_hook solo se dispara al activar, así que si subo una versión nueva del plugin con columnas añadidas, necesito otro punto de enganche que se ejecute siempre. Para eso uso plugins_loaded, comparando un número de versión guardado en las opciones:

add_action( 'plugins_loaded', 'indaga_mp_comprobar_version_bd' );

function indaga_mp_comprobar_version_bd() {
    if ( get_option( 'indaga_mp_db_version' ) !== INDAGA_MP_DB_VERSION ) {
        indaga_mp_activar();
    }
}

Así, cuando subí la versión 2.0.0 con los campos de autor y tags, la tabla se actualizó sola en cuanto WordPress cargó los plugins, sin que el usuario tuviera que desactivar y reactivar nada.

admin_menu: añadir la página al panel

Este hook se dispara mientras WordPress construye el menú de administración. Es el sitio correcto para registrar una nueva pantalla:

add_action( 'admin_menu', 'indaga_mp_menu_admin' );

function indaga_mp_menu_admin() {
    add_menu_page(
        'Mis Plugins',
        'Mis Plugins',
        'manage_options',
        INDAGA_MP_SLUG,
        'indaga_mp_render_pagina_admin',
        'dashicons-admin-plugins',
        80
    );
}

admin_init: procesar el formulario, nunca en el callback del menú

Este es el hook con el que más cuidado tengo, porque un error aquí ya me ha dado algún quebradero de cabeza en otros plugins: procesar un formulario POST dentro de la función que pinta la página de administración puede dejar la pantalla congelada. La solución es mover ese procesamiento a admin_init, comprobando primero en qué pantalla estamos:

add_action( 'admin_init', 'indaga_mp_procesar_admin_init' );

function indaga_mp_procesar_admin_init() {
    if ( ! isset( $_GET['page'] ) || INDAGA_MP_SLUG !== $_GET['page'] ) {
        return;
    }
    if ( ! current_user_can( 'manage_options' ) ) {
        return;
    }
    // Aquí se valida el nonce y se guarda o borra el registro.
}

admin_enqueue_scripts: cargar estilos solo donde tocan

Para no meter CSS en todas las pantallas del panel, engancho los estilos a este hook y compruebo en qué página estoy antes de cargarlos:

add_action( 'admin_enqueue_scripts', 'indaga_mp_admin_estilos' );

function indaga_mp_admin_estilos( $hook ) {
    if ( strpos( (string) $hook, INDAGA_MP_SLUG ) === false ) {
        return;
    }
    wp_register_style( 'indaga-mp-admin', false );
    wp_enqueue_style( 'indaga-mp-admin' );
    wp_add_inline_style( 'indaga-mp-admin', '/* CSS aquí */' );
}

add_shortcode: mostrarlo en el front end

No es técnicamente un action ni un filter, pero funciona con la misma lógica de enganche: le dices a WordPress que, cuando encuentre el shortcode mis_plugins (entre brackets, que si no se ejecuta aquí)  en el contenido, ejecute tu función y sustituya el shortcode por lo que devuelva:

add_shortcode( 'mis_plugins', 'indaga_mp_shortcode' );

function indaga_mp_shortcode( $atts ) {
    $atts = shortcode_atts( array(
        'por_pagina' => 10,
    ), $atts, 'mis_plugins' );

    // Consulta a la base de datos y devuelve el HTML con ob_start()/ob_get_clean().
}

Un detalle interesante para quien quiera profundizar en filters: shortcode_atts, por dentro, aplica el filter shortcode_atts_mis_plugins, que permite a otro plugin modificar los atributos por defecto sin tocar mi código. Es un buen ejemplo de cómo WordPress deja puertas abiertas incluso en funciones tan pequeñas como esta.

Resumen

Con seis hooks (uno de activación, uno de carga, tres de administración y un shortcode) queda montado un plugin completo, con su propia tabla, su formulario y su vista en el front end. Si quieres verlo funcionando, tienes el listado en Control de plugins, con el catálogo de todos los que he ido publicando aquí.

Cómo eliminar el aviso de zona horaria de Modern Events Calendar Lite

Valencia, 17/09/2026, G.B.
Aunque lo he publicado en los foros de Puntocomunica, quiero dejar también aquí constancia, para que no se me olvide si volviera a necesitar solucionarlo. Creo en la redundancia en la Red…

Sitio: (no lo indico por privacidad)
Plugin implicado: Modern Events Calendar Lite (Webnus), v7.36.4
Fecha de resolución: septiembre de 2026

El problema

Tras importar eventos en el calendario, aparece este aviso en el escritorio
de WordPress, en cualquier pantalla del área de administración y no solo en
los ajustes del calendario:

It is advisable to utilize a geographic timezone, such as
«America/Los_Angeles» instead of a UTC timezone offset, like «UTC+0,»
while using The Modern Events Calendar. The latter may cause issues when
importing events or with Daylight Saving Time.

Causa raíz

El propio plugin comprueba, en
wp-content/plugins/modern-events-calendar-lite/app/features/events.php
—líneas 208-218—, si la opción interna de WordPress
timezone_string está vacía:

// Timezone Notice
$timezone_string = get_option('timezone_string');

if (trim($timezone_string) === '')
{
    add_action('admin_notices', function ()
    {
        echo '<div class="notice notice-warning is-dismissible">
            <p>' . esc_html__('It is advisable to utilize a geographic timezone...', 'modern-events-calendar-lite') . '</p>
        </div>';
    });
}

timezone_string queda vacía cuando, en
Ajustes → Generales, se utiliza un desplazamiento numérico
—por ejemplo, UTC+2— en lugar de una zona horaria geográfica,
como Europe/Madrid.

Aunque el aviso incluye la clase is-dismissible, no hay ningún
código que recuerde el descarte. Por eso reaparece en cada carga de página
hasta corregir la causa.

Complicación encontrada en este sitio

En Ajustes → Generales, el desplegable de zona horaria solo
ofrece desplazamientos UTC —UTC+0, UTC+1, etc.—,
sin la lista habitual de ciudades y continentes.

Esto indica que la base de datos de zonas horarias de PHP,
timezone_identifiers_list(), no está completa en el servidor.
Se trata de un problema de configuración de PHP o del hosting, no de
WordPress en sí. Por eso no se pudo resolver simplemente seleccionando una
ciudad en el desplegable.

Solución aplicada: editar directamente wp_options mediante phpMyAdmin

Como el desplegable no permitía elegir una zona geográfica, se editaron
directamente las dos opciones implicadas en la tabla
wp_options:

  • timezone_string: antes estaba vacía; se estableció como
    Europe/Madrid.
  • gmt_offset: antes tenía el valor 2, reflejo de
    UTC+2 en horario de verano; se dejó vacío.

Pasos

  1. Entrar en phpMyAdmin, seleccionar la base de datos del sitio y abrir la
    tabla wp_options.
  2. Buscar la fila cuyo option_name sea
    timezone_string, editar option_value y
    establecerlo como Europe/Madrid.
  3. Buscar la fila cuyo option_name sea
    gmt_offset y vaciar su valor.
  4. Guardar los cambios y recargar el escritorio de WordPress.

El aviso desaparece de inmediato porque
trim($timezone_string) === '' deja de cumplirse.

Nota sobre falsas pistas descartadas

Antes de llegar a la causa real, se investigaron dos vías que no aplicaban
a este caso. Se mantienen como referencia por si el aviso reapareciera con
un texto ligeramente distinto:

  • El texto del aviso es casi idéntico al que muestra
    The Events Calendar (Modern Tribe/StellarWP), un plugin de
    calendario distinto con un nombre parecido. Ese plugin no está instalado
    en la web, pero en wp_options quedan opciones residuales
    con el prefijo tec_, como
    tec_ct1_migration_state y
    tec_timed_events_timezone_update_needed. Esto indica que
    estuvo instalado en algún momento y se desinstaló sin limpiar por
    completo la base de datos. Estas opciones no afectan al problema del
    MEC, pero podrían eliminarse en una limpieza futura.
  • Un mirror desactualizado del código de MEC Lite en GitHub, versión
    5.21.2 de 2021, no contenía el texto del aviso. Fue necesario conseguir
    el ZIP real de la versión 7.36.4 instalada para localizar el código
    exacto.

Un plugin propio de FAQs con lógica condicional para WordPress

Hace poco os contaba cómo monté Comandos Linux, un plugin propio con su tabla, su panel y su shortcode. Esta vez le tocaba a algo que llevaba tiempo queriendo hacer: una sección de preguntas frecuentes con lógica condicional, sin depender de un plugin de formularios de terceros

Valencia, 12/09/2026, G.B.
En un proyecto para una intranet usé Fluent Forms para montar unas FAQs con lógica condicional: el usuario elegía un tema y solo veía las preguntas de ese tema. Funciona muy bien, pero es una herramienta pensada para formularios complejos, y para una sección de FAQs en Indaga.net me sobraba casi todo. Así que hice lo de siempre aquí: quedarme solo con la pieza que necesito y montarla yo mismo.

El planteamiento

La «lógica condicional» que quería no es tan sofisticada como la de un constructor de formularios: no necesito árboles de decisión ni preguntas que dependan de respuestas anteriores. Lo que necesito es más simple y, para una FAQ, más que suficiente: el usuario elige una categoría (por ejemplo «WordPress» o «Facturación») y solo se muestran las preguntas de esa categoría. El resto permanece oculto hasta que cambia de categoría.

Con eso claro, el plugin se reduce a tres piezas: una tabla propia en la base de datos, un panel de administración para gestionar las preguntas, y un shortcode que pinta el selector y las preguntas en el front end.

La tabla

Cada pregunta se guarda con seis campos: categoria, pregunta, respuesta (admite HTML básico, para poder meter enlaces o negritas en la respuesta), orden (para decidir en qué posición aparece dentro de su categoría) y activo (para poder desactivar una pregunta sin borrarla). Nada de post types personalizados ni de meta fields sueltos: una tabla con dbDelta() en la activación, como en el resto de plugins que he ido publicando aquí.

El panel de administración

El formulario de alta y edición es sencillo: categoría (con autocompletado a partir de las categorías ya creadas, para no acabar con «Facturación», «facturacion» y «Facturación » conviviendo en la misma tabla), pregunta, respuesta con el editor visual de WordPress, orden y una casilla de activo. El listado de abajo tiene buscador por texto, filtro por categoría y paginación, para cuando la lista crezca y no quepa cómodamente en una sola pantalla.

Aquí me llevé un buen susto durante las pruebas: el formulario, al guardar, se quedaba la página a medio cargar, como congelada. La causa no tenía nada que ver con la base de datos ni con archivos grandes, sino con el orden de ejecución de WordPress: el guardado del formulario y la redirección posterior se ejecutaban dentro de la misma función que pinta el HTML del panel, y para ese punto WordPress ya ha enviado las cabeceras HTTP. Una redirección que llega tarde, simplemente, no llega. La solución fue mover todo el procesamiento del formulario a un enganche admin_init, que se ejecuta antes de que se imprima nada, y que la función de renderizado se limite a pintar. Detalle poco vistoso, pero de esos que conviene tener en la cabeza si escribís vuestros propios paneles de administración.

La lógica condicional, en el front end

Aquí está el motivo del plugin. Al insertar el shortcode, el plugin agrupa las preguntas por categoría y, si hay más de una, añade automáticamente unos botones de selección arriba del todo, empezando por un botón «Todas». Al pulsar sobre una categoría, un poco de JavaScript sin dependencias oculta el resto de preguntas y muestra solo las de esa categoría, sin recargar la página. Si solo existe una categoría, o si el shortcode se usa fijando una en concreto, el selector directamente no aparece: no tiene sentido mostrar un filtro con una sola opción.

A eso se suma un buscador de texto libre y una paginación, ambos también resueltos en el cliente: no hace falta ir a la base de datos cada vez que alguien escribe una letra o cambia de página, porque una sección de FAQs no suele tener miles de registros. Las preguntas se despliegan en acordeón al hacer clic, así que la página no se llena de texto de golpe.

El shortcode

Para insertarlo en cualquier entrada o página basta con:

indaga_faqs (entre corchetes, [ ])

Y admite varios parámetros para adaptarlo a cada sitio:

indaga_faqs categoria=»Facturación»

Muestra solo esa categoría, sin selector. Útil si queréis una página de FAQs dedicada a un único tema.

indaga_faqs mostrar_selector=»no»

Lista todas las categorías seguidas, sin botones de filtro.

indaga_faqs mostrar_buscador=»no»

Oculta la barra de búsqueda superior.

indaga_faqs por_pagina=»5″

Cambia cuántas preguntas se muestran por página (10 por defecto).

Por qué así

La misma filosofía de siempre: identificar qué parte de una herramienta de terceros es la que de verdad necesito, y quedarme solo con eso, escrito a medida, ligero y sin dependencias que arrastrar en cada actualización. Para una intranet con formularios complejos, Fluent Forms tiene todo el sentido. Para una sección de FAQs en un blog, con esto sobra.

Descargar plugin de mi cuenta de pCloud

Míralo en acción: Indaga FAQs

Plugin para importar CSV o Excel a cualquier tabla de WordPress

Un plugin propio para convertir un CSV o un Excel en filas listas para cualquier tabla de la base de datos, con dos modos: inserción directa o script SQL descargable

Valencia, 10/09/2026, G.B.
Cada vez que necesito poblar una tabla propia —la de comandos Linux, la de frases aleatorias, la de FAQs— acababa haciendo lo mismo: montar el INSERT INTO línea a línea a partir de un listado o insertarlo manualmente en el panel de administración del plugin. Pero si necesito crear muchos registros a la vez, la cosa se hacía algo pesada. Así que he preparado un plugin genérico para no repetir ese trabajo: Importador CSV/Excel a Tabla, que sube un archivo y lo convierte en inserciones para la tabla que le indiques.

Qué hace

El plugin añade una página en Herramientas → Importador CSV/Excel. Desde ahí se sube un archivo .csv o .xlsx y se indican tres cosas:

  • El nombre real de la tabla destino, con su prefijo (por ejemplo, wp_fa_frases)
  • Las columnas destino, separadas por comas y en el mismo orden que las columnas del archivo (sin incluir id si es autoincremental)
  • Si la primera fila es cabecera, para que se ignore

Y después hay que elegir el modo:

  • Insertar directamente en la base de datos, fila a fila, con un resumen de cuántas se han insertado, cuántas han fallado y cuántas se han descartado por no tener suficientes columnas
  • Generar un script SQL descargable, con el INSERT INTO completo, para revisarlo y ejecutarlo yo mismo en phpMyAdmin en vez de dejar que el plugin toque la tabla directamente

Cómo lee los archivos

Con el CSV no hay mucho misterio: se parsea con fgetcsv(), detectando automáticamente si el delimitador es coma, punto y coma o tabulador, y respetando el BOM UTF-8 que añaden Excel o LibreCalc al exportar.

El XLSX es más delicado porque, en el fondo, es un ZIP con varios XML dentro. En vez de tirar de una librería de terceros como PhpSpreadsheet, lo abro con ZipArchive y leo directamente sharedStrings.xml (donde Excel guarda los textos) y sheet1.xml (donde guarda la estructura de celdas) con SimpleXML. Las dos son extensiones nativas de PHP, así que sigo sin añadir ninguna dependencia externa al sitio.

Esto tiene límites que asumo: solo lee la primera hoja del libro, no soporta celdas combinadas ni fórmulas, y las fechas se leen como el valor crudo de la celda, sin formatear. Para el uso que le voy a dar —listados sencillos de una sola hoja— es más que suficiente.

Por qué así

El plugin sigue la misma arquitectura que el resto de los que tengo en marcha: procesamiento del formulario enganchado a admin_init en lugar de dentro del callback del menú (para evitar el típico problema de cabeceras ya enviadas), patrón POST/Redirect/GET para no reenviar el formulario al recargar, y comprobación de nonce y de capacidad manage_options antes de tocar nada. debo comprobar si eso ralentiza el «pintado» del escritorio de WordPress.

La idea de fondo es la misma de siempre: cubrir una necesidad muy concreta —pasar una hoja de cálculo a SQL— con el menor peso posible y sin depender de plugins de terceros que no controlo.

Qué me falta por probar

Lo acabo de instalar y todavía no lo he puesto a prueba con datos reales: antes tengo que revisar la estructura de las tablas con las que voy a trabajar para asegurarme de que las columnas coinciden. Si funciona bien, es probable que añada una vista previa de las primeras filas del archivo antes de confirmar la importación, para pillar errores de columnas antes de que lleguen a la base de datos.

Descargar plugin de mi cuenta de pCloud

Mu-plugins en WordPress: qué son, para qué sirven y un ejemplo práctico

Valencia, 09/09/2026, G.B.
Si llevas un tiempo trasteando con WordPress, seguro que conoces la carpeta wp-content/plugins, donde viven los plugins que activas y desactivas desde el escritorio. Pero existe otra carpeta, mucho menos conocida, que merece la pena tener en el radar: wp-content/mu-plugins.

Qué son los mu-plugins

«MU» viene de Must-Use (de uso obligatorio). Son plugins que WordPress carga automáticamente en cada petición, sin que tengan que activarse desde el panel de administración. De hecho, ni siquiera aparecen en el listado habitual de plugins junto al botón de «Desactivar»: aparecen en una sección aparte, de solo lectura, llamada «Uso obligatorio».

Las diferencias clave respecto a un plugin normal son:

  • Se cargan siempre. No hay riesgo de que alguien los desactive sin querer desde el escritorio.
  • Se cargan antes que los plugins normales. Esto es importante si necesitas engancharte a hooks muy tempranos en el ciclo de carga de WordPress.
  • No admiten subcarpetas. WordPress solo lee los archivos .php que están directamente dentro de mu-plugins/, no los que están en subdirectorios (aunque sí puedes tener un archivo suelto que a su vez incluya otros ficheros desde una subcarpeta).
  • No tienen pantalla de ajustes activar/desactivar. Se gestionan a nivel de sistema de archivos, subiendo o quitando el .php correspondiente.

Para qué sirven

Los mu-plugins son la herramienta adecuada cuando necesitas que algo se ejecute siempre, de forma incondicional, sin dejarlo al albur de que un cliente, un editor o tú mismo en un despiste desactivéis un plugin importante. Casos típicos:

  • Funcionalidad crítica del sitio: cosas de las que depende el funcionamiento correcto y que no deberían poder desactivarse por error.
  • Ajustes de seguridad: por ejemplo, desactivar la edición de archivos desde el escritorio, forzar SSL en el admin, o bloquear XML-RPC.
  • Configuración de hosting o multisitio: en instalaciones multisitio son muy usados para forzar comportamientos comunes a toda la red, ya que se cargan en todos los sitios sin necesidad de activarlos uno a uno.
  • Pequeñas utilidades propias que no justifican un plugin completo con cabecera, opciones y demás, pero que quieres tener siempre disponibles.
  • Parches o correcciones rápidas, ideales cuando necesitas asegurarte de que algo se aplica sin depender de que nadie recuerde activarlo.

Cómo se instalan

No hay instalador ni pantalla de subida. El proceso es manual:

  1. Si la carpeta wp-content/mu-plugins/ no existe, se crea.
  2. Se sube el archivo .php directamente a esa carpeta.
  3. Listo. WordPress lo detecta y lo ejecuta en la siguiente petición, sin activar nada.

Ejemplo práctico: mostrar la fecha de última actualización en las entradas

Un caso de uso muy típico en un blog: mostrar al lector cuándo se actualizó por última vez un artículo, además de (o en lugar de) la fecha de publicación. Es útil para dar señales de que el contenido se mantiene al día, algo que además ayuda en SEO.

Vamos a crear un mu-plugin que añada esa fecha automáticamente al principio del contenido de las entradas, solo cuando la fecha de modificación sea distinta a la de publicación (para no mostrar información redundante si el artículo nunca se ha tocado).

<?php
/**
* Plugin Name: Indaga – Last Updated Notice
* Description: Displays the last updated date on blog posts.
* Version: 1.0
* Author: Guillermo Beltrán Pilato
*/

// Exit if this file is loaded directly.
if ( ! defined( ‘ABSPATH’ ) ) {
exit;
}

/**
* Prepends the last updated notice to the post content.
*/
function indaga_show_last_updated_notice( $content ) {

// Only run on single posts, within the main content loop.
if ( ! is_single() || ! in_the_loop() || ! is_main_query() ) {
return $content;
}

$published_date = get_the_date( ‘U’ );
$modified_date = get_the_modified_date( ‘U’ );

// If the difference is less than a day, we consider there was no
// real update (avoids false positives from minor resaves).
if ( ( $modified_date – $published_date ) < DAY_IN_SECONDS ) {
return $content;
}

$formatted_date = get_the_modified_date( ‘F j, Y’ );

$notice = sprintf(
‘<p class=»indaga-last-updated-notice»><em>Last updated: %s</em></p>’,
esc_html( $formatted_date )
);

return $notice . $content;
}
add_filter( ‘the_content’, ‘indaga_show_last_updated_notice’ );

Descargar plugin desde mi cuenta de pCloud

Feeds, sitemap y una cabecera que sobra: pequeña auditoría de un sitio WordPress con curl

curl

Valencia, 08/09/2026, G.B.
Hay comandos que uno lanza casi por rutina y que, sin embargo, acaban destapando cosas interesantes. La semana pasada me tocó revisar el sitemap nativo de uno de mis sitios en WordPress y, de paso, aproveché para repasar algo que tenía pendiente desde hace tiempo: los feeds del sitio y una cabecera HTTP que no debería estar ahí.

Qué es el feed de un sitio en WordPress

El feed es una versión del contenido en XML —normalmente RSS 2.0, aunque WordPress también sabe hablar Atom— pensada para que lectores de noticias, agregadores o scripts consuman las entradas sin tener que «leer» el HTML de la página. Es una de esas piezas del núcleo de WordPress que llevan ahí desde siempre y que casi nadie mira hasta que algo deja de funcionar (a mí me pasó hace poco con un gadget de Blogger de terceros que dejó de refrescar entradas porque un Disallow: */feed/ en el robots.txt se lo estaba impidiendo).

Lo interesante es que WordPress no genera un único feed, sino uno por cada «vista» relevante del sitio:

  • Feed general, con todas las entradas: /feed/
  • Feed de comentarios de todo el sitio: /comments/feed/
  • Feed de una categoría: /category/nombre-categoria/feed/
  • Feed de una etiqueta: /tag/nombre-etiqueta/feed/
  • Feed de un autor: /author/nombre-autor/feed/
  • Feed de comentarios de una entrada concreta: /nombre-entrada/feed/
  • Feed de una búsqueda: /?s=termino&feed=rss2
  • Feed de un tipo de contenido personalizado, si el CPT lo soporta: /tipo-contenido/feed/

Y todavía hay una capa más: dentro de cada uno de esos ámbitos, el formato de salida se puede forzar con el parámetro ?feed=, con hasta cuatro variantes: rss2 (el que usa WordPress por defecto), rss (RSS 0.92, un formato ya legado), atom y rdf (RSS 1.0). En total, contando ámbitos y formatos, un sitio WordPress típico expone bastantes más «puertas de entrada» XML de las que uno recuerda a primera vista.

El sitemap no es un feed

Conviene no confundir los feeds con el sitemap. Desde WordPress 5.5 (2020), el núcleo genera automáticamente un sitemap XML nativo en /wp-sitemap.xml, sin necesidad de plugins de SEO. No es RSS ni Atom: es un índice que agrupa entradas, páginas, taxonomías y usuarios en sub-sitemaps independientes (wp-sitemap-posts-post-1.xml, wp-sitemap-users-1.xml, etc.), pensado para que los buscadores rastreen el sitio de forma eficiente.

Para comprobar rápidamente si el sitemap responde bien, sin descargarlo entero, uso el comando curl:

curl -I https://midominio.com/wp-sitemap.xml

El -I fuerza una petición HEAD: solo pide las cabeceras HTTP de la respuesta, no el cuerpo. Es la forma más rápida de saber si una URL existe, qué código de estado devuelve y qué tipo de contenido sirve, sin gastar ancho de banda en el XML completo.

Lo que las cabeceras cuentan (y lo que delatan)

Al lanzar ese curl -I contra uno de mis sitios, obtuve algo así (he sustituido la versión de PHP que aparecía por X.X.XX en el código que adjunto por motivos de seguridad):

HTTP/2 200
content-type: text/xml;charset=UTF-8
date: Fri, 04 Sep 2026 06:42:34 GMT
server: Apache
x-powered-by: PHP/X.X.XX

Todo correcto salvo un detalle: x-powered-by: PHP/X.X.XX está anunciando al mundo, con número de versión exacto, qué motor PHP corre detrás. No es una vulnerabilidad en sí misma, pero sí información gratuita para cualquiera que esté buscando qué CVEs probar contra esa versión concreta. Es el tipo de cabecera que no aporta nada al visitante legítimo y sí algo a quien está reconociendo el terreno.

La buena noticia es que server: Apache ya venía sin número de versión, así que esa parte no hacía falta tocarla.

Cómo quitar la cabecera con php.ini

Yo tengo creado el archivo php.ini (o .user.ini) propio en el directorio del sitio. El mío ya incluía algunos ajustes habituales:

upload_max_filesize = 128M
post_max_size = 128M
max_execution_time = 300
memory_limit = 512M
zlib.output_compression = 1
zlib.output_compression_level = 9

Simplemente basta con añadir una línea más:

expose_php = Off

Tras guardar el archivo -y en algunos casos esperar unos minutos a que el hosting recargue la configuración- un nuevo curl -I me ha confirmado que la cabecera x-powered-by había desaparecido. Un cambio de una sola línea, pero de esos que conviene no dejar pendientes.

Resumiendo

  • Un sitio WordPress puede exponer más de media docena de feeds distintos, cada uno con hasta cuatro formatos posibles.
  • El sitemap nativo (/wp-sitemap.xml) es una pieza distinta, propia del núcleo desde la versión 5.5.
  • curl -I es la herramienta de cabecera rápida para auditar cualquier URL sin descargar su contenido.
  • Si tu hosting lo permite, expose_php = Off en tu php.ini o .user.ini es un cambio mínimo con un beneficio de seguridad nada despreciable.

Un plugin propio para aprender comandos básicos de Linux en WordPress

plugin comandos linux

Tal y como comentaba aquí, he preparado un pequeño plugin para mostrar comandos de Linux, para aprender, vaya. Insertaré el shortcode en una nueva página, en vez de en una entrada o post

Valencia, 06/09/2026, G.B.
Llevo tiempo trabajando en plugins a medida, sin dependencias externas, para cubrir necesidades muy concretas sin cargar el sitio con librerías de terceros. El último que he montado va dirigido a quien empieza con Linux: Comandos Linux, un plugin que permite gestionar una pequeña base de datos de comandos —con su explicación y un ejemplo práctico— y mostrarla en el front end mediante un shortcode.

Qué hace

El plugin crea su propia tabla en la base de datos con cuatro campos por registro:

  • Nombre del comando (ls, grep, chmod…)
  • Explicación, en lenguaje sencillo
  • Ejemplo práctico, listo para copiar y probar en terminal

Desde el panel de administración se pueden crear, editar y eliminar comandos con un formulario simple, y el listado incluye un buscador y paginación para cuando la lista crezca.

El shortcode

Para mostrarlo en cualquier entrada o página basta con insertar, entre corchetes []:

comandos_linux

El front end reproduce la misma lógica que el panel: buscador propio y resultados paginados, sin necesidad de recargar toda la página gracias a los parámetros por URL. Si se quiere ajustar cuántos comandos se muestran por página, se puede indicar con (entre corchetes []:

comandos_linux por_pagina="10"

Porqué así

Como en el resto de plugins que he ido publicando aquí, la idea es mantenerlo ligero: sin librerías externas, con consultas preparadas contra la base de datos, y siguiendo el patrón POST/Redirect/GET para evitar reenvíos accidentales de formularios. Es la misma filosofía que ya apliqué en el plugin de recursos o en el de autores: cubrir una necesidad concreta con el menor peso posible.

De momento arranca con comandos básicos como ls, cd, grep o chmod, pero la idea es irlo ampliando poco a poco a medida que vaya cubriendo más terreno, desde gestión de permisos hasta redes o procesos.

Así queda insertado el shortcode en una página  (iré añadiendo más y mejorándolo): Comandos Linux


Descargar el plugin desde mi cuenta de pCloud

Hooks, actions y filters en WordPress: la diferencia que todo el mundo confunde

Valencia, 05/09/2026, G.B.
Si llevas un tiempo tocando el functions.php de WordPress o escribiendo plugins, seguro que te has encontrado con add_action() y add_filter() mil veces. Pero cuando hay que explicar la diferencia entre ambos, es fácil quedarse en un «bueno, son parecidos». No lo son tanto, y entender bien la diferencia te ahorra bugs raros el día menos pensado.

Hook es el concepto general

Lo primero es aclarar la terminología. En español solemos traducir hook como «gancho», y es el término general que engloba a los otros dos: cualquier punto del código de WordPress donde puedes «engancharte» para ejecutar tu propia función. Ese término general se divide en dos tipos:

  • Actions (acciones)
  • Filters (filtros)

Todo lo demás son matices de estos dos tipos.

Actions: «haz algo aquí»

Una acción es un punto del código donde WordPress simplemente dice: «en este momento ha pasado algo, si quieres, ejecuta tu función». No espera que le devuelvas nada.

// Se ejecuta cada vez que se publica una entrada
add_action( 'publish_post', function( $post_id ) {
    wp_mail( 'admin@midominio.com', 'Nueva entrada publicada', 'ID: ' . $post_id );
});

Aquí no hay ningún dato que recoger de vuelta. La función hace su trabajo (enviar un correo, escribir un log, mostrar algo en pantalla) y punto.

Filters: «toma este dato, modifícalo y devuélvemelo»

Un filtro, en cambio, existe porque WordPress necesita un valor para seguir funcionando: un título, un contenido, un booleano que decide qué hacer. Tu función recibe ese valor, opcionalmente lo cambia, y tiene que devolverlo siempre.

// Añade un emoji al final de cada título
add_filter( 'the_title', function( $title ) {
    return $title . ' 🚀';
});

Si te olvidas del return, rompes el filtro para todo el sitio — es el error clásico de quien empieza con filtros viniendo de acciones.

La tabla que lo resume

ActionFilter
¿Qué hace?Ejecuta código en un punto concretoModifica un dato y lo devuelve
¿Recibe un valor?Opcionalmente, como parámetrosSí, el dato a modificar
¿Debe devolver algo?NoSiempre
Ejemplo típicoEnviar un email al publicar una entradaCambiar el texto de un título

Una forma rápida de identificarlos si estás leyendo código fuente de WordPress: si ves do_action(...), es un hook de tipo acción; si ves apply_filters(...), es un filtro. Y add_action() / add_filter() son las funciones que usas tú para «engancharte» a esos puntos.

Un ejemplo real: desactivar el editor de bloques

El otro día leí un artículo de Ayudawordpress.com (Fernando Tellado) sobre cómo desactivar el editor de bloques de WordPress (Gutenberg) para desactivarlo en una instalación multisitio. El filtro que se usa es un buen ejemplo de por qué tiene que ser un filter y no una action:

add_filter( 'use_block_editor_for_post_type', '__return_false' );
add_filter( 'use_widgets_block_editor', '__return_false' );

WordPress necesita saber, para cada tipo de contenido, si debe cargar el editor de bloques o el clásico — y eso es exactamente un booleano que hay que devolver. Por eso no podría implementarse como acción: una acción no tiene forma de decirle a WordPress «no, usa esto otro en su lugar». Necesita el mecanismo de ida y vuelta que ofrece el filtro.

Por qué merece la pena tenerlo claro

Más allá de la teoría, esto tiene una consecuencia práctica muy directa: si alguna vez te preguntas «¿por qué mi función no está haciendo efecto?», una de las primeras cosas que hay que revisar es si te has enganchado a una acción cuando en realidad necesitabas un filtro (o viceversa). Es un error tonto, pero muy común, y entender la diferencia de raíz evita perder tiempo depurando algo que en realidad es un fallo de concepto, no de sintaxis.

De solución casera a plugin: Indaga Menu Search ya funciona también en Puntocomunica.com

Valencia, 03/09/2026, G.B.
Ayer os contaba cómo había sustituido Ivory Search por un buscador propio, integrado directamente en el menú mediante unas pocas líneas en el functions.php del child theme de INDAGA.net. Funcionaba bien, pero tenía una limitación evidente: estaba atado a este sitio en concreto. Si quería el mismo buscador en otra web, tocaba copiar y pegar código a mano, sitio por sitio.

Así que le he dado una vuelta y lo he convertido en un plugin independiente: Indaga Menu Search.

Por qué merecía la pena convertirlo en plugin

El código en sí no cambia demasiado —sigue usando exclusivamente el buscador nativo de WordPress, sin dependencias externas ni llamadas remotas—, pero empaquetarlo como plugin da varias ventajas:

  • Se activa y desactiva desde el panel, sin tocar el functions.php de ningún tema.
  • Sobrevive a un cambio de tema, porque el código ya no vive dentro del child theme.
  • Es reutilizable en cualquier proyecto WordPress, no solo en los que usan Twenty Seventeen.
  • La ubicación del menú se elige desde una pantalla de ajustes (Ajustes → Buscador en el menú), con un desplegable que muestra las ubicaciones que registra el tema activo y qué menú tiene asignado cada una. Nada de shortcodes ni de editar código: se elige la ubicación y el icono aparece automáticamente en todo el sitio.

Para quien quiera forzarlo por código en un proyecto concreto, sigue existiendo el filtro indaga_menu_search_location, que tiene prioridad sobre lo que se elija en el panel.

Primera instalación real: Puntocomunica.com

La primera prueba fuera de INDAGA.net ha sido en Puntocomunica.com, donde lo he instalado y activado sin ningún ajuste adicional más allá de seleccionar la ubicación del menú correspondiente desde el panel. En cuestión de minutos, el buscador nativo de WordPress estaba ya funcionando ahí también, con el mismo comportamiento: icono discreto en el menú que despliega el formulario de búsqueda al pulsarlo.

Poder instalar el mismo plugin en dos sitios completamente distintos, sin adaptar una sola línea de código más allá de un clic en un desplegable, es exactamente el objetivo de convertir una solución puntual en una herramienta reutilizable.

Descargar plugin de mi cuenta de pCloud

Adiós a Ivory Search: cómo he puesto un buscador propio en el menú de INDAGA.net

Valencia, 02/09/2026, G.B.
Desde hace tiempo, el buscador de INDAGA.net dependía de un plugin llamado Ivory Search. Funcionaba bien… hasta que ha dejado de hacerlo. Empezó a fallar sin motivo aparente, y en vez de perder horas depurando un plugin de terceros, decidí hacer lo que suelo hacer con este tipo de problemas: prescindir de él y montar algo propio, ligero y sin dependencias externas. Aquí os cuento el proceso completo, con sus vueltas incluidas.

El punto de partida

WordPress trae de serie un buscador nativo perfectamente funcional: el clásico ?s= que consulta directamente la base de datos a través de WP_Query. No necesita ningún plugin. Lo único que hacía falta era darle un sitio visible en la web, concretamente un icono en el menú de navegación que, al pulsarlo, desplegara el cuadro de búsqueda.

Como ya tengo un child theme de Twenty Seventeen (Indaga Child lo he llamado) con código PHP propio, la solución más limpia era añadir el buscador ahí mismo, aprovechando los hooks que ofrece WordPress para modificar el menú.

El código: un filtro y un poco de CSS

WordPress permite enganchar contenido extra a cualquier menú mediante el filtro wp_nav_menu_items. Con esto añadí un elemento más a la lista del menú: un botón con el icono de lupa (usando Dashicons, la librería de iconos que trae WordPress) y, justo debajo, el formulario de búsqueda nativo oculto por defecto:

add_filter('wp_nav_menu_items', 'indaga_add_search_to_menu', 10, 2);
function indaga_add_search_to_menu($items, $args) {
    if (isset($args->theme_location) && $args->theme_location === 'top') {
        $search_html  = '<li>';
        $search_html .= '<button type="button" aria-expanded="false" aria-controls="menu-search-form" aria-label="Abrir buscador">';
        $search_html .= '<span class="dashicons dashicons-search"></span>';
        $search_html .= '</button>';
        $search_html .= '<div id="menu-search-form">';
        $search_html .= get_search_form(false);
        $search_html .= '</div>';
        $search_html .= '</li>';

        $items .= $search_html;
    }
    return $items;
}

Este filtro lo añadí directamente al functions.php de mi child theme, junto al resto de funciones personalizadas que ya tenía (como el aviso de tiempo de lectura o la notificación por correo al publicar). Es el mismo archivo donde centralizo todo el código propio del tema.

El CSS se encarga de que el formulario permanezca oculto hasta que se pulsa el icono. Para el comportamiento de abrir y cerrar (toggle), en vez de meter el JavaScript directamente en el footer, preferí crear un archivo aparte: assets/js/menu-search-toggle.js, dentro de la carpeta del child theme. Mantener el JS en su propio archivo, en vez de incrustarlo en línea, hace que sea más fácil de mantener y que el navegador pueda cachearlo por separado.

document.addEventListener('DOMContentLoaded', function () {
    var toggleBtn = document.querySelector('.search-toggle-btn');
    var searchForm = document.querySelector('.menu-search-form');

    if (!toggleBtn || !searchForm) return;

    toggleBtn.addEventListener('click', function () {
        var isOpen = searchForm.classList.toggle('is-open');
        toggleBtn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
        if (isOpen) {
            var input = searchForm.querySelector('input[type="search"]');
            if (input) input.focus();
        }
    });

    document.addEventListener('click', function (e) {
        if (!searchForm.contains(e.target) && !toggleBtn.contains(e.target)) {
            searchForm.classList.remove('is-open');
            toggleBtn.setAttribute('aria-expanded', 'false');
        }
    });
});

Para que WordPress cargue ese archivo, hace falta encolarlo correctamente, también desde functions.php, en vez de simplemente enlazarlo a mano:

add_action('wp_enqueue_scripts', 'indaga_enqueue_search_toggle_script');
function indaga_enqueue_search_toggle_script() {
    wp_enqueue_script(
        'indaga-menu-search-toggle',
        get_stylesheet_directory_uri() . '/assets/js/menu-search-toggle.js',
        array(),
        wp_get_theme()->get('Version'),
        true
    );
}

Usar wp_get_theme()->get('Version') como número de versión del script tiene una ventaja práctica: cada vez que subo cambios al JS y actualizo la versión del child theme, WordPress invalida automáticamente la caché del navegador para ese archivo, evitando que se quede una versión antigua cacheada, tal y como ya hacía con el CSS.

El primer obstáculo: identificar el menú correcto

WordPress diferencia entre el nombre visible de un menú (el que ve el administrador en el panel, en mi caso «Principal (2)») y su theme_location, que es el identificador interno que usa el tema para saber dónde pintarlo. Son cosas distintas, y confundirlas es un error habitual.

Como Twenty Seventeen solo registra dos ubicaciones —top (menú superior) y social (redes sociales)— y mi child theme no añade ninguna ubicación propia, bastaba con usar top en el filtro. Lo confirmé revisando directamente el código fuente del tema en su repositorio oficial, sin necesidad de tocar nada en el servidor.

El segundo obstáculo: el icono invisible

Con el código subido, el HTML del icono aparecía correctamente en el código fuente de la página (lo comprobé viendo el código fuente del navegador), pero visualmente no se veía nada en el menú. Aquí es donde toca ser metódico:

¿Está el HTML presente? Sí, confirmado.

¿Puede ser la caché? Casi seguro. Uso Autoptimize para combinar y minificar CSS y JS, y si no se regenera bien después de subir cambios, el navegador sigue sirviendo la versión antigua.

¿Y si el icono está ahí pero es «invisible» por color? Bingo. El icono de la lupa (Dashicons) no heredaba el color blanco del resto del menú, así que se confundía con el fondo. Solo se veía al pasar el ratón por encima, porque el estado hover sí forzaba un cambio de color.

Intentar arreglarlo fijando color: #ffffff en el botón no fue suficiente: el pseudo-elemento que dibuja el icono de la fuente Dashicons no siempre hereda el color tan limpiamente como cabría esperar, sobre todo cuando hay otras reglas CSS con más peso de por medio. La solución definitiva y más robusta fue añadir directamente un fondo oscuro al botón en el CSS personalizado del tema:

button {
    background: #000000;
    border: none;
    cursor: pointer;
    padding: 8px 12px;
    line-height: 1;
    color: #ffffff;
}

button .dashicons {
    color: inherit;
    font-size: 20px;
    width: 20px;
    height: 20px;
}

Con el fondo negro y el icono en blanco, el contraste queda garantizado sin depender de si el navegador hereda bien o mal el color de un pseudo-elemento de fuente.

El resultado

Un buscador funcional, integrado en el menú «Principal (2)», que usa exclusivamente el motor de búsqueda nativo de WordPress. Sin plugins de terceros que mantener, sin sorpresas de compatibilidad, y con el código totalmente bajo mi control para poder ajustarlo cuando quiera.