diff --git a/CHANGELOG.md b/CHANGELOG.md
index 015eb606..19e2a55d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,9 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### 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, now tries the Laravel queue first, then Action Scheduler, then WP-Cron; a `sync` or `null` connection is always skipped. 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
- 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
+- 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
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/src/Attributes/Action.php b/src/Attributes/Action.php
index 0bafe195..0e1eae4b 100755
--- a/src/Attributes/Action.php
+++ b/src/Attributes/Action.php
@@ -6,6 +6,8 @@
use Attribute;
use Pollora\Hook\Domain\Contract\Action as ActionService;
+use Pollora\Hook\Infrastructure\Services\AsyncAttributeRegistrar;
+use Psr\Log\LoggerInterface;
use ReflectionClass;
use ReflectionMethod;
@@ -38,11 +40,32 @@ public function handle(
return;
}
- $actionService->add(
+ // #[Action] targets methods only
+ if (! $context instanceof ReflectionMethod) {
+ return;
+ }
+
+ // Asynchronously when the method or its class carries #[Async]
+ (new AsyncAttributeRegistrar($actionService, $this->logger($serviceLocator)))->register(
$attribute->hook,
- [$instance, $context->getName()],
+ $instance,
+ $context,
$attribute->priority,
$context->getNumberOfParameters()
);
}
+
+ /**
+ * The application logger, when the service locator provides one.
+ */
+ private function logger(object $serviceLocator): ?LoggerInterface
+ {
+ try {
+ $logger = $serviceLocator->get(LoggerInterface::class);
+ } catch (\Throwable) {
+ return null;
+ }
+
+ return $logger instanceof LoggerInterface ? $logger : 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/Infrastructure/Providers/HookServiceProvider.php b/src/Hook/Infrastructure/Providers/HookServiceProvider.php
index dcc08479..61a64ce6 100755
--- a/src/Hook/Infrastructure/Providers/HookServiceProvider.php
+++ b/src/Hook/Infrastructure/Providers/HookServiceProvider.php
@@ -16,6 +16,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).
@@ -76,7 +77,8 @@ public function register(): void
// 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
));
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..4cc6e430
--- /dev/null
+++ b/src/Hook/Infrastructure/Services/AsyncAttributeRegistrar.php
@@ -0,0 +1,186 @@
+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));
+
+ 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