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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ This repository is the source of the documentation of **Pollora, the Laravel fra
## WordPress Integration

- [Hooks](hooks.md) — `#[Action]` / `#[Filter]` attributes, hookable classes
- [Asynchronous Actions](async-actions.md) — `->async()` and `#[Async]`: run an action after the request, through WP-Cron, Action Scheduler or a Laravel queue
- [Authentication](auth.md) — WordPress authentication guard
- [Roles & Capabilities](roles.md) — `$user->can()`, `@can`, `#[Role]`, `#[ModifyRole]`, post types with their own capabilities
- [WP REST API](wp-rest-api.md) — `#[WpRestRoute]` attribute, custom REST endpoints
Expand Down
332 changes: 332 additions & 0 deletions async-actions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,332 @@
# Asynchronous Actions

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.md) — `#[Action]`, `#[Filter]` and the facades
- [Scheduling](schedule-events.md) — recurring tasks with `#[Schedule]`
- [Dashboard & Status](dashboard.md) — `pollora:doctor`
9 changes: 5 additions & 4 deletions dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,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

Expand Down Expand Up @@ -86,8 +86,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
},
Expand Down Expand Up @@ -122,7 +122,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.
Expand Down Expand Up @@ -163,6 +163,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](async-actions.md#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.

Expand Down
4 changes: 2 additions & 2 deletions getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,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
Expand Down Expand Up @@ -67,7 +67,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

Expand Down
Loading