diff --git a/CHANGELOG.md b/CHANGELOG.md index 1254c15..2788b38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/resources/boost/guidelines/core.blade.php b/resources/boost/guidelines/core.blade.php index b4f782a..9698a6f 100644 --- a/resources/boost/guidelines/core.blade.php +++ b/resources/boost/guidelines/core.blade.php @@ -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 {} @@ -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) @@ -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. diff --git a/resources/boost/guidelines/framework.blade.php b/resources/boost/guidelines/framework.blade.php index dc3ccf8..989d97e 100644 --- a/resources/boost/guidelines/framework.blade.php +++ b/resources/boost/guidelines/framework.blade.php @@ -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 diff --git a/resources/boost/skills/pollora-hooks/SKILL.md b/resources/boost/skills/pollora-hooks/SKILL.md index 8d91deb..051628c 100644 --- a/resources/boost/skills/pollora-hooks/SKILL.md +++ b/resources/boost/skills/pollora-hooks/SKILL.md @@ -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) @@ -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 ```