Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Skill** `pollora-hooks`: asynchronous actions — `->async()` and `#[Async]`, their options, what travels and what is refused, `capture()` / `when()`, drivers and what must run them, the rules for a replayable handler, `Async::fake()`, `pollora:async:list` and `pollora:make:action --async`
- **Core and 13.x guidelines**: `#[Async]` in the attribute snippet, the `pollora:async:list` command, the async checks of `pollora:doctor`

## [1.4.0] - 2026-10-07

### Added
Expand Down
10 changes: 8 additions & 2 deletions resources/boost/guidelines/core.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ public function onInit(): void {}
#[Filter('the_content')]
public function filterContent(string $content): string {}

// Asynchronous action (v13.35.2+) — queued, run after the request by a queue worker, Action Scheduler or WP-Cron
#[Action('save_post_event')]
#[Async(tries: 3, unique: true)]
public function syncToCrm(int $postId): void {}

// REST API endpoints
#[WpRestRoute(namespace: 'app/v1', route: 'items')]
class ItemAPI {}
Expand Down Expand Up @@ -139,7 +144,8 @@ class InternalHelper {}
- `pollora:make:plugin` / `pollora:plugin:list` / `pollora:plugin:status` — Plugins
- `pollora:make:block` — Generate a Gutenberg block (dynamic Blade by default, `--static` for `save.jsx`)
- `pollora:make:post-type` / `pollora:make:taxonomy` — Generate post type and taxonomy classes
- `pollora:make:action` / `pollora:make:filter` / `pollora:make:hook` — Generate hook classes
- `pollora:make:action` / `pollora:make:filter` / `pollora:make:hook` — Generate hook classes (`pollora:make:action --async` adds `#[Async]`)
- `pollora:async:list` — Asynchronous actions, their driver and options, and where the default driver comes from
- `pollora:make:model` / `pollora:make:wp-cli` — Generate an Eloquent model or a WP-CLI command class
- `pollora:make:role` / `pollora:make:binding` — Generate a `#[Role]` class or a `#[BlockBinding]` source
- `pollora:meta:list` / `pollora:meta:audit` — List typed meta; read every stored value and name those that cannot be read as their type (exits 1, for CI)
Expand All @@ -148,7 +154,7 @@ class InternalHelper {}
- `pollora:binding:list` — Binding sources, their fields, and the blocks WordPress lets bind
- `discovery:run` / `discovery:clear` — Manage component discovery cache
- `pollora:status` — Show framework status
- `pollora:doctor` — Check the project for silent failures (core patch, patches lock, `.env`, discovery cache, theme build, patterns…) and print the fix for each, including bindings that can never show a value, typed meta that cannot register and users left with a removed role; the same checks are in Tools › Site Health. **Run it first when something fails without an error**
- `pollora:doctor` — Check the project for silent failures (core patch, patches lock, `.env`, discovery cache, theme build, patterns…) and print the fix for each, including bindings that can never show a value, typed meta that cannot register, users left with a removed role, and asynchronous actions nothing will run (ignored `#[Async]`, unavailable driver, overdue WP-Cron, jobs without a worker); the same checks are in Tools › Site Health. **Run it first when something fails without an error**

Commands use the colon convention since v13.32; the former dashed names (`pollora:make-theme`…) still work as aliases.

Expand Down
2 changes: 2 additions & 0 deletions resources/boost/guidelines/framework.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@

### Request Lifecycle

Asynchronous actions, from v13.35.2 (`pollora/hook` `^1.4`): `Action::add(...)->async()` or `#[Async]` next to `#[Action]` queues the handler instead of running it in the request. The default driver `auto` takes the Laravel queue only once `HOOKS_ASYNC_CONNECTION` is set, then Action Scheduler, then WP-Cron; Pollora sets `DISABLE_WP_CRON`, so WP-Cron and Action Scheduler need a system cron, the queue a worker. A handler may run twice and in any order: make it safe to replay. See the `pollora-hooks` skill.

WordPress is loaded from a service provider's `boot()`; `wp()` runs there. Since v13.35.1, `template_redirect` (then `pollora_loaded`) fires once every provider has booted, still before routing, so a plugin or theme rendering a view from `template_redirect` gets the theme's view composers and shared data. Code that relied on `template_redirect` having fired during the providers' boot must move to a later hook.

### Extracted Packages
Expand Down
67 changes: 65 additions & 2 deletions resources/boost/skills/pollora-hooks/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
---
name: pollora-hooks
description: Register and manage WordPress actions and filters using Pollora PHP 8 attributes and facades.
description: Register and manage WordPress actions and filters using Pollora PHP 8 attributes and facades, including asynchronous actions (->async(), #[Async]) run after the request.
---

# Pollora Hooks Development

## When to use this skill
Use this skill when registering WordPress actions or filters, whether via PHP attributes (declarative) or facades (imperative).
Use this skill when registering WordPress actions or filters, whether via PHP attributes (declarative) or facades (imperative), or when an action should run after the request instead of inside it.

## Attribute-Based Hooks (Recommended)

Expand Down Expand Up @@ -101,10 +101,73 @@ Action::remove('init', [MyHandler::class, 'boot']);
$callbacks = Action::callbacks('init');
```

## Asynchronous Actions

An action whose work is slow or calls a third party (CRM, ERP, email, indexing) can run after the request: it is queued when the hook fires, then run by a Laravel queue worker, Action Scheduler or WP-Cron. Only actions: a filter returns a value and cannot be deferred.

```php
use Pollora\Attributes\Action;
use Pollora\Attributes\Async;
use Pollora\Hook\Async\AsyncContext;

class SyncEventToCrm
{
#[Action('save_post_event', priority: 20)]
#[Async(tries: 3, unique: true, when: 'isRealSave', capture: 'captureStatus')]
public function handle(int $postId, CrmClient $crm, AsyncContext $context): void
{
// $crm comes from the container; $context->get('status') was recorded at trigger time
}

public function isRealSave(int $postId): bool
{
return ! wp_is_post_revision($postId) && ! wp_is_post_autosave($postId);
}

public function captureStatus(int $postId): array
{
return ['status' => get_post_status($postId)];
}
}

// Same with the facade
Action::add('woocommerce_order_status_completed', [OrderExporter::class, 'export'])
->async()->delay(60)->tries(3)->backoff([30, 300])->onQueue('integrations');
```

- Options (chained or named on `#[Async]`): `delay`, `via` (`queue`, `action-scheduler`, `wp-cron`, `sync`), `onQueue`, `unique` (`true` or seconds), `tries`, `backoff`, `asUser`, `capture`, `when`, `keepMissing`, `except` (hooks that stay synchronous).
- `#[Async]` on a class applies to every `#[Action]` method; a method's own `#[Async]` replaces it.
- An `#[Async]` that cannot be honoured (unknown hook in `except`, missing or non-public `capture`/`when`, invalid `tries`/`backoff`/`unique`, on a `#[Filter]`, without `#[Action]`) is logged and the action **runs synchronously**; `pollora:doctor` lists it.
- Arguments travel as values; `WP_Post`, `WP_Term`, `WP_User`, `WP_Comment` and Eloquent models travel by ID and are reloaded at execution. Any other object is refused. Capture an ID, never a secret: captured values sit in the database until execution.
- The handler runs without a current user unless `asUser`; site and locale are restored.
- Default driver `auto`: the Laravel queue only once `HOOKS_ASYNC_CONNECTION` is set, then Action Scheduler, then WP-Cron. `config/hooks.php`: `php artisan vendor:publish --tag=pollora-hooks`. `HOOKS_ASYNC_DRIVER=sync` runs everything at once in development.
- **Something must run the queue**: Pollora sets `DISABLE_WP_CRON`, so WP-Cron and Action Scheduler need a system cron requesting `wp-cron.php`; the queue needs `php artisan queue:work`.

Rules to respect in the handler:
- **At least once**: a retry or a double trigger can run it twice — make it safe to replay (check before sending, upsert).
- **No order** between asynchronous actions on the same hook, and a **variable delay**.
- **Prefer a class to a closure**: a class runs the deployed code, a closure the code as it was when queued.
- It cannot redirect, print, or change what the current request reads.

Testing:

```php
use Pollora\Hook\Async\Async;

Async::fake();
// ... fire the hook
Async::assertDispatched(SyncEventToCrm::class, fn ($payload, int $delay) => $payload->captured['status'] === 'publish');
Async::assertDispatchedTimes(SyncEventToCrm::class, 1);
Async::runDispatched(); // run what was recorded
```

`php artisan pollora:async:list` shows every asynchronous action, its driver and options, and where the default driver comes from.

## Generating Hook Classes

```bash
php artisan pollora:make:action MyAction
php artisan pollora:make:action SyncEventToCrm --hook=save_post_event --async # with #[Async]
php artisan pollora:make:filter MyFilter
```

Expand Down
Loading