Giáo trình ôn tập WordPress bài bản + Vibe Coding

Chương 2 – Plugin Development + Custom Post Type/Taxonomy

Cỡ chữ: 16px
Font:
Nền:
← Chương Trước Chương Tiếp →

PHẦN A: CẤU TRÚC PLUGIN CHUẨN

1. File chính của plugin — Header bắt buộc

<?php
/**
 * Plugin Name: My Book Manager
 * Plugin URI: https://example.com
 * Description: Quản lý sách qua Custom Post Type
 * Version: 1.0.0
 * Author: Your Name
 * License: GPL v2 or later
 * Text Domain: my-book-manager
 */

// Chặn truy cập trực tiếp file — thói quen bảo mật bắt buộc
if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

Giống theme, WordPress đọc comment header này để nhận diện plugin, hiện tên/mô tả trong danh sách Plugins ở wp-admin. Thiếu Plugin Name → WP không nhận đây là plugin hợp lệ.

2. Vì sao nên tổ chức theo Class thay vì hàm rời rạc

Cách yếu (hay gặp ở code cũ/tự học):

function my_register_cpt() { ... }
add_action('init', 'my_register_cpt');

function my_add_meta_box() { ... }
add_action('add_meta_boxes', 'my_add_meta_box');

Vấn đề: dễ đụng tên hàm với plugin khác (namespace conflict), khó quản lý khi plugin lớn dần.

Cách chuẩn hơn — đóng gói bằng Class:

class My_Book_Manager {

    public function __construct() {
        add_action( 'init', [ $this, 'register_post_type' ] );
        add_action( 'add_meta_boxes', [ $this, 'add_meta_boxes' ] );
        add_action( 'save_post', [ $this, 'save_meta' ] );
    }

    public function register_post_type() {
        // ...
    }

    public function add_meta_boxes() {
        // ...
    }

    public function save_meta( $post_id ) {
        // ...
    }
}

new My_Book_Manager();

Lợi ích: tất cả hàm nằm trong 1 namespace (tên class) → gần như không bao giờ đụng tên với plugin khác. Đây là tư duy code có tổ chức, không chỉ “code chạy được”.

3. Activation / Deactivation / Uninstall — 3 khái niệm hay bị nhầm

// Chạy 1 LẦN DUY NHẤT khi user bấm "Activate" plugin
register_activation_hook( __FILE__, function() {
    // VD: tạo custom table, flush rewrite rules
    flush_rewrite_rules();
});

// Chạy khi user bấm "Deactivate" — TẠM NGƯNG, dữ liệu vẫn còn nguyên
register_deactivation_hook( __FILE__, function() {
    flush_rewrite_rules();
});
// uninstall.php — file RIÊNG, đặt cùng cấp với file chính plugin
// Chỉ chạy khi user bấm "Delete" plugin hẳn trong wp-admin
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
    exit; // chặn truy cập trực tiếp — bắt buộc có dòng này
}

// VD: xoá hẳn dữ liệu (options, custom table...)
delete_option( 'my_book_manager_settings' );
global $wpdb;
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}my_books" );

Bảng phân biệt — câu hỏi hay gặp:

Khi nào chạyDữ liệuDùng để làm gì
ActivationBấm “Activate”Không đụngSetup ban đầu (tạo table, set default option, flush rewrite)
DeactivationBấm “Deactivate”Giữ nguyênDọn tạm thời (VD: bỏ lịch cron job)
UninstallBấm “Delete” hẳnXoá vĩnh viễnDọn sạch hoàn toàn dữ liệu plugin để lại

Lỗi hay gặp: nhét code xoá dữ liệu vào register_deactivation_hook — sai, vì user chỉ tắt tạm để test, không có ý xoá dữ liệu. Xoá dữ liệu chỉ được làm ở uninstall.php.

4. Vì sao flush_rewrite_rules() cần thiết khi đăng ký CPT

WordPress cache sẵn bảng “URL nào map với loại nội dung nào” (rewrite rules) để tăng tốc — không tính toán lại mỗi request. Khi bạn đăng ký CPT mới (thêm URL pattern mới), rules cũ không tự cập nhật → truy cập link CPT sẽ ra lỗi 404 cho tới khi rules được “flush” (tính lại).

⚠️ Lưu ý quan trọng: KHÔNG gọi flush_rewrite_rules() mỗi lần trang load (VD: trong hook init) — cực kỳ tốn hiệu năng vì đây là thao tác nặng. Chỉ gọi 1 lần lúc activate plugin, hoặc khi có thay đổi cấu trúc CPT thực sự.


PHẦN B: CUSTOM POST TYPE (CPT)

1. Đăng ký CPT — code đầy đủ + giải thích từng tham số quan trọng

add_action( 'init', function() {
    register_post_type( 'book', [
        'labels' => [
            'name'          => __( 'Sách', 'my-book-manager' ),
            'singular_name' => __( 'Sách', 'my-book-manager' ),
            'add_new_item'  => __( 'Thêm sách mới', 'my-book-manager' ),
        ],
        'public'       => true,   // hiển thị ra frontend + có UI trong admin
        'show_in_rest' => true,   // ⚠️ BẮT BUỘC true nếu muốn dùng Gutenberg editor hoặc REST API
        'has_archive'  => true,   // có trang danh sách (archive-book.php)
        'rewrite'      => [ 'slug' => 'sach' ],   // URL: /sach/ten-sach thay vì /book/ten-sach
        'supports'     => [ 'title', 'editor', 'thumbnail', 'excerpt' ],
        'menu_icon'    => 'dashicons-book',
        'menu_position'=> 20,
    ] );
} );

Giải thích các tham số hay bị hỏi:

  • public: thực chất là “tổng hợp” của nhiều tham số con (publicly_queryable, show_ui, show_in_nav_menus, show_in_menu). Set true là cách nhanh để bật hết; nếu cần control chi tiết (VD: có UI admin nhưng không public ra frontend) thì set public => false và khai báo riêng từng cái.
  • show_in_rest: nếu để false hoặc bỏ qua, CPT sẽ KHÔNG dùng được Gutenberg block editor (chỉ hiện editor cũ hoặc lỗi), và không có REST endpoint tự động (/wp-json/wp/v2/book). Đây là lỗi rất hay gặp khi nâng cấp code cũ lên dùng Gutenberg.
  • supports: khai rõ tính năng nào post type này có. Thiếu 'thumbnail' → không thấy khung “Featured Image” trong admin dù đã add_theme_support('post-thumbnails') ở theme.
  • rewrite: custom slug URL. Sau khi đổi, phải flush rewrite rules (vào Settings → Permalinks bấm Save 1 lần, hoặc code flush lúc activate) mới có hiệu lực.

2. Custom Taxonomy — Hierarchical vs Non-hierarchical

add_action( 'init', function() {
    // Hierarchical = giống Category (có cha-con, checkbox UI)
    register_taxonomy( 'genre', 'book', [
        'labels'       => [ 'name' => __( 'Thể loại', 'my-book-manager' ) ],
        'hierarchical' => true,
        'show_in_rest' => true,
        'rewrite'      => [ 'slug' => 'the-loai' ],
    ] );

    // Non-hierarchical = giống Tag (phẳng, UI dạng nhập tự do có gợi ý)
    register_taxonomy( 'book_tag', 'book', [
        'labels'       => [ 'name' => __( 'Tag', 'my-book-manager' ) ],
        'hierarchical' => false,
        'show_in_rest' => true,
    ] );
} );

Câu hỏi: “Khi nào chọn hierarchical, khi nào chọn non-hierarchical?”

Trả lời: Hierarchical khi dữ liệu có quan hệ cha-con rõ ràng và số lượng term giới hạn, biết trước (VD: Thể loại sách: Văn học > Tiểu thuyết > Trinh thám). Non-hierarchical khi dữ liệu mở, người dùng tự thêm thoải mái, không có cấu trúc phân cấp (VD: Tag tự do).

3. Query theo Taxonomy

$query = new WP_Query([
    'post_type' => 'book',
    'tax_query' => [
        [
            'taxonomy' => 'genre',
            'field'    => 'slug',
            'terms'    => 'trinh-tham',
        ],
    ],
]);

PHẦN C: CUSTOM FIELDS — LÀM TAY VỚI META BOX

Đây là phần hay bị hỏi sâu nhất vì liên quan trực tiếp tới Security (nonce, sanitize, capability check).

1. Đăng ký Meta Box

add_action( 'add_meta_boxes', function() {
    add_meta_box(
        'book_author_meta',              // ID duy nhất
        __( 'Thông tin tác giả', 'my-book-manager' ),  // Tiêu đề hiển thị
        'render_book_author_meta_box',   // Callback render HTML
        'book',                          // Chỉ hiện ở post type 'book'
        'side',                          // Vị trí: side / normal / advanced
        'default'                        // Độ ưu tiên
    );
} );

2. Render HTML — bắt buộc có Nonce field

function render_book_author_meta_box( $post ) {
    // Nonce field — "chữ ký" xác nhận request này đến từ đúng form của mình
    wp_nonce_field( 'save_book_author_meta', 'book_author_nonce' );

    $author = get_post_meta( $post->ID, '_book_author', true );
    ?>
    <label for="book_author_field">Tên tác giả</label>
    <input
        type="text"
        id="book_author_field"
        name="book_author_field"
        value="<?php echo esc_attr( $author ); ?>"
        style="width:100%"
    />
    <?php
}

Vì sao esc_attr() bắt buộc ở đây: giá trị $author sẽ nằm trong thuộc tính value="" của HTML. Nếu tác giả nhập tên có ký tự " (dấu ngoặc kép), không escape sẽ làm hỏng cấu trúc HTML hoặc mở đường XSS. Quy tắc: bất cứ giá trị nào in ra HTML đều phải escape đúng ngữ cảnh (esc_attr cho attribute, esc_html cho nội dung text, esc_url cho link).

3. Xử lý Save — phần quan trọng nhất, dễ sai bảo mật nhất

add_action( 'save_post', function( $post_id ) {

    // ① Check nonce — chống CSRF (kẻ xấu dụ admin submit form giả)
    if ( ! isset( $_POST['book_author_nonce'] ) ||
         ! wp_verify_nonce( $_POST['book_author_nonce'], 'save_book_author_meta' ) ) {
        return;
    }

    // ② Bỏ qua khi WordPress autosave (autosave không có $_POST đầy đủ, dễ gây lỗi/mất data)
    if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
        return;
    }

    // ③ Check quyền — user hiện tại có được sửa bài này không
    if ( ! current_user_can( 'edit_post', $post_id ) ) {
        return;
    }

    // ④ Sanitize input trước khi lưu — KHÔNG BAO GIỜ lưu $_POST thô vào DB
    if ( isset( $_POST['book_author_field'] ) ) {
        $clean_value = sanitize_text_field( $_POST['book_author_field'] );
        update_post_meta( $post_id, '_book_author', $clean_value );
    }

} );

4 bước này là “công thức” bắt buộc phải thuộc — gần như mọi câu hỏi test về custom field/security đều xoay quanh đây:

BướcThiếu thì sao?
① Check nonceBị tấn công CSRF — trang khác có thể âm thầm submit form thay đổi data của bạn
② Check autosaveData có thể bị ghi đè sai hoặc lỗi PHP vì $_POST không đầy đủ lúc autosave
③ Check current_user_canUser không đủ quyền vẫn có thể sửa được data (lỗ hổng phân quyền)
④ Sanitize inputMở đường SQL Injection / Stored XSS — dữ liệu độc lưu thẳng vào DB, phát tán khi hiển thị lại

4. Hàm sanitize hay dùng theo loại dữ liệu

sanitize_text_field( $_POST['name'] );      // text thường, loại bỏ HTML/script tag
sanitize_email( $_POST['email'] );          // email
sanitize_textarea_field( $_POST['bio'] );   // textarea, giữ line break
absint( $_POST['age'] );                    // ép về số nguyên dương
sanitize_key( $_POST['option_key'] );       // key/slug, chỉ giữ a-z0-9_-
wp_kses_post( $_POST['content'] );          // cho phép HTML an toàn (giống nội dung post)

PHẦN D: ACF (Advanced Custom Fields) — SO SÁNH VỚI LÀM TAY

Bảng so sánh — nên thuộc để trả lời câu “khi nào dùng ACF, khi nào code tay”

Tiêu chíLàm tay (meta box thủ công)ACF
Tốc độ phát triểnChậm hơn, viết nhiều code lặpNhanh, kéo-thả cấu hình field
Kiểm soát/tuỳ biếnToàn quyền, tối ưu đúng ýBị giới hạn trong khả năng của plugin
Phụ thuộcKhông phụ thuộc plugin ngoàiPhụ thuộc ACF — nếu gỡ ACF, mất field hiển thị (dữ liệu vẫn còn trong DB)
Chi phíMiễn phí hoàn toànBản Pro trả phí cho tính năng nâng cao (Repeater, Flexible Content…)
Phù hợpSản phẩm dài hạn, cần tối ưu hiệu năng, ít fieldDự án cần triển khai nhanh, nhiều field, khách hàng tự quản trị nội dung

Câu trả lời mẫu:

“Em ưu tiên ACF cho các field đơn giản, số lượng nhiều, cần bàn giao nhanh cho khách tự quản trị — vì tốc độ phát triển nhanh và UI thân thiện. Nhưng với các trường hợp cần tối ưu hiệu năng cao, hoặc logic phức tạp gắn liền với business logic riêng, em code tay để kiểm soát trọn vẹn, tránh phụ thuộc plugin ngoài.”


PHẦN E: THỰC HÀNH TAY — GHÉP TẤT CẢ LẠI

Viết đầy đủ 1 plugin từ đầu, cấu trúc file:

my-book-manager/
├── my-book-manager.php   ← file chính, header + class chính
├── uninstall.php
└── includes/
    └── class-book-cpt.php

Yêu cầu tự làm (không xem lại code mẫu trên, viết từ trí nhớ):

  1. Đăng ký CPT book với show_in_rest => true.
  2. Đăng ký taxonomy genre (hierarchical).
  3. Thêm meta box “Tác giả” — đầy đủ 4 bước bảo mật ở Phần C.3.
  4. Test: tạo 1 sách, gán thể loại, nhập tên tác giả, save, F5 lại xem data còn không.
  5. Thử tấn công giả định: xoá nonce field khỏi HTML (dùng DevTools), submit lại form — phải bị chặn (không lưu được) → chứng minh nonce hoạt động.

PHẦN F: TỰ KIỂM TRA CUỐI NGÀY

  1. Phân biệt Activation / Deactivation / Uninstall — dữ liệu bị ảnh hưởng thế nào ở mỗi loại? (Activation/Deactivation giữ nguyên data, Uninstall xoá vĩnh viễn)
  2. Vì sao show_in_rest => true bắt buộc phải có khi đăng ký CPT thời nay? (Cần cho Gutenberg editor + REST API endpoint tự động)
  3. Kể đủ 4 bước bắt buộc khi xử lý save_post cho custom field, thiếu bước nào gây hậu quả gì? (nonce → CSRF; autosave check → lỗi data; capability check → phân quyền; sanitize → injection/XSS)
  4. esc_attr() khác sanitize_text_field() ở điểm nào, dùng lúc nào? (sanitize khi LƯU vào DB, escape khi IN ra HTML — làm ở 2 thời điểm khác nhau)
  5. Khi nào bạn chọn ACF thay vì code tay, và ngược lại?
📝 2,559 từ 📅 13/07/2026 👁 5 lượt đọc
← Chương Trước Chương Tiếp →
Giáo trình ôn tập WordPress bài bản + Vibe Coding

Giáo trình ôn tập WordPress bài bản + Vibe Coding

Tác giả: Giang Thanh Nhon

Trạng thái: Đang tiến hành

Số chương: 7

Đánh giá: ⭐ 10/10

💬 Bình luận (0)

Hãy là người đầu tiên bình luận về chương này!