📖 WordPress Development - Plugin Development
90 phút

Plugin Development

Cấu trúc plugin

my-plugin/
├── my-plugin.php          # Main file với plugin header
├── readme.txt
├── uninstall.php
├── includes/
│   ├── class-my-plugin.php
│   ├── class-admin.php
│   └── class-shortcodes.php
├── admin/
│   ├── css/
│   ├── js/
│   └── views/
├── public/
│   ├── css/
│   ├── js/
│   └── views/
└── languages/

Plugin header

<?php
/**
 * Plugin Name:       My Plugin
 * Plugin URI:        https://example.com/my-plugin
 * Description:       A custom WordPress plugin
 * Version:           1.0.0
 * Requires at least: 6.0
 * Requires PHP:      7.4
 * Author:            Your Name
 * Author URI:        https://example.com
 * License:           GPL v2 or later
 * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain:       my-plugin
 * Domain Path:       /languages
 */

// Prevent direct access
if (!defined('ABSPATH')) {
    exit;
}

// Constants
define('MY_PLUGIN_VERSION', '1.0.0');
define('MY_PLUGIN_FILE', __FILE__);
define('MY_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('MY_PLUGIN_URL', plugin_dir_url(__FILE__));
define('MY_PLUGIN_BASENAME', plugin_basename(__FILE__));

// Autoloader hoặc includes
require_once MY_PLUGIN_DIR . 'includes/class-my-plugin.php';

// Activation/Deactivation hooks
register_activation_hook(__FILE__, ['My_Plugin', 'activate']);
register_deactivation_hook(__FILE__, ['My_Plugin', 'deactivate']);

// Initialize
function my_plugin_init() {
    My_Plugin::instance();
}
add_action('plugins_loaded', 'my_plugin_init');

Main Plugin Class

<?php
// includes/class-my-plugin.php

class My_Plugin {
    private static $instance = null;

    public static function instance() {
        if (self::$instance === null) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    private function __construct() {
        $this->define_hooks();
        $this->load_dependencies();
    }

    private function define_hooks() {
        add_action('init', [$this, 'register_post_types']);
        add_action('admin_menu', [$this, 'add_admin_menu']);
        add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_assets']);
        add_action('wp_enqueue_scripts', [$this, 'enqueue_public_assets']);
        add_shortcode('my_plugin', [$this, 'render_shortcode']);
        add_action('wp_ajax_my_plugin_action', [$this, 'handle_ajax']);
    }

    private function load_dependencies() {
        require_once MY_PLUGIN_DIR . 'includes/class-admin.php';
        require_once MY_PLUGIN_DIR . 'includes/class-shortcodes.php';
    }

    public function enqueue_admin_assets($hook) {
        if (strpos($hook, 'my-plugin') === false) {
            return;
        }

        wp_enqueue_style(
            'my-plugin-admin',
            MY_PLUGIN_URL . 'admin/css/admin.css',
            [],
            MY_PLUGIN_VERSION
        );

        wp_enqueue_script(
            'my-plugin-admin',
            MY_PLUGIN_URL . 'admin/js/admin.js',
            ['jquery'],
            MY_PLUGIN_VERSION,
            true
        );

        wp_localize_script('my-plugin-admin', 'myPlugin', [
            'ajaxUrl' => admin_url('admin-ajax.php'),
            'nonce'   => wp_create_nonce('my_plugin_nonce'),
        ]);
    }

    public function enqueue_public_assets() {
        wp_enqueue_style(
            'my-plugin-public',
            MY_PLUGIN_URL . 'public/css/public.css',
            [],
            MY_PLUGIN_VERSION
        );

        wp_enqueue_script(
            'my-plugin-public',
            MY_PLUGIN_URL . 'public/js/public.js',
            [],
            MY_PLUGIN_VERSION,
            true
        );
    }

    public function add_admin_menu() {
        add_menu_page(
            __('My Plugin', 'my-plugin'),
            __('My Plugin', 'my-plugin'),
            'manage_options',
            'my-plugin',
            [$this, 'render_admin_page'],
            'dashicons-admin-generic',
            30
        );

        add_submenu_page(
            'my-plugin',
            __('Settings', 'my-plugin'),
            __('Settings', 'my-plugin'),
            'manage_options',
            'my-plugin-settings',
            [$this, 'render_settings_page']
        );
    }

    public function render_admin_page() {
        if (!current_user_can('manage_options')) {
            wp_die(__('Access denied', 'my-plugin'));
        }
        include MY_PLUGIN_DIR . 'admin/views/dashboard.php';
    }

    public function render_settings_page() {
        include MY_PLUGIN_DIR . 'admin/views/settings.php';
    }

    public function render_shortcode($atts) {
        $atts = shortcode_atts([
            'title' => 'Default Title',
            'limit' => 5,
        ], $atts, 'my_plugin');

        ob_start();
        include MY_PLUGIN_DIR . 'public/views/shortcode.php';
        return ob_get_clean();
    }

    public function handle_ajax() {
        check_ajax_referer('my_plugin_nonce', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(['message' => 'Unauthorized']);
        }

        $action = sanitize_text_field($_POST['action_type'] ?? '');

        switch ($action) {
            case 'save_settings':
                update_option('my_plugin_option', sanitize_text_field($_POST['value']));
                wp_send_json_success(['message' => 'Saved']);
                break;
            default:
                wp_send_json_error(['message' => 'Invalid action']);
        }
    }

    public static function activate() {
        // Create database table
        global $wpdb;
        $table_name = $wpdb->prefix . 'my_plugin_data';
        $charset_collate = $wpdb->get_charset_collate();

        $sql = "CREATE TABLE $table_name (
            id bigint(20) NOT NULL AUTO_INCREMENT,
            user_id bigint(20) NOT NULL,
            data longtext NOT NULL,
            created_at datetime DEFAULT CURRENT_TIMESTAMP,
            PRIMARY KEY (id),
            KEY user_id (user_id)
        ) $charset_collate;";

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

        // Set default options
        add_option('my_plugin_version', MY_PLUGIN_VERSION);

        // Flush rewrite rules
        flush_rewrite_rules();
    }

    public static function deactivate() {
        flush_rewrite_rules();
        // Không xóa data khi deactivate
    }
}

Admin Page

<!-- admin/views/dashboard.php -->
<div class="wrap my-plugin-admin">
    <h1><?php echo esc_html(get_admin_page_title()); ?></h1>

    <div class="my-plugin-stats">
        <div class="stat-card">
            <h3><?php esc_html_e('Total Items', 'my-plugin'); ?></h3>
            <p class="stat-value"><?php echo esc_html($total_items ?? 0); ?></p>
        </div>
    </div>

    <form id="my-plugin-form" method="post">
        <?php wp_nonce_field('my_plugin_save', 'my_plugin_nonce'); ?>

        <table class="form-table">
            <tr>
                <th scope="row">
                    <label for="my_plugin_setting"><?php esc_html_e('Setting', 'my-plugin'); ?></label>
                </th>
                <td>
                    <input type="text"
                           id="my_plugin_setting"
                           name="my_plugin_setting"
                           value="<?php echo esc_attr(get_option('my_plugin_setting', '')); ?>"
                           class="regular-text">
                </td>
            </tr>
        </table>

        <?php submit_button(); ?>
    </form>

    <button id="my-plugin-ajax-btn" class="button button-primary">
        <?php esc_html_e('Test AJAX', 'my-plugin'); ?>
    </button>
</div>

Admin JavaScript

// admin/js/admin.js
jQuery(function ($) {
    $('#my-plugin-ajax-btn').on('click', function () {
        const $btn = $(this);

        $btn.prop('disabled', true).text('Loading...');

        $.ajax({
            url: myPlugin.ajaxUrl,
            type: 'POST',
            data: {
                action: 'my_plugin_action',
                nonce: myPlugin.nonce,
                action_type: 'save_settings',
                value: 'test value'
            },
            success: function (response) {
                if (response.success) {
                    alert(response.data.message);
                } else {
                    alert('Error: ' + response.data.message);
                }
            },
            error: function () {
                alert('AJAX error');
            },
            complete: function () {
                $btn.prop('disabled', false).text('Test AJAX');
            }
        });
    });
});

Settings API

class My_Plugin_Admin {
    public function __construct() {
        add_action('admin_init', [$this, 'register_settings']);
    }

    public function register_settings() {
        register_setting('my_plugin_settings', 'my_plugin_options', [
            'type'              => 'array',
            'sanitize_callback' => [$this, 'sanitize_options'],
            'default'           => [
                'enabled' => true,
                'text'    => '',
                'color'   => '#0073aa',
            ],
        ]);

        add_settings_section(
            'my_plugin_general',
            __('General Settings', 'my-plugin'),
            [$this, 'section_callback'],
            'my_plugin_settings'
        );

        add_settings_field(
            'enabled',
            __('Enable feature', 'my-plugin'),
            [$this, 'render_checkbox'],
            'my_plugin_settings',
            'my_plugin_general',
            ['label_for' => 'enabled', 'field' => 'enabled']
        );

        add_settings_field(
            'text',
            __('Text', 'my-plugin'),
            [$this, 'render_text_input'],
            'my_plugin_settings',
            'my_plugin_general',
            ['label_for' => 'text', 'field' => 'text']
        );
    }

    public function sanitize_options($input) {
        return [
            'enabled' => !empty($input['enabled']),
            'text'    => sanitize_text_field($input['text'] ?? ''),
            'color'   => sanitize_hex_color($input['color'] ?? '#0073aa'),
        ];
    }

    public function section_callback() {
        echo '<p>' . esc_html__('Configure the plugin behavior.', 'my-plugin') . '</p>';
    }

    public function render_checkbox($args) {
        $options = get_option('my_plugin_options', []);
        $field = $args['field'];
        ?>
        <input type="checkbox"
               id="<?php echo esc_attr($field); ?>"
               name="my_plugin_options[<?php echo esc_attr($field); ?>]"
               value="1"
               <?php checked(!empty($options[$field])); ?>>
        <?php
    }

    public function render_text_input($args) {
        $options = get_option('my_plugin_options', []);
        $field = $args['field'];
        ?>
        <input type="text"
               id="<?php echo esc_attr($field); ?>"
               name="my_plugin_options[<?php echo esc_attr($field); ?>]"
               value="<?php echo esc_attr($options[$field] ?? ''); ?>"
               class="regular-text">
        <?php
    }
}

Custom Database Table

class My_Plugin_DB {
    public static function get_table() {
        global $wpdb;
        return $wpdb->prefix . 'my_plugin_data';
    }

    public static function create(array $data): int|false {
        global $wpdb;
        $result = $wpdb->insert(
            self::get_table(),
            [
                'user_id'    => $data['user_id'],
                'data'       => maybe_serialize($data['data']),
                'created_at' => current_time('mysql'),
            ],
            ['%d', '%s', '%s']
        );
        return $result ? $wpdb->insert_id : false;
    }

    public static function get(int $id): ?object {
        global $wpdb;
        $table = self::get_table();
        return $wpdb->get_row(
            $wpdb->prepare("SELECT * FROM $table WHERE id = %d", $id)
        );
    }

    public static function get_by_user(int $user_id): array {
        global $wpdb;
        $table = self::get_table();
        return $wpdb->get_results(
            $wpdb->prepare(
                "SELECT * FROM $table WHERE user_id = %d ORDER BY created_at DESC",
                $user_id
            )
        );
    }

    public static function delete(int $id): bool {
        global $wpdb;
        return (bool) $wpdb->delete(
            self::get_table(),
            ['id' => $id],
            ['%d']
        );
    }
}

Shortcode

class My_Plugin_Shortcodes {
    public function __construct() {
        add_shortcode('my_plugin', [$this, 'render']);
        add_shortcode('my_plugin_form', [$this, 'render_form']);
    }

    public function render($atts): string {
        $atts = shortcode_atts([
            'title' => __('My Plugin', 'my-plugin'),
            'limit' => 5,
        ], $atts, 'my_plugin');

        $items = My_Plugin_DB::get_by_user(get_current_user_id());
        $items = array_slice($items, 0, (int) $atts['limit']);

        ob_start();
        ?>
        <div class="my-plugin-shortcode">
            <h3><?php echo esc_html($atts['title']); ?></h3>
            <?php if ($items): ?>
                <ul>
                    <?php foreach ($items as $item): ?>
                        <li><?php echo esc_html($item->id); ?> - 
                            <?php echo esc_html($item->created_at); ?></li>
                    <?php endforeach; ?>
                </ul>
            <?php else: ?>
                <p><?php esc_html_e('No items found.', 'my-plugin'); ?></p>
            <?php endif; ?>
        </div>
        <?php
        return ob_get_clean();
    }

    public function render_form(): string {
        ob_start();
        ?>
        <form class="my-plugin-form" method="post">
            <?php wp_nonce_field('my_plugin_submit', 'my_plugin_nonce'); ?>
            <input type="text" name="my_data" required>
            <button type="submit"><?php esc_html_e('Submit', 'my-plugin'); ?></button>
        </form>
        <?php
        return ob_get_clean();
    }
}

REST API Endpoints

add_action('rest_api_init', function () {
    register_rest_route('my-plugin/v1', '/items', [
        [
            'methods'             => WP_REST_Server::READABLE,
            'callback'            => 'my_plugin_get_items',
            'permission_callback' => function () {
                return is_user_logged_in();
            },
        ],
        [
            'methods'             => WP_REST_Server::CREATABLE,
            'callback'            => 'my_plugin_create_item',
            'permission_callback' => function () {
                return current_user_can('edit_posts');
            },
            'args' => [
                'title' => [
                    'required'          => true,
                    'type'              => 'string',
                    'sanitize_callback' => 'sanitize_text_field',
                    'validate_callback' => function ($value) {
                        return strlen($value) >= 2;
                    },
                ],
            ],
        ],
    ]);
});

function my_plugin_get_items(WP_REST_Request $request): WP_REST_Response {
    $items = My_Plugin_DB::get_by_user(get_current_user_id());
    return new WP_REST_Response($items, 200);
}

function my_plugin_create_item(WP_REST_Request $request): WP_REST_Response {
    $id = My_Plugin_DB::create([
        'user_id' => get_current_user_id(),
        'data'    => ['title' => $request->get_param('title')],
    ]);

    if (!$id) {
        return new WP_REST_Response(['error' => 'Failed to create'], 500);
    }

    return new WP_REST_Response(['id' => $id], 201);
}

Uninstall

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

global $wpdb;

// Drop custom tables
$wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}my_plugin_data");

// Delete options
delete_option('my_plugin_version');
delete_option('my_plugin_settings');
delete_option('my_plugin_options');

// Delete user meta
delete_metadata('user', 0, 'my_plugin_user_setting', '', true);

// Delete posts (nếu plugin tạo CPT)
$posts = get_posts([
    'post_type'   => 'my_cpt',
    'numberposts' => -1,
    'post_status' => 'any',
]);
foreach ($posts as $post) {
    wp_delete_post($post->ID, true);
}

Bài tập thực hành

Hãy tạo plugin đầy đủ với admin page, shortcode và REST API!

📝 Bài tập (1)

  1. Tạo plugin quản lý sách