Hooks
A hook file is a plain PHP file in your project’s hooks/ directory. Deployed with lps hook push, each file’s class binds one or more WordPress actions, filters, or cron jobs directly, the same primitive a functions.php snippet would use, just version-controlled and pushed like the rest of your project.
Unlike custom API routes, a hook has no URL and no permission check of its own: once pushed, it runs automatically whenever WordPress fires the hook it’s bound to, with no manage_options-style gate in front of it. For #[Action]/#[Filter] that means every request that fires that specific hook, from init (every page) to something narrower like save_post (only a save); for #[Cron] it means every occurrence of the schedule, in the background, not tied to any visitor at all. Keep that in mind when writing one, a mistake affects everything that hits the hook it’s bound to, not just calls to one endpoint.
Anatomy of a hook file
Section titled “Anatomy of a hook file”<?php
declare(strict_types=1);
use Loopress\Hooks\Attribute\Action;use Loopress\Hooks\Attribute\Filter;
class ContentHooks{ #[Filter('the_content', priority: 20)] public function appendNotice(string $content): string { return $content . '<p>Thanks for reading!</p>'; }
#[Action('save_post')] public function onSave(int $postId): void { // ... }}Saved as hooks/content-hooks.php and pushed, appendNotice() filters the_content at priority 20, and onSave() runs on save_post.
Two things tie everything together:
| Element | Rule |
|---|---|
| Class | Exactly one class per file, and nothing outside it but its namespace, use imports and attributes. The class name itself doesn’t matter, discovered from the file’s own tokens, not from its filename. Don’t write an ABSPATH guard: the plugin adds one on push, and a guard you write yourself is rejected as code outside the class. |
| Method | Any public method attributed with #[Action], #[Filter], or #[Cron]. A file needs at least one, but a single class can carry as many as it wants, mixed freely. |
#[Action] and #[Filter]
Section titled “#[Action] and #[Filter]”#[Action(string $hook, int $priority = 10, int $acceptedArgs = 1)]#[Filter(string $hook, int $priority = 10, int $acceptedArgs = 1)]Both map straight onto WordPress’s own add_action() / add_filter(): hook is the WordPress hook name, priority and acceptedArgs behave exactly as they do there.
A #[Filter] method must return the (possibly modified) value it receives:
#[Filter('excerpt_length')]public function shorten(int $length): int{ return 20;}If a hook method throws, it’s caught and logged rather than left to break the page for every visitor: an #[Action] simply doesn’t run, a #[Filter] falls back to the original, unfiltered value. The last error each file threw is also kept on the site and shown by lps hook list and lps doctor, so a hook that keeps failing doesn’t just look like it does nothing.
#[Cron]
Section titled “#[Cron]”#[Cron(string $recurrence, ?string $hook = null)]Runs a method on a recurring schedule instead of an existing WordPress event:
use Loopress\Hooks\Attribute\Cron;
class InvoiceCleanup{ #[Cron('daily')] public function cleanup(): void { // ... }}recurrence is any WordPress schedule name: the built-in hourly, twicedaily, and daily, or a custom one your own code registers via the cron_schedules filter.
The event is scheduled the first time the method is seen, and left alone after that: WordPress’s own wp_next_scheduled() check means it’s only ever scheduled once. Changing #[Cron('daily')] to #[Cron('hourly')] later doesn’t reschedule the already-running event, clear it yourself first with wp_clear_scheduled_hook() if you need the new recurrence to take effect immediately.
By default the WordPress action bound to the job is loopress_hooks_cron_{slug}_{method} (slashes in a nested slug become underscores), pass hook to #[Cron] to name it yourself, useful if another part of your code needs to do_action() the same job on demand:
#[Cron('daily', hook: 'acme_invoice_cleanup')]public function cleanup(): void { /* ... */ }Typical workflow
Section titled “Typical workflow”# 1. Download existing hook files from WordPresslps hook pull
# 2. Edit locally, commit to Gitgit add hooks/ && git commit -m "feat: append a reading notice via the_content"
# 3. Deploy back to WordPresslps hook pushCommands
Section titled “Commands”Every command below also accepts the common flags: --env, --json, and --yes where it applies.
lps hook pull
Section titled “lps hook pull”Download all hook files from WordPress.
lps hook pull [path]| Argument | Default | Description |
|---|---|---|
path |
./hooks (or loopress.json’s hooksDir) |
Local directory where hook files are written |
| Flag | Description |
|---|---|
--dry-run / -d |
Show what would be written without touching the filesystem |
A local .php file whose slug no longer exists on WordPress is removed on pull, see pull mirrors the site.
lps hook push
Section titled “lps hook push”Upload .php files from a local directory to WordPress.
lps hook push [path]| Argument | Default | Description |
|---|---|---|
path |
./hooks (or loopress.json’s hooksDir) |
Local directory to read .php files from |
| Flag | Description |
|---|---|
--dry-run / -d |
Show what would be pushed without making any changes |
--prune |
After pushing, delete hook files on WordPress that have no local counterpart (confirmation prompt; needs --yes in a non-interactive shell) |
--yes / -y |
Skip the --prune confirmation prompt |
Each file must start with declare(strict_types=1); exactly once, and declare exactly one class with no code outside it, same validation as lps api push (syntax check, class-name collision detection). On push the plugin adds an ABSPATH guard itself, right after the declare line, so a file is never reachable directly over HTTP: don’t write one, it would be rejected as code outside the class. Pulls strip it again. Each file is also capped at 512 KB, and the whole hooks/ directory at 8 MB total; a file over either limit is skipped at load time (logged, and shown in the admin tab) rather than risking memory exhaustion for the request. Both limits are adjustable with the loopress_max_file_bytes and loopress_max_files_total_bytes filters.
Without --prune, pushing never removes a hook file from the server.
lps hook list
Section titled “lps hook list”Print all hook files currently on WordPress, with the reason a file failed to load, and the last error one of its methods threw while running:
$ lps hook listFound 2 hook files:
content-hooks caps grant() threw while running as a filter, last at 2026-10-12T10:00:00+00:00: Undefined array key "role"A runtime error is reported until the file changes: push a fixed version and it disappears. The same error is recorded at most once a minute, so a hook failing on every request doesn’t write to the database on every request.
| Flag | Description |
|---|---|
--json |
Output raw JSON instead of formatted text |
lps hook diff
Section titled “lps hook diff”Show what differs between your local hook files and a WordPress environment, or between two environments with --against. Also part of the aggregate lps diff (--only hook).
lps hook diff [path]lps hook diff --env staging --against productionExits 0 in sync, 1 on drift, 2 when the comparison failed.
lps hook rollback
Section titled “lps hook rollback”Restore hook files on WordPress to the snapshot saved automatically before an earlier lps hook push. See Rollback and Snapshots.
lps hook rollback # the most recent snapshotlps hook rollback --list # available snapshotslps hook rollback --to <id> # an older onelps hook rm
Section titled “lps hook rm”Delete one hook file from WordPress. Hook files live under wp-content/, outside the plugin directory, so deactivating the plugin does not remove them; rm is the way to take one off the server through the CLI.
lps hook rm <filename>lps hook remove is an alias.
| Argument | Description |
|---|---|
filename |
The hook slug without .php, e.g. content-filters or content/filters |
| Flag | Description |
|---|---|
--yes / -y |
Skip the confirmation prompt (required in a non-interactive shell) |
--dry-run / -d |
Show what would be removed without deleting anything |