Module 3 · Hooks and events
Duration: 60 min · Prerequisites: module 1, and knowing what a WordPress action and filter are.
By the end of this module you declare your hooks on the method that handles them, with no add_action() or add_filter() in functions.php, and you know when a Laravel event is a better fit than a hook.
1. One hook, one method
Section titled “1. One hook, one method”With WordPress, the hook name, the priority and the argument count live on one line of functions.php, and the callback somewhere else. With Pollora, the method carries its own declaration:
<?php
namespace App\Cms\Hooks;
use Pollora\Attributes\Action;use Pollora\Attributes\Filter;
class Seo{ #[Action('wp_head', priority: 1)] public function metaDescription(): void { if (is_singular()) { echo '<meta name="description" content="'.esc_attr(get_the_excerpt()).'">'; } }
#[Filter('the_content', priority: 10)] public function shine(string $content): string { return str_replace('ugly', 'shiny', $content); }}#[Action]and#[Filter]take the hook name and an optionalpriority(10by default).- They only apply to methods, and they are repeatable: one method can listen to several hooks.
- There is no argument count to declare: Pollora counts the parameters in the signature and passes that number to WordPress. If you only need the first argument, declare only the first one.
#[Action('init', priority: 10)]#[Action('wp_loaded', priority: 15)]public function setup(): void{ // Runs on both hooks}2. Where the class lives and how it is found
Section titled “2. Where the class lives and how it is found”You register nothing: auto-discovery scans app/ (as well as active themes and modules), finds the public methods carrying #[Action] or #[Filter] and registers them with WordPress. Abstract classes are skipped.
app/Cms/Hooks is the convention, and it is where the generators write, but any class under app/ is found. Group by feature (Seo, Media, Admin), not by hook name.
php artisan pollora:make:action Seophp artisan pollora:make:filter ContentFiltersTo keep a class out of discovery, add #[SkipDiscovery] (from Pollora\Attributes\SkipDiscovery).
3. Services in your hooks
Section titled “3. Services in your hooks”Discovered classes are built by the Laravel service container, so constructor injection works as in a controller. One instance is created per class and shared by all its hook methods.
use Illuminate\Contracts\Config\Repository as Config;use Pollora\Attributes\Action;
class Analytics{ public function __construct( private readonly Config $config, ) {}
#[Action('wp_footer')] public function printSnippet(): void { $id = $this->config->get('services.analytics.id');
if ($id) { echo view('partials.analytics', ['id' => $id])->render(); } }}Keep constructors light and do the WordPress work inside the hook methods, where you know which hook is running.
4. The Action and Filter facades
Section titled “4. The Action and Filter facades”Attributes suit hooks that are always on. When registration depends on a runtime condition, use the facades, for example in a service provider:
use App\Cms\Hooks\ContentHandler;use Pollora\Support\Facades\Action;use Pollora\Support\Facades\Filter;
public function boot(): void{ // Builds ContentHandler through the container and calls its theContent() method Filter::add('the_content', ContentHandler::class, 20);
if (config('app.debug')) { Action::add('wp_footer', function (): void { echo '<!-- debug -->'; }); }}| Need | Facade |
|---|---|
| Register | Action::add(), Filter::add() |
| Fire | Action::do(), Filter::apply() (like do_action() and apply_filters()) |
| Check existence | Action::exists(), Filter::exists() |
| Remove | Action::remove(), Filter::remove() (with the same priority as the registration) |
| List | Action::callbacks(), Filter::callbacks() |
- A class passed alone (
Filter::add('the_content', ContentHandler::class)) has the method named after the hook in camel case called:theContent(). With[ContentHandler::class, 'modify'], it ismodify(). - Like
add_action(), the facades accept a callback that is not defined yet (a function from a plugin loaded later): it is checked when the hook fires.
5. Hooks as Laravel events
Section titled “5. Hooks as Laravel events”For common WordPress events, Pollora also dispatches typed Laravel events, handled by ordinary listeners with dependency injection.
<?php
namespace App\Listeners;
use Pollora\Events\WordPress\Post\PostPublished;
class NotifySubscribers{ public function handle(PostPublished $event): void { $post = $event->post; // WP_Post // ... }}The classes follow a naming scheme: Pollora\Events\WordPress\Post\PostCreated and PostPublished, …\User\UserLoggedIn, …\Media\…, …\Taxonomy\TermCreated, …\Comment\…, …\Menu\…, …\Widget\…, …\Option\OptionUpdated. Each one exposes the WordPress objects involved as typed properties ($event->post, $event->user, $event->term…). The full list is in the events reference; php artisan event:list shows the registered events and listeners.
These are standard Laravel listeners: for heavy work (image optimisation, notifications), have them queued with ShouldQueue so they do not slow down the admin. Defining, discovering and queuing listeners is described in the Laravel documentation.
Hook or listener?
- You must change a WordPress value or output: a hook (
#[Filter]; a filter must return a value). - You react to something that already happened, especially if it is slow: a listener, possibly queued.
- WordPress events fire often: only register the listeners you need.
- To run an action after the request,
#[Async]next to#[Action]also exists: see module 7.
Check what you remember
Section titled “Check what you remember”The argument count
A #[Filter('body_class')] method declares two parameters. What must you do for WordPress to pass both values?
- Pass
accepted_args: 2to the attributeThere is no argument count to declare in the attribute.
- Nothing: Pollora counts the parameters in the signature
- Call
Filter::add()instead, the only way to receive several argumentsBoth ways detect the argument count: the facade does it from the callback when you omit it.
If you only care about the first argument, declare only the first parameter.
The hook does not fire
You just added #[Action('init')] to a method of a class under app/Cms/Hooks, and nothing happens in development. What do you do first?
-
php artisan discovery:clear, thenphp artisan discovery:run --discovery=hooksto check the count - Add an
add_action('init', …)infunctions.phpThat would bypass discovery instead of understanding why it does not see the method.
- Register the class in a service provider
Classes are found by auto-discovery: no manual registration.
If the count still does not move: is the method public? the class not abstract? the attribute imported from Pollora\Attributes? the class buildable by the container?
A filter without return
What must a #[Filter('the_title')] method do?
- Print the new title with
echo - Return the value it received, changed
A filter changes a value and returns it to its caller.
- Return nothing: the attribute takes care of the value
The attribute only registers the hook: without a
return, WordPress receivesnull.
Only actions can do something without returning anything.
Several hooks, one method
The same method must run on init (priority 10) and on wp_loaded (priority 15). How?
- Impossible: one attribute per method
- Two
#[Action]attributes on the method, each with its priority#[Action]and#[Filter]are repeatable. - Two separate methods, one calling the other
That would work around the repeatable attributes, which exist for exactly this case.
With the facade, Action::add() accepts a hook name or an array of names.
Hook or listener?
After a post is published you must notify an external service, which takes several seconds. Which approach fits best?
- A
#[Filter('the_content')]that makes the call on the wayA filter changes a value and runs inside the request: the slow call would slow it down.
- A
PostPublishedlistener that implementsShouldQueue - A
PostPublishedlistener without a queue, to keep it simpleIt would work, but heavy work slows down the admin: queue it.
An #[Action] with #[Async] (module 7) is the other way to take the work out of the request.
Quiz Progress
0 / 0 questions answered (0%)
0 correct
Your score
0%
0 / 0 correct
Exercises
Section titled “Exercises”Exercise 05 · A prefix on titles
Section titled “Exercise 05 · A prefix on titles”lab/bin/lab reset 05, then edit app/Cms/Hooks/EventTitles.php.
Goal. Make the prefix() method a filter on the_title, with priority 20, that makes every title start with Event: . No add_filter(): everything goes through the attribute.
lab/bin/lab check 05 applies the the_title filter to a title and checks the result, then checks that the prefix is added after the priority-10 filters.
Going further
Section titled “Going further”- Actions & Filters: attributes, facades, injection, removal
- WordPress hooks with PHP 8 attributes: the step-by-step guide
- Events & Listeners and the events reference
- Asynchronous Actions:
#[Async], the topic of module 7 - Auto-discovery: exclusions and cache
Answered: 0 / 0 (0%)