morningtrain/wp-hooks
A Laravel package bringing WordPress-style actions and filters to your PHP applications. Register, fire, and remove hooks with a familiar API, making it easy to add extensibility and event-driven customization to your code.
Install and wire up autoloading
composer require morningtrain/wp-hooks
The package assumes Composer autoloading is present in your WordPress runtime. If you're on Bedrock or a Sage/Roots-style theme, this is already handled. On a classic install, add a require_once __DIR__ . '/vendor/autoload.php'; near the top of functions.php (or from a must-use plugin) before anything registers hooks.
Where to look first
README.md — the package's primary API surface is small, so the usage examples there are effectively the whole contract.src/ — locate the main hook-registration class/facade. That one file tells you whether the API is static (Hook::action(...)), instance-based, or class-attribute driven.composer.json — check the PHP version constraint and whether it declares a roots/wordpress or johnpbloch/wordpress-core dependency, since that dictates whether you can drop it into an existing non-Bedrock site.First use case: taming functions.php
Pick one self-contained feature — a custom post type, a set of admin notices, a content filter — and move only that into a dedicated class. Register its hooks through the package instead of scattering add_action / add_filter calls. You get a single file that owns the entire feature, and deleting the file plus one registration line removes the feature cleanly.
// functions.php or a plugin bootstrap
Feature\BookPostType::register();
The point of the exercise isn't the syntax; it's proving to yourself that a feature can be described in one place. Once that works for one feature, the migration of the remaining hooks is mechanical.
One class per feature, not one class per hook
The failure mode of "organize your hooks" packages is creating a Hooks.php that becomes a second functions.php. The pattern that actually pays off is a class per domain concern — PostTypes\Book, Admin\DashboardWidgets, Frontend\ContentFilters — each exposing a static register() that declares its own hooks. The bootstrap file becomes a short manifest:
Book::register();
DashboardWidgets::register();
ContentFilters::register();
Separate admin and frontend registration
Wrap registration in context checks so you don't load admin-only callbacks on every frontend request:
if (is_admin()) {
Admin\Settings::register();
} else {
Frontend\ContentFilters::register();
}
Be deliberate about which hook you check context on. is_admin() is available early; is_user_logged_in() and most conditionals are not reliable before init or wp_loaded.
Chain priority and argument count at the declaration site
The main ergonomic win over raw WordPress is co-locating the callback with its priority and accepted-args. Keep the declaration and the callback adjacent so a reader never has to hunt for add_filter('the_content', ..., 99, 2) elsewhere. If the package supports fluent configuration, use it; if it only takes arrays, prefer named constants for priorities used in more than one place (Priority::LATE = 99) so ordering intent survives refactors.
Filters must return; actions must not
Establish a convention in your codebase: every filter callback in a feature class returns the value, every action callback returns void. Mixed contracts are the single most common source of "the whole page went blank" bugs when adopting any hook-organizing layer, because the indirection makes the missing return harder to spot in review.
Register late enough, but not too late
If a feature class depends on theme supports, plugins, or options, register its hooks on after_setup_theme or plugins_loaded rather than at file load. Conversely, anything that must run on plugins_loaded itself has to be registered before that — a classic off-by-one. Pick an earliest-safe boundary per feature and document it in the class docblock.
Test the callbacks, not the hook layer
The hook registration itself is thin; the value is in the callbacks. Structure feature classes so the callback is a public (or protected + tested via subclass) method that takes plain arguments. Then unit tests call $instance->filterContent('<p>x</p>') directly without booting WordPress. Reserve integration tests for verifying the hook is actually attached (has_filter).
Keep the package at the edges
Treat it as sugar, not architecture. Feature classes should be usable from a plain add_action if the dependency ever disappears. That means no package-specific base class baked into your domain logic where avoidable — a thin register() static that happens to call into the package is easy to swap later.
Autoload timing beats everything
If vendor/autoload.php isn't loaded before your hook files execute, you get a fatal on the class name rather than a friendly "hooks not firing" symptom. Confirm autoloading early in functions.php or, better, from an mu-plugin that loads first. On Bedrock this is handled; on hand-rolled installs it is the number one setup failure.
Closures can't be unhooked
If the API encourages closure callbacks, remember remove_action / remove_filter needs the identical callable. A closure registered by the package is effectively permanent. For anything a third party (or a future you) might need to disable — theme compatibility shims, third-party plugin overrides — register a named static method or a first-class callable instead.
Double registration is silent
WordPress does not complain when the same callback is added twice at the same priority; it just runs twice. When refactoring hooks into classes, a feature that used to be registered in functions.php and is now also registered via the new class will run its side effects twice — duplicated meta boxes, doubled content, repeated emails. Grep the old codebase for the hook names before deleting, and delete in the same commit.
Priority defaults are not your friend for filters that must win
The default priority of 10 means "arrives in registration order, which depends on plugin load order." If your filter must run after a specific plugin's, use an explicit late priority. If it must run before, use a low one. Never rely on load order — Composer's autoload ordering and plugin activation order both change it.
Debugging: verify attachment before debugging the callback
global $wp_filter;
var_dump(isset($wp_filter['the_content']));
or has_filter('the_content', [ContentFilters::class, 'filter']). Query Monitor's Hooks panel is the fastest way to see what's attached, at what priority, and from which file. If the callback never fires, it's almost always a registration-timing or context-guard problem, not a logic bug.
Filter callbacks receiving the wrong number of args
accepted_args defaults to 1. A callback declared with two parameters but registered without accepted_args = 2 gets null for the second — no warning, no error, just silently missing data. When a filter callback starts producing bizarre results, check the arg count first.
doing_it_wrong on early hook use
Calling functions like wp_enqueue_script, add_shortcode, or translation functions before their hooks fire produces _doing_it_wrong notices — and with WP_DEBUG on, those notices can break JSON responses and redirects. Guard feature registration behind the correct lifecycle hook rather than at autoload time.
Bus factor and version pinning
This is a two-star package with no dependents, maintained by a single agency. That's fine — it's small and MIT — but pin the version in composer.json, commit composer.lock, and make sure the abstraction is shallow enough that removal is a one-hour job. If the repo goes quiet, you want the option to inline the handful of lines it provides rather than fork it.
Extension points
Read the source for the base registration class before you need it: subclassing it, overriding a resolver method, or supplying your own callable-normalization is usually the sanctioned way to handle unusual cases (e.g. registering a hook whose callback needs dependency injection). If no such seam exists, resist patching vendor/ — wrap the package in your own one-file helper instead.
How can I help you explore Laravel packages today?