Publicado originalmente en mi blog: Cómo crear un plugin de WordPress con PHP, WooCommerce y Elementor. Esta es la versión adaptada para DEV Community.
Mucha personalización de WordPress termina en functions.php. Funciona hasta que cambia el tema y la funcionalidad desaparece con él. Todo lo que debe sobrevivir al tema, como una regla de envío, una regla de precios o un widget propio, pertenece a un plugin.
Esta guía construye un plugin pequeño como estructuro el trabajo de clientes: un archivo de entrada limpio, hooks en lugar de ediciones, seguridad por defecto, una regla de WooCommerce y un widget de Elementor. La parte de WooCommerce refleja el tipo de trabajo que hice en Industria Social para GroovePro, donde las reglas de envío y los precios de bundles se mantuvieron en dos plugins PHP separados.
Los ejemplos usan un plugin inventado llamado Acme Shipping Rules. Cambia el prefijo (acme_sr_) por el tuyo.
Crea wp-content/plugins/acme-shipping-rules/acme-shipping-rules.php como archivo principal y añade ahí los hooks y funciones de los pasos 2 a 5. El widget va en widgets/class-acme-savings-widget.php; la limpieza va en uninstall.php. Los bloques que continúan el archivo principal no necesitan otra apertura <?php. La consulta SQL del paso 3 es un ejemplo independiente, no parte del plugin de envíos.
Paso 1: el archivo del plugin y su encabezado
Un plugin es una carpeta en wp-content/plugins/ con un archivo PHP principal. Solo Plugin Name es obligatorio en el encabezado, pero un plugin real debería declarar más, según los requisitos del encabezado de plugins de WordPress:
<?php
/**
* Plugin Name: Acme Shipping Rules
* Description: Ajusta las tarifas de envío de WooCommerce según la cantidad por paquete.
* Version: 1.0.0
* Requires at least: 6.5
* Requires PHP: 8.3
* Author: Fernando Peralta
* License: GPL v2 or later
* Text Domain: acme-shipping-rules
* Requires Plugins: woocommerce
*/
// Bloquea el acceso directo al archivo.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
Requires Plugins permite que WordPress se niegue a activar tu plugin si falta WooCommerce (WordPress 6.5 y posteriores). La guarda ABSPATH evita que alguien cargue el archivo fuera de WordPress.
Este ejemplo declara WordPress 6.5 como mínimo para usar Requires Plugins y PHP 8.3 como base. La versión de WooCommerce instalada puede exigir una versión de WordPress superior; comprueba sus requisitos de entorno. Elementor es opcional: sin él, el plugin mantiene la regla de envío y no registra el widget.
Dos hábitos que previenen la mayoría de las colisiones entre plugins:
-
Prefija todo: funciones, opciones, hooks y nombres de clases (
acme_sr_). -
Usa un text domain y envuelve los textos en
__()oesc_html__()desde el primer día, para que traducir después no sea una refactorización.
Paso 2: hooks, no ediciones
WordPress es dirigido por eventos. Nunca editas el núcleo ni otro plugin: te enganchas a actions (hacer algo en un momento) y filters (cambiar un valor mientras pasa).
// Ejecuta algo cuando se activa el plugin.
register_activation_hook( __FILE__, 'acme_sr_activate' );
function acme_sr_activate() {
add_option( 'acme_sr_threshold', 3 );
}
register_activation_hook() solo se dispara al activar, por lo que es el lugar correcto para crear opciones por defecto. Deja ahí cualquier preparación más pesada, como crear tablas, en lugar de hacerla en cada petición.
Paso 3: la seguridad no es opcional
El manejador comprueba permisos, verifica el nonce y valida un entero mayor o igual a 1 en el servidor. min="1" ayuda en el navegador, pero no sustituye esta validación. Los arrays, campos ausentes, texto, decimales y valores menores que 1 se rechazan sin cambiar la opción:
add_action( 'admin_post_acme_sr_save', 'acme_sr_save_settings' );
function acme_sr_save_settings() {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( esc_html__( 'No tienes permiso.', 'acme-shipping-rules' ), 403 );
}
// Verifica el token contra CSRF; no sustituye los permisos.
check_admin_referer( 'acme_sr_save' );
// Valida el tipo y el rango antes de guardar.
$raw = isset( $_POST['threshold'] ) && is_string( $_POST['threshold'] )
? wp_unslash( $_POST['threshold'] )
: '';
$threshold = filter_var( $raw, FILTER_VALIDATE_INT, [
'options' => [ 'min_range' => 1 ],
] );
if ( false === $threshold ) {
wp_die( esc_html__( 'La cantidad mínima debe ser un entero mayor o igual a 1.', 'acme-shipping-rules' ), 400 );
}
update_option( 'acme_sr_threshold', $threshold );
wp_safe_redirect( admin_url( 'admin.php?page=acme-shipping-rules' ) );
exit;
}
Registra una página en WooCommerce → Reglas de envío mediante admin_menu y add_submenu_page(). Este bloque pertenece al archivo principal e incluye la función que muestra el formulario:
add_action( 'admin_menu', 'acme_sr_register_settings_page' );
function acme_sr_register_settings_page() {
add_submenu_page(
'woocommerce',
esc_html__( 'Reglas de envío', 'acme-shipping-rules' ),
esc_html__( 'Reglas de envío', 'acme-shipping-rules' ),
'manage_woocommerce',
'acme-shipping-rules',
'acme_sr_render_settings_page'
);
}
function acme_sr_render_settings_page() {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( esc_html__( 'No tienes permiso.', 'acme-shipping-rules' ), 403 );
}
?>
<div class="wrap">
<h1><?php esc_html_e( 'Reglas de envío', 'acme-shipping-rules' ); ?></h1>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<input type="hidden" name="action" value="acme_sr_save">
<?php wp_nonce_field( 'acme_sr_save' ); ?>
<label for="threshold"><?php esc_html_e( 'Cantidad mínima por paquete', 'acme-shipping-rules' ); ?></label>
<input id="threshold" name="threshold" type="number" min="1" step="1" required
value="<?php echo esc_attr( get_option( 'acme_sr_threshold', 3 ) ); ?>">
<button type="submit"><?php esc_html_e( 'Guardar', 'acme-shipping-rules' ); ?></button>
</form>
</div>
<?php
}
Notas que vale la pena recordar:
- Un nonce ayuda a proteger contra CSRF; no demuestra por sí solo el origen de una petición ni concede permisos. Usa también
current_user_can(), como explica la documentación de nonces de WordPress. - Escapa en el momento de la salida:
esc_html(),esc_attr(),esc_url(). Sanear al entrar no vuelve segura la salida. - Cualquier SQL propio pasa por
$wpdb->prepare():
global $wpdb;
$table = $wpdb->prefix . 'acme_sr_log';
$rows = $wpdb->get_results(
$wpdb->prepare( "SELECT * FROM {$table} WHERE order_id = %d", $order_id )
);
Paso 4: una regla de envío en WooCommerce
El filtro woocommerce_package_rates recibe las tarifas y un paquete de envío. Este ejemplo descuenta 20 % del costo y sus impuestos cuando ese paquete contiene la cantidad mínima. Si el carrito se divide en varios paquetes, cada uno se evalúa por separado; no se suman todos los productos del carrito.
// Incluye la regla en el hash del paquete para evitar tarifas antiguas.
add_filter( 'woocommerce_cart_shipping_packages', 'acme_sr_shipping_rule_cache_key' );
function acme_sr_shipping_rule_cache_key( $packages ) {
$threshold = max( 1, (int) get_option( 'acme_sr_threshold', 3 ) );
foreach ( $packages as $key => $package ) {
$packages[ $key ]['acme_sr_threshold'] = $threshold;
}
return $packages;
}
add_filter( 'woocommerce_package_rates', 'acme_sr_adjust_rates', 10, 2 );
function acme_sr_adjust_rates( $rates, $package ) {
$quantity = 0;
foreach ( $package['contents'] as $item ) {
$quantity += (int) $item['quantity'];
}
if ( $quantity < max( 1, (int) get_option( 'acme_sr_threshold', 3 ) ) ) {
return $rates;
}
foreach ( $rates as $rate ) {
// 20% de descuento si este paquete alcanza la cantidad mínima.
$rate->set_cost( (float) $rate->get_cost() * 0.8 );
// Ajusta los impuestos proporcionalmente al costo descontado.
$taxes = array_map(
fn( $tax ) => $tax * 0.8,
$rate->get_taxes()
);
$rate->set_taxes( $taxes );
}
return $rates;
}
$rate es un WC_Shipping_Rate, que expone get_cost() y set_cost(), además de get_taxes() y set_taxes(). Dos advertencias prácticas:
-
La caché debe reflejar la regla.
acme_sr_shipping_rule_cache_key()añade la cantidad mínima a cada paquete antes de calcular las tarifas. Al guardar otra cantidad, cambia el hash y WooCommerce recalcula en la siguiente solicitud del cliente. El valor es estable: no invalida la caché en cada visita. El código de envíos de WooCommerce muestra cómo el paquete participa en el hash. - Prueba con impuestos activados y desactivados, con varias zonas de envío y con cupones que otorgan envío gratis.
Declara compatibilidad con HPOS
El High-Performance Order Storage de WooCommerce cambia dónde viven los pedidos. Un plugin que no declara compatibilidad no aparece como compatible en los ajustes de funciones de WooCommerce, por lo que debes revisar su código antes de declarar compatibilidad desde el archivo principal:
add_action( 'before_woocommerce_init', function () {
if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) {
\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility(
'custom_order_tables',
__FILE__,
true
);
}
} );
Declararlo es una promesa: lee y escribe pedidos con los métodos CRUD de WooCommerce (wc_get_order(), $order->get_meta()), no con consultas directas a wp_postmeta.
Paso 5: un widget de Elementor
En sitios construidos con Elementor, el cliente suele querer un bloque que pueda soltar en cualquier página. Un widget es una clase PHP que extiende \Elementor\Widget_Base, siguiendo la estructura de widgets de Elementor:
<?php
// widgets/class-acme-savings-widget.php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class Acme_Savings_Widget extends \Elementor\Widget_Base {
public function get_name(): string {
return 'acme_savings';
}
public function get_title(): string {
return esc_html__( 'Ahorro en envío', 'acme-shipping-rules' );
}
public function get_icon(): string {
return 'eicon-code';
}
public function get_categories(): array {
return [ 'general' ];
}
protected function register_controls(): void {
$this->start_controls_section( 'content_section', [
'label' => esc_html__( 'Contenido', 'acme-shipping-rules' ),
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
] );
$this->add_control( 'message', [
'label' => esc_html__( 'Mensaje', 'acme-shipping-rules' ),
'type' => \Elementor\Controls_Manager::TEXT,
'default' => esc_html__( 'El descuento de envío se calcula por paquete.', 'acme-shipping-rules' ),
] );
$this->end_controls_section();
}
protected function render(): void {
$settings = $this->get_settings_for_display();
echo '<p class="acme-savings">' . esc_html( $settings['message'] ) . '</p>';
}
}
Regístralo desde el archivo principal del plugin. El archivo de la clase se incluye dentro del hook, porque las clases de Elementor no existen hasta que Elementor se cargó:
add_action( 'elementor/widgets/register', function ( $widgets_manager ) {
require_once __DIR__ . '/widgets/class-acme-savings-widget.php';
$widgets_manager->register( new \Acme_Savings_Widget() );
} );
El widget ya aparece en el panel de Elementor, su control message es editable por el cliente y la salida está escapada.
El widget es informativo: su mensaje se edita en Elementor y no determina la elegibilidad para el descuento. La regla se aplica al calcular el envío para cada paquete.
Paso 6: rendimiento y mantenimiento
Un plugin se ejecuta en cada petición, así que cuida lo que cargas:
-
Carga assets solo donde se necesitan. Encola scripts y estilos dentro de
wp_enqueue_scriptsy protégelos con condicionales comois_cart()ois_checkout(). - No consultes en cada página. Guarda en caché los resultados costosos con transients e invalídalos cuando los datos cambien.
- Limpia al desinstalar. Elimina solo datos propiedad del plugin; no borres pedidos ni datos de otros plugins.
- Versiona tus assets con la versión del plugin para que los navegadores no sirvan archivos viejos después de un lanzamiento.
Para este ejemplo, uninstall.php elimina únicamente la opción del plugin al desinstalarlo. Usa WP_UNINSTALL_PLUGIN como guarda en ese archivo; la desactivación no debe borrar la configuración:
<?php
// uninstall.php
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
exit;
}
delete_option( 'acme_sr_threshold' );
Antes de entregar, pasa el código por los WordPress Coding Standards con PHP_CodeSniffer y por el plugin oficial Plugin Check, que señala problemas de seguridad y compatibilidad.
Lista previa al lanzamiento
- Encabezado completo, guarda
ABSPATHen el principal y el widget, yWP_UNINSTALL_PLUGINenuninstall.php. - Todas las funciones, opciones y clases con prefijo.
- Permisos, nonce y validación del entero mínimo en el servidor; salida escapada en el formulario y el widget.
-
$wpdb->prepare()en cualquier SQL con variables. - HPOS declarado y pedidos leídos con las APIs de WooCommerce.
- Assets encolados solo donde se necesitan.
- Pruebas con cantidades bajo/en/sobre el mínimo, varios paquetes, impuestos, zonas, envío gratis y cambio de mínimo en una sesión con tarifas en caché.
-
uninstall.phpy changelog listos.
Cuándo construir en lugar de comprar
Un plugin ya hecho es la decisión correcta cuando hace exactamente lo que necesitas y tiene mantenimiento activo. Un plugin a medida se justifica cuando la regla es específica de tu negocio, como una fórmula de envío o un precio por bundle, o cuando de otro modo apilarías tres plugins para aproximarla.
Este es el trabajo que hago como desarrollador WordPress en CDMX. Puedes ver proyectos relacionados en los casos de estudio, como el sitio bilingüe en WordPress y Elementor para Mezcal Viviente. Si necesitas un plugin a medida, escríbeme con la regla que quieres automatizar.
Recurso adicional: desplegar WordPress en Azure
Si además de desarrollar plugins quieres explorar opciones de despliegue, Microsoft ofrece documentación para alojar WordPress mediante Azure App Service y Azure Database for MySQL.
Puedes consultar la guía oficial para implementar WordPress en Azure, que explica cómo configurar y administrar una instalación de WordPress en la nube.
Sobre mí
Soy Fernando Peralta, desarrollador web especializado en proyectos WordPress, WooCommerce y desarrollo a medida. Comparto artículos técnicos y proyectos en mi sitio web.
También trabajo en Devstoremx, mi agencia de desarrollo web en México, donde desarrollamos sitios web, tiendas en línea, aplicaciones e integraciones a medida.
Puedes consultar la guía original en mi blog y explorar otros casos reales de desarrollo WordPress.
Top comments (0)