diff --git a/CHANGELOG.md b/CHANGELOG.md
index e14a92d3..a9c3bac5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,25 @@ All notable changes to the Pollora framework will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-## [Unreleased](https://github.com/Pollora/framework/compare/v13.35.1...develop)
+## [Unreleased](https://github.com/Pollora/framework/compare/v13.35.2...develop)
+
+## [v13.35.2](https://github.com/Pollora/framework/compare/v13.35.1...v13.35.2) - 2026-10-07
+
+### Added
+- Asynchronous actions in the framework (pollora/hook 1.4): `Action::add(...)->async()` can queue handlers as Laravel jobs. The `queue` driver dispatches a `RunAsyncAction` job on `hooks.async.queue.connection` (the default connection when null) and on the action's `onQueue()` or `hooks.async.queue.queue`. `auto`, the default, tries the Laravel queue first once `HOOKS_ASYNC_CONNECTION` names the connection a worker runs (without a worker, jobs would never run, so the queue is opt-in), then Action Scheduler, then WP-Cron; a `sync` or `null` connection is always skipped. `via('queue')` and `HOOKS_ASYNC_DRIVER=queue` use the default connection. The job is tried once: retries are new jobs queued after the backoff, and the last failure lands in the failed jobs table. It also clears WordPress's in-memory cache before each handler, since a worker lives across jobs
+- `config/hooks.php`, published with `php artisan vendor:publish --tag=pollora-hooks`: default driver (`HOOKS_ASYNC_DRIVER`, `sync` in a developer's `.env` runs every handler at once), attempts, backoff, `as_user`, queue connection and name
+- `#[Async]`, next to `#[Action]`, makes a method asynchronous with the options of `->async()` as named parameters: `delay`, `via`, `onQueue`, `unique` (`true` or the lock duration), `tries`, `backoff`, `asUser`, `capture` and `when` (public methods of the class), `keepMissing`, `except`. On a class it applies to every `#[Action]` method, and a method's own `#[Async]` replaces it. A declaration that cannot be honoured (a hook in `except` the method does not declare, a missing or non-public `capture`/`when` method, invalid attempts, backoff or lock) is logged and the action runs synchronously; `#[Async]` on a `#[Filter]` or without `#[Action]` is logged
+- `pollora:doctor` and Site Health check asynchronous actions (`async-actions`): an ignored `#[Async]` (now kept, not only logged), an unavailable default driver (every handler then runs in the request) or `via()` driver, a `HOOKS_ASYNC_CONNECTION` that is undefined or `sync`/`null`, WP-Cron events overdue for an hour while WP-Cron or Action Scheduler is in use (Pollora sets `DISABLE_WP_CRON`: a system cron must request `wp-cron.php`), jobs of a database queue waiting 15 minutes for a worker, and payloads or unique locks the daily recovery task left behind
+- `pollora:make:action --async` generates the method with `#[Async]` and its import, in a new class or added to an existing one
+- `pollora:async:list` lists the asynchronous actions (hook, priority, handler, driver, delay, attempts and backoff, unique lock, queue, as user), which drivers are available in this request, and the default driver with where it comes from (`HOOKS_ASYNC_DRIVER`, the `POLLORA_ASYNC_DRIVER` constant, the `pollora/hook/async_driver` filter, or `auto` and the drivers it tries); `--json` for scripts
+- Async handlers in the framework: debug mode follows `app.debug`, incidents go to the Laravel log, closures are signed with the application key, Eloquent models travel by class and key and are reloaded at execution, and parameters typed with a service are resolved from the container
+
+### Fixed
+- `pollora:make:action` and `pollora:make:filter` were registered as `pollora:make:action {name}` and `pollora:make:filter {name}`: the console reached them by prefix, but `Artisan::call('pollora:make:action')` did not find them
+- The method generated for a hook with separators read `handleSavepost` for `save_post`: it is now `handleSavePost`
+- Errors met while registering hooks discovered by attribute went to no logger: `HookDiscovery` now receives the application's logger
+- Login screen: the language switcher's label ("Language" and its icon, shown when a second language is installed) sat at the left edge of the window, far from its dropdown. The card style given to the login form also reached the switcher's form and made it a full-width block; it is an inline block again, as in WordPress's own sheet
+- Login screen: the rules for the buttons were written `.pollora-login .wp-core-ui …`, but both classes are on `
`, so they never matched. The language switcher's button kept wp-admin's blue instead of the theme's primary colour, and the submit button kept WordPress's `button-large` padding
## [v13.35.1](https://github.com/Pollora/framework/compare/v13.35.0...v13.35.1) - 2026-10-07
diff --git a/composer.json b/composer.json
index 039de3f5..a215a651 100644
--- a/composer.json
+++ b/composer.json
@@ -32,7 +32,7 @@
"pollora/abilities": "^1.0",
"pollora/ajax": "^1.1",
"pollora/entity": "^1.2",
- "pollora/hook": "^1.1",
+ "pollora/hook": "^1.4",
"pollora/option": "^1.1",
"watson/rememberable": "^7.1",
"cweagans/composer-patches": "^2.0",
diff --git a/config/hooks.php b/config/hooks.php
new file mode 100644
index 00000000..8b485337
--- /dev/null
+++ b/config/hooks.php
@@ -0,0 +1,47 @@
+async() and #[Async] queue a handler instead of running
+ | it inside the request that fires the hook.
+ |
+ | default: auto picks the Laravel queue once HOOKS_ASYNC_CONNECTION names
+ | the connection a queue worker runs (never a "sync" or "null" one), then
+ | Action Scheduler when a plugin bundling it is active, then WP-Cron.
+ | Without a worker, queued jobs would never run, so the queue is opt-in;
+ | via('queue') or HOOKS_ASYNC_DRIVER=queue uses the default connection.
+ | Set HOOKS_ASYNC_DRIVER=sync in a developer's .env to run every handler
+ | at once, without touching the code.
+ |
+ | Supported: "auto", "queue", "action-scheduler", "wp-cron", "sync"
+ |
+ */
+
+ 'async' => [
+
+ 'default' => env('HOOKS_ASYNC_DRIVER', 'auto'),
+
+ // Attempts when a handler throws, and seconds before each retry (the last value repeats)
+ 'tries' => 1,
+ 'backoff' => [10, 60, 300],
+
+ // Run handlers as the user who fired the hook. Off by default: without a user, a capability check fails, which is the safe behaviour
+ 'as_user' => false,
+
+ 'queue' => [
+ // The connection a worker runs. null: the default connection, and auto leaves the queue out
+ 'connection' => env('HOOKS_ASYNC_CONNECTION'),
+ // Overridden per action by onQueue() or #[Async(onQueue: ...)]
+ 'queue' => env('HOOKS_ASYNC_QUEUE', 'default'),
+ ],
+
+ ],
+
+];
diff --git a/rector.php b/rector.php
index bfb229f9..79d51b3f 100644
--- a/rector.php
+++ b/rector.php
@@ -41,6 +41,8 @@
// Binding sources each broken on purpose (an untyped field, a private
// one): fixing them would remove what the tests check is refused.
__DIR__.'/tests/Unit/BlockBinding/Fixtures/Invalid.php',
+ // Hook classes with empty, unused or private methods on purpose: they carry the attributes under test.
+ __DIR__.'/tests/Feature/Hook/Async/Fixtures/AsyncAttributeFixtures.php',
])
->withPreparedSets(
deadCode: true,
diff --git a/resources/css/login.css b/resources/css/login.css
index c3e9e775..79b52b60 100644
--- a/resources/css/login.css
+++ b/resources/css/login.css
@@ -74,6 +74,15 @@ body.login.pollora-login::before {
display: flow-root;
}
+/* The language switcher is a form too, but not a card: WordPress's
+ #language-switcher rule already strips the surface above, and this gives
+ back the inline-block its own sheet sets. As a block the form spanned the
+ page, and its left-aligned label ("Language" and its icon) sat at the
+ window's edge, far from the dropdown. */
+.pollora-login .language-switcher form {
+ display: inline-block;
+}
+
.pollora-login form label {
color: var(--pollora-login-foreground);
font-size: .875rem;
@@ -132,7 +141,7 @@ body.login.pollora-login::before {
of the primary's family the sweep goes muddy — measured on apiary, slate
#1f2937 into amber #fbbf24. Two shades of one role read as one button on
every palette, and on theme-default they are Pollora's coral and pink. */
-.pollora-login .wp-core-ui .button-primary,
+.pollora-login.wp-core-ui .button-primary,
.pollora-login .button-primary {
background: linear-gradient(135deg, var(--pollora-login-primary), var(--pollora-login-primary-hover));
border: 0;
@@ -146,32 +155,32 @@ body.login.pollora-login::before {
transition: filter .15s ease, box-shadow .15s ease, transform .15s ease;
}
-.pollora-login .wp-core-ui .button-primary:hover,
+.pollora-login.wp-core-ui .button-primary:hover,
.pollora-login .button-primary:hover {
filter: saturate(1.1) brightness(1.03);
box-shadow: 0 4px 12px -2px color-mix(in srgb, var(--pollora-login-primary) 45%, transparent);
transform: translateY(-1px);
}
-.pollora-login .wp-core-ui .button-primary:focus,
+.pollora-login.wp-core-ui .button-primary:focus,
.pollora-login .button-primary:focus {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--pollora-login-primary) 35%, transparent);
transform: none;
}
-.pollora-login .wp-core-ui .button-primary:active,
+.pollora-login.wp-core-ui .button-primary:active,
.pollora-login .button-primary:active {
transform: none;
filter: brightness(.97);
}
@media (prefers-reduced-motion: reduce) {
- .pollora-login .wp-core-ui .button-primary,
+ .pollora-login.wp-core-ui .button-primary,
.pollora-login .button-primary {
transition: none;
}
- .pollora-login .wp-core-ui .button-primary:hover,
+ .pollora-login.wp-core-ui .button-primary:hover,
.pollora-login .button-primary:hover {
transform: none;
}
@@ -183,7 +192,7 @@ body.login.pollora-login::before {
`.button`, and left alone it kept wp-admin's blue next to a palette that
has none. The password-reveal toggle is a `.button` too, and is styled
above as the icon it is, so it stays out of this. */
-.pollora-login .wp-core-ui .button:not(.button-primary):not(.wp-hide-pw) {
+.pollora-login.wp-core-ui .button:not(.button-primary):not(.wp-hide-pw) {
color: var(--pollora-login-primary);
border-color: var(--pollora-login-outline);
background: var(--pollora-login-surface);
@@ -191,7 +200,7 @@ body.login.pollora-login::before {
min-height: 2.25rem;
}
-.pollora-login .wp-core-ui .button:not(.button-primary):not(.wp-hide-pw):hover {
+.pollora-login.wp-core-ui .button:not(.button-primary):not(.wp-hide-pw):hover {
color: var(--pollora-login-primary-hover);
border-color: var(--pollora-login-primary);
background: var(--pollora-login-surface);
diff --git a/src/Attributes/Action.php b/src/Attributes/Action.php
index 0bafe195..fad73f62 100755
--- a/src/Attributes/Action.php
+++ b/src/Attributes/Action.php
@@ -5,7 +5,10 @@
namespace Pollora\Attributes;
use Attribute;
+use Pollora\Hook\Application\Services\AsyncDeclarationFailures;
use Pollora\Hook\Domain\Contract\Action as ActionService;
+use Pollora\Hook\Infrastructure\Services\AsyncAttributeRegistrar;
+use Psr\Log\LoggerInterface;
use ReflectionClass;
use ReflectionMethod;
@@ -38,11 +41,40 @@ public function handle(
return;
}
- $actionService->add(
+ // #[Action] targets methods only
+ if (! $context instanceof ReflectionMethod) {
+ return;
+ }
+
+ // Asynchronously when the method or its class carries #[Async]
+ $logger = $this->optional($serviceLocator, LoggerInterface::class);
+ $failures = $this->optional($serviceLocator, AsyncDeclarationFailures::class);
+
+ (new AsyncAttributeRegistrar($actionService, $logger, $failures))->register(
$attribute->hook,
- [$instance, $context->getName()],
+ $instance,
+ $context,
$attribute->priority,
$context->getNumberOfParameters()
);
}
+
+ /**
+ * A service the service locator may not provide: the logger, the record of ignored #[Async].
+ *
+ * @template T of object
+ *
+ * @param class-string $class
+ * @return T|null
+ */
+ private function optional(object $serviceLocator, string $class): ?object
+ {
+ try {
+ $service = $serviceLocator->get($class);
+ } catch (\Throwable) {
+ return null;
+ }
+
+ return $service instanceof $class ? $service : null;
+ }
}
diff --git a/src/Attributes/Async.php b/src/Attributes/Async.php
new file mode 100644
index 00000000..fb36b91c
--- /dev/null
+++ b/src/Attributes/Async.php
@@ -0,0 +1,53 @@
+async(): #[Action] keeps the hook and
+ * the priority, #[Async] carries the deferred-execution options.
+ *
+ * #[Action('save_post_event')]
+ * #[Async(delay: 60, unique: true, tries: 3)]
+ * public function syncToCrm(int $postId): void {}
+ *
+ * On a class, it applies to every #[Action] method; an #[Async] on a method
+ * replaces the class options for that method. With several #[Action] on a
+ * method, it applies to all of them, except the hooks listed in $except.
+ */
+#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)]
+final readonly class Async
+{
+ /**
+ * @param int|\DateInterval|null $delay Minimum delay before execution, in seconds
+ * @param string|null $via Driver: queue, action-scheduler, wp-cron, sync
+ * @param string|null $onQueue Queue name, the group with Action Scheduler
+ * @param bool|int $unique Merge identical triggers until the first one runs; an integer sets how long the lock lasts, in seconds
+ * @param int|null $tries Attempts when the handler throws
+ * @param int|list|null $backoff Seconds before each retry
+ * @param bool|null $asUser Run as the user who fired the hook
+ * @param string|null $capture Public method of the class recording values at trigger time
+ * @param string|null $when Public method of the class deciding, at trigger time, whether to queue
+ * @param bool $keepMissing Run with null in place of a referenced object deleted in the meantime
+ * @param string|list $except Hooks of the method that stay synchronous
+ */
+ public function __construct(
+ public int|\DateInterval|null $delay = null,
+ public ?string $via = null,
+ public ?string $onQueue = null,
+ public bool|int $unique = false,
+ public ?int $tries = null,
+ public int|array|null $backoff = null,
+ public ?bool $asUser = null,
+ public ?string $capture = null,
+ public ?string $when = null,
+ public bool $keepMissing = false,
+ public string|array $except = [],
+ ) {}
+}
diff --git a/src/Hook/Application/Services/AsyncDeclarationFailures.php b/src/Hook/Application/Services/AsyncDeclarationFailures.php
new file mode 100644
index 00000000..c92f7ad7
--- /dev/null
+++ b/src/Hook/Application/Services/AsyncDeclarationFailures.php
@@ -0,0 +1,33 @@
+ Reason, by method ("Class::method()")
+ */
+ private array $failures = [];
+
+ public function fail(string $class, string $method, string $reason): void
+ {
+ $this->failures[sprintf('%s::%s()', ltrim($class, '\\'), $method)] = $reason;
+ }
+
+ /**
+ * @return array Reason, by method ("Class::method()")
+ */
+ public function all(): array
+ {
+ return $this->failures;
+ }
+}
diff --git a/src/Hook/Infrastructure/Async/EloquentModelReference.php b/src/Hook/Infrastructure/Async/EloquentModelReference.php
new file mode 100644
index 00000000..86afd59f
--- /dev/null
+++ b/src/Hook/Infrastructure/Async/EloquentModelReference.php
@@ -0,0 +1,53 @@
+exists;
+ }
+
+ public function reference(object $object): array
+ {
+ /** @var Model $object */
+ $reference = ['class' => $object::class, 'key' => $object->getKey()];
+
+ if ($object->getConnectionName() !== null) {
+ $reference['connection'] = $object->getConnectionName();
+ }
+
+ return $reference;
+ }
+
+ public function resolve(array $reference): ?object
+ {
+ $class = $reference['class'] ?? null;
+
+ if (! is_string($class) || ! is_a($class, Model::class, true)) {
+ return null;
+ }
+
+ $query = isset($reference['connection']) && is_string($reference['connection'])
+ ? $class::on($reference['connection'])
+ : $class::query();
+
+ return $query->find($reference['key'] ?? null);
+ }
+}
diff --git a/src/Hook/Infrastructure/Async/QueueDriver.php b/src/Hook/Infrastructure/Async/QueueDriver.php
new file mode 100644
index 00000000..7ae7dab4
--- /dev/null
+++ b/src/Hook/Infrastructure/Async/QueueDriver.php
@@ -0,0 +1,50 @@
+config->get(sprintf('queue.connections.%s.driver', $this->connection()));
+
+ return is_string($driver) && ! in_array($driver, ['sync', 'null'], true);
+ }
+
+ public function dispatch(AsyncPayload $payload, int $delay = 0): void
+ {
+ $job = (new RunAsyncAction($payload->toJson()))
+ ->onConnection($this->connection())
+ ->onQueue($payload->queue ?? $this->config->get('hooks.async.queue.queue', 'default'));
+
+ if ($delay > 0) {
+ $job->delay($delay);
+ }
+
+ $this->dispatcher->dispatch($job);
+ }
+
+ private function connection(): string
+ {
+ return (string) ($this->config->get('hooks.async.queue.connection') ?? $this->config->get('queue.default', 'sync'));
+ }
+}
diff --git a/src/Hook/Infrastructure/Checks/AsyncActionsCheck.php b/src/Hook/Infrastructure/Checks/AsyncActionsCheck.php
new file mode 100644
index 00000000..1ce6440f
--- /dev/null
+++ b/src/Hook/Infrastructure/Checks/AsyncActionsCheck.php
@@ -0,0 +1,238 @@
+failures->all();
+ $registrations = $this->inspector->registrations();
+
+ if ($failures === [] && $registrations === []) {
+ return CheckResult::ok('No asynchronous action registered.');
+ }
+
+ /** @var list $errors Line and fix */
+ $errors = [];
+ /** @var list $warnings Line and fix */
+ $warnings = [];
+
+ foreach ($failures as $method => $reason) {
+ $errors[] = [sprintf('%s: #[Async] is ignored, the action runs synchronously — %s', $method, $reason), 'Fix the #[Async] declaration named in each line, then run php artisan discovery:clear'];
+ }
+
+ $default = Async::defaultDriver();
+ $defaultProblem = $this->unavailable($default);
+
+ if ($defaultProblem !== null) {
+ $errors[] = [sprintf('The default driver "%s" is unavailable, so every asynchronous action runs inside the request that fires it: %s', $default, $defaultProblem), 'Set HOOKS_ASYNC_DRIVER (config/hooks.php async.default) to a driver this site has, or to auto'];
+ }
+
+ $used = $defaultProblem === null ? [$default] : [];
+
+ foreach ($this->requestedDrivers($registrations) as $driver => $hooks) {
+ $problem = $this->unavailable($driver);
+
+ if ($problem === null) {
+ $used[] = $driver;
+
+ continue;
+ }
+
+ $warnings[] = [sprintf('%s ask(s) for the driver "%s", unavailable, and go(es) through "%s" instead: %s', implode(', ', $hooks), $driver, $default, $problem), sprintf('Make the driver "%s" available, or remove via()', $driver)];
+ }
+
+ $warnings = [...$warnings, ...$this->connectionProblems(), ...$this->unprocessed(array_values(array_unique($used)))];
+
+ $lines = static fn (array $problems): array => array_map(static fn (array $problem): string => $problem[0], $problems);
+
+ if ($errors !== []) {
+ return CheckResult::error(sprintf('%d asynchronous action problem(s) that change how handlers run.', count($errors)), [...$lines($errors), ...$lines($warnings)], $errors[0][1]);
+ }
+
+ if ($warnings !== []) {
+ return CheckResult::warning(sprintf('%d problem(s) with asynchronous actions.', count($warnings)), $lines($warnings), $warnings[0][1]);
+ }
+
+ return CheckResult::ok(sprintf('%d asynchronous action(s), default driver "%s".', count($registrations), $default));
+ }
+
+ /**
+ * Why a driver cannot queue in this request, or null.
+ */
+ private function unavailable(string $driver): ?string
+ {
+ try {
+ Async::driver($driver);
+ } catch (DriverUnavailable $driverUnavailable) {
+ return $driverUnavailable->getMessage();
+ }
+
+ return null;
+ }
+
+ /**
+ * Hooks of the registrations that name their driver, by driver.
+ *
+ * @param list $registrations
+ * @return array>
+ */
+ private function requestedDrivers(array $registrations): array
+ {
+ $drivers = [];
+
+ foreach ($registrations as $registration) {
+ $driver = $registration->options->driver();
+
+ if ($driver !== null) {
+ $drivers[$driver][] = $registration->hook;
+ }
+ }
+
+ return array_map(static fn (array $hooks): array => array_values(array_unique($hooks)), $drivers);
+ }
+
+ /**
+ * HOOKS_ASYNC_CONNECTION names a connection the queue driver cannot use.
+ *
+ * @return list
+ */
+ private function connectionProblems(): array
+ {
+ $connection = $this->config->get('hooks.async.queue.connection');
+
+ if ($connection === null) {
+ return [];
+ }
+
+ $driver = $this->config->get(sprintf('queue.connections.%s.driver', $connection));
+
+ if (! is_string($driver)) {
+ return [[sprintf('HOOKS_ASYNC_CONNECTION names the connection "%s", which config/queue.php does not define: auto leaves the queue out', $connection), 'Set HOOKS_ASYNC_CONNECTION to a connection of config/queue.php that a worker runs']];
+ }
+
+ if (in_array($driver, ['sync', 'null'], true)) {
+ return [[sprintf('HOOKS_ASYNC_CONNECTION names the "%s" connection, whose %s driver %s: auto leaves the queue out', $connection, $driver, $driver === 'sync' ? 'runs jobs inside the request' : 'discards jobs'), 'Set HOOKS_ASYNC_CONNECTION to the connection a worker runs (database, redis…)']];
+ }
+
+ return [];
+ }
+
+ /**
+ * What runs WP-Cron on this site, and so what to add when nothing does.
+ */
+ private function cronFix(): string
+ {
+ if ($this->config->get('wordpress.use_laravel_scheduler')) {
+ return 'WP-Cron events are Laravel jobs here (wordpress.use_laravel_scheduler): run a queue worker, php artisan queue:work';
+ }
+
+ return $this->inspector->cronRunsOnPageLoad()
+ ? 'The site gets too few visits to run WP-Cron: add a system cron that requests wp-cron.php every minute'
+ : 'DISABLE_WP_CRON is set (Pollora sets it): add a system cron, e.g. * * * * * curl -s https://example.com/cms/wp-cron.php, or wp cron event run --due-now';
+ }
+
+ /**
+ * What the drivers in use queued and nothing ran.
+ *
+ * @param list $drivers
+ * @return list
+ */
+ private function unprocessed(array $drivers): array
+ {
+ $problems = [];
+
+ if (array_intersect($drivers, ['wp-cron', 'action-scheduler']) !== []) {
+ $overdue = $this->inspector->overdueCronEvents(self::CRON_GRACE);
+
+ if ($overdue['count'] > 0) {
+ $problems[] = [
+ sprintf('%d WP-Cron event(s) overdue, the oldest since %s: WP-Cron and Action Scheduler run nothing until wp-cron.php is requested', $overdue['count'], gmdate('Y-m-d H:i', (int) $overdue['oldest']).' UTC'),
+ $this->cronFix(),
+ ];
+ }
+ }
+
+ if (in_array('queue', $drivers, true)) {
+ $connection = (string) ($this->config->get('hooks.async.queue.connection') ?? $this->config->get('queue.default'));
+ $waiting = $this->inspector->waitingJobs($connection, self::QUEUE_GRACE);
+
+ if ($waiting !== null && $waiting['count'] > 0) {
+ $problems[] = [
+ sprintf('%d job(s) of the "%s" queue connection waiting since %s: no worker picks them up', $waiting['count'], $connection, gmdate('Y-m-d H:i', (int) $waiting['oldest']).' UTC'),
+ sprintf('Run a worker, kept alive by Supervisor or systemd: php artisan queue:work %s', $connection),
+ ];
+ }
+ }
+
+ $stranded = $this->inspector->strandedPayloads(self::MAINTENANCE_GRACE);
+
+ if ($stranded > 0) {
+ $problems[] = [sprintf('%d stored payload(s) older than a day with no event to run them: the daily recovery task (%s) does not run', $stranded, 'pollora/async/recover'), 'Make sure WP-Cron runs: wp cron event run pollora/async/recover schedules them again'];
+ }
+
+ $locks = $this->inspector->expiredLocks(self::MAINTENANCE_GRACE);
+
+ if ($locks > 0) {
+ $problems[] = [sprintf('%d unique lock(s) expired for more than a day: the daily maintenance task (%s) does not run', $locks, 'pollora/async/recover'), 'Make sure WP-Cron runs: wp cron event run pollora/async/recover deletes them'];
+ }
+
+ return $problems;
+ }
+}
diff --git a/src/Hook/Infrastructure/Jobs/RunAsyncAction.php b/src/Hook/Infrastructure/Jobs/RunAsyncAction.php
new file mode 100644
index 00000000..62f58fad
--- /dev/null
+++ b/src/Hook/Infrastructure/Jobs/RunAsyncAction.php
@@ -0,0 +1,58 @@
+payload, throwOnFinalFailure: true);
+ }
+
+ /**
+ * Name shown by Horizon and in the failed jobs table: the hook and the handler.
+ */
+ public function displayName(): string
+ {
+ $payload = json_decode($this->payload, true);
+
+ if (! is_array($payload) || ! is_string($payload['hook'] ?? null) || ! is_string($payload['handler'] ?? null)) {
+ return self::class;
+ }
+
+ $handler = str_starts_with($payload['handler'], 'closure:') ? 'closure' : $payload['handler'];
+
+ return sprintf('pollora/async %s %s', $payload['hook'], $handler);
+ }
+}
diff --git a/src/Hook/Infrastructure/Providers/AsyncServiceProvider.php b/src/Hook/Infrastructure/Providers/AsyncServiceProvider.php
new file mode 100644
index 00000000..705cbe79
--- /dev/null
+++ b/src/Hook/Infrastructure/Providers/AsyncServiceProvider.php
@@ -0,0 +1,98 @@
+mergeConfigFrom(__DIR__.'/../../../../config/hooks.php', 'hooks');
+
+ if ($this->app->runningInConsole()) {
+ $this->commands([AsyncListCommand::class]);
+ }
+ }
+
+ public function boot(): void
+ {
+ $this->publishes([
+ __DIR__.'/../../../../config/hooks.php' => config_path('hooks.php'),
+ ], 'pollora-hooks');
+
+ // A check of pollora:doctor and Site Health; tagged on boot, so it comes after the framework's own
+ $this->app->tag([AsyncActionsCheck::class], DoctorServiceProvider::CHECKS_TAG);
+
+ $this->configureAsync($this->app->make(Repository::class));
+ }
+
+ private function configureAsync(Repository $config): void
+ {
+ $container = $this->app;
+
+ Async::extend('queue', static fn (): AsyncDriver => new QueueDriver($container->make(Dispatcher::class), $config));
+ Async::setAutoDrivers(self::autoDrivers($config));
+
+ $default = $config->get('hooks.async.default', 'auto');
+ Async::setDefaultDriver(is_string($default) && $default !== '' && $default !== 'auto' ? $default : null);
+
+ Async::setDefaults(
+ tries: (int) $config->get('hooks.async.tries', 1),
+ backoff: (array) $config->get('hooks.async.backoff', [10, 60, 300]),
+ asUser: (bool) $config->get('hooks.async.as_user', false),
+ );
+
+ Async::setDebug((bool) $config->get('app.debug', false));
+ Async::useClosureKey(is_string($key = $config->get('app.key')) ? $key : null);
+ Async::reference(new EloquentModelReference);
+ Async::injectParametersUsing(static fn (\ReflectionParameter $parameter): mixed => self::resolveParameter($container, $parameter));
+
+ Async::reportUsing(static function (string $message, array $context) use ($container): void {
+ $container->make(LoggerInterface::class)->log(isset($context['exception']) ? 'error' : 'warning', $message, $context);
+ });
+ }
+
+ /**
+ * The drivers auto tries, in order. The Laravel queue needs a worker: it comes first only once a connection is set for async actions.
+ *
+ * @return list
+ */
+ public static function autoDrivers(Repository $config): array
+ {
+ return $config->get('hooks.async.queue.connection') !== null
+ ? ['queue', 'action-scheduler', 'wp-cron']
+ : ['action-scheduler', 'wp-cron'];
+ }
+
+ private static function resolveParameter(Container $container, \ReflectionParameter $parameter): mixed
+ {
+ $type = $parameter->getType();
+
+ return $type instanceof \ReflectionNamedType ? $container->make($type->getName()) : null;
+ }
+}
diff --git a/src/Hook/Infrastructure/Providers/HookServiceProvider.php b/src/Hook/Infrastructure/Providers/HookServiceProvider.php
index dcc08479..cf22814d 100755
--- a/src/Hook/Infrastructure/Providers/HookServiceProvider.php
+++ b/src/Hook/Infrastructure/Providers/HookServiceProvider.php
@@ -9,6 +9,7 @@
use Pollora\Application\Application\Services\ConsoleDetectionService;
use Pollora\Hook\Adapter\Out\WordPress\Action;
use Pollora\Hook\Adapter\Out\WordPress\Filter;
+use Pollora\Hook\Application\Services\AsyncDeclarationFailures;
use Pollora\Hook\Domain\Contract\Action as ActionContract;
use Pollora\Hook\Domain\Contract\CallbackResolverInterface;
use Pollora\Hook\Domain\Contract\Filter as FilterContract;
@@ -16,6 +17,7 @@
use Pollora\Hook\Infrastructure\Services\HookDiscovery;
use Pollora\Hook\UI\Console\ActionMakeCommand;
use Pollora\Hook\UI\Console\FilterMakeCommand;
+use Psr\Log\LoggerInterface;
/**
* Service provider for Hook feature (Infrastructure layer).
@@ -73,10 +75,15 @@ public function register(): void
$this->app->alias(Action::class, ActionContract::class);
$this->app->alias(Filter::class, FilterContract::class);
+ // #[Async] declarations discovery refuses, listed by pollora:doctor
+ $this->app->singleton(AsyncDeclarationFailures::class);
+
// Register Hook Discovery
$this->app->singleton(HookDiscovery::class, fn (Application $app): HookDiscovery => new HookDiscovery(
$app->make(ActionContract::class),
- $app->make(FilterContract::class)
+ $app->make(FilterContract::class),
+ $app->bound(LoggerInterface::class) ? $app->make(LoggerInterface::class) : null,
+ $app->make(AsyncDeclarationFailures::class),
));
if ($this->consoleDetectionService->isConsole()) {
diff --git a/src/Hook/Infrastructure/Services/AsyncAttributeRegistrar.php b/src/Hook/Infrastructure/Services/AsyncAttributeRegistrar.php
new file mode 100644
index 00000000..21829005
--- /dev/null
+++ b/src/Hook/Infrastructure/Services/AsyncAttributeRegistrar.php
@@ -0,0 +1,189 @@
+getAttributes(Async::class) ?: $method->getDeclaringClass()->getAttributes(Async::class);
+
+ return $attributes === [] ? null : $attributes[0]->newInstance();
+ }
+
+ /**
+ * Whether the method itself carries #[Async].
+ */
+ public static function isDeclaredOn(ReflectionMethod $method): bool
+ {
+ return $method->getAttributes(Async::class) !== [];
+ }
+
+ /**
+ * Register one hook of an #[Action] method.
+ *
+ * @param int|null $acceptedArgs Null to detect it from the method
+ */
+ public function register(string $hook, object $instance, ReflectionMethod $method, int $priority, ?int $acceptedArgs = null): void
+ {
+ $this->actions->add($hook, [$instance, $method->getName()], $priority, $acceptedArgs);
+
+ $attribute = self::attributeFor($method);
+
+ if (! $attribute instanceof Async) {
+ return;
+ }
+
+ $problem = $this->problem($method, $attribute);
+
+ if ($problem !== null) {
+ $this->logger?->error(sprintf('#[Async] on %s::%s() is ignored, the action runs synchronously: %s', $method->getDeclaringClass()->getName(), $method->getName(), $problem));
+ $this->failures?->fail($method->getDeclaringClass()->getName(), $method->getName(), $problem);
+
+ return;
+ }
+
+ if (in_array($hook, (array) $attribute->except, true)) {
+ return;
+ }
+
+ $this->configure($this->actions->async(), $attribute, $instance);
+ }
+
+ /**
+ * Why a declaration cannot be honoured, or null.
+ */
+ private function problem(ReflectionMethod $method, Async $attribute): ?string
+ {
+ $declaredOnMethod = self::isDeclaredOn($method);
+ $hooks = $this->actionHooks($method, $declaredOnMethod);
+
+ $unknown = array_values(array_diff((array) $attribute->except, $hooks));
+ if ($unknown !== []) {
+ return sprintf("except names '%s', which is not a hook of its #[Action] (%s)", implode("', '", $unknown), implode(', ', $hooks));
+ }
+
+ foreach (['capture' => $attribute->capture, 'when' => $attribute->when] as $option => $name) {
+ if ($name !== null && ! $this->isPublicMethod($method->getDeclaringClass(), $name)) {
+ return sprintf("%s names '%s', which is not a public method of the class", $option, $name);
+ }
+ }
+
+ try {
+ if ($attribute->tries !== null) {
+ PendingAsync::validTries($attribute->tries);
+ }
+
+ if ($attribute->backoff !== null) {
+ PendingAsync::validBackoff($attribute->backoff);
+ }
+ } catch (\InvalidArgumentException $invalidArgumentException) {
+ return $invalidArgumentException->getMessage();
+ }
+
+ if (is_int($attribute->unique) && $attribute->unique < 1) {
+ return sprintf('unique expects true or a lock of at least 1 second, %d given', $attribute->unique);
+ }
+
+ return null;
+ }
+
+ /**
+ * Hooks of the #[Action] attributes of the method, or of the whole class for a class-level #[Async].
+ *
+ * @return list
+ */
+ private function actionHooks(ReflectionMethod $method, bool $declaredOnMethod): array
+ {
+ $methods = $declaredOnMethod ? [$method] : $method->getDeclaringClass()->getMethods(ReflectionMethod::IS_PUBLIC);
+ $hooks = [];
+
+ foreach ($methods as $candidate) {
+ foreach ($candidate->getAttributes(Action::class) as $action) {
+ $hooks[] = $action->newInstance()->hook;
+ }
+ }
+
+ return array_values(array_unique($hooks));
+ }
+
+ /**
+ * @param \ReflectionClass