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
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@

All notable changes to `pollora/hook` are documented in this file.

## [Unreleased]
## [1.5.0] - 2026-10-09

### Added

- `AbstractHook::all()` lists every registration made through a hook instance, keyed by hook name, for debugging tools that mark Pollora's callbacks
- The `pollora/async/dispatched` action announces each queued handler with its payload and delay

## [1.4.1] - 2026-10-07

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ Choose the default with the `POLLORA_ASYNC_DRIVER` constant in `wp-config.php`,

**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 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.
**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. Each handler queued is announced through `pollora/async/dispatched` (the payload and the delay), for debugging tools. 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:

Expand Down
5 changes: 5 additions & 0 deletions src/Async/AsyncDispatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ public function dispatch(QueuedHandler $handler, array $arguments): void
);

$driver->dispatch($payload, $handler->options->delayInSeconds());

// Debugging tools list what a request queued; nothing else needs it
if (function_exists('do_action')) {
do_action('pollora/async/dispatched', $payload, $handler->options->delayInSeconds());
}
} catch (\Throwable $throwable) {
if ($uniqueKey !== null) {
UniqueLock::release($uniqueKey);
Expand Down
14 changes: 14 additions & 0 deletions src/Domain/Service/AbstractHook.php
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,20 @@ public function callbacks(string $hook): ?array
return $this->hooks[$hook];
}

/**
* Every registration made through this instance, by hook name.
*
* WordPress's own `$wp_filter` holds the same callbacks, but no longer says
* who registered them: a profiler reads this to mark the ones Pollora put
* there, and the class and method they resolved to.
*
* @return array<string, list<array{hook: string, callback: callable|string|array, priority: int, args: int, handler?: callable|string|array}>>
*/
public function all(): array
{
return $this->hooks;
}

/**
* Resolve the callback for the hook.
*
Expand Down
23 changes: 23 additions & 0 deletions tests/Unit/Async/FrameworkHooksTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -192,3 +192,26 @@ function failedAnnouncementsCount(): int
expect(RecordingHandler::$calls)->toBe([]);
});
});

describe('Announcing a dispatch', function (): void {
it('announces each queued handler with its payload and delay', function (): void {
$this->action->add('save_post', [RecordingHandler::class, 'handle'])->async()->delay(30);

wp_stub_fire('save_post', 7);

$announced = array_values(array_filter($GLOBALS['wp_actions_done'], fn (array $call): bool => $call['hook'] === 'pollora/async/dispatched'));

expect($announced)->toHaveCount(1)
->and($announced[0]['args'][0])->toBeInstanceOf(AsyncPayload::class)
->and($announced[0]['args'][0]->hook)->toBe('save_post')
->and($announced[0]['args'][1])->toBe(30);
});

it('announces nothing when the condition keeps the handler from being queued', function (): void {
$this->action->add('save_post', [RecordingHandler::class, 'handle'])->async()->when(fn (): bool => false);

wp_stub_fire('save_post', 7);

expect(array_filter($GLOBALS['wp_actions_done'], fn (array $call): bool => $call['hook'] === 'pollora/async/dispatched'))->toBe([]);
});
});
25 changes: 25 additions & 0 deletions tests/Unit/Domain/Service/AbstractHookTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -255,3 +255,28 @@ protected function removeHookEvent(string $hook, callable|string|array $callback
expect($this->hook->callbacks('save_post'))->toBeNull();
});
});

describe('Listing every registration', function (): void {
it('lists the registrations of every hook, keyed by hook name', function (): void {
$this->hook->add('init', 'boot_something', 5);
$this->hook->add(['wp_head', 'wp_footer'], 'print_something');

$all = $this->hook->all();

expect(array_keys($all))->toBe(['init', 'wp_head', 'wp_footer'])
->and($all['init'][0]['callback'])->toBe('boot_something')
->and($all['init'][0]['priority'])->toBe(5)
->and($all['wp_footer'][0]['hook'])->toBe('wp_footer');
});

it('lists nothing before any registration', function (): void {
expect($this->hook->all())->toBe([]);
});

it('forgets a registration once it is removed', function (): void {
$this->hook->add('init', 'boot_something');
$this->hook->remove('init', 'boot_something');

expect($this->hook->all()['init'] ?? [])->toBe([]);
});
});
Loading