CreateIT
CreateIT
BLOG

How to create a WP plugin with admin forms

Ninja peeking from behind a laptop

How to create a WP plugin with admin forms

SHARE

Challenge:
create a WordPress plugin with wp-admin forms that saves data in the wp_options table
Solution:
submit a custom form, handle POST data, save to database and display the success message

In this tutorial, you will see good practices when creating a custom WordPress plugin for the Admin Dashboard. The plugin displays forms prefilled with wp_options data. The content of the forms is defined in the Controller. PHP templates (views) are responsible for rendering form elements.

WP Admin plugin with forms

Do you need a simple solution for saving configuration options in your plugin or saving theme options with custom code? Here you will find a solution. The source code of the plugin can be downloaded from GitHub – more info at the end of article.

The plugin has simple directory structure and is object oriented (PHP). It’s good boilerplate to start your own WordPress plugin that will render forms in the Admin Dashboard area. It can be used as a plugin starter in your own project.

The code demonstrates how to render wp_options displayed as: input, textarea, checkbox or select. The submitted data is saved as a serialized array in a WordPress table called “wp_options”. By following the presented approach, you can extend the plugin to add as many wp-admin forms as you like.

WordPress dashboard my menu section

Add the WordPress plugin

For WordPress CMS to recognize a new plugin in the wp-admin Plugins section, we need to create a PHP file with the plugin name and version added as a PHP comment. In addition, we’re going to include helpers functions and PHP Class for core functionality.

<?php
// /wp-content/plugins/ct-wp-admin-form/ct-wp-admin-form.php
/**
 * Plugin Name:       CT WP Admin Form Example
 * Description:       Create custom wp-admin forms
 * Version:           1.0.0
 * Text Domain:       ct-admin
 */
// If this file is called directly, abort.
if ( ! defined( 'WPINC' ) ) {
    die;
}
define( 'CT_WP_ADMIN_VERSION', '1.0.0' );
define( 'CT_WP_ADMIN_DIR', 'ct-wp-admin-form' );
/**
 * Helpers
 */
require plugin_dir_path( __FILE__ ) . 'includes/helpers.php';
/**
 * The core plugin class
 */
require plugin_dir_path( __FILE__ ) . 'includes/class-ct-wp-admin-form.php';
function run_ct_wp_admin_form() {
    $plugin = new Ct_Admin_Form();
    $plugin->init();
}
run_ct_wp_admin_form();

WordPress dashboard plugins section

Add menu and submenu page

To add a new section in the wp-admin Dashboard area, we’re going to use wp core functions: add_menu_page and add_submenu_page. The entire plugin core logic is defined in PHP class Ct_Admin_Form. Below, you will find a part of the class responsible for adding the admin menu.

<?php
// /wp-content/plugins/ct-wp-admin-form/includes/class-ct-wp-admin-form.php
class Ct_Admin_Form
{
    const ID = 'ct-admin-forms';
    public function init()
    {
        add_action('admin_menu', array($this, 'add_menu_page'), 20);
    }
    public function get_id()
    {
        return self::ID;
    }
    public function add_menu_page()
    {
        add_menu_page(
            esc_html__('My menu section', 'ct-admin'),
            esc_html__('My menu section', 'ct-admin'),
            'manage_options',
            $this->get_id(),
            array(&$this, 'load_view'),
            'dashicons-admin-page'
        );
        add_submenu_page(
            $this->get_id(),
            esc_html__('Submenu', 'ct-admin'),
            esc_html__('Submenu', 'ct-admin'),
            'manage_options',
            $this->get_id() . '_view1',
            array(&$this, 'load_view')
        );
    }
}

WordPress dashboard with a cursor in the middle

Load data and render the template

For rendering forms, we’re using the load_view method. The method is doing multiple things:

  1. check the wp-admin path to determine what data to load
  2. use the protected array $views to identify the proper function name (ex: view1_data ) to fetch data to the $args variable
  3. include a template for displaying alerts (success messages)
  4. include a template for a particular view (ex: view1.php )
  5. define the main HTML structure
function load_view()
{
    $this->default_values = $this->get_defaults();
    $this->current_page = ct_admin_current_view();
    
    $current_views = isset($this->views[$this->current_page]) ? $this->views[$this->current_page] : $this->views['not-found'];
    $step_data_func_name = $this->current_page . '_data';
    $args = [];
    /**
     * prepare data for view
     */
    if (method_exists($this, $step_data_func_name)) {
        $args = $this->$step_data_func_name();
    }
    /**
     * Default Admin Form Template
     */
    echo '<div class="ct-admin-forms ' . $this->current_page . '">';
    echo '<div class="container container1">';
    echo '<div class="inner">';
    $this->includeWithVariables(ct_admin_template_server_path('views/alerts', false));
    $this->includeWithVariables(ct_admin_template_server_path($current_views, false), $args);
    echo '</div>';
    echo '</div>';
    echo '</div> <!-- / ct-admin-forms -->';
}

WordPress – pass data to the template

Our method includeWithVariables is really useful. It extracts variables (args) and provides direct access to them from the PHP template. It’s an elegant way to define all data in the controller and to use the template only for data presentation. We’re saying no to spaghetti code!

function includeWithVariables($filePath, $variables = array(), $print = true)
{
    $output = NULL;
    if (file_exists($filePath)) {
        // Extract the variables to a local namespace
        extract($variables);
        // Start output buffering
        ob_start();
        // Include the template file
        include $filePath;
        // End buffering and return its contents
        $output = ob_get_clean();
    }
    if ($print) {
        print $output;
    }
    return $output;
}

Wp-admin forms – define fields

To define form fields, we need to specify the group and key for a particular form element. The group string will be used as wp_options option_name and a key and value will be added as a serialized array into the option_value column.

To understand exactly the concept, see the section “Submit wp-admin form”, where custom logic for saving data and screenshots from the database are presented.

Here are sample form fields defined in our plugin. Each view template has a separate method to provide the data and form fields definition:

/**
 * Prepare data for views
 */
private function view0_data()
{
    $args = [];
    $values = array(
        '' => esc_html__('Select', 'ct-admin'),
        'cs' => 'Čeština',
        'de' => 'Deutsch',
        'en' => 'English',
        'es' => 'Español',
        'fr' => 'Français',
        'hr' => 'Hrvatski',
        'hu' => 'Magyar',
        'no' => 'Norwegian',
        'it' => 'Italiano',
        'nl' => 'Nederlands',
        'pl' => 'Polski',
        'pt' => 'Português',
        'ro' => 'Română',
        'ru' => 'Русский',
        'sk' => 'Slovenčina',
        'dk' => 'Danish',
        'bg' => 'Bulgarian',
        'sv' => 'Swedish'
    );
    $args['cookie_content_language'] = $this->render_select('ct-admin-cookie', 'cookie_content_language', $values);
    $args['cookie_content'] = $this->render_textarea('ct-admin-cookie', 'cookie_content');
    $args['cookie_popup_label_accept'] = $this->render_input('ct-admin-cookie', 'cookie_popup_label_accept');
    $args['forgotten_automated_forget'] = $this->render_checkbox('ct-admin-forgotten', 'forgotten_automated_forget');
    return $args;
}
private function view1_data()
{
    $services_args = array(
        'post_type'        => 'any',
        'numberposts'      => - 1,
        'suppress_filters' => false,
    );
    $blog_posts = get_posts($services_args);
    $args = [];
    $args['posts'] = $blog_posts;
    // add options
    $values = array(
        'manual'                     => __( 'Never', 'ct-admin' ),
        'ct-admin-weekly'    => __( 'Weekly', 'ct-admin' ),
        'ct-admin-monthly'   => __( 'Monthly', 'ct-admin' ),
        'ct-admin-quarterly' => __( 'Quarterly', 'ct-admin' )
    );
    $args['cookie_scan_period'] = $this->render_select('ct-admin-cookie', 'cookie_scan_period', $values);
    return $args;
}

WordPress form – field types

We have 4 different types of fields:

  • text input
  • textarea
  • choice select
  • checkbox options

Fields show the current value that is read from the database (wp_options). On submission, wp_options values will be updated with the submitted data.

You might notice that render_checkbox() function uses hidden input. That is required to save an empty checkbox value to the database (unchecked checkbox is not usually visible in POST data).

/**
 * Form elements outputs
 */
private function render_input($group, $key, $required = false)
{
    $inputValue = isset($this->default_values[$group][$key]) ? stripslashes($this->default_values[$group][$key]) : '';
    $requiredAttr = ($required) ? "required" : '';
    return '<input type="text" id="' . $key . '" name="' . $group . '[' . $key . ']" class="form-control" value="' . $inputValue . '" ' . $requiredAttr . '>';
}
private function render_textarea($group, $key)
{
    $defaultValue = isset($this->default_values[$group][$key]) ? stripslashes($this->default_values[$group][$key]) : '';
    return '<textarea class="form-control" rows="6" autocomplete="off" id="' . $key . '" name="' . $group . '[' . $key . ']">' . $defaultValue . '</textarea>';
}
private function render_select($group, $key, $options)
{
    $selectedVal = isset($this->default_values[$group][$key]) ? $this->default_values[$group][$key] : '';
    $html = '';
    $html .= '<select class="form-control" id="' . $key . '" name="' . $group . '[' . $key . ']">';
    $html .= ($selectedVal == '') ? '<option value=""></option>' : '';
    foreach ($options as $key => $opt) {
        $selectedOpt = '';
        if ($selectedVal == $key) {
            $selectedOpt = 'selected="selected"';
        }
        $html .= '<option value="' . $key . '" ' . $selectedOpt . '>' . $opt . '</option>';
    }
    $html .= '</select>';
    return $html;
}
private function render_checkbox($group, $key)
{
    $checkedVal = isset($this->default_values[$group][$key]) ? $this->default_values[$group][$key] : '';
    $checkedAttr = "";
    if ($checkedVal != '') {
        $checkedAttr = "checked";
    }
    $html = '';
    $html .= '
    <input type="hidden" name="' . $group . '[' . $key . ']" value="">
    <input class="form-check-input" type="checkbox" value="on" id="' . $key . '" name="' . $group . '[' . $key . ']" ' . $checkedAttr . '>';
    return $html;
}

Submit the wp-admin form

The action “admin_post_ct_admin_save” is used to define the PHP function for saving form data. As a result, proper data will be saved as wp options.

Form Nonce validation is good practice regarding security. For additional security measure we’re also checking if current user has permissions as Administrator.

Whitelisted keys are option_name keys that are used in forms (groups). By defining it as constants, we’re making sure that we’re not updating some records in wp_options by mistake.

// /wp-content/plugins/ct-wp-admin-form/ct-wp-admin-form.php
const NONCE_KEY = 'ct_admin';
const WHITELISTED_KEYS = array(
    'ct-admin-cookie',
    'ct-admin-forgotten'
);
private $default_values = array();
private $current_page = '';
public function init()
{
    add_action('admin_post_ct_admin_save', array($this, 'submit_save'));
}
public function get_nonce_key()
{
    return self::NONCE_KEY;
}
public function get_whitelisted_keys()
{
    return self::WHITELISTED_KEYS;
}
public function submit_save()
{
    $nonce = sanitize_text_field($_POST[$this->get_nonce_key()]);
    $action = sanitize_text_field($_POST['action']);
    if (!isset($nonce) || !wp_verify_nonce($nonce, $action)) {
        print 'Sorry, your nonce did not verify.';
        exit;
    }
    if (!current_user_can('manage_options')) {
        print 'You can\'t manage options';
        exit;
    }
    /**
     * whitelist keys that can be updated
     */
    $whitelisted_keys = $this->get_whitelisted_keys();
    $fields_to_update = [];
    foreach ($whitelisted_keys as $key) {
        if (array_key_exists($key, $_POST)) {
            $fields_to_update[$key] = $_POST[$key];
        }
    }
    /**
     * Loop through form fields keys and update data in DB (wp_options)
     */
    $this->db_update_options($fields_to_update);
    $redirect_to = $_POST['redirectToUrl'];
    if ($redirect_to) {
        add_settings_error('ct_msg', 'ct_msg_option', __("Changes saved."), 'success');
        set_transient('settings_errors', get_settings_errors(), 30);
        wp_safe_redirect($redirect_to);
        exit;
    }
}
private function db_update_options($group)
{
    foreach ($group as $key => $fields) {
        $db_opts = get_option($key);
        $db_opts = ($db_opts === '') ? array() : $db_opts;
        if(!$db_opts){
            $db_opts = array();
        }
        $updated = array_merge($db_opts, $fields);
        update_option($key, $updated);
    }
}

Blue and black text in white and grey windows

View for the template form

Here is the view template that includes HTML code for the form. WordPress dashboard forms are submitted using the admin-post.php url. The hidden input “action” in the form allows to define a custom function for saving data. Another hidden input “redirectToUrl” allows to redirect the page after a successful submission. Here, we’re redirecting to the same url.

<?php
// /wp-content/plugins/ct-wp-admin-form/views/view0.php
/** @var string $cookie_content_language */
/** @var string $cookie_content */
/** @var string $cookie_popup_label_accept */
/** @var string $forgotten_automated_forget */
?>
<h1><?php echo esc_html__('Main title', 'ct-admin'); ?></h1>
<p><?php echo esc_html__('Lorem ipsum dolor sit amet', 'ct-admin'); ?></p>
<form method="POST" action="<?php echo esc_html(admin_url('admin-post.php')); ?>">
    <input type="hidden" name="action" value="ct_admin_save">
    <?php wp_nonce_field('ct_admin_save', 'ct_admin'); ?>
    <input type="hidden" name="redirectToUrl" value="<?php echo ct_admin_view_pagename(''); ?>">
    <div class="row g-5">
        <div class="col-md-6">
            <fieldset class="mt-3">
                <legend class="mb-3"><?php echo esc_html__('Section 1', 'ct-admin') ?></legend>
                <div class="mb-3 row">
                    <div class="col-md-4">
                        <label for="cookie_content_language"
                               class="form-label"><?php echo esc_html__('Option 1', 'ct-admin') ?></label>
                    </div>
                    <div class="col-md-8">
                        <?php echo $cookie_content_language; ?>
                    </div>
                </div>
                <div class="mb-3">
                    <label for="cookie_content"
                           class="form-label"><?php echo esc_html__('Option 2', 'ct-admin') ?></label>
                    <?php echo $cookie_content; ?>
                </div>
            </fieldset>
        </div>
        <div class="col-md-6">
            <fieldset class="mt-3">
                <legend class="mb-3"><?php echo esc_html__('Section 2', 'ct-admin') ?></legend>
                <div class="mb-3">
                    <label for="cookie_popup_label_accept"
                           class="form-label"><?php echo esc_html__("Option 3", 'ct-admin') ?></label>
                    <?php echo $cookie_popup_label_accept; ?>
                </div>
                <div class="mb-3 form-checkbox">
                    <?php echo $forgotten_automated_forget; ?>
                    <label for="forgotten_automated_forget"
                           class="form-check-label"><?php echo esc_html__("Option 4", 'ct-admin') ?></label>
                </div>
            </fieldset>
        </div>
    </div>
    <!-- / row -->
    <?php ct_admin_submit(esc_html__('Submit')); ?>
</form>

Admin form – displaying an error

The plugin saves submitted data, redirecting to the page defined in “redirectToUrl”. Now, we want to display an error or success message. The challenge here is that WordPress admin area disallows for appending custom query strings to url.

For example, if we’re going to redirect to url: /wp-admin/admin.php?page=ct-admin-forms?success=1  – we will see the message: “Sorry, you are not allowed to access this page.”

What will be the solution for displaying an error message in the custom admin forms? Two WordPress functions will be helpful here: add_settings_error and set_transient. Working solution below:

public function submit_save()
{
    /**
     * 1. Handle submit here
     */
    
    /**
     * 2. Save data
     */
    /**
     * 3. Add success message and redirect
     */
    
    $redirect_to = $_POST['redirectToUrl'];
    if ($redirect_to) {
        add_settings_error('ct_msg', 'ct_msg_option', __("Changes saved."), 'success');
        set_transient('settings_errors', get_settings_errors(), 30);
        wp_safe_redirect($redirect_to);
        exit;
    }
}

And view for displaying WordPress alert / alerts in the admin dashboard panel. Our plugin will display a success message: Changes saved.

<?php
// /wp-content/plugins/ct-wp-admin-form/views/alerts.php
$form_errors = get_transient("settings_errors");
delete_transient("settings_errors");
if(!empty($form_errors)){
    foreach($form_errors as $error){
        echo ct_admin_message($error['message'], $error['type']);
    }
}

Black text on white background with a black toolbar featuring grey icons

Helpers functions

We use multiple helpers functions that are defined as global WordPress functions. Those are available globally and can be used in any place in the plugin or theme. Here are the functionalities we’ve implemented:

  • get name of current view (based on page query param)
  • include PHP template using server path
  • get full file path using plugins_url
  • get path for subpage view
  • render the submit button
  • render the success message
<?php
// /wp-content/plugins/ct-wp-admin-form/includes/helpers.php
/**
 * helpers
 */
function ct_admin_current_view()
{
    $current_step = isset($_GET['page']) ? $_GET['page'] : 'view0';
    if (strpos($current_step, '_') === false) {
        return 'view0';
    }
    return str_replace("ct-admin-forms_", "", $current_step);
}
function ct_admin_template_server_path($file_path, $include = true, $options = array())
{
    $my_plugin_dir = WP_PLUGIN_DIR . "/" . CT_WP_ADMIN_DIR . "/";
    if ( is_dir( $my_plugin_dir ) ) {
        $path_to_file = $my_plugin_dir . $file_path . '.php';
        if ($include) {
            include $path_to_file;
        }
        return $path_to_file;
    }
    // view options
    $options = apply_filters('ct_admin_locate_template_options', $options, $name);
    $include_dir_path = rtrim(get_stylesheet_directory(), '/')."/ct-admin";
    $path_to_file     = rtrim($include_dir_path, '/')."/$name.php";
    if (!is_readable($path_to_file)) {
        $include_dir_path = __DIR__."/views";
    }
    $include_dir_path = apply_filters('ct_admin_locate_template_path', $include_dir_path, $name);
    $path_to_file     = rtrim($include_dir_path, '/')."/$name.php";
    if ($include) {
        include $path_to_file;
    }
    return $path_to_file;
}
function ct_admin_url($append = '')
{
    return plugins_url($append, __DIR__);
}
function ct_admin_view_pagename($step)
{
    $view_url_part = '';
    if($step){
        $view_url_part = '_' . $step;
    }
    return admin_url('admin.php?page=ct-admin-forms' . $view_url_part);
}
function ct_admin_submit($submit_text, $hide_class = "sr-only"){ ?>
    <div class="form__submit <?php echo $hide_class ?>">
        <p class="submit">
            <input type="submit" name="submit5" id="submit5" class="button" value="<?php echo $submit_text; ?>">
        </p>
    </div>
<?php }
/**
 * @param $message
 * @param $msg_type
 * @return void
 * warning, info, success
 */
function ct_admin_message($message, $msg_type = 'info') {
    return "<div id='message' class='alert alert-$msg_type'>$message</div>";
}

Load assets on custom admin pages

In case we need to load assets files (CSS/JS) in the admin dashboard, here is the snippet to do it properly. We will load the bootstrap library, custom styles and javascript files. Those assets will be loaded only on our custom admin pages.

// /wp-content/plugins/ct-wp-admin-form/ct-wp-admin-form.php
public function init()
{
    add_action('admin_enqueue_scripts', array($this, 'admin_enqueue_scripts'));
}
public function admin_enqueue_scripts($hook_suffix)
{
    if (strpos($hook_suffix, $this->get_id()) === false) {
        return;
    }
    wp_enqueue_style('ct-admin-form-bs', 'https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css', CT_WP_ADMIN_VERSION);
    wp_enqueue_script('ct-admin-form-bs', 'https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js',
        array('jquery'),
        CT_WP_ADMIN_VERSION,
        true
    );
    
    wp_enqueue_style('ct-admin-form', ct_admin_url('assets/style.css'), CT_WP_ADMIN_VERSION);
    wp_enqueue_script('ct-admin-form-js', ct_admin_url('assets/custom.js'),
        array('jquery'),
        CT_WP_ADMIN_VERSION,
        true
    );
}

Wp-admin custom forms source code

We’ve created a simple WordPress plugin that allows to create custom admin pages, enqueue assets and render customized forms. The submitted data is saved as wp_options and can be later reused. The entire source code can be found in the GitHub repository: https://github.com/createit-dev/116-wp-plugin-with-admin-forms

Just activate the plugin and check out how it works!

That’s it for today’s tutorial. Make sure to follow us for more tips and guidelines and don’t forget to subscribe to our newsletter.

WordPress dashboard plugins section

That’s it for today’s tutorial. Don’t forget to subscribe to our newsletter to stay up to date with useful tutorials and interesting news!

Need help?

  • Looking for support from experienced programmers?

  • Need to fix a bug in the code?

  • Want to customize your webste/application?

ADD COMMENT

Your email address will not be published.

createIT Contact