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: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ All notable changes to `pollora/hook` are documented in this file.

## [Unreleased]

## [1.3.0] - 2026-10-07

### Changed

- **The default driver is now `auto`**: Action Scheduler when a plugin bundling it is active and initialised, WP-Cron otherwise, resolved on each dispatch. A site with WooCommerce therefore queues through Action Scheduler after upgrading; payloads already queued through WP-Cron still run. Set `POLLORA_ASYNC_DRIVER` to `wp-cron` to keep the previous behaviour.
Expand Down Expand Up @@ -50,5 +52,6 @@ All notable changes to `pollora/hook` are documented in this file.
- A `[ClassName::class, 'method']` callback naming an instance method is now instantiated at registration, through the callback resolver when one is set, as a class name is. WordPress used to receive it as a static call and threw a `TypeError` when the hook fired. Static methods and classes not loaded yet are unchanged.
- `remove()` and `exists()` accept that same `[ClassName::class, 'method']` form and find the instance it was registered as. `exists()` now accepts a non-callable array or string as its callback.

[Unreleased]: https://github.com/Pollora/hook/compare/v1.2.0...HEAD
[Unreleased]: https://github.com/Pollora/hook/compare/v1.3.0...HEAD
[1.3.0]: https://github.com/Pollora/hook/compare/v1.2.0...v1.3.0
[1.2.0]: https://github.com/Pollora/hook/compare/v1.1.1...v1.2.0
55 changes: 48 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ $action->add('wp_loaded', MyInitializer::class);

## Asynchronous actions

Add `async()` after `add()` and the handler no longer runs inside the request that fires the hook: it is queued, then run later by WP-Cron.
Add `async()` after `add()` and the handler no longer runs inside the request that fires the hook: it is queued, then run later by Action Scheduler or WP-Cron.

```php
use Pollora\Hook\Action;
Expand All @@ -100,10 +100,10 @@ Action::add('save_post_event', [CrmSync::class, 'push'])->async();

An asynchronous handler changes guarantees. Four rules to keep in mind:

- **At least once, not exactly once.** A double trigger can run the handler twice: it must be safe to replay.
- **At least once, not exactly once.** A retry or a double trigger can run the handler twice: it must be safe to replay.
- **No guaranteed order.** Two asynchronous actions on the same hook may run in any order.
- **A variable delay.** With WP-Cron and no system cron, the handler waits for the next visit to the site.
- **A class or a named function, not a closure.** The handler travels by name and runs in its current version; closures are rejected.
- **Prefer a class to a closure.** A class or a named function travels by name and runs in its current version; a closure runs as it was when queued, and needs `laravel/serializable-closure`.

The handler is written as a synchronous one. It receives the hook arguments and, if it declares a parameter of that type, an `AsyncContext`:

Expand All @@ -114,26 +114,67 @@ final class CrmSync
{
public function push(int $postId, WP_Post $post, AsyncContext $context): void
{
$context->userId; // user who fired the hook
$context->dispatchedAt; // trigger date
$context->userId; // user who fired the hook
$context->dispatchedAt; // trigger date
$context->attempt; // attempt number
$context->get('status'); // value recorded by capture()
}
}
```

**Arguments.** Scalars and arrays keep their value. `WP_Post`, `WP_Term`, `WP_User` and `WP_Comment` travel as a reference and are reloaded, in their state at execution time; if one was deleted in the meantime, the handler is dropped (`keepMissing()` runs it with `null` instead). Enums and dates are rebuilt, a `JsonSerializable` object becomes its array. Any other object is refused.

**Capturing values at trigger time.** At execution there is no request any more, and the content may have changed. `capture()` runs in the original request, with the hook arguments, and its values are read back through `$context->get()`. A handler class's public `capture()` method is used when `capture()` is not called. Capture an ID rather than personal data or a secret: values wait in the database.

```php
Action::add('save_post_event', [CrmSync::class, 'push'])
->async()
->when(fn (int $postId) => ! wp_is_post_revision($postId) && ! wp_is_post_autosave($postId))
->capture(fn (int $postId, WP_Post $post) => ['status' => $post->post_status])
->unique()
->tries(3);
```

**Options**, chained after `async()`:

| Method | Effect |
|---|---|
| `delay(60)` | Minimum delay, in seconds or as a `DateInterval` |
| `when(fn (...) => bool)` | Queue only when the condition, given the hook arguments, returns `true` |
| `capture(fn (...) => [...])` | Record values at trigger time |
| `unique()` | Merge identical triggers (same hook, handler and arguments) until the first one runs; `unique(for: 3600)` sets how long the lock lasts if never released (a day by default) |
| `tries(3)` | Attempts when the handler throws; `backoff([10, 60, 300])` sets the seconds before each retry (the default) |
| `asUser()` | Run as the user who fired the hook, with that user's capabilities. Without it, there is no current user |
| `via('sync')` | Driver for this registration |
| `onQueue('integrations')` | Queue name: the Action Scheduler group |
| `keepMissing()` | Run with `null` in place of a deleted object |
| `except('save_post_page')` | Keep some hooks of the same `add()` synchronous; also `async(except: …)` |

**Drivers.** `wp-cron` is the default: the payload is stored in an option that is not autoloaded and the event carries only its identifier. A daily task schedules again the payloads whose event was lost. `sync` runs the handler right away, which suits development and end-to-end tests. Choose the default with the `POLLORA_ASYNC_DRIVER` constant in `wp-config.php`, or the `pollora/hook/async_driver` filter. More drivers register through `Async::extend()`.
**Drivers.** The default, `auto`, picks Action Scheduler when a plugin bundling it (WooCommerce, for example) is active and initialised, WP-Cron otherwise.

- `action-scheduler`: the payload travels in the action, the queue name becomes its group (`pollora` by default), and history shows in Tools › Scheduled Actions.
- `wp-cron`: the payload is stored in an option that is not autoloaded and the event carries only its identifier. A daily task schedules again the payloads whose event was lost.
- `sync`: runs the handler right away, which suits development and end-to-end tests.

Choose the default with the `POLLORA_ASYNC_DRIVER` constant in `wp-config.php`, or the `pollora/hook/async_driver` filter. More drivers register through `Async::extend()`.

**Closures.** With `laravel/serializable-closure` installed, a closure can be queued. It is serialized and signed with a key derived from the WordPress salts, and the signature is checked before anything is unserialized. Declare it `static` and let it use IDs rather than objects.

**Failures.** At execution, the original site and locale are restored, and a handler that fires its own hook does not queue itself again. A failing handler is reported and announced through the `pollora/async/failed` action. When an action cannot be queued, `WP_DEBUG` throws; otherwise the incident goes to the PHP error log (or `Async::reportUsing()`) and the handler runs in place, so the work always happens.
**Failures.** At execution, the original site and locale are restored, and a handler that fires its own hook does not queue itself again. A handler that throws is retried while it has attempts left; the last failure is reported and announced through the `pollora/async/failed` action. When an action cannot be queued, `WP_DEBUG` throws; otherwise the incident goes to the PHP error log (or `Async::reportUsing()`) and the handler runs in place, so the work always happens.

**Testing.** `Async::fake()` records queued handlers instead of queuing them:

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

Async::fake();

wp_update_post(['ID' => $postId, 'post_title' => 'New title']);

Async::assertDispatched(CrmSync::class, fn (AsyncPayload $payload) => $payload->captured['status'] === 'publish');
Async::assertDispatchedTimes(CrmSync::class, 1);
```

## Documentation

Expand Down
72 changes: 72 additions & 0 deletions tests/WordPress/AsyncWpCronTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Pollora\Hook\Async\Async;
use Pollora\Hook\Async\AsyncPayload;
use Pollora\Hook\Async\Drivers\WpCronDriver;
use Pollora\Hook\Async\UniqueLock;

/*
* Asynchronous actions in a real WordPress, without the framework. Queued
Expand All @@ -20,7 +21,13 @@
"DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
$wpdb->esc_like(WpCronDriver::OPTION_PREFIX).'%',
));
$wpdb->query($wpdb->prepare(
"DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
$wpdb->esc_like(UniqueLock::OPTION_PREFIX).'%',
));
delete_option(PolloraHookFixture::RUNS);
delete_option(PolloraHookFixture::FAILURES);
unset($_POST['acme_source']);
wp_unschedule_hook(Async::HOOK);
wp_unschedule_hook(WpCronDriver::RECOVERY_HOOK);
wp_set_current_user(0);
Expand Down Expand Up @@ -119,6 +126,7 @@ function fixtureRuns(): array
'userId' => 1,
'attempt' => 1,
'cron' => true,
'currentUser' => 0,
]])
->and(scheduledExecutions())->toBe([])
->and(get_option(WpCronDriver::OPTION_PREFIX.$id))->toBeFalse();
Expand Down Expand Up @@ -238,3 +246,67 @@ function fixtureRuns(): array
Async::flush();
}
});

it('captures values at trigger time, request data included, beside the reloaded post', function (): void {
$_POST['acme_source'] = 'API';

do_action('pollora_fixture_captured', $this->postId, get_post($this->postId));
unset($_POST['acme_source']);
wp_update_post(['ID' => $this->postId, 'post_status' => 'draft']);

runWpCron();

expect(fixtureRuns())->toBe([['key' => 'captured', 'statusThen' => 'publish', 'statusNow' => 'draft', 'source' => 'api']]);
});

it('queues only when the condition holds', function (): void {
$revisionId = wp_save_post_revision($this->postId) ?: _wp_put_post_revision(get_post($this->postId));

do_action('pollora_fixture_when', $revisionId);
expect(scheduledExecutions())->toBe([]);

do_action('pollora_fixture_when', $this->postId);
expect(scheduledExecutions())->toHaveCount(1);
});

it('merges identical triggers until the first one runs', function (): void {
global $wpdb;

do_action('pollora_fixture_unique', 7);
do_action('pollora_fixture_unique', 7);

$locks = $wpdb->get_results($wpdb->prepare(
"SELECT autoload FROM {$wpdb->options} WHERE option_name LIKE %s",
$wpdb->esc_like(UniqueLock::OPTION_PREFIX).'%',
));
expect(scheduledExecutions())->toHaveCount(1)
->and($locks)->toHaveCount(1)
->and($locks[0]->autoload)->toBeIn(['off', 'no']);

runWpCron();
do_action('pollora_fixture_unique', 7);

expect(fixtureRuns())->toBe([['key' => 'counted', 'id' => 7]])
->and(scheduledExecutions())->toHaveCount(1);
});

it('runs as the user who fired the hook with asUser(), with that user\'s capabilities', function (): void {
wp_set_current_user(1);
do_action('pollora_fixture_user', 1);
wp_set_current_user(0);

runWpCron();

expect(fixtureRuns())->toBe([['key' => 'user', 'currentUser' => 1, 'canEditPosts' => true, 'triggeredBy' => 1]]);
});

it('retries a failing handler through WP-Cron', function (): void {
update_option(PolloraHookFixture::FAILURES, 1, false);

do_action('pollora_fixture_retried', 1);
runWpCron();
runWpCron();

expect(array_column(fixtureRuns(), 'attempt'))->toBe([1, 2])
->and(scheduledExecutions())->toBe([]);
});
36 changes: 36 additions & 0 deletions tests/WordPress/Fixtures/async-actions.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,33 @@ public function event(int $postId, WP_Post $post, AsyncContext $context): void
'userId' => $context->userId,
'attempt' => $context->attempt,
'cron' => defined('DOING_CRON') && DOING_CRON,
'currentUser' => get_current_user_id(),
]);
}

public function captured(int $postId, WP_Post $post, AsyncContext $context): void
{
self::record('captured', [
'statusThen' => $context->get('statusThen'),
'statusNow' => $post->post_status,
'source' => $context->get('source'),
]);
}

public function user(int $id, AsyncContext $context): void
{
self::record('user', [
'currentUser' => get_current_user_id(),
'canEditPosts' => current_user_can('edit_posts'),
'triggeredBy' => $context->userId,
]);
}

public function counted(int $id): void
{
self::record('counted', ['id' => $id]);
}

public function large(int $id, AsyncContext $context): void
{
self::record('large', ['id' => $id, 'length' => strlen((string) $context->get('report'))]);
Expand Down Expand Up @@ -84,6 +108,18 @@ public function loop(int $id): void
->capture(fn (int $id): array => ['report' => str_repeat('x', 9000)]);
Action::add('pollora_fixture_flaky', [PolloraHookFixture::class, 'flaky'])->async()->tries(2)->backoff(0);

// Phase 2 options
Action::add('pollora_fixture_captured', [PolloraHookFixture::class, 'captured'])->async()
->capture(fn (int $postId, WP_Post $post): array => [
'statusThen' => $post->post_status,
'source' => isset($_POST['acme_source']) ? sanitize_key(wp_unslash($_POST['acme_source'])) : 'admin',
]);
Action::add('pollora_fixture_when', [PolloraHookFixture::class, 'counted'])->async()
->when(fn (int $postId): bool => ! wp_is_post_revision($postId));
Action::add('pollora_fixture_unique', [PolloraHookFixture::class, 'counted'])->async()->unique();
Action::add('pollora_fixture_user', [PolloraHookFixture::class, 'user'])->async()->asUser();
Action::add('pollora_fixture_retried', [PolloraHookFixture::class, 'flaky'])->async()->tries(2)->backoff(0);

// A closure, signed with a key derived from the WordPress salts
$closureLabel = 'closure';
Action::add('pollora_fixture_closure', static function (int $id, AsyncContext $context) use ($closureLabel): void {
Expand Down
Loading