¿Por qué lo hacemos a mano y tan difícil, si se puede con plugins?
WordPress es el CMS más popular de internet y tiene a disposición un sin fin de plugins para hacer de todo, incluyendo un simple formulario de contacto, sin embargo, “lo barato sale caro” Mucha de estas supuestas solución traen consigo problemas de seguridad (Brechas, puertas traseras), y como empresa ya hemos tenido malas experiencias con estos plugins de terceros. Por este mortivos hemos diseñado nuestra propia solución usando el mismo core de WordPress y API de google reCAPCHA de forma directa para evitar futuros inconvenientes.
¿Cómo funciona la solución del formulario?
Vamos a tener un formulario de contacto que se recepcionarán en una función personalizada, donde revisamos si los datos cumple con el formato adecuado y que no tenga código o script maliciosos, posteriormente validamos con reCAPTCHA bot y finalmente guardamos en base datos. Luego mandamos al correo los datos del formulario creado y complementamos la funcionalidad con un plugins personalizados que permite descargar todos los datos recopilados en un documento de excel.

Debido al nivel de complejidad de este componente, solo hablaremos en este artículo de la parte principal.

Los demás módulos serán explicados por separado en los siguientes artículos:
1. Verificación de formulario con reCAPTCHA,
2. Envío de correo con información del formulario,
3. Plugin personalizado para descargar los datos del custom post type en un archivo excel.
Archivos y construcción del componente.
Lo primero que debemos hacer es crear un custom post type, una taxonomía personalizada que usaremos para guardar las opciones de los select

Luego creamos el formulario y lo mostramos por medio del template de la página de contactos.

Sí, hacemos todo bien, al llenar los datos y enviar el formulario podremos ver la información en administrador en el menú de Contacto

Archivos Usados
1.
post-contact.php
PHP
functions/posts/
Setting
post-contact.php
PHP
functions/posts/
Setting
1.1. En este código vamos a crear el custom post type nuevo y para eso necesitamos las sigientes variables.

Si deseas ver más iconos, puede buscarlo aquí https://developer.wordpress.org/resource/dashicons/
1.2. También debemos tener en cuenta que los nombres de las funciones deben ser únicos en todo proyecto, para asegurarnos de eso, vamos a construir los nombres de las funciones con el nombre del custom post y caso que lo usemos para otro tipo de post deberemos cambiarlo.

Código:
<?php
//variables
$variables = [
'post_type' => 'post_contact',
'icon' => 'dashicons-buddicons-buddypress-logo',
'label_single' => 'Contacto',
'label_plural' => 'Contactos',
'position_menu' => 7,
];
// Register Custom Post Type
function post_type_contact($variables) {
$post_type = $variables['post_type'];
$icon = $variables['icon'];
$label_single = $variables['label_single'];
$label_plural = $variables['label_plural'];
$position_menu = $variables['position_menu'];
$labels = array(
'name' => _x( $label_plural , 'Post Type General Name', $label_single.'_domain' ),
'singular_name' => _x( $label_single, 'Post Type Singular Name', $label_single.'_domain' ),
'menu_name' => __( $label_plural , $label_single.'_domain' ),
'name_admin_bar' => __( $label_plural , $label_single.'_domain' ),
'archives' => __( 'Archivo '.$label_plural , $label_single.'_domain' ),
'attributes' => __( 'Atributos '.$label_single, $label_single.'_domain' ),
'parent_item_colon' => __( $label_single.' padre:', $label_single.'_domain' ),
'all_items' => __( 'Todos', $label_single.'_domain' ),
'add_new_item' => __( 'Agregar nueva', $label_single.'_domain' ),
'add_new' => __( 'Agregar', $label_single.'_domain' ),
'new_item' => __( 'Nueva', $label_single.'_domain' ),
'edit_item' => __( 'Editar', $label_single.'_domain' ),
'update_item' => __( 'Actualizar', $label_single.'_domain' ),
'view_item' => __( 'Ver '.$label_single, $label_single.'_domain' ),
'view_items' => __( 'Ver '.$label_single, $label_single.'_domain' ),
'search_items' => __( 'Buscar '.$label_single, $label_single.'_domain' ),
'not_found' => __( 'No encontrado', $label_single.'_domain' ),
'not_found_in_trash' => __( 'No encontrado en la papelera', $label_single.'_domain' ),
'featured_image' => __( 'Imagen destacada', $label_single.'_domain' ),
'set_featured_image' => __( 'Asignar imagen destacada', $label_single.'_domain' ),
'remove_featured_image' => __( 'Remover imagen', $label_single.'_domain' ),
'use_featured_image' => __( 'Usar como imagen destacada', $label_single.'_domain' ),
'insert_into_item' => __( 'Insertar en '.$label_single, $label_single.'_domain' ),
'uploaded_to_this_item' => __( 'Subir a '.$label_single, $label_single.'_domain' ),
'items_list' => __( 'Lista '.$label_single, $label_single.'_domain' ),
'items_list_navigation' => __( 'Navegación '.$label_single, $label_single.'_domain' ),
'filter_items_list' => __( 'Filtro '.$label_single, $label_single.'_domain' ),
);
$args = array(
'label' => __( $label_single, $label_single.'_domain' ),
'description' => __( 'Contenido de '.$label_single, $label_single.'_domain' ),
'labels' => $labels,
'supports' => array( 'title', 'editor' ),
'taxonomies' => array(),
'hierarchical' => false,
'public' => true,
'show_ui' => true,
'show_in_menu' => true,
'menu_position' => $position_menu,
'menu_icon' => $icon,
'show_in_admin_bar' => false,
'show_in_nav_menus' => false,
'can_export' => true,
'has_archive' => true,
'exclude_from_search' => true,
'publicly_queryable' => false,
'capability_type' => 'page',
'show_in_rest' => false,
'capabilities' => array(
'create_posts' => 'do_not_allow',
),
'map_meta_cap' =>true,
);
register_post_type( $post_type, $args );
}
add_action( 'init', function() use ($variables){
post_type_contact($variables);
});
2.
tax-contact-interest.php
PHP
wp-content/themes/depura_theme/functions/posts/
Setting
tax-contact-interest.php
PHP
wp-content/themes/depura_theme/functions/posts/
Setting
2.1 Creamos una taxonomía personalizada para guardar las opciones para campo de Temas de interés.

2.2. En este archivo usamos las siguientes variables

2.3. En las funciones vamos a usar el nombre de post type y el de la taxonomía.

Código
<?php
//Variables
$variables = [
'post_type' => 'post_contact',
'taxonomy_name' => 'interest',
'label_single' => 'Tema de interés',
'label_plural' => 'Temas de interés',
'all_filter' => 'Todos los temas',
];
// Registrar la taxonomía personalizada
function taxonomy_contact_interest($variables) {
$post_type = $variables['post_type'];
$taxonomy_name = $variables['taxonomy_name'];
$label_single = $variables['label_single'];
$label_plural = $variables['label_plural'];
$labels = array(
'name' => $label_plural,
'singular_name' => 'Nueva '. $label_single,
'menu_name' => $label_plural,
'all_items' => 'Todas las '. $label_plural,
'edit_item' => 'Editar '. $label_single,
'view_item' => 'Ver '. $label_single,
'update_item' => 'Actualizar '. $label_single,
'add_new_item' => 'Agregar Nuevo '. $label_single,
'new_item_name' => 'Nombre del Nuevo '. $label_single,
'parent_item' => $label_single.' Padre',
'parent_item_colon' => $label_single.' Padre:',
'search_items' => 'Buscar '. $label_plural,
'popular_items' => $label_plural. ' Populares',
'separate_items_with_commas' => 'Separar '.$label_plural.' con comas',
'add_or_remove_items' => 'Agregar o remover '. $label_single,
'choose_from_most_used' => 'Elegir los '. $label_plural,
'not_found' => 'No se encontraron '. $label_plural,
);
$args = array(
'labels' => $labels,
'hierarchical' => false,
'public' => false,
'show_ui' => true,
'show_admin_column' => true,
'query_var' => false,
'rewrite' => false
);
register_taxonomy($taxonomy_name, [$post_type], $args);
}
add_action('init', function() use ($variables){
taxonomy_contact_interest($variables);
});
// Mostrar en el dropdown el termino para filtrar en todos los posts
function dropdown_contact_interest($variables) {
global $typenow;
$post_type = $variables['post_type'];
$taxonomy_name = $variables['taxonomy_name'];
$all_filter = $variables['all_filter'];
if ($typenow == $post_type) {
$terms = get_terms( array(
'taxonomy' => $taxonomy_name,
'object_type' => $post_type,
'hide_empty' => false,
) );
if ($terms) {
echo '<select name="' . $taxonomy_name . '" id="' . $taxonomy_name . '">';
echo '<option value="">'.$all_filter.'</option>';
foreach ($terms as $term) {
echo '<option value="' . $term->slug . '">' . $term->name . '</option>';
}
echo '</select>';
}
}
}
add_action('restrict_manage_posts', function() use ($variables){
dropdown_contact_interest($variables);
});
//Agregar el termino al filtro de todos los posts
function filter_contact_interest($variables) {
global $typenow, $wp_query;
$post_type = $variables['post_type'];
$taxonomy_name = $variables['taxonomy_name'];
if ($typenow == $post_type && isset( $_GET[$taxonomy_name] ) && $_GET[$taxonomy_name] != '' ) {
$term_slug = sanitize_text_field( $_GET[$taxonomy_name] );
$wp_query->query_vars['tax_query'] = [[
'taxonomy' => $taxonomy_name,
'field' => 'slug',
'terms' => $term_slug,
]];
}
}
add_action('parse_query', function() use ($variables){
filter_contact_interest($variables);
});
3.
contact-tpl.php
PHP
wp-content/themes/depura_theme/templates/
Template
contact-tpl.php
PHP
wp-content/themes/depura_theme/templates/
Template
3.1 En este template vamos a llamar el archivo que tiene el formulario.

Código
<?php
/**
* @file
* Template Name: Contact.
*/
get_template_part('includes/header');
?>
<section>
<div class="container">
<div class="row">
<div class="col-lg-6">
</div>
<div class="col-lg-6">
<?php get_template_part('includes/form_contact'); ?>
</div>
</div>
</div>
</section>
<section>
<?php the_content() ?>
</section>
<?php
get_template_part('includes/footer');
?>
4.
form_contact.php
PHP
wp-content/themes/depura_theme/includes/
Form
form_contact.php
PHP
wp-content/themes/depura_theme/includes/
Form
4.1. De los atributos de formulario cabe resaltar lo siguiente
id Lo usaremos para identificar el formulario en el archivo que recibe los datos, con method guardamos el tipo petición “enviar (post)”, ya que estamos usando el protocolo de API REST.
La URL de la API donde se va a mandar los datos se guarda el atributo action, la cual obtenemos con la función admin_url( 'admin-post.php' )

4.2. En los input lo más importante es la propiedad name, porque a través de esta obtendremos el valor.
4.3. Otra cosa a tener en cuenta es que si deseamos que el campo sea obligatorio, colocamos la propiedad required

4.4. En el caso del select, vamos a obtener la option de los términos de la taxonomía personalizada interest.

4.5. En los botones tenemos guardado, de forma oculta(hidden), la uri que nos servirá para regresar a la página actual, el botón de submit para enviar los datos
4.6. Y lo más relevante, el input action que en su propiedad value guarda el nombre de la función que procesará los datos del formulario.

Código
<form
class="form-contact"
id="frm-contact" method="post"
action="<?php echo admin_url( 'admin-post.php' ) ?>"
>
<div class="row g-3">
<input
class="form-contact__text"
type="text" name="name" id="name"
size="40" maxlength="40" minlength="3"
placeholder="<?php echo 'Nombre *' ?>"
required
>
<input
class="form-contact__text"
type="email" name="email" id="email"
size="40" maxlength="40" minlength="3"
placeholder="<?php echo 'Correo Electrónico *'?>"
required
>
<select class="form-contact__select" name="interest" id="interest">
<option value="" class="placeholder">
<?php echo 'Tema de interés' ?>
</option>
<?php
$terms = get_terms( array(
'taxonomy' => 'interest',
'object_type' => 'post_contact',
'hide_empty' => true,
) );
foreach ($terms as $term):?>
<option value="<?php echo $term->slug ?>">
<?php echo $term->name ?>
</option>
<?php endforeach; ?>
</select>
<textarea class="form-contact__textarea" cols="40" rows="10" maxlength="500" minlength="3" aria-invalid="false" placeholder="Mensaje" name="message"></textarea>
<div>
<input type="hidden" name="uri" value="<?php echo get_permalink()?>">
<input type="hidden" name="action" value="process_form_contact">
<input class="form-contact__submit" type="submit" name="submit" value="Enviar">
</div>
</div>
</form>
5.
receive-contact.php
PHP
wp-content/themes/depura_theme/functions/posts/
Setting
receive-contact.php
PHP
wp-content/themes/depura_theme/functions/posts/
Setting
5.1. En este archivo vamos a recibir y procesar el formulario, donde para esto usaremos dos hook admin_post_nopriv y admin_post_process los cuales son el administrador de post para usuario no registrados y usuarios registrados respectivamente.

5.2. La segunda parte del nombre del hook, debe ser igual al valor del input action del formulario.

5.3. El objeto $_POST contiene el valor todos los datos enviados en el formulario, los cuales vamos a obtener por medio de la propiedad name de los inputs.

5.4. Las funciones sanitize nos permiten validar y evitar ataques por inyección de código.

5.5. Por último, mandamos la información a la base de datos y redireccionamos a la página en donde estaba el formulario.

Código
<?php
add_action('admin_post_nopriv_process_form_contact', 'receive_contact');
add_action('admin_post_process_form_contact', 'receive_contact');
function receive_contact()
{
//variables
$uri = sanitize_url( $_POST['uri'] );
$name = sanitize_text_field($_POST['name']);
$email = sanitize_email($_POST['email']);
$interest_form = sanitize_text_field($_POST['interest']);
$interest_form__term = get_term_by('slug', $interest_form, 'interest', 'contact_post_type');
$message = sanitize_text_field($_POST['message']);
//Cuerpo del mensaje
$msg = "<strong>Tema de Interés: </strong>" . $interest_form__term->name . "\n";
$msg .= "<strong>Nombre: </strong>" . $name . "\n";
$msg .= "<strong>Correo: </strong>" . $email . "\n";
$msg .= $message;
//Guardar el post en DB
$post_id = wp_insert_post([
'post_title' => 'Mensaje de ' . $name,
'post_type' => 'post_contact',
'post_content' => $msg,
'post_status' => 'private',
]);
wp_set_object_terms($post_id, $interest_form, 'interest', false);
wp_redirect($uri);
}