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

Cómo nació Indaga Tabs: de cambiar un plugin que fallaba a montarme el mío propio

Valencia, 09/10/2026, G.B.
En Indaga.net me gusta ir trasteando con mis webs, mejorar la experiencia de usuario y, de paso, aprender cosas nuevas. Hace poco me encontré con un pequeño contratiempo: el plugin Tabs Responsive (que llevaba usando desde 2019 en puntocomunica.com para organizar la sección de Lecturas) fue retirado del repositorio de WordPress y Wordfence me avisó de que tenía un fallo de seguridad importante.

En lugar de ponerme a buscar otro plugin de terceros que acabara pesando demasiado o haciendo más de la cuenta, me pareció buena idea montarme una alternativa rápida, ligera y sin complicaciones: así nació Indaga Tabs.

Lo que iba a ser un apaño sencillo de un par de tardes me sirvió para repasar unas cuantas lecciones sobre cómo maneja WordPress los datos, la ejecución de scripts y algún que otro tropiezo por el camino.

Aquí te cuento de forma resumida cómo fue el proceso y las piedras con las que me fui topando hasta llegar a la versión 2.3.0.

El primer cambio: necesitaba usarlo en más de un sitio

Al principio programé el plugin para gestionar una sola lista de pestañas. Sin embargo, pensé que si quería usarlo también en otras secciones o webs (por ejemplo, en servicios o en la barra lateral), me venía mejor poder crear varios grupos independientes.

Así que le añadí soporte para múltiples instancias usando parámetros sencillos en el shortcode (como o ).

Ahí fue donde la cosa se puso interesante y empezaron los pequeños quebraderos de cabeza.

Los tropezones del camino (y cómo los apañé)

1. Duplicar scripts sin querer en JavaScript

  • El problema: Para que cada bloque funcionara de forma independiente, al principio metí el script de JS dentro del propio shortcode. En cuanto ponía dos grupos de pestañas en la misma página, el script se imprimía dos veces y se montaba un lío en el navegador al intentar definir la misma función.
  • La solución: Saqué el código de ahí y lo llevé al pie de página (wp_footer). Ahora el script se carga una sola vez al final y se encarga de hacer funcionar cualquier grupo de pestañas que encuentre en la pantalla mediante delegación de eventos nativa.

2. El típico pantallazo blanco de PHP

  • El problema: Al cambiar la forma de guardar los datos en la base de datos (pasando de una lista simple a una estructura con varias instancias), si el código intentaba leer datos antiguos que no tenían la nueva forma, PHP protestaba y saltaba el temido mensaje de «Ha habido un error crítico en tu web».
  • La solución: Reescribí la lectura de datos con get_option() de forma mucho más precavida. Ahora el plugin comprueba primero que todo sea un grupo válido (is_array()) antes de tocar nada y convierte los datos viejos a la nueva estructura automáticamente sin romper la web.

Ficha Técnica para el Control de Plugins (Estilo Indaga.net)

Gestor de contenido dinámico por pestañas interactivas con soporte para múltiples instancias, selector y administración de grupos desde el panel de control de WordPress.

  • Acceso: Indaga Tabs (wp-admin/admin.php?page=indaga-tabs)

  • Alias / Shortcodes:

    Contenido inicial de la primera pestaña.
    Contenido de la segunda pestaña.
    ,
    Contenido inicial de la primera pestaña.
    Contenido de la segunda pestaña.
    (admite parámetro id="instancia", ej. )

  • Dependencias: Ninguna (100% nativo con API de WordPress, JavaScript Vanilla y CSS dinámico).

  • Seguridad: Sanitización completa con sanitize_text_field() y wp_kses_post(), escapado riguroso en salidas (esc_html, esc_attr), verificación de Nonces anti-CSRF (wp_nonce_field, check_admin_referer) y control de acceso por perfil mediante manage_options.

Descargar desde mi cuenta de pCloud: Descargar Indaga Tabs v2.3.0

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.

Recursos Plugin v2.0: unificando dos plugins en uno

plugin recursos

Valencia, 29/08/2026, G.B.
Uno de esos proyectos que llevaba tiempo rondándome era simplificar el mantenimiento de dos plugins de WordPress que, con el tiempo, habían acabado haciendo cosas muy parecidas. La solución: fusionarlos en uno solo, Recursos Plugin v2.0, y de paso aprovechar para resolver algunos problemas técnicos que arrastraba desde hacía tiempo.

Por qué unificar

Mantener dos plugins con funcionalidades solapadas significa duplicar esfuerzo cada vez que hay que tocar algo: dos bases de código que revisar, dos sitios donde aplicar el mismo parche, dos superficies de error. Unificarlos en un único plugin reduce esa carga de mantenimiento y deja un código más coherente y fácil de razonar.

Como en el resto de mis plugins, la premisa se mantiene: cero dependencias externas. Todo el código es PHP nativo, sin librerías de terceros que puedan quedar desactualizadas o introducir vulnerabilidades.

El reto técnico: búsqueda vía GET

La búsqueda de recursos funciona con parámetros en la URL (GET) en lugar de POST. Esto tiene una ventaja clara para el usuario: los resultados de búsqueda son enlazables y compartibles. Puedes copiar la URL después de buscar y enviarla directamente a alguien, o guardarla en marcadores, y al abrirla se reproduce la misma búsqueda.

Para los formularios que sí requieren envío de datos, apliqué el patrón POST/Redirect/GET: el formulario se envía por POST, el servidor procesa la petición y responde con una redirección a una URL GET. Esto evita el clásico problema de que, al recargar la página después de enviar un formulario, el navegador pregunte si quieres «reenviar los datos» —o peor, los reenvíe sin avisar duplicando la acción.

El arreglo de compatibilidad con MySQL

Este plugin hereda el mismo ajuste que ya había aplicado en mi plugin de Autores: cambiar la longitud de las claves de índice a VARCHAR(191).

¿Por qué 191 y no 255? Según lo que he consultado 191 es un límite práctico impuesto por MySQL 5.7 cuando se usa el juego de caracteres utf8mb4 (necesario para soportar emojis y caracteres Unicode completos). Con utf8mb4, cada carácter puede ocupar hasta 4 bytes, y el límite de InnoDB para un índice es de 767 bytes en total. Eso deja un máximo de 191 caracteres para columnas indexadas antes de que MySQL lance un error.

Es uno de esos detalles que no se nota hasta que un día, sin previo aviso, la creación de una tabla falla con un mensaje críptico. Ya solucionado, y de forma consistente en todos mis plugins.

Instrucciones rápidas (desde el panel de control del plugin)

  1. Rellena el formulario de abajo y pulsa «Añadir Recurso» para crear uno nuevo.
  2. Pulsa «Editar» en la tabla para modificar un recurso existente.
  3. Pulsa «Eliminar» para borrarlo (se pedirá confirmación).
  4. Para mostrar los recursos en una página o entrada, usa el shortcode mostrar_recursos (entre corchetes [ ])
    (con buscador y paginación).
  5. Para mostrar solo los 4 recursos más recientes, usa el shortcode ultimos_recursos (entre corchetes [ ])

    Ejemplo con el shortcode para mostrar los últimos recursos añadidos:

    Mega
    Servicio con sede en Nueva Zelanda que destaca por su cifrado de extremo a extremo por defecto (zero-knowledge): ni siquiera Mega puede acceder al contenido de tus archivos. Ofrece 20 GB gratuitos de entrada.
    Dropbox
    Uno de los pioneros del sector, conocido por su sincronización robusta y fiable entre dispositivos. Ofrece funciones interesantes de recuperación de versiones anteriores de archivos y buena integración con apps de terceros.
    OneDrive
    Servicio de Microsoft integrado de forma nativa en Windows y en la suite Office 365. Cómodo si ya trabajas en el ecosistema Microsoft, con edición colaborativa directa en Word, Excel y PowerPoint online.
    pCloud
    Almacenamiento en la nube con sede en Suiza, con opción de pago único de por vida (en lugar de suscripción mensual) y cifrado adicional opcional (pCloud Crypto). Buena alternativa para quien busca privacidad reforzada.

Resultado

Después de un tiempo probándolo en producción, el plugin unificado funciona correctamente. Un solo punto de mantenimiento, búsquedas enlazables, sin reenvíos de formulario molestos, y compatibilidad garantizada con instalaciones MySQL 5.7. Os dejo el enlace de descarga del plugin, por si álguien quiere probarlo:

Descargar plugin (en formato .zip) de mi cuenta de pCloud

Plugin de WordPress para frases aleatorias

Valencia, 29/08/2026, G.B.
Tal y comenté recientemente en este post, aquí va el plugin de WordPress para frases aleatorias. Os paso también el archivo.php.

Recordad que para subir un plugin a WordPress (que no esté en el repositorio, como es el caso), hay que crear una carpeta con el mismo nombre del archivo .php, comprimirlo en .zip y subirlo por FTP o S FTP (mejor esto último) al apartado del escritorio de WordPress «Subir plugin».

Aquí va e código de la primera versión del plugin. Si no consigues verlo correcatente en este post, descarga el archivo frases-aleatorias.zip de mi cuenta de pCloud e instálalo en WordPRess – > Plugins.

<?php
/**
* Plugin Name: Frases Aleatorias Widget
* Description: Widget, shortcode

y panel de administración para gestionar frases (añadir, editar, eliminar). Cada frase incluye enlace de traducción automática al castellano. Sin dependencias externas.
* Version: 2.0
* Author: Puntocomunica.com
*/

if ( ! defined( ‘ABSPATH’ ) ) {
exit; // Salida directa no permitida.
}

define( ‘FA_TABLA’, ‘fa_frases’ ); // se le añadirá el prefijo de WP al usarla

/* ==========================================================
* INSTALACIÓN: crea la tabla y la rellena con las 50 frases
* la primera vez que se activa el plugin.
* ========================================================== */
function fa_activar_plugin() {
global $wpdb;
$tabla = $wpdb->prefix . FA_TABLA;
$charset_collate = $wpdb->get_charset_collate();

$sql = «CREATE TABLE $tabla (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
texto TEXT NOT NULL,
autor VARCHAR(191) NOT NULL,
PRIMARY KEY (id)
) $charset_collate;»;

require_once ABSPATH . ‘wp-admin/includes/upgrade.php’;
dbDelta( $sql );

// Solo se siembran datos si la tabla está vacía (primera instalación)
$total = $wpdb->get_var( «SELECT COUNT(*) FROM $tabla» );
if ( (int) $total === 0 ) {
$frases_iniciales = array(
array( «He who blames others has a long way to go on his journey. He who blames himself is halfway there. He who blames no one has arrived.», «Chinese Proverb» ),
array( «The only way to do great work is to love what you do.», «Steve Jobs» ),
array( «In the middle of difficulty lies opportunity.», «Albert Einstein» ),
array( «It does not matter how slowly you go as long as you do not stop.», «Confucius» ),
array( «Fall seven times, stand up eight.», «Japanese Proverb» ),
array( «The journey of a thousand miles begins with a single step.», «Lao Tzu» ),
array( «Whether you think you can or you think you can’t, you’re right.», «Henry Ford» ),
array( «The best way to predict the future is to create it.», «Abraham Lincoln» ),
array( «Success is not final, failure is not fatal: it is the courage to continue that counts.», «Winston Churchill» ),
array( «Believe you can and you’re halfway there.», «Theodore Roosevelt» ),
array( «Do not go where the path may lead, go instead where there is no path and leave a trail.», «Ralph Waldo Emerson» ),
array( «The mind is everything. What you think you become.», «Buddha» ),
array( «Water which is too pure has no fish.», «Chinese Proverb» ),
array( «A smooth sea never made a skilled sailor.», «African Proverb» ),
array( «He that can have patience can have what he will.», «Benjamin Franklin» ),
array( «You miss 100% of the shots you don’t take.», «Wayne Gretzky» ),
array( «Life is what happens when you’re busy making other plans.», «John Lennon» ),
array( «The only impossible journey is the one you never begin.», «Tony Robbins» ),
array( «Not all those who wander are lost.», «J.R.R. Tolkien» ),
array( «It always seems impossible until it’s done.», «Nelson Mandela» ),
array( «Do what you can, with what you have, where you are.», «Theodore Roosevelt» ),
array( «If you want to go fast, go alone. If you want to go far, go together.», «African Proverb» ),
array( «The best time to plant a tree was 20 years ago. The second best time is now.», «Chinese Proverb» ),
array( «What lies behind us and what lies before us are tiny matters compared to what lies within us.», «Ralph Waldo Emerson» ),
array( «Turn your wounds into wisdom.», «Oprah Winfrey» ),
array( «Knowing yourself is the beginning of all wisdom.», «Aristotle» ),
array( «The unexamined life is not worth living.», «Socrates» ),
array( «He who has a why to live can bear almost any how.», «Friedrich Nietzsche» ),
array( «Out of difficulties grow miracles.», «Jean de La Bruyère» ),
array( «A journey of a thousand miles must begin with a single step.», «Lao Tzu» ),
array( «Even a small star shines in the darkness.», «Finnish Proverb» ),
array( «The bamboo that bends is stronger than the oak that resists.», «Japanese Proverb» ),
array( «Better to light a candle than curse the darkness.», «Chinese Proverb» ),
array( «When the winds of change blow, some build walls, others build windmills.», «Chinese Proverb» ),
array( «Fall down seven times, get up eight.», «Japanese Proverb» ),
array( «One who plants a tree knows they will not sit in its shade.», «Indian Proverb» ),
array( «The nail that sticks out gets hammered down.», «Japanese Proverb» ),
array( «A single arrow is easily broken, but not ten in a bundle.», «Japanese Proverb» ),
array( «He who has never failed has never tried anything new.», «African Proverb» ),
array( «Even the greatest of trees was once a nut who held its ground.», «American Proverb» ),
array( «The river that forgets its source will dry up.», «African Proverb» ),
array( «A bird does not sing because it has an answer, it sings because it has a song.», «Chinese Proverb» ),
array( «Patience is bitter, but its fruit is sweet.», «Aristotle» ),
array( «He who is not courageous enough to take risks will accomplish nothing in life.», «Muhammad Ali» ),
array( «The best preparation for tomorrow is doing your best today.», «H. Jackson Brown Jr.» ),
array( «Success usually comes to those who are too busy to be looking for it.», «Henry David Thoreau» ),
array( «Do not wait for the perfect moment, take the moment and make it perfect.», «Unknown» ),
array( «Great things are done by a series of small things brought together.», «Vincent van Gogh» ),
array( «Perseverance is not a long race; it is many short races one after the other.», «Walter Elliot» ),
array( «You cannot cross the sea merely by standing and staring at the water.», «Rabindranath Tagore» ),
);

foreach ( $frases_iniciales as $f ) {
$wpdb->insert( $tabla, array( ‘texto’ => $f[0], ‘autor’ => $f[1] ) );
}
}
}
register_activation_hook( __FILE__, ‘fa_activar_plugin’ );

/* ==========================================================
* FUNCIONES DE ACCESO A DATOS
* ========================================================== */
function fa_obtener_frases() {
global $wpdb;
$tabla = $wpdb->prefix . FA_TABLA;
return $wpdb->get_results( «SELECT id, texto, autor FROM $tabla ORDER BY id ASC» );
}

function fa_obtener_frases_json() {
$frases = fa_obtener_frases();
$salida = array();
foreach ( $frases as $f ) {
$salida[] = array( ‘texto’ => $f->texto, ‘autor’ => $f->autor );
}
return wp_json_encode( $salida );
}

/* ==========================================================
* RENDERIZADO EN PORTADA (widget y shortcode)
* Incluye enlace de traducción automática al castellano
* (Google Translate), generado por JS según la frase mostrada.
* ========================================================== */
function fa_render_markup_y_script( $instancia_id ) {
ob_start();
?>
<div id=»fa-widget-<?php echo esc_attr( $instancia_id ); ?>» class=»fa-widget» style=»font-family: Georgia, serif; padding: 16px; border-left: 4px solid #444; background: #f9f9f9;»>
<p class=»fa-texto» style=»font-style: italic; font-size: 16px; line-height: 1.5; margin: 0 0 8px 0;»></p>
<p class=»fa-autor» style=»text-align: right; font-weight: bold; margin: 0 0 8px 0;»></p>
<p style=»text-align: right; margin: 0 0 12px 0;»>
<a class=»fa-traducir» href=»#» target=»_blank» rel=»noopener noreferrer» style=»font-size: 13px; text-decoration: underline;»>Traducir al castellano</a>
</p>
<button class=»fa-btn» type=»button» style=»cursor: pointer; padding: 6px 14px; border: none; border-radius: 4px; background: #444; color: #fff; font-size: 14px;»>Otra frase</button>
</div>
<script>
(function() {
var frases = <?php echo fa_obtener_frases_json(); ?>;
var contenedor = document.getElementById(‘fa-widget-<?php echo esc_js( $instancia_id ); ?>’);
if (!contenedor || !frases.length) return;
var elTexto = contenedor.querySelector(‘.fa-texto’);
var elAutor = contenedor.querySelector(‘.fa-autor’);
var elTraducir = contenedor.querySelector(‘.fa-traducir’);
var elBtn = contenedor.querySelector(‘.fa-btn’);

function mostrarFraseAleatoria() {
var indice = Math.floor(Math.random() * frases.length);
var frase = frases[indice];
elTexto.textContent = ‘\u201C’ + frase.texto + ‘\u201D’;
elAutor.textContent = ‘\u2014 ‘ + frase.autor;
elTraducir.href = ‘https://translate.google.com/?sl=en&tl=es&text=’ + encodeURIComponent(frase.texto) + ‘&op=translate’;
}

elBtn.addEventListener(‘click’, mostrarFraseAleatoria);
mostrarFraseAleatoria();
})();
</script>
<?php
return ob_get_clean();
}

function fa_shortcode_frases_aleatorias() {
static $contador = 0;
$contador++;
return fa_render_markup_y_script( ‘sc-‘ . $contador );
}
add_shortcode( ‘frases_aleatorias’, ‘fa_shortcode_frases_aleatorias’ );

class FA_Widget_Frases extends WP_Widget {
public function __construct() {
parent::__construct(
‘fa_widget_frases’,
__( ‘Frase Aleatoria’, ‘frases-aleatorias’ ),
array( ‘description’ => __( ‘Muestra una frase célebre al azar, con enlace de traducción y botón para cambiarla.’, ‘frases-aleatorias’ ) )
);
}

public function widget( $args, $instance ) {
echo $args[‘before_widget’];
if ( ! empty( $instance[‘titulo’] ) ) {
echo $args[‘before_title’] . apply_filters( ‘widget_title’, $instance[‘titulo’] ) . $args[‘after_title’];
}
echo fa_render_markup_y_script( ‘widget-‘ . $this->id );
echo $args[‘after_widget’];
}

public function form( $instance ) {
$titulo = ! empty( $instance[‘titulo’] ) ? $instance[‘titulo’] : __( ‘Frase del día’, ‘frases-aleatorias’ );
?>
<p>
<label for=»<?php echo esc_attr( $this->get_field_id( ‘titulo’ ) ); ?>»><?php _e( ‘Título:’, ‘frases-aleatorias’ ); ?></label>
<input class=»widefat» id=»<?php echo esc_attr( $this->get_field_id( ‘titulo’ ) ); ?>» name=»<?php echo esc_attr( $this->get_field_name( ‘titulo’ ) ); ?>» type=»text» value=»<?php echo esc_attr( $titulo ); ?>»>
</p>
<?php
}

public function update( $new_instance, $old_instance ) {
$instance = array();
$instance[‘titulo’] = ( ! empty( $new_instance[‘titulo’] ) ) ? sanitize_text_field( $new_instance[‘titulo’] ) : »;
return $instance;
}
}
add_action( ‘widgets_init’, function () {
register_widget( ‘FA_Widget_Frases’ );
} );

/* ==========================================================
* PANEL DE ADMINISTRACIÓN: listar, añadir, editar, eliminar
* ========================================================== */
add_action( ‘admin_menu’, function () {
add_menu_page(
‘Frases Aleatorias’,
‘Frases Aleatorias’,
‘manage_options’,
‘fa-frases’,
‘fa_pagina_admin’,
‘dashicons-format-quote’,
58
);
} );

function fa_pagina_admin() {
if ( ! current_user_can( ‘manage_options’ ) ) {
return;
}

global $wpdb;
$tabla = $wpdb->prefix . FA_TABLA;

/* ———- Procesar acciones enviadas por formulario ———- */
if ( isset( $_POST[‘fa_accion’] ) && check_admin_referer( ‘fa_guardar_frase’, ‘fa_nonce’ ) ) {
$texto = isset( $_POST[‘fa_texto’] ) ? sanitize_textarea_field( wp_unslash( $_POST[‘fa_texto’] ) ) : »;
$autor = isset( $_POST[‘fa_autor’] ) ? sanitize_text_field( wp_unslash( $_POST[‘fa_autor’] ) ) : »;

if ( $texto !== » && $autor !== » ) {
if ( $_POST[‘fa_accion’] === ‘crear’ ) {
$wpdb->insert( $tabla, array( ‘texto’ => $texto, ‘autor’ => $autor ) );
echo ‘<div class=»notice notice-success is-dismissible»><p>Frase añadida.</p></div>’;
} elseif ( $_POST[‘fa_accion’] === ‘actualizar’ && ! empty( $_POST[‘fa_id’] ) ) {
$wpdb->update( $tabla, array( ‘texto’ => $texto, ‘autor’ => $autor ), array( ‘id’ => (int) $_POST[‘fa_id’] ) );
echo ‘<div class=»notice notice-success is-dismissible»><p>Frase actualizada.</p></div>’;
}
} else {
echo ‘<div class=»notice notice-error is-dismissible»><p>El texto y el autor no pueden estar vacíos.</p></div>’;
}
}

if ( isset( $_GET[‘fa_eliminar’] ) && check_admin_referer( ‘fa_eliminar_frase’ ) ) {
$wpdb->delete( $tabla, array( ‘id’ => (int) $_GET[‘fa_eliminar’] ) );
echo ‘<div class=»notice notice-success is-dismissible»><p>Frase eliminada.</p></div>’;
}

/* ———- ¿Estamos editando una frase? ———- */
$editando = null;
if ( isset( $_GET[‘fa_editar’] ) ) {
$editando = $wpdb->get_row( $wpdb->prepare( «SELECT * FROM $tabla WHERE id = %d», (int) $_GET[‘fa_editar’] ) );
}

$frases = fa_obtener_frases();
?>
<div class=»wrap»>
<h1>Frases Aleatorias</h1>
<p>Shortcode: <code>

</code> · También disponible como widget en Apariencia → Widgets.</p>

<h2><?php echo $editando ? ‘Editar frase’ : ‘Añadir nueva frase’; ?></h2>
<form method=»post» style=»max-width: 600px;»>
<?php wp_nonce_field( ‘fa_guardar_frase’, ‘fa_nonce’ ); ?>
<input type=»hidden» name=»fa_accion» value=»<?php echo $editando ? ‘actualizar’ : ‘crear’; ?>»>
<?php if ( $editando ) : ?>
<input type=»hidden» name=»fa_id» value=»<?php echo esc_attr( $editando->id ); ?>»>
<?php endif; ?>

<table class=»form-table»>
<tr>
<th><label for=»fa_texto»>Texto (inglés)</label></th>
<td><textarea name=»fa_texto» id=»fa_texto» rows=»3″ class=»large-text» required><?php echo $editando ? esc_textarea( $editando->texto ) : »; ?></textarea></td>
</tr>
<tr>
<th><label for=»fa_autor»>Autor</label></th>
<td><input type=»text» name=»fa_autor» id=»fa_autor» class=»regular-text» required value=»<?php echo $editando ? esc_attr( $editando->autor ) : »; ?>»></td>
</tr>
</table>

<?php submit_button( $editando ? ‘Actualizar frase’ : ‘Añadir frase’ ); ?>
<?php if ( $editando ) : ?>
<a href=»<?php echo esc_url( admin_url( ‘admin.php?page=fa-frases’ ) ); ?>» class=»button»>Cancelar edición</a>
<?php endif; ?>
</form>

<h2>Frases existentes (<?php echo count( $frases ); ?>)</h2>
<table class=»widefat striped»>
<thead>
<tr>
<th style=»width: 55%;»>Texto</th>
<th>Autor</th>
<th style=»width: 160px;»>Acciones</th>
</tr>
</thead>
<tbody>
<?php foreach ( $frases as $f ) : ?>
<tr>
<td><?php echo esc_html( $f->texto ); ?></td>
<td><?php echo esc_html( $f->autor ); ?></td>
<td>
<a href=»<?php echo esc_url( admin_url( ‘admin.php?page=fa-frases&fa_editar=’ . $f->id ) ); ?>»>Editar</a>
|
<a href=»<?php echo esc_url( wp_nonce_url( admin_url( ‘admin.php?page=fa-frases&fa_eliminar=’ . $f->id ), ‘fa_eliminar_frase’ ) ); ?>»
onclick=»return confirm(‘¿Eliminar esta frase?’);» style=»color: #b32d2e;»>Eliminar</a>
|
<a href=»https://translate.google.com/?sl=en&tl=es&text=<?php echo rawurlencode( $f->texto ); ?>&op=translate» target=»_blank» rel=»noopener noreferrer»>Traducir</a>
</td>
</tr>
<?php endforeach; ?>
<?php if ( empty( $frases ) ) : ?>
<tr><td colspan=»3″>Todavía no hay frases.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<?php
}

Por qué desactivar Disable Comments no basta para reabrir los comentarios en WordPress

comentarios wordpress

Valencia, 22/08/2026, G.B.
Hace unos días me encontré con un problema aparentemente sencillo que terminó siendo bastante más terco de lo esperado: quería volver a permitir comentarios en las entradas de este blog, y por más que lo intentaba, el formulario para escribir un comentario nuevo seguía sin aparecer. Los comentarios antiguos se veían perfectamente, pero no había forma de dejar uno nuevo.

El sospechoso obvio: Disable Comments

En algún momento había usado el plugin Disable Comments para bloquear los comentarios en ciertos tipos de contenido. Cuando decidí revertir eso, hice lo que parecía razonable:

  1. Entré en los ajustes del plugin y cambié el tipo de contenido (la taxonomía de entradas) para el que se bloqueaban los comentarios, dejando «Entradas» fuera del bloqueo.
  2. Guardé los cambios.
  3. Desactivé el plugin.

Nada. Los comentarios seguían sin poder crearse.

Pensando que quizás el plugin no se había desactivado del todo bien, fui un paso más allá:

  1. Lo desinstalé por completo, borrándolo del sitio.
  2. Vacié toda la caché, tanto la del plugin de caché del sitio como cualquier caché a nivel de servidor.

Seguía sin funcionar. En este punto ya no tenía sentido seguir sospechando del plugin: ni siquiera estaba instalado.

Reduciendo el problema

Revisé el archivo single.php del tema por si el formulario de comentarios se estaba omitiendo por algún motivo relacionado con la plantilla, pero el código era el estándar, sin nada raro. También descarté que hubiera algún hook personalizado interfiriendo en functions.php del tema hijo: nada relacionado con comments_open, save_post ni wp_insert_post_data.

Lo que sí encontré, consultando directamente la base de datos, fue esto: entradas ya publicadas, incluso una programada, tenían el campo comment_status en closed en la tabla wp_posts, a pesar de que el ajuste general de WordPress (Ajustes > Comentarios > «Permitir que la gente envíe comentarios en las entradas nuevas») estaba correctamente en abierto.

La explicación tiene sentido una vez se entiende cómo funciona WordPress internamente: el ajuste default_comment_status solo se aplica en el momento en que se crea la fila del post por primera vez. Si esa entrada se creó (aunque fuera como borrador) mientras el ajuste global estaba en cerrado, o mientras Disable Comments seguía activo bloqueando ese tipo de contenido, el valor closed queda grabado de forma permanente en esa fila concreta. Cambiar el ajuste general después no reescribe retroactivamente las entradas que ya existían; solo afecta a las que se creen a partir de ese momento.

Es decir, el problema nunca estuvo en el plugin en sí, sino en un dato ya escrito en la base de datos que el plugin dejó como herencia antes de desactivarse.

La solución: una consulta SQL

Con muchas entradas afectadas, corregirlas una a una desde el panel de WordPress (edición en lote, cambiando «Comentarios» a «Permitir») era viable pero lento. La solución rápida fue una actualización directa en la base de datos desde phpMyAdmin:

USE nombre_de_tu_base_de_datos;
UPDATE wp_posts SET comment_status = 'open' WHERE post_type = 'post' AND comment_status = 'closed';

Esta consulta actualiza de golpe el estado de comentarios de todas las entradas (post_type = 'post') que estuvieran cerradas, dejando fuera páginas y otros tipos de contenido. Antes de ejecutarla conviene hacer una copia de seguridad de la tabla wp_posts (tienes que poner tu prefijo de las tablas. Por defecto, si no lo has cambiado en la instalación de WordPress es wp_), y si se quiere comprobar el alcance antes de aplicar el cambio, un simple SELECT COUNT(*) FROM wp_posts WHERE post_type = 'post' AND comment_status = 'closed'; da el número exacto de entradas que se van a ver afectadas.

Conclusión

Cuando un plugin como Disable Comments actúa escribiendo valores directamente en la base de datos en lugar de aplicar solo un filtro en tiempo real, desactivarlo (o incluso desinstalarlo) no revierte esos cambios. Los ajustes generales de WordPress tampoco se aplican de forma retroactiva a entradas ya existentes. En estos casos, revisar directamente la base de datos con una consulta SQL es la forma más fiable de confirmar la causa real del problema y, de paso, la manera más rápida de solucionarlo cuando hay muchas entradas implicadas.