do_action Với Event Object: Viết Hook WordPress Sạch Hơn Không Cần Framework

Nếu bạn viết plugin hay theme cho WordPress thì chắc chắn đã gọi do_action() hàng trăm lần. Mình cũng vậy. Nhưng gần đây mình đọc được một bài trên WordPress Developer Blog của Justin Tadlock mà thấyáng giật mình: chỉ cần đổi cách truyền tham số — thay vì một đống biến lỏng lẻo thì truyền một object duy nhất — là gần như mọi khó chịu quen thuộc của hook biến mất hết. Không cần library, không cần framework, vẫn là đúng cái do_action() mà WordPress có từ phiên bản 1.2 năm 2004.

Trong bài này mình sẽ đi lại toàn bộ kỹ thuật đó theo cách mình hiểu, kèm code mẫu từng bước, để bạn áp dụng được ngay vào plugin của mình ngay tuần này.

Hook WordPress vốn có những vấn đề gì?

Câu trả lời ngắn gọn: vấn đề không nằm ở bản thân hook mà nằm ở payload — cách mình đưa dữ liệu qua hook. Tham số vị trí phải nhớ thứ tự, tên hook sống trong namespace toàn cục dễ đụng nhau, và không có gì được type rõ ràng. Với filter thì còn thêm một cái bẫy chết người: quên return là đứt cả chuỗi filter mà lỗi rất khó truy ngược. Tất cả những papercut này đều chữa được cùng một lúc bằng một thay đổi nhỏ.

Giả sử mình đang viết plugin đăng ký thành viên. Cách truyền thống sẽ như thế này — phát action khi có người đăng ký:

do_action( 'myplugin_member_registered', $userId, $plan );

Và một plugin khác lắng nghe:

add_action( 'myplugin_member_registered', static function ( $userId, $plan ) {
    // ...
}, 10, 2 );

Còn khi cần “hỏi lại ý kiến” — thành viên này có được gửi email chào mừng không — thì chuyển sang filter:

$sendWelcomeEmail = apply_filters( 'myplugin_send_welcome_mail', true, $userId, $plan );

if ( $sendWelcomeEmail ) {
    // ...queue email chào mừng.
}

Nghe quen đúng không? Ba vấn đề mình gặp hoài với cách viết này:

  • Tham số theo vị trí: thứ tự là $userId, $plan hay $plan, $userId? Có mấy tham số? Phải mở lại chỗ gọi để đọc, rồi nhớ truyền 10, 2 hay 10, 3 cho khớp.
  • Tên hook nằm trong scope toàn cục: myplugin_member_registered chia sẻ một không gian phẳng với mọi plugin khác trên site. Bạn gắn prefix và cầu may.
  • Không có type: $plan là string, là ID, hay là object? Editor không giúp gì được.

Với filter thì còn papercut thứ tư: quên return $send; là filter sau đó trong chuỗi nhận giá trị null, và lỗi này cực khó truy vết.

Ideá cốt lõi chỉ dài một dòng là gì?

Câu trả lời: thay vì truyền nhiều tham số lỏng lẻo, bạn truyền một object mô tả sự kiện vừa xảy ra, và dùng chính tên class của object đó làm tên hook:

do_action( $event::class, $event );

Chỉ vậy thôi. Payload giờ là một object có type rõ ràng. Tên hook là fully-qualified class name nên được namespaced sẵn — không plugin nào của bạn đụng plugin nào khác trừ khi cố tình. Điểm thú vị nhất: mình vẫn dùng do_action() chứ không cần apply_filters(). Vì một property mutable trên event object đóng vai giá trị trả về của filter — mà không có chuyện ai quên return.

Đặt tên hook bằng class hay chuỗi tùy chỉnh?

Câu trả lời phụ thuộc một câu hỏi duy nhất: bạn có muốn tên hook không bao giờ thay đổi không? Dùng ::class thì khi rename hoặc dời namespace của class, IDE đổi tên hook theo — tiện nhưng listener cũ trỏ về tên cũ sẽ im lặng hết tác dụng. Dùng chuỗi cố định như 'myplugin/member-registered' thì tên khóa cứng vĩnh viễn, bạn thoải mái refactor class mà listener không hề hay biết.

Hai lựa chọn cụ thể:

// Cách 1: tên hook bám theo class
do_action( MemberRegistered::class, $event );

// Cách 2: tên hook khóa cứng vĩnh viễn
do_action( 'myplugin/member-registered', $event );

Quy tắc của mình: dùng ::class khi mình chấp nhận tên hook đi theo class (dự án nội bộ, mình kiểm soát cả hai đầu), dùng chuỗi cố định khi hook là API công khai cho bên thứ ba phụ thuộc.

Xây event class và dispatch như thế nào?

Câu trả lời: tạo một class event nhỏ chỉ chứa dữ liệu sự kiện, rồi gọi do_action() ngay sau khi logic chính chạy xong. Mình làm theo đúng ví dụ đăng ký thành viên, đặt toàn bộ trong namespace MyPlugin\Members để tên hook khỏi đụng ai.

Bước 1 — tạo event object. Chú ý mình chia property thành hai loại: readonly cho dữ liệucontext listener chỉ được đọc, và mutable cho “câu trả lời” listener được phép thay đổi:

namespace MyPlugin\Members;

final class MemberRegistered
{
    public function __construct(
        public readonly int    $userId,
        public readonly string $plan,
        public          bool   $sendWelcomeEmail = true,
    ) {}
}

Bước 2 — dispatch event tại điểm phù hợp trong luồng đăng ký:

namespace MyPlugin\Members;

final class MemberRegistrar
{
    public function register( int $userId, string $plan ): void
    {
        // ...tạo tài khoản, gán plan, v.v.

        $event = new MemberRegistered( userId: $userId, plan: $plan );

        // Thành viên đã đăng ký. Thông báo cho mọi bên quan tâm.
        do_action( $event::class, $event );

        if ( $event->sendWelcomeEmail ) {
            // ...queue email chào mừng.
        }
    }
}

Điểm mấu chốt: sau khi do_action() chạy xong, MemberRegistrar đọc lại $event->sendWelcomeEmail. Nếu một listener nào đó đã tắt nó thì email không gửi — hành vi giống filter nhưng không ai phải return gì cả. Object trong PHP truyền bằng handle nên mọi thay đổi của listener được nhìn thấy ngay.

Listener viết kiểu gì khi nhận event object?

Câu trả lời: có hai dạng — listener quan sát chỉ đọc và phản ứng, còn listener thay đổi thì mutate property trên event. Cả hai đều type-hint thẳng class event nên editor tự gợi ý thuộc tính, khỏi nhớ thứ tự tham số.

Listener quan sát — ghi log mỗi lượt đăng ký:

use MyPlugin\Members\MemberRegistered;

add_action( MemberRegistered::class, static function ( MemberRegistered $event ): void {
    error_log( sprintf(
        'Member #%d registered on the %s plan.',
        $event->userId,
        $event->plan
    ) );
} );

Listener thay đổi — thành viên gói free thì bỏ qua chuỗi email chào mừng:

use MyPlugin\Members\MemberRegistered;

add_action( MemberRegistered::class, function ( MemberRegistered $event ): void {
    if ( 'free' === $event->plan ) {
        $event->sendWelcomeEmail = false;
    }
} );

Nhìn signature function ( MemberRegistered $event ) — đó là phần mình thích nhất. Autocomplete hoạt động, static analysis bắt được lỗi, và bạn kiểm soát chính xác cái gì mutable bằng cách chọn property nào readonly, property nào không.

Kỹ thuật này thay được PSR-14 hoàn chỉnh không?

Câu trả lời: với phần lớn custom hook bạn sẽ viết trong đời — có, là đủ. Hai bài toán khó nhất là tên hook an toàn và payload có cấu trúc đều được giải quyết ngay khi bạn truyền object dưới tên class. Nhưng nếu dự án cần dừng truyền event giữa chừng (propagation control), listener provider tùy chỉnh, subscriber đăng ký hàng loạt, hay thay dispatcher khi viết test — thì đó là lúc cân nhắc một event system đầy đủ theo chuẩn PSR-14.

Điều mình đánh giá cao ở góc nhìn của Tadlock: đây không phải hack cài vào hook. do_action() và add_action() vốn dĩ đã là event-listener-dispatcher từ 2004 — trước cả khi PHP thế giới chuẩn hóa PSR-14. Bạn không học paradigm mới, chỉ là cuối cùng cũng dùng đúng cái nền mình đang đứng.

Mình đánh giá kỹ thuật này ra sao sau khi thử?

Nói thật là mình đã áp dụng thử cho một plugin nhỏ đang maintain, và cảm giác như được viết lại code chứ không phải học gì mới. Kiến trúc plugin giờ tự документ hóa — nhìn vào thư mục Event/ là biết plugin đó bung ra những điểm mở rộng nào. Cái mình tiếc là mình không biết kỹ thuật này sớm hơn, vì toàn bộ hook cũ của mình vẫn đang dùng tham số vị trí và prefix thủ công.

Một lưu ý thực tế cho ai muốn áp dụng: event đặt file riêng theo chuẩn coding, và nếu hook của bạn là API công khai thì nghiêng về chuỗi tên cố định thay vì ::class để không phá plugin bên thứ ba khi refactor. Còn nếu bạn đang dùng AI viết code plugin như mình từng chia sẻ trong bài guardrails cho AI code WordPress, thì kỹ thuật này còn giúp AI ít sai hơn — payload có type nghĩa là reviewer tự động và static analysis bắt lỗi được thay vì người đọc line by line.

Nếu bạn đang chuẩn bị cho các API mới của WordPress 7.x như Icon API mình đã hướng dẫn hay dõi theo những thay đổi để đời ở WordPress 7.2 qua Gutenberg 23.8-23.9, thì đây chính là lúc dọn lại cách viết hook cho plugin của mình trước khi codebase lớn thêm.

Bạn đã thử pattern này chưa? Nếu có vấn đề gì khi áp dụng, kể mình nghe ở phần bình luận nhé.

Thanh Tùng

Mình là Thanh Tùng. Bạn bè gọi mình là "bác sĩ máy tính" vì hễ máy nào có vấn đề là mình muốn mò vào xem sao. Mình viết hướng dẫn theo cách mà mình mong người khác đã viết cho mình ngày xưa — từng bước rõ ràng, không bỏ sót, và nói luôn cái gì hay bị lỗi. Ngoài giờ làm mình chơi guitar, nuôi mèo, và có một con VPS riêng dành riêng cho việc cài thử đủ thứ linh tinh.

Xem tất cả bài viết →

Để lại một bình luận

Email của bạn sẽ không được hiển thị công khai. Các trường bắt buộc được đánh dấu *