Skip to content

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.

<?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(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(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 { /* ... */ }
Terminal window
# 1. Download existing hook files from WordPress
lps hook pull
# 2. Edit locally, commit to Git
git add hooks/ && git commit -m "feat: append a reading notice via the_content"
# 3. Deploy back to WordPress
lps hook push

Every command below also accepts the common flags: --env, --json, and --yes where it applies.

Download all hook files from WordPress.

Terminal window
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.


Upload .php files from a local directory to WordPress.

Terminal window
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.


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:

Terminal window
$ lps hook list
Found 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

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).

Terminal window
lps hook diff [path]
lps hook diff --env staging --against production

Exits 0 in sync, 1 on drift, 2 when the comparison failed.


Restore hook files on WordPress to the snapshot saved automatically before an earlier lps hook push. See Rollback and Snapshots.

Terminal window
lps hook rollback # the most recent snapshot
lps hook rollback --list # available snapshots
lps hook rollback --to <id> # an older one

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.

Terminal window
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