From 4931cfed9b02c25e6fd9551b29760c1e1e70e30d Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 7 Oct 2026 22:35:32 +0200 Subject: [PATCH] docs: v13.35.2, asynchronous actions --- scripts/sync-docs.sh | 6 +- .../blog/pollora-13-35-2-async-actions.md | 55 +++ src/content/blog/roadmap-async-actions.md | 2 + src/content/docs/advanced/dashboard.md | 9 +- src/content/docs/compare.md | 2 +- src/content/docs/faq.md | 2 +- .../docs/getting-started/installation.md | 4 +- src/content/docs/hooks/actions-filters.md | 5 + src/content/docs/hooks/async-actions.md | 339 ++++++++++++++++++ src/content/docs/hooks/events-listeners.md | 2 +- .../docs/hooks/wordpress-events-reference.md | 2 +- src/content/docs/why.md | 2 +- src/pages/index.astro | 2 +- src/pages/press.astro | 4 +- 14 files changed, 420 insertions(+), 16 deletions(-) create mode 100644 src/content/blog/pollora-13-35-2-async-actions.md create mode 100644 src/content/docs/hooks/async-actions.md diff --git a/scripts/sync-docs.sh b/scripts/sync-docs.sh index 8f934d2..a2cad3b 100755 --- a/scripts/sync-docs.sh +++ b/scripts/sync-docs.sh @@ -34,6 +34,7 @@ rewrite_links() { -e 's|(meta\.md\(#[^)]*\)\?)|(/content/typed-meta/\1)|g' \ -e 's|(options\.md\(#[^)]*\)\?)|(/content/options/\1)|g' \ -e 's|(hooks\.md\(#[^)]*\)\?)|(/hooks/actions-filters/\1)|g' \ + -e 's|(async-actions\.md\(#[^)]*\)\?)|(/hooks/async-actions/\1)|g' \ -e 's|(events-listeners\.md\(#[^)]*\)\?)|(/hooks/events-listeners/\1)|g' \ -e 's|(wordpress-events-reference\.md\(#[^)]*\)\?)|(/hooks/wordpress-events-reference/\1)|g' \ -e 's|(theming\.md\(#[^)]*\)\?)|(/theming/theme-structure/\1)|g' \ @@ -119,8 +120,9 @@ sync_file "options.md" "content/options.md" "Options" "Read and write WordPress # Hooks & Events sync_file "hooks.md" "hooks/actions-filters.md" "Actions & Filters" "Register WordPress actions and filters in Pollora with PHP 8 attributes or the Action and Filter facades, including deferred callbacks and service access." 1 -sync_file "events-listeners.md" "hooks/events-listeners.md" "Events & Listeners" "Listen to WordPress actions and filters as typed Laravel events in Pollora, and use standard Laravel listeners and subscribers inside a WordPress site." 2 -sync_file "wordpress-events-reference.md" "hooks/wordpress-events-reference.md" "WordPress Events Reference" "Complete list of the Laravel events Pollora dispatches for WordPress core and supported plugins, with the class to listen to for each of them." 3 +sync_file "async-actions.md" "hooks/async-actions.md" "Asynchronous Actions" "Run a WordPress action after the request in Pollora with ->async() or #[Async]: queued to a Laravel worker, Action Scheduler or WP-Cron, with retries and tests." 2 +sync_file "events-listeners.md" "hooks/events-listeners.md" "Events & Listeners" "Listen to WordPress actions and filters as typed Laravel events in Pollora, and use standard Laravel listeners and subscribers inside a WordPress site." 3 +sync_file "wordpress-events-reference.md" "hooks/wordpress-events-reference.md" "WordPress Events Reference" "Complete list of the Laravel events Pollora dispatches for WordPress core and supported plugins, with the class to listen to for each of them." 4 # Theming sync_file "theming.md" "theming/theme-structure.md" "Theme Structure" "Create a Pollora theme: folder structure, theme.json, Blade templates and the template hierarchy, Vite and Tailwind CSS, localization and the login screen." 1 diff --git a/src/content/blog/pollora-13-35-2-async-actions.md b/src/content/blog/pollora-13-35-2-async-actions.md new file mode 100644 index 0000000..eb82374 --- /dev/null +++ b/src/content/blog/pollora-13-35-2-async-actions.md @@ -0,0 +1,55 @@ +--- +title: "Pollora 13.35.2: asynchronous actions" +description: Add ->async() to an action, or #[Async] next to #[Action], and its handler runs after the request, through a Laravel queue worker, Action Scheduler or WP-Cron. +date: 2026-10-07 +category: release +version: 13.35.2 +--- + +A handler attached to `save_post` or to a WooCommerce order runs inside the request that fires it. When it calls a CRM, an ERP or an email API, the editor waits for it, and when that service fails, the save fails with it. Pollora 13.35.2 lets that handler run a moment later, without changing how it is written. + +## One line + +```php title="app/Cms/Hooks/SyncEventToCrm.php" +use Pollora\Attributes\Action; +use Pollora\Attributes\Async; + +class SyncEventToCrm +{ + #[Action('save_post_event', priority: 20)] + #[Async(tries: 3, unique: true)] + public function handle(int $postId, CrmClient $crm): void + { + // Runs after the request; $crm comes from the container + } +} +``` + +Remove `#[Async]` and the action is synchronous again. The facade has the same option: `Action::add(...)->async()->delay(60)->tries(3)`. `php artisan pollora:make:action SyncEventToCrm --hook=save_post_event --async` generates the class. + +The hook arguments travel with the job. A `WP_Post`, a `WP_User` or an Eloquent model travels by ID and is reloaded when the handler runs. `capture()` records what would otherwise be gone by then, such as the post status at save time, and `when()` leaves out revisions and autosaves before anything is queued. + +## Three drivers, one setting + +The default driver, `auto`, uses the Laravel queue once `HOOKS_ASYNC_CONNECTION` names a connection a worker runs, then Action Scheduler when WooCommerce or another plugin bundles it, then WP-Cron. The queue is opt-in because a fresh project ships with a `database` queue and no worker: the default would otherwise queue jobs that never run. `HOOKS_ASYNC_DRIVER=sync` in a developer's `.env` runs everything at once. + +One thing to set up: Pollora disables WP-Cron on page loads (`DISABLE_WP_CRON`). With WP-Cron or Action Scheduler, a system cron must request `wp-cron.php` every minute; with the queue, a worker must run. + +## Knowing what runs + +Asynchronous work fails quietly, so the tooling looks for it: + +- `pollora:doctor` and Site Health flag an `#[Async]` that was ignored (and so runs synchronously), an unavailable driver, WP-Cron events an hour late, and jobs waiting fifteen minutes for a worker. +- `pollora:async:list` shows every asynchronous action, its driver and options, and where the default driver comes from. +- `Async::fake()` records what would be queued in a test, with `assertDispatched()` and `runDispatched()`. + +The mechanism itself lives in [`pollora/hook`](https://github.com/Pollora/hook) 1.4, which a plain WordPress plugin can use on its own. + +## Upgrade + +```bash +composer update pollora/framework --with-dependencies +php artisan pollora:doctor +``` + +An asynchronous handler can run twice and in any order: the [Asynchronous Actions](/hooks/async-actions/) page starts with the four rules to follow. diff --git a/src/content/blog/roadmap-async-actions.md b/src/content/blog/roadmap-async-actions.md index 64643d2..6b034fb 100644 --- a/src/content/blog/roadmap-async-actions.md +++ b/src/content/blog/roadmap-async-actions.md @@ -5,6 +5,8 @@ date: 2026-10-07 category: roadmap --- +> **Update, 7 October 2026: shipped in [Pollora 13.35.2](/blog/pollora-13-35-2-async-actions/).** One change from the design below: `auto` takes the Laravel queue only once `HOOKS_ASYNC_CONNECTION` names the connection a worker runs, since a fresh project ships a `database` queue and no worker. See [Asynchronous Actions](/hooks/async-actions/). + A WordPress hook runs inside the request that fires it. Attach a call to a CRM, an ERP or an email API to `save_post`, and every editor waits for that API each time they press *Save*. Attach it to a WooCommerce order, and the customer waits at checkout. If the third-party service fails, the save or the payment can fail with it. This post describes the next feature we are building. It is a design, not a release: names and options may still move, and this is the right moment to tell us what you would change. diff --git a/src/content/docs/advanced/dashboard.md b/src/content/docs/advanced/dashboard.md index 71d104f..8fd1f7e 100644 --- a/src/content/docs/advanced/dashboard.md +++ b/src/content/docs/advanced/dashboard.md @@ -50,7 +50,7 @@ php artisan pollora:status Example output: ``` -Pollora v13.35.1 (latest: v13.35.1) ✓ +Pollora v13.35.2 (latest: v13.35.2) ✓ PHP 8.4.12 | Laravel 13.35 | WordPress 7.1 @@ -93,8 +93,8 @@ This outputs the complete system information as a JSON object: ```json { "framework": { - "current": "13.35.1", - "latest": "13.35.1", + "current": "13.35.2", + "latest": "13.35.2", "update_available": false, "development": false }, @@ -129,7 +129,7 @@ This outputs the complete system information as a JSON object: When running a dev branch (`dev-develop`, `13.x-dev`, etc.), the command adapts its output: ``` -Pollora dev-develop (latest stable: v13.35.1) +Pollora dev-develop (latest stable: v13.35.2) ``` No misleading "update available" warning is shown for development installations: `development` is `true` in the JSON output, and Site Health reports a development build instead of comparing it with releases. @@ -170,6 +170,7 @@ It looks for failures that stay silent — the site renders, every command exits | Theme pattern cache | pattern files missing from WordPress's cached list | | Routes over block templates | `Route::wp()` routes answering in place of a block theme's templates | | Blocks in the legacy folder | blocks still in `resources/blocks`, which stops loading in v15 | +| Asynchronous actions | an ignored `#[Async]`, an unavailable driver, WP-Cron events overdue or queued jobs waiting with nothing to run them — see [Asynchronous Actions](/hooks/async-actions/#something-must-run-the-queue) | The builds, directories, placeholders and blocks are checked for the active theme, every Pollora plugin and every enabled Laravel module. diff --git a/src/content/docs/compare.md b/src/content/docs/compare.md index 87566ae..b07322e 100644 --- a/src/content/docs/compare.md +++ b/src/content/docs/compare.md @@ -28,7 +28,7 @@ Pollora's own WordPress models are built on **`pollora/colt`, a fork of Corcel** | **Testing** | Laravel's testing tools | Laravel's testing tools | Your setup | Pest, Playwright, linting in GitHub Actions | Your app's tests | | **AI tooling** | [Nectar](/nectar/overview/): guidelines, 9 agent skills and 10 MCP tools, on Laravel Boost | | | | | | **License and price** | MIT, free | MIT, free | MIT, free | One-time purchase: $80 for one site, $240 for unlimited sites | MIT, free | -| **Maturity** (October 2026) | v13.35.0 stable, a young project | v6.3.0, about 2.66M Packagist installs | v11.2.1, about 13.3k GitHub stars | Commercial product from Roots | v9.0.0, about 4.8k GitHub stars | +| **Maturity** (October 2026) | v13.35.2 stable, a young project | v6.3.0, about 2.66M Packagist installs | v11.2.1, about 13.3k GitHub stars | Commercial product from Roots | v9.0.0, about 4.8k GitHub stars | Sources: each project's GitHub repository, Packagist and product page (roots.io/acorn, roots.io/radicle), checked on 1 October 2026. diff --git a/src/content/docs/faq.md b/src/content/docs/faq.md index ed88313..f32e516 100644 --- a/src/content/docs/faq.md +++ b/src/content/docs/faq.md @@ -17,7 +17,7 @@ Yes. The framework (`pollora/framework`), the project skeleton (`pollora/pollora ## Is Pollora production ready? -Yes, the current release, v13.35.0, is a stable release, and `composer create-project pollora/pollora` installs it. Pollora has gone through several major versions: Nectar ships upgrade prompts from Pollora 12 to 13. Its community is still small compared with older WordPress tooling, which is worth weighing for long-lived projects. +Yes, the current release, v13.35.2, is a stable release, and `composer create-project pollora/pollora` installs it. Pollora has gone through several major versions: Nectar ships upgrade prompts from Pollora 12 to 13. Its community is still small compared with older WordPress tooling, which is worth weighing for long-lived projects. ## How do Pollora version numbers work? diff --git a/src/content/docs/getting-started/installation.md b/src/content/docs/getting-started/installation.md index 4cbe0a3..fa9ec6a 100644 --- a/src/content/docs/getting-started/installation.md +++ b/src/content/docs/getting-started/installation.md @@ -29,7 +29,7 @@ number in its `composer.lock` — which is what `create-project` installs from, whatever constraint the `composer.json` carries. So one version number describes an install completely. -The current release is **v13.35.1**, a stable release: a plain +The current release is **v13.35.2**, a stable release: a plain `composer create-project pollora/pollora` installs it. ## Installation Methods @@ -74,7 +74,7 @@ With `--ddev`, the CLI configures DDEV (WordPress project type, PHP 8.4, MariaDB | `--ver=VERSION` | Install a specific version or constraint (e.g. `13.35.0`, `^13.35`) | | `--stable` | Install the latest stable release instead of the latest pre-release | -`pollora new` installs the latest release **including pre-releases**: today that is the stable v13.35.1, and a beta published after it would be picked up. Pass `--stable` to never get a pre-release, or `--ver` to pin an exact version. +`pollora new` installs the latest release **including pre-releases**: today that is the stable v13.35.2, and a beta published after it would be picked up. Pass `--stable` to never get a pre-release, or `--ver` to pin an exact version. ### 2. Composer create-project diff --git a/src/content/docs/hooks/actions-filters.md b/src/content/docs/hooks/actions-filters.md index 5ca5b91..a8748af 100644 --- a/src/content/docs/hooks/actions-filters.md +++ b/src/content/docs/hooks/actions-filters.md @@ -224,6 +224,10 @@ $callbacks = Action::callbacks('init'); $filterCallbacks = Filter::callbacks('the_content'); ``` +## Asynchronous actions + +An action can run after the request instead of inside it: add `->async()` after `Action::add()`, or `#[Async]` next to `#[Action]`. See [Asynchronous Actions](/hooks/async-actions/). + ## Deferred callbacks Pollora aligns with WordPress behavior for callback registration: callbacks that are not yet callable at registration time (e.g., functions defined by plugins loaded later) are accepted without throwing an exception. Validation occurs at execution time, just like WordPress's native `add_action()`/`add_filter()`. @@ -259,5 +263,6 @@ class MyService { ## See also +- [Asynchronous Actions](/hooks/async-actions/) - [WordPress hooks with PHP 8 attributes](https://pollora.dev/guides/wordpress-hooks-php-attributes/) - [How Pollora runs WordPress inside Laravel](https://pollora.dev/guides/how-pollora-runs-wordpress-inside-laravel/) diff --git a/src/content/docs/hooks/async-actions.md b/src/content/docs/hooks/async-actions.md new file mode 100644 index 0000000..a4fe00e --- /dev/null +++ b/src/content/docs/hooks/async-actions.md @@ -0,0 +1,339 @@ +--- +title: Asynchronous Actions +description: "Run a WordPress action after the request in Pollora with ->async() or #[Async]: queued to a Laravel worker, Action Scheduler or WP-Cron, with retries and tests." +editUrl: https://github.com/Pollora/documentation/edit/main/async-actions.md +sidebar: + order: 2 +--- + + +An action becomes asynchronous by adding `->async()` to its registration, or `#[Async]` next to `#[Action]`. When the hook fires, the handler does not run inside the request: it is queued, then run a moment later by WP-Cron, Action Scheduler or a Laravel queue worker. A slow call to a CRM, an ERP or an email API no longer slows down saving a post or paying for an order, and its failure no longer makes that operation fail. + +- [Four rules first](#four-rules-first) +- [Making an action asynchronous](#making-an-action-asynchronous) + - [With the facade](#with-the-facade) + - [With attributes](#with-attributes) + - [Generate with Artisan](#generate-with-artisan) +- [Options](#options) +- [What the handler receives](#what-the-handler-receives) + - [Hook arguments](#hook-arguments) + - [The context](#the-context) + - [Capturing values at trigger time](#capturing-values-at-trigger-time) + - [Skipping the queue](#skipping-the-queue) +- [Drivers](#drivers) + - [The default driver](#the-default-driver) + - [Something must run the queue](#something-must-run-the-queue) + - [Configuration](#configuration) +- [Retries and failures](#retries-and-failures) +- [Closures](#closures) +- [Testing](#testing) +- [Inspecting](#inspecting) +- [Without the framework](#without-the-framework) + +## Four rules first + +The handler is written exactly like a synchronous one, but its guarantees change: + +1. **At least once, not exactly once.** A retry after a failure, or a hook fired twice, can run the handler twice. It must be safe to replay: check before you send, upsert rather than insert. +2. **No guaranteed order.** Two asynchronous actions on the same hook may run in any order. +3. **A variable delay.** A few seconds with a queue worker; with WP-Cron, until something requests `wp-cron.php` (see [Something must run the queue](#something-must-run-the-queue)). +4. **Prefer a class to a closure.** A class runs in the version currently deployed. A closure runs as it was when it was queued. + +An asynchronous handler cannot change the current request either: it cannot redirect, print, or alter what the rest of the request reads. Only actions can be asynchronous — a filter returns a value to its caller. + +## Making an action asynchronous + +### With the facade + +```php +use Pollora\Support\Facades\Action; + +Action::add('save_post_event', [SyncEventToCrm::class, 'handle'], 10, 2)->async(); + +// Options chain after async(), in the style of Laravel jobs +Action::add('woocommerce_order_status_completed', [OrderExporter::class, 'export']) + ->async() + ->delay(60) + ->unique() + ->tries(3); +``` + +`async()` applies to every hook of the `add()` call just before it. `except` keeps some of them synchronous: + +```php +Action::add(['save_post_event', 'save_post_page'], [Indexer::class, 'index']) + ->async(except: 'save_post_page'); +``` + +A hook in `except` that the registration does not have is an error, to catch typos. + +### With attributes + +`#[Async]` goes next to `#[Action]`: `#[Action]` keeps the hook and the priority, `#[Async]` carries the options. Removing the line makes the action synchronous again. + +```php +use Pollora\Attributes\Action; +use Pollora\Attributes\Async; + +class SyncEventToCrm +{ + #[Action('save_post_event', priority: 20)] + #[Async(delay: 60, tries: 3, unique: true)] + public function handle(int $postId): void + { + // Runs after the request, through the default driver + } +} +``` + +- **Several hooks.** `#[Action]` is repeatable; one `#[Async]` applies to all of them, except those in `except`: `#[Async(except: ['save_post_page'])]`. +- **On a class.** `#[Async]` on the class makes every `#[Action]` method asynchronous. A method's own `#[Async]` replaces the class options for that method. + +A declaration that cannot be honoured — a hook in `except` the method does not declare, a `capture` or `when` method that is missing or not public, invalid attempts, backoff or lock — is logged and the action **runs synchronously**: the work still happens. So is `#[Async]` on a `#[Filter]` or on a method without `#[Action]`. `pollora:doctor` lists them. Run `php artisan discovery:clear` after adding attributes during development. + +### Generate with Artisan + +```bash +php artisan pollora:make:action SyncEventToCrm --hook=save_post_event --async +``` + +The generated method carries `#[Async]`. Run the command again on the same class with another `--hook` to add a method to it. + +## Options + +Every option exists as a chained method after `async()` and as a named parameter of `#[Async]`. + +| Option | Default | Role | +|---|---|---| +| `delay` | `0` | Minimum delay before execution, in seconds or as a `DateInterval` | +| `via` | the default driver | Driver for this action: `queue`, `action-scheduler`, `wp-cron`, `sync` | +| `onQueue` | `hooks.async.queue.queue` | Queue name. The group with Action Scheduler; ignored by WP-Cron | +| `unique` | off | Merges identical triggers (same hook, handler and arguments) until the first one starts. `unique()` locks for a day; `unique(for: 600)` or `#[Async(unique: 600)]` sets the duration in seconds | +| `tries` | `hooks.async.tries` (1) | Attempts when the handler throws | +| `backoff` | `hooks.async.backoff` ([10, 60, 300]) | Seconds before each retry; the last value repeats | +| `asUser` | `hooks.async.as_user` (false) | Runs the handler as the user who fired the hook | +| `capture` | none | Records values at trigger time, see [below](#capturing-values-at-trigger-time) | +| `when` | none | Decides at trigger time whether to queue at all | +| `keepMissing` | off | Runs the handler with `null` in place of an object deleted in the meantime | +| `except` | none | Hooks of the registration that stay synchronous | + +With the attribute, `capture` and `when` name a public method of the same class. + +## What the handler receives + +### Hook arguments + +The handler receives the hook arguments, as it would synchronously. They are stored when the hook fires; objects are stored as a reference and reloaded at execution, like Laravel's `SerializesModels`. + +| Argument | Stored as | At execution | +|---|---|---| +| Scalar, array of scalars | Its value | Identical | +| `WP_Post`, `WP_Term`, `WP_User`, `WP_Comment` | Type and ID | Reloaded, in its state at that moment | +| Eloquent model | Class and key | Reloaded | +| Enum, date | Value, ISO 8601 date | Rebuilt | +| `JsonSerializable` object | Array | Array | +| Any other object | Refused | — | + +An argument that cannot travel throws an exception in debug mode. In production the action runs synchronously and the incident goes to the Laravel log. When a referenced object was deleted before execution, the handler is skipped, unless `keepMissing` is set. + +Other parameters typed with a class are resolved from the container, as for a synchronous action: + +```php +public function handle(int $postId, CrmClient $crm): void +``` + +### The context + +A parameter typed `AsyncContext`, anywhere in the signature, receives the context of the trigger: + +```php +use Pollora\Hook\Async\AsyncContext; + +public function handle(int $postId, AsyncContext $context): void +{ + $context->userId; // user who fired the hook (0 without one) + $context->blogId; // site, on a multisite + $context->locale; + $context->hook; + $context->dispatchedAt; // DateTimeImmutable + $context->attempt; // from 1 + $context->get('status'); // a value recorded by capture() +} +``` + +The site and the locale are restored before the handler runs. The user is not, unless `asUser` is set: without a user, a capability check fails, which is the safe behaviour. + +### Capturing values at trigger time + +Between the trigger and the execution, things change: there is no current user or request data any more, and the post may have been edited. `capture()` runs at trigger time, in the original request, with the hook arguments, and returns the values to keep: + +```php +Action::add('save_post_event', [SyncEventToCrm::class, 'handle'], 10, 3) + ->async() + ->capture(fn (int $postId, WP_Post $post, bool $update): array => [ + 'status' => $post->post_status, // the state at save time + 'source' => $_POST['acme_source'] ?? 'admin', + ]); +``` + +With the attribute, `capture` names a method of the class. A class passed to `Action::add()` that has a public `capture()` method uses it without being told. + +```php +#[Action('transition_post_status', priority: 10)] +#[Async(capture: 'captureEditor')] +public function notify(string $new, string $old, WP_Post $post, AsyncContext $context): void +{ + Mail::to($context->get('editorEmail'))->send(new StatusChanged($post, $old, $new)); +} + +public function captureEditor(string $new, string $old, WP_Post $post): array +{ + return ['editorEmail' => wp_get_current_user()->user_email]; +} +``` + +Captured values follow the same rules as arguments, and they stay in the database, in clear, until execution: capture an ID rather than personal data, never a secret. + +### Skipping the queue + +`when()` receives the hook arguments and returns a boolean. On `false`, nothing is queued — the place to leave out revisions and autosaves, and the first way to keep a busy hook from flooding the queue (`unique` is the second): + +```php +Action::add('save_post', [Indexer::class, 'index']) + ->async() + ->when(fn (int $postId): bool => ! wp_is_post_revision($postId) && ! wp_is_post_autosave($postId)); +``` + +## Drivers + +| Driver | Queues with | Runs | Needs | +|---|---|---|---| +| `queue` | A Laravel `RunAsyncAction` job | In a queue worker | A worker on the connection | +| `action-scheduler` | `as_enqueue_async_action()` | In Action Scheduler's runner; history in Tools › Scheduled Actions | A plugin bundling it (WooCommerce…), and WP-Cron running | +| `wp-cron` | `wp_schedule_single_event()` | On the next WP-Cron run | WP-Cron running | +| `sync` | Nothing | At once, in the request | — | + +Action Scheduler is never required: the driver is used when a plugin that bundles it is active. + +### The default driver + +The default is `auto`, resolved each time a hook fires: the Laravel queue first **only if `HOOKS_ASYNC_CONNECTION` names a connection**, then Action Scheduler when it is available, then WP-Cron. + +The queue is opt-in because the skeleton ships `QUEUE_CONNECTION=database`: with the queue first by default, every asynchronous action of a fresh project would wait for a worker nobody started. A `sync` or `null` connection is always skipped. + +`via()` forces a driver for one action. When it is not available as the hook fires, an exception is thrown in debug mode; in production the default driver takes over and the incident is logged. + +### Something must run the queue + +Nothing tells you when queued work never runs. Check that the driver you use has something behind it: + +- **WP-Cron and Action Scheduler.** Pollora sets `DISABLE_WP_CRON`, so page loads never run WP-Cron. Add a system cron that requests it every minute: + + ```bash + * * * * * curl -s https://example.com/cms/wp-cron.php > /dev/null + # or, with WP-CLI + * * * * * cd /path/to/project && wp cron event run --due-now + ``` + + With `wordpress.use_laravel_scheduler`, WP-Cron events are Laravel jobs: a queue worker runs them instead. +- **The queue.** Keep a worker running on the connection, under Supervisor or systemd: + + ```bash + php artisan queue:work database + ``` + +`pollora:doctor` warns when WP-Cron events are an hour late or queued jobs have waited fifteen minutes. + +### Configuration + +```bash +php artisan vendor:publish --tag=pollora-hooks +``` + +publishes `config/hooks.php`: + +```php +return [ + 'async' => [ + // auto, queue, action-scheduler, wp-cron, sync + 'default' => env('HOOKS_ASYNC_DRIVER', 'auto'), + + 'tries' => 1, + 'backoff' => [10, 60, 300], + 'as_user' => false, + + 'queue' => [ + // The connection a worker runs. null: the default connection, and auto leaves the queue out + 'connection' => env('HOOKS_ASYNC_CONNECTION'), + 'queue' => env('HOOKS_ASYNC_QUEUE', 'default'), + ], + ], +]; +``` + +`HOOKS_ASYNC_DRIVER=sync` in a developer's `.env` runs every handler at once, without touching the code. + +## Retries and failures + +When the handler throws, it is queued again after the `backoff` delay, until `tries` is reached. With the queue, each retry is a new job and the last failure lands in the failed jobs table (`php artisan queue:failed`). Every final failure goes to the Laravel log and fires the `pollora/async/failed` action with the payload and the exception: + +```php +#[Action('pollora/async/failed')] +public function alert(AsyncPayload $payload, Throwable $exception): void +``` + +A handler whose class or method no longer exists fails with an explicit message and is not retried. A handler attached to `save_post` that calls `wp_update_post()` does not queue itself again while it runs. + +## Closures + +A closure can be asynchronous. It is serialized with `laravel/serializable-closure` and signed with the application key; its signature is checked before it runs. Prefer a class anyway: a closure runs as it was when queued, and changing `APP_KEY` makes the closures still in the queue fail. An anonymous class cannot be queued. + +## Testing + +`Async::fake()` records what would be queued, without running it: + +```php +use Pollora\Hook\Async\Async; +use Pollora\Hook\Async\AsyncPayload; + +Async::fake(); + +wp_update_post(['ID' => $event->ID, 'post_title' => 'New title']); + +Async::assertDispatched(SyncEventToCrm::class, fn (AsyncPayload $payload, int $delay): bool => + $payload->captured['status'] === 'publish' && $delay === 60 +); +Async::assertDispatchedTimes(SyncEventToCrm::class, 1); +Async::assertNotDispatched(Indexer::class); +``` + +`Async::runDispatched()` then runs what was recorded. Without `fake()`, `HOOKS_ASYNC_DRIVER=sync` runs everything at once, which suits end-to-end tests. + +## Inspecting + +```bash +php artisan pollora:async:list +php artisan pollora:async:list --json +``` + +lists every asynchronous action (hook, priority, handler, driver, delay, attempts, lock, queue, user), which drivers are available, and the default driver with where it comes from. + +`pollora:doctor` and Tools › Site Health (check *Asynchronous actions*) flag what fails silently: an ignored `#[Async]`, an unavailable driver, a `HOOKS_ASYNC_CONNECTION` naming a `sync` or undefined connection, overdue WP-Cron events, jobs waiting for a worker, and payloads or locks the daily `pollora/async/recover` task left behind. + +## Without the framework + +The mechanism lives in [`pollora/hook`](https://github.com/Pollora/hook), which a plain WordPress plugin or theme can use on its own, with the same API: + +```php +use Pollora\Hook\Action; + +Action::add('save_post', [SyncToCrm::class, 'handle'])->async(); +``` + +There, `auto` picks Action Scheduler then WP-Cron, set the driver with the `POLLORA_ASYNC_DRIVER` constant in `wp-config.php` or the `pollora/hook/async_driver` filter, and closures need `laravel/serializable-closure` installed. The `queue` driver, `#[Async]` and `config/hooks.php` come with the framework. + +## See also + +- [Hooks](/hooks/actions-filters/) — `#[Action]`, `#[Filter]` and the facades +- [Scheduling](/advanced/scheduling/) — recurring tasks with `#[Schedule]` +- [Dashboard & Status](/advanced/dashboard/) — `pollora:doctor` diff --git a/src/content/docs/hooks/events-listeners.md b/src/content/docs/hooks/events-listeners.md index 9a28780..140f457 100644 --- a/src/content/docs/hooks/events-listeners.md +++ b/src/content/docs/hooks/events-listeners.md @@ -3,7 +3,7 @@ title: Events & Listeners description: "Listen to WordPress actions and filters as typed Laravel events in Pollora, and use standard Laravel listeners and subscribers inside a WordPress site." editUrl: https://github.com/Pollora/documentation/edit/main/events-listeners.md sidebar: - order: 2 + order: 3 --- diff --git a/src/content/docs/hooks/wordpress-events-reference.md b/src/content/docs/hooks/wordpress-events-reference.md index 0d51820..a9bf1b4 100644 --- a/src/content/docs/hooks/wordpress-events-reference.md +++ b/src/content/docs/hooks/wordpress-events-reference.md @@ -3,7 +3,7 @@ title: WordPress Events Reference description: "Complete list of the Laravel events Pollora dispatches for WordPress core and supported plugins, with the class to listen to for each of them." editUrl: https://github.com/Pollora/documentation/edit/main/wordpress-events-reference.md sidebar: - order: 3 + order: 4 --- diff --git a/src/content/docs/why.md b/src/content/docs/why.md index eb91772..634ede6 100644 --- a/src/content/docs/why.md +++ b/src/content/docs/why.md @@ -112,6 +112,6 @@ class ContentHooks **Two frameworks to keep updated.** This is the most common concern, and Pollora's versioning is designed around it. Version numbers follow the Laravel release Pollora is built on: 13.34 means Laravel 13.34. The skeleton and the framework are tagged together, and a skeleton tag pins the framework tag of the same number, so one version number describes an install. WordPress core is a Composer dependency like the rest. For major upgrades, [Nectar](/nectar/overview/) ships upgrade prompts that walk AI coding agents through the steps. -**A smaller community.** Pollora is maintained by AmphiBee and its community is much smaller than that of the Roots projects. The current release, v13.35.0, is stable, but you will find fewer third-party tutorials. +**A smaller community.** Pollora is maintained by AmphiBee and its community is much smaller than that of the Roots projects. The current release, v13.35.2, is stable, but you will find fewer third-party tutorials. If those trade-offs work for you, [install Pollora](/getting-started/installation/) and try it, or read the [FAQ](/faq/). diff --git a/src/pages/index.astro b/src/pages/index.astro index 79de915..bd3a350 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -18,7 +18,7 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; // Current release, shown in the hero badge and in the structured data -const version = '13.35.1'; +const version = '13.35.2'; const siteUrl = 'https://pollora.dev/'; const ogImage = new URL('/og-image.png', siteUrl).href; diff --git a/src/pages/press.astro b/src/pages/press.astro index ed8663d..324d0da 100644 --- a/src/pages/press.astro +++ b/src/pages/press.astro @@ -13,7 +13,7 @@ const embedCode = `