Todos los sistemas operativos200+ sitios que cuidamosRespuesta en menos de 2h+54 9 11 7820-8554
← Blog

Cómo crear un plugin de WordPress: arquitectura y seguridad

Tutorial para desarrolladores y CTOs: construye un plugin de WordPress pequeño y listo para producción, desde la estructura y los hooks hasta seguridad, actualizaciones, desinstalación y tests.

werun.dev19 min de lectura
Obtén un resumen conChatGPTClaudeModo IA de Google
Bloques modulares apilados sostenidos por una viga chartreuse, metáfora de cómo crear un plugin de WordPress sobre una arquitectura limpia

La mayoría de las guías sobre cómo crear un plugin de WordPress se quedan en el Hello World. El plugin funciona en tu portátil y luego se rompe cuando lo toca un segundo desarrollador, cuando un sitio usa otra versión de PHP o cuando un editor envía un formulario sin el rol adecuado.

Este tutorial construye un plugin pequeño, Acme Notes, y añade lo que necesita un plugin de producción: una estructura de archivos limpia, hooks, un custom post type, una página de ajustes, un endpoint REST, controles de seguridad, actualizaciones seguras, limpieza al desinstalar, tests y una arquitectura basada en clases. Es la visión de ingeniería. Para la visión del comprador (cuándo tiene sentido un plugin a medida, cuánto cuesta y cómo se desarrolla un proyecto), lee nuestra guía de desarrollo de plugins de WordPress a medida.

Cada fragmento se apoya en el anterior y usa el prefijo acme_notes. No citamos aquí versiones mínimas de PHP ni de WordPress porque cambian. Consulta el WordPress Plugin Handbook para ver las vigentes antes de fijar las tuyas.

Puntos clave

  • Un plugin necesita un archivo principal con cabecera y una guarda ABSPATH, y un prefijo único en cada función, opción y post type.
  • Los hooks son la única superficie de integración, así que registra en el hook correcto del ciclo de vida y no edites nunca el core ni otros plugins.
  • La seguridad son cuatro controles en orden: capacidad, nonce, sanear y validar, escapar en la salida, más $wpdb->prepare() para SQL propio.
  • Guarda una versión de base de datos junto a la versión del código y ejecuta migraciones idempotentes en plugins_loaded, porque los hooks de activación no se ejecutan al actualizar.
  • Pasa a clases con inyección por constructor cuando el plugin supere unos cientos de líneas o se incorpore un segundo desarrollador.

Cómo crear un plugin de WordPress: estructura de archivos

Un plugin es una carpeta en wp-content/plugins con un archivo PHP que lleva un comentario de cabecera. WordPress lee esa cabecera, lista el plugin y, una vez activo, carga el archivo en cada petición. Todo lo demás es decisión tuya, y las decisiones de la primera hora fijan el coste de la número trescientos.

Estructura de carpetas que escala

Mantén la raíz delgada. El archivo principal arranca, las clases viven en src y todo lo que no es código de ejecución (tests, herramientas, el readme) queda al lado. La misma estructura sirve para un plugin de 200 líneas y para uno de 20.000.

acme-notes/
├── acme-notes.php
├── uninstall.php
├── readme.txt
├── composer.json
├── src/
│   ├── Plugin.php
│   ├── Settings.php
│   └── Rest_Controller.php
├── tests/
└── vendor/

Tres reglas la mantienen honesta. Un solo archivo principal, con el nombre de la carpeta. Nada de lógica en el nivel superior de otros archivos salvo definiciones de clases y funciones. Y ninguna consulta, salida ni carga de assets en el momento del include: un archivo registra hooks y termina.

Bandejas anidadas dispuestas como un árbol de carpetas con la raíz iluminada, que ilustra la estructura de archivos de un plugin con un archivo principal
Un archivo principal en la raíz y todo lo demás dentro de src

Un boilerplate de plugin de WordPress ahorra teclear, no pensar. Los más populares traen pares de clases admin y public, una clase loader que envuelve add_action y stubs de i18n, y acabarás borrando la mitad. Nosotros partimos de la estructura anterior y añadimos orden cuando un archivo se lo gana. Usa un boilerplate cuando tu equipo ya lo conoce y acepta sus convenciones. Descártalo cuando no puedas explicar cada archivo que genera.

La cabecera del plugin y la guarda de acceso directo

Solo Plugin Name es obligatorio en la cabecera, pero Version, Requires PHP, Text Domain y License son los campos que lamentarás haber omitido. La guarda ABSPATH impide que alguien solicite el archivo PHP directamente y ejecute código fuera de WordPress.

<?php
/**
 * Plugin Name:       Acme Notes
 * Description:       Internal notes with a settings page and a REST endpoint.
 * Version:           1.0.0
 * Requires PHP:      7.4
 * Author:            Acme
 * License:           GPL-2.0-or-later
 * Text Domain:       acme-notes
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

define( 'ACME_NOTES_VERSION', '1.0.0' );
define( 'ACME_NOTES_DB_VERSION', 2 );
define( 'ACME_NOTES_FILE', __FILE__ );
define( 'ACME_NOTES_DIR', plugin_dir_path( __FILE__ ) );

Las constantes guardan tres cosas: la versión del plugin, una versión de base de datos aparte (más sobre ella en la sección de actualizaciones) y las rutas. Prefija todo: funciones, constantes, opciones, post types. Los nombres sin prefijo son la forma en que dos plugins se provocan un fatal error mutuo al activarse.

Autoload con Composer PSR-4

Pasado un puñado de clases, deja de escribir líneas require_once. El autoloader PSR-4 de Composer asocia un namespace a una carpeta, de modo que Acme\Notes\Settings se carga desde src/Settings.php. Lo usamos desde la sección de refactor, pero configurarlo ahora no cuesta nada.

{
  "name": "acme/acme-notes",
  "type": "wordpress-plugin",
  "require": { "php": ">=7.4" },
  "autoload": {
    "psr-4": { "Acme\\Notes\\": "src/" }
  }
}

Después carga el autoloader generado desde el archivo principal.

$acme_notes_autoload = ACME_NOTES_DIR . 'vendor/autoload.php';

if ( is_readable( $acme_notes_autoload ) ) {
	require_once $acme_notes_autoload;
}

Incluye la carpeta vendor dentro del zip de la versión, generada con composer install --no-dev. Comprueba que el archivo existe antes de requerirlo, para que una carpeta ausente no tumbe un sitio en producción.

Hooks: acciones y filtros en la práctica

Los hooks son la única superficie de integración que te da WordPress. Nunca editas el core, un tema ni otro plugin: enganchas un callback a un punto con nombre y WordPress lo llama. Aprende el ciclo de vida y la mitad de la depuración de plugins desaparece.

Acciones frente a filtros

Una acción anuncia que ha ocurrido algo. Tu callback hace trabajo y no devuelve nada. Un filtro hace pasar un valor. Tu callback lo recibe y debe devolverlo, cambiado o no. Olvidar el return en un filtro es la forma clásica de dejar en blanco el contenido de un sitio.

AspectoAcciónFiltro
Se registra conadd_actionadd_filter
Se dispara condo_actionapply_filters
El callback devuelveNadaEl valor, modificado o no
Uso típicoRegistrar, guardar, encolar, enviarCambiar contenido, títulos, argumentos de consulta
add_action( 'init', 'acme_notes_register_post_type' );
add_filter( 'the_content', 'acme_notes_append_footer' );

function acme_notes_append_footer( $content ) {
	if ( is_singular( 'acme_note' ) && in_the_loop() && is_main_query() ) {
		$label    = get_option( 'acme_notes_label', 'Internal note' );
		$content .= '<p class="acme-notes-footer">' . esc_html( $label ) . '</p>';
	}

	return $content;
}

El filtro protege tres condiciones: el post type correcto, el loop y la consulta principal. Sin ellas añade tu marcado a widgets, extractos y feeds. Ambas funciones de registro aceptan una prioridad (10 por defecto, el número menor se ejecuta antes) y el número de argumentos que recibe tu callback.

Elegir el hook y la prioridad correctos

Elige el hook más temprano en el que exista todo lo que necesitas, y no antes.

  • plugins_loaded: todos los plugins activos están cargados. Construye aquí tus objetos y ejecuta las comprobaciones de actualización.
  • init: WordPress está cargado y el usuario autenticado. Registra aquí post types, taxonomías y shortcodes.
  • admin_menu y admin_init: añaden páginas de administración y registran ajustes.
  • rest_api_init: registra rutas REST.
  • wp_enqueue_scripts y admin_enqueue_scripts: cargan CSS y JavaScript, solo en las pantallas que los necesitan.

Las comprobaciones de capacidades y las cadenas traducidas no son fiables antes de initpor eso registrar un post type antes es un bug aunque parezca funcionar. Cambia una prioridad solo con un motivo, por ejemplo para ejecutarte después del filtro de otro plugin, y escribe ese motivo en un comentario.

Hooks de activación y desactivación

register_activation_hook( ACME_NOTES_FILE, 'acme_notes_activate' );
register_deactivation_hook( ACME_NOTES_FILE, 'flush_rewrite_rules' );

function acme_notes_activate() {
	// El hook init aún no se ha ejecutado, así que registramos el tipo directamente.
	acme_notes_register_post_type();
	flush_rewrite_rules();
	add_option( 'acme_notes_db_version', ACME_NOTES_DB_VERSION );
}

La activación se ejecuta una vez, en un momento en que tus hooks de init todavía no se han disparado, por eso llamamos directamente a la función de registro antes de regenerar las reglas de reescritura. No llames nunca a flush_rewrite_rules en cada petición: reescribe una opción de la base de datos cada vez. Usa la desactivación para limpieza temporal, como regenerar reglas o desprogramar eventos de cron. Borrar datos corresponde a la desinstalación.

La activación tampoco se ejecuta cuando se actualiza un plugin. Ese único hecho condiciona el diseño de las actualizaciones que verás más adelante.

Añade un custom post type, una página de ajustes y un endpoint REST

Estas tres funcionalidades aparecen en la mayoría de los plugins. Cada una tiene una API de WordPress que resuelve las partes difíciles si la usas como fue diseñada.

Registrar un custom post type

function acme_notes_register_post_type() {
	register_post_type(
		'acme_note',
		array(
			'labels'       => array(
				'name'          => __( 'Notes', 'acme-notes' ),
				'singular_name' => __( 'Note', 'acme-notes' ),
			),
			'public'       => true,
			'show_in_rest' => true,
			'supports'     => array( 'title', 'editor', 'author' ),
			'menu_icon'    => 'dashicons-edit-page',
			'rewrite'      => array( 'slug' => 'notes' ),
		)
	);
}

Con show_in_rest en true, el tipo se expone en la REST API y se puede editar en el editor de bloques. La clave del post type se limita a 20 caracteres, así que mantenla corta y con prefijo. Decide public y rewrite a conciencia: un tipo público con slug crea URLs en el front-end, y un tipo interno no debería.

Página de ajustes con la Settings API

add_action( 'admin_init', 'acme_notes_register_settings' );
add_action( 'admin_menu', 'acme_notes_add_settings_page' );

function acme_notes_register_settings() {
	register_setting(
		'acme_notes',
		'acme_notes_label',
		array(
			'type'              => 'string',
			'sanitize_callback' => 'sanitize_text_field',
			'default'           => 'Internal note',
		)
	);
	add_settings_section( 'acme_notes_main', '', '__return_false', 'acme-notes' );
	add_settings_field(
		'acme_notes_label',
		__( 'Footer label', 'acme-notes' ),
		'acme_notes_render_label_field',
		'acme-notes',
		'acme_notes_main'
	);
}

function acme_notes_render_label_field() {
	printf(
		'<input type="text" class="regular-text" name="acme_notes_label" value="%s">',
		esc_attr( get_option( 'acme_notes_label', 'Internal note' ) )
	);
}

function acme_notes_add_settings_page() {
	add_options_page(
		__( 'Acme Notes', 'acme-notes' ),
		__( 'Acme Notes', 'acme-notes' ),
		'manage_options',
		'acme-notes',
		'acme_notes_render_settings_page'
	);
}

function acme_notes_render_settings_page() {
	if ( ! current_user_can( 'manage_options' ) ) {
		return;
	}
	?>
	<div class="wrap">
		<h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
		<form action="options.php" method="post">
			<?php
			settings_fields( 'acme_notes' );
			do_settings_sections( 'acme-notes' );
			submit_button();
			?>
		</form>
	</div>
	<?php
}

register_setting asocia el callback de saneado a la opción, de modo que cada guardado pasa por él, incluidos los que hace otro código. El formulario envía a options.phpque verifica el nonce impreso por settings_fields y comprueba la capacidad por ti. No construyas tu propio manejador de guardado para opciones simples. Usa la Settings API y heredas ambas comprobaciones.

Un endpoint REST con comprobación de permisos

add_action( 'rest_api_init', 'acme_notes_register_routes' );

function acme_notes_register_routes() {
	register_rest_route(
		'acme-notes/v1',
		'/notes',
		array(
			'methods'             => WP_REST_Server::READABLE,
			'callback'            => 'acme_notes_rest_list',
			'permission_callback' => function () {
				return current_user_can( 'edit_posts' );
			},
			'args'                => array(
				'per_page' => array(
					'type'              => 'integer',
					'default'           => 10,
					'sanitize_callback' => 'absint',
					'validate_callback' => function ( $value ) {
						return is_numeric( $value ) && (int) $value >= 1 && (int) $value <= 50;
					},
				),
			),
		)
	);
}

function acme_notes_rest_list( WP_REST_Request $request ) {
	$notes = get_posts(
		array(
			'post_type'   => 'acme_note',
			'numberposts' => $request['per_page'],
		)
	);

	$items = array();
	foreach ( $notes as $note ) {
		$items[] = array(
			'id'    => $note->ID,
			'title' => get_the_title( $note ),
		);
	}

	return rest_ensure_response( $items );
}

Importan tres detalles. El permission_callback decide quién puede llamar a la ruta. Si lo omites, WordPress lanza un aviso, y devuelve __return_true solo cuando la ruta deba ser pública. El array args valida y sanea la entrada antes de que se ejecute tu callback, con la validación primero. Y el namespace lleva una versión, de modo que una v2 pueda convivir con la v1. El REST API Handbook documenta esquemas y clases controller para APIs mayores.

Buenas prácticas de seguridad en plugins de WordPress

La seguridad de un plugin de WordPress son cuatro controles aplicados en el orden correcto, no criptografía ingeniosa. El fallo recurrente es una comprobación que falta, no un exploit exótico. La guía de seguridad para plugins del handbook lo plantea igual, y el panorama general está en nuestro artículo sobre seguridad de aplicaciones web y prioridades OWASP.

Cada control responde a una pregunta distinta y se aplica en un momento distinto de la petición.

Quién

Capacidad

Quién puede hacer esto. Comprueba la capacidad más ajustada con current_user_can() antes de cualquier cambio.

Desde dónde

Nonce

Si la petición viene de nuestro formulario, para este usuario. Usa wp_nonce_field() y check_admin_referer().

En la entrada

Sanear

Limpia los datos antes de guardarlos con sanitize_text_field() o absint(), y rechaza lo inválido.

En la salida

Escapar

Haz la salida segura para su contexto con esc_html(), esc_attr() o esc_url().

Capacidades y nonces: quién y desde dónde

Una capacidad responde a quién tiene permiso. Un nonce responde a si esta petición vino de un formulario o enlace que generamos para este usuario. Necesitas ambos en toda petición que cambie estado. Este es un manejador admin-post propio, el patrón para todo lo que la Settings API no cubre.

add_action( 'admin_post_acme_notes_save', 'acme_notes_handle_save' );

function acme_notes_handle_save() {
	if ( ! current_user_can( 'manage_options' ) ) {
		wp_die( esc_html__( 'You are not allowed to do this.', 'acme-notes' ), '', array( 'response' => 403 ) );
	}

	check_admin_referer( 'acme_notes_save', 'acme_notes_nonce' );

	$label = isset( $_POST['label'] ) ? sanitize_text_field( wp_unslash( $_POST['label'] ) ) : '';
	$page  = admin_url( 'options-general.php?page=acme-notes' );

	if ( '' === $label || strlen( $label ) > 60 ) {
		wp_safe_redirect( add_query_arg( 'acme_error', 'label', $page ) );
		exit;
	}

	update_option( 'acme_notes_label', $label );
	wp_safe_redirect( add_query_arg( 'acme_saved', '1', $page ) );
	exit;
}

El formulario correspondiente imprime el nombre de la acción y el nonce.

<form action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" method="post">
	<input type="hidden" name="action" value="acme_notes_save">
	<?php wp_nonce_field( 'acme_notes_save', 'acme_notes_nonce' ); ?>
	<input type="text" name="label" value="<?php echo esc_attr( get_option( 'acme_notes_label', '' ) ); ?>">
	<?php submit_button(); ?>
</form>

Comprueba primero la capacidad y falla con un 403. Después verifica el nonce con check_admin_refererque detiene la petición si falla. Los nonces caducan y están ligados al usuario y a la acción, así que limitan el cross-site request forgery, pero no son autenticación.

Nonce no es permiso

Un nonce prueba la intención: la petición vino de un formulario o enlace generado para este usuario. No prueba que el usuario pueda realizar la acción. Comprueba siempre también una capacidad y no confíes nunca solo en un nonce.

Elige la capacidad más estrecha que encaje. manage_options sirve para una página de ajustes, edit_posts para tomar notas, y una capacidad personalizada para lo que quieras entregar a un solo rol. No protejas nunca acciones de administración con is_admin(): solo indica que la petición apunta a una pantalla de admin, no que el usuario sea administrador.

El diagrama sigue el envío de un formulario de ajustes por los controles. Cada control puede rechazar la petición, y el rechazo ocurre antes de tocar ningún dato.

Flujo del envío de un formulario de ajustes que muestra dónde los controles de capacidad, nonce y validación rechazan una petición antes de guardar datos
Dónde cada control de seguridad detiene una petición errónea

Sanear en la entrada, validar, escapar en la salida

Sanear limpia la entrada antes de guardarla. Validar rechaza la entrada que no es aceptable en absoluto. Escapar hace la salida segura para el contexto donde se imprime. Haz lo primero y lo segundo cuando llegan los datos, y lo tercero en el último momento antes de la salida, siempre, incluso con valores que guardaste tú.

Recuerda que los valores de $_POST llegan con barras de escape, así que llama a wp_unslash antes de sanear. Después escapa según el contexto, no según el origen. Texto, atributos, URLs y texto enriquecido de confianza tienen cada uno su función.

Contexto de salidaFunciónÚsala para
Texto HTMLesc_htmlTexto plano entre etiquetas
Atributo HTMLesc_attrAtributos value, class y data
URLesc_urlValores de href y src
Texto enriquecidowp_kses_postMarcado de confianza, solo etiquetas permitidas
Datos para JavaScriptwp_json_encodePasar valores de PHP a scripts
<?php if ( isset( $_GET['acme_saved'] ) ) : ?>
	<div class="notice notice-success">
		<p><?php echo esc_html( get_option( 'acme_notes_label', '' ) ); ?></p>
	</div>
<?php endif; ?>

Consultas a la base de datos con $wpdb->prepare

Prefiere las APIs de WordPress (WP_Query``get_postslas funciones de opciones) al SQL directo. Cuando necesites una consulta propia, pasa cada variable por prepare con un marcador: %d para enteros, %s para cadenas. Los nombres de tabla salen de $wpdb->prefixnunca de la entrada del usuario.

global $wpdb;

$table = $wpdb->prefix . 'acme_notes_log';
$rows  = $wpdb->get_results(
	$wpdb->prepare(
		'SELECT id, note_id FROM ' . $table . ' WHERE user_id = %d AND action = %s LIMIT %d',
		$user_id,
		$action,
		20
	)
);

prepare no sanea nada. Separa los datos de la sentencia. Valida primero los valores y deja que prepare haga su única tarea.

Versionado, actualizaciones y limpieza al desinstalar

Un plugin que no se puede actualizar con seguridad es un riesgo en cada sitio que lo ejecuta. Tres piezas hacen aburridas las actualizaciones: un esquema de versiones, una versión de base de datos guardada con rutinas de actualización y una desinstalación que limpia solo lo que debe.

Versionado semántico y opción de versión de BD

Usa versionado semántico: MAJOR para cambios incompatibles, MINOR para funcionalidades retrocompatibles, PATCH para correcciones. La versión vive en dos sitios, la cabecera y la constante, y un script de release debería fallar cuando difieran. Añade un readme.txt con un Stable tag y un changelog aunque nunca publiques en WordPress.org, porque el formato es un buen registro de versiones.

Mantén un segundo número: la versión de base de datos. La versión del plugin describe tu código. La versión de BD describe la forma de lo que guardaste, como nombres de opciones, tablas propias y claves meta. La mayoría de las versiones cambian solo la primera. Cuando una versión cambia datos guardados, subes la versión de BD y entregas una migración. Es el patrón que seguimos al mantener plugins como werun.Toolkit.

Ejecutar rutinas de actualización con seguridad

Los hooks de activación no se ejecutan cuando se actualiza un plugin, y las actualizaciones automáticas sustituyen archivos sin tocar la base de datos. Por eso la comprobación de actualización debe ejecutarse en una petición normal, pronto. Nosotros la enganchamos a plugins_loaded.

  1. Lee la versión guardada

    Carga acme_notes_db_version con un valor por defecto de 0 para instalaciones anteriores a ella.

  2. Compara con la constante del código

    Si el valor guardado es igual o mayor, termina sin hacer nada.

  3. Ejecuta cada migración en orden

    Salta las que están en la versión guardada o por debajo y ejecuta el resto, de la más antigua a la más reciente.

  4. Actualiza la versión guardada

    Escríbela tras cada migración correcta, no una sola vez al final.

  5. Registra los fallos

    Detente en el primer fallo, escríbelo en el log y reintenta en la siguiente carga.

add_action( 'plugins_loaded', 'acme_notes_maybe_upgrade' );

function acme_notes_maybe_upgrade() {
	$stored = (int) get_option( 'acme_notes_db_version', 0 );

	if ( $stored >= ACME_NOTES_DB_VERSION ) {
		return;
	}

	$migrations = array(
		2 => 'acme_notes_migrate_to_2',
	);

	foreach ( $migrations as $target => $callback ) {
		if ( $stored >= $target ) {
			continue;
		}

		if ( true !== call_user_func( $callback ) ) {
			error_log( sprintf( 'acme-notes: migration to %d failed', $target ) );
			return;
		}

		update_option( 'acme_notes_db_version', $target );
		$stored = $target;
	}
}

function acme_notes_migrate_to_2() {
	// La versión 2 renombró la opción del pie a la opción de etiqueta.
	$old = get_option( 'acme_notes_footer' );

	if ( false !== $old ) {
		update_option( 'acme_notes_label', $old );
		delete_option( 'acme_notes_footer' );
	}

	return true;
}

Tres hábitos mantienen seguras las migraciones. Haz cada una idempotente, de modo que ejecutarla dos veces no cause daño. No ejecutes nunca una migración pesada dentro de una petición web: programa lotes con WP-Cron o Action Scheduler. Y registra la nueva versión solo después de que una migración tenga éxito, para que un fallo se reintente en la siguiente carga en lugar de saltarse.

Limpieza al desinstalar con uninstall.php

Cuando un administrador elimina el plugin, WordPress ejecuta uninstall.php si existe. Es el lugar para quitar opciones, tablas propias y eventos programados. La constante de guarda confirma que WordPress llamó al archivo y que nadie lo solicitó directamente.

<?php
// uninstall.php
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
	exit;
}

if ( get_option( 'acme_notes_delete_data' ) ) {
	$ids = get_posts(
		array(
			'post_type'   => 'acme_note',
			'post_status' => 'any',
			'numberposts' => -1,
			'fields'      => 'ids',
		)
	);

	foreach ( $ids as $id ) {
		wp_delete_post( $id, true );
	}
}

delete_option( 'acme_notes_delete_data' );
delete_option( 'acme_notes_label' );
delete_option( 'acme_notes_db_version' );

Borrar contenido es irreversible, así que hazlo opcional. Añade un ajuste Borrar datos al desinstalar, desactivado por defecto, y elimina las notas solo cuando esté activado. Las opciones son baratas y seguras de borrar. El contenido generado por usuarios no. En multisite, recorre los sitios y repite la limpieza en cada uno.

Tests y estándares de código antes de publicar

Un plugin que nadie puede cambiar con seguridad es un plugin que nadie actualizará. Los tests son la forma más barata de seguir cambiándolo. El filtro mínimo tiene tres capas: comprobaciones estáticas, tests automatizados y una lista de verificación de release.

PHPCS y WordPress Coding Standards

PHP_CodeSniffer con los WordPress Coding Standards detecta errores de seguridad (salida sin escapar, consultas sin preparar, verificación de nonce ausente) además de problemas de estilo. Ejecútalo en local y en CI.

composer require --dev wp-coding-standards/wpcs dealerdirect/phpcodesniffer-composer-installer
vendor/bin/phpcs --standard=WordPress --extensions=php --ignore=vendor .

El estándar está publicado en el handbook de WordPress Coding Standards en developer.wordpress.org. Corrige los errores antes de discutir los avisos, y anota cada excepción deliberada con un comentario ignore que explique por qué.

Tests unitarios y de integración

La lógica PHP pura, como un validador o un formateador, se puede probar con PHPUnit sin más. Todo lo que toque hooks, opciones, usuarios o REST necesita la suite de tests de WordPress, que arranca un WordPress real contra una base de datos de pruebas. WP-CLI genera el andamiaje con wp scaffold plugin-tests acme-notes.

class Test_Notes_Rest extends WP_UnitTestCase {
	public function test_anonymous_request_is_rejected() {
		$response = rest_do_request( new WP_REST_Request( 'GET', '/acme-notes/v1/notes' ) );

		$this->assertSame( 401, $response->get_status() );
	}

	public function test_editor_can_list_notes() {
		wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) );

		$response = rest_do_request( new WP_REST_Request( 'GET', '/acme-notes/v1/notes' ) );

		$this->assertSame( 200, $response->get_status() );
	}
}

Los tests de permisos dan el mejor retorno. Prueban que la comprobación que añadiste sigue ahí tras el próximo refactor, y ocupan unas pocas líneas cada uno.

Lista de verificación de release

Antes de cada release, repasa la misma lista. El plugin oficial Plugin Check de WordPress.org automatiza parte de ella y señala problemas habituales de cabeceras, archivos readme y escapado.

  • PHPCS pasa con el estándar de WordPress y sin ignores sin explicar
  • Los tests de PHPUnit pasan, incluidos los de permisos y actualización
  • Plugin Check no reporta errores
  • Versión subida en la cabecera, la constante y el Stable tag del readme
  • Changelog actualizado en readme.txt
  • Actualización probada en staging desde la versión soportada más antigua
  • Desinstalación probada en staging, con y sin la opción de borrar datos
  • Zip de release generado con composer install --no-dev

Cómo crear la arquitectura de un plugin de WordPress que escale

La versión procedural funciona y, para un plugin pequeño, basta. La pregunta es qué pasa cuando el plugin tiene tres funcionalidades, dos desarrolladores y una suite de tests. Aquí es donde una arquitectura de plugin de WordPress pensada se paga sola.

De funciones a clases

La presión aparece al crecer el plugin: las funciones con prefijo se acumulan en un solo namespace, el orden de carga vive en tu cabeza y nada se puede probar sin arrancar WordPress. Nuestro artículo sobre el problema de los plugins explica cómo el crecimiento improvisado genera esta deuda en todo un sitio. Las clases la resuelven dentro de un plugin.

El refactor mueve cada responsabilidad a una clase bajo el namespace Acme\Notes y deja el archivo principal como un pequeño bootstrap. Una clase registra sus propios hooks en un método, normalmente register(). El post type y la rutina de actualización pasan a clases de la misma manera.

Inyección de dependencias sin framework

Inyección de dependencias significa que una clase recibe lo que necesita en lugar de crearlo. La inyección por constructor es la forma más simple y no necesitas un contenedor para usarla. Un solo lugar, la raíz de composición, construye los objetos y los conecta.

// src/Settings.php
namespace Acme\Notes;

class Settings {
	public const OPTION = 'acme_notes_label';

	public function register(): void {
		add_action( 'admin_init', array( $this, 'register_setting' ) );
	}

	public function register_setting(): void {
		register_setting(
			'acme_notes',
			self::OPTION,
			array(
				'type'              => 'string',
				'sanitize_callback' => 'sanitize_text_field',
				'default'           => 'Internal note',
			)
		);
	}

	public function get_label(): string {
		return (string) get_option( self::OPTION, 'Internal note' );
	}
}
// src/Rest_Controller.php
namespace Acme\Notes;

class Rest_Controller {
	private Settings $settings;

	public function __construct( Settings $settings ) {
		$this->settings = $settings;
	}

	public function register(): void {
		add_action( 'rest_api_init', array( $this, 'register_routes' ) );
	}

	public function register_routes(): void {
		register_rest_route(
			'acme-notes/v1',
			'/notes',
			array(
				'methods'             => 'GET',
				'callback'            => array( $this, 'list_notes' ),
				'permission_callback' => array( $this, 'can_list' ),
			)
		);
	}

	public function can_list(): bool {
		return current_user_can( 'edit_posts' );
	}

	public function list_notes() {
		$ids = get_posts(
			array(
				'post_type'   => 'acme_note',
				'numberposts' => 10,
				'fields'      => 'ids',
			)
		);

		return rest_ensure_response(
			array(
				'label' => $this->settings->get_label(),
				'ids'   => $ids,
			)
		);
	}
}
// src/Plugin.php
namespace Acme\Notes;

final class Plugin {
	private Settings $settings;
	private Rest_Controller $rest;

	public function __construct( Settings $settings, Rest_Controller $rest ) {
		$this->settings = $settings;
		$this->rest     = $rest;
	}

	public function boot(): void {
		$this->settings->register();
		$this->rest->register();
	}
}

La raíz de composición es el único lugar que usa new. Vive en el archivo principal, sobre plugins_loadedy de él desaparecen las antiguas líneas add_action de estas funcionalidades.

add_action(
	'plugins_loaded',
	static function () {
		$settings = new \Acme\Notes\Settings();
		$rest     = new \Acme\Notes\Rest_Controller( $settings );

		( new \Acme\Notes\Plugin( $settings, $rest ) )->boot();
	}
);

Rest_Controller no llama a get_option. Se lo pide a un objeto Settingsasí que un test puede pasarle un fake. Las dependencias se ven en los constructores y el orden de carga se ve en una sola función. Ese es todo el beneficio, y no necesita ningún framework.

Punto de partida

Funciones con prefijo

  • Menos archivos, primera versión más rápida
  • Orden de carga implícito en el orden de archivos
  • Colisiones evitadas con disciplina de prefijos
  • Difícil de probar sin arrancar WordPress
  • El coste del refactor crece con cada funcionalidad

Nuestra opción pasadas unas cientos de líneas

Clases con inyección por constructor

  • Dependencias declaradas en los constructores
  • Orden de carga explícito en una raíz de composición
  • Los namespaces eliminan colisiones de nombres
  • Los fakes sustituyen dependencias en los tests
  • El coste del refactor se paga una vez

Cuándo no sobrediseñar

No todos los plugins necesitan esto. Un plugin de propósito único de unas pocas centenas de líneas, un desarrollador y sin tests puede seguir siendo procedural con un prefijo estricto. Pasa a clases cuando se incorpore un segundo desarrollador, cuando añadas una segunda área funcional o cuando quieras tests.

Añade un contenedor solo cuando el cableado de la raíz de composición sea lo bastante largo como para doler, y no antes. El refactor es más barato mientras el plugin es pequeño, así que deja que lo dispare el tamaño del equipo y la necesidad de tests, no el número de líneas.

Por dónde empezar

Aplica esto en orden. Primero el esqueleto: cabecera, guarda, prefijo y Composer. Después los controles de seguridad, porque son los más difíciles de añadir a posteriori. Luego versionado y desinstalación, después los tests y, al final, el refactor a clases, cuando el código te muestre dónde están las costuras.

Si prefieres que un equipo que hace esto cada semana construya y mantenga tu plugin, mira nuestro trabajo con plugins de WordPress o habla con nosotros. Construimos con este estándar y seguimos operando el plugin después del lanzamiento.

Preguntas frecuentes

¿Cómo creo un plugin de WordPress desde cero?

Crea una carpeta en wp-content/plugins con un archivo PHP que lleve su nombre, añade un comentario de cabecera con al menos Plugin Name, añade una guarda ABSPATH y registra tu código en hooks. Actívalo en el admin. Añade un prefijo único, escapado y comprobaciones de capacidad antes de que maneje datos reales.

¿Cuál es el archivo mínimo que necesita un plugin de WordPress?

Un archivo PHP con un comentario de cabecera que contenga Plugin Name. Con eso WordPress puede listarlo y activarlo. Un plugin real necesita además un readme.txt, un uninstall.php y un prefijo único en todo lo que define.

¿Cómo hago seguro un plugin de WordPress?

Comprueba una capacidad y un nonce en cada petición que cambie estado, sanea y valida la entrada, escapa la salida según su contexto y usa $wpdb->prepare en las consultas propias. Da a cada ruta REST un permission_callback. Ejecuta PHPCS con los WordPress Coding Standards para detectar las comprobaciones que se te hayan pasado.

¿Cuál es la diferencia entre acciones y filtros en WordPress?

Una acción ejecuta tu código cuando ocurre algo y no devuelve nada. Un filtro recibe un valor, te permite cambiarlo y debe devolverlo. Se registran con add_action y add_filter.

¿Debería usar un boilerplate de plugin de WordPress?

Úsalo si tu equipo ya lo conoce, porque ahorra tiempo de configuración. No sustituye la comprensión. Una estructura pequeña con archivo principal, carpeta src y autoload de Composer suele ser más fácil de mantener que un boilerplate lleno de archivos que nunca usas.

¿Cómo actualizo un plugin de WordPress sin romper sitios?

Versiona tu código con versionado semántico, guarda una versión de BD aparte y ejecuta migraciones idempotentes en plugins_loaded. Prueba la actualización desde tu versión soportada más antigua en staging. No pongas nunca migraciones en el hook de activación, porque no se ejecuta cuando se actualiza un plugin.

¿Te pasa esto en tu propio stack?

Cuéntanos la plataforma y el problema. Te diremos qué hace falta y quién lo hace.

Sin compromiso. Solo una conversación.