Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
beeb0c2
fix(login): language switcher layout and button colours on the login …
ogorzalka Oct 7, 2026
a0d9759
Merge pull request #426 from Pollora/fix/login-language-switcher
ogorzalka Oct 7, 2026
7d9ed45
feat: queue async actions through Laravel and configure them from con…
ogorzalka Oct 7, 2026
8a0fee9
test: cover the async queue driver, job, model reference and provider
ogorzalka Oct 7, 2026
b4c6d8c
Merge pull request #427 from Pollora/feat/async-queue-driver
ogorzalka Oct 7, 2026
1687acd
feat: make #[Action] methods asynchronous with #[Async]
ogorzalka Oct 7, 2026
5dbe9c8
Merge pull request #428 from Pollora/feat/async-attribute
ogorzalka Oct 7, 2026
fbc9326
feat: leave the Laravel queue out of auto until a connection is set f…
ogorzalka Oct 7, 2026
cdd5ff1
Merge pull request #429 from Pollora/feat/async-queue-opt-in
ogorzalka Oct 7, 2026
553b92f
feat: check asynchronous actions in pollora:doctor and Site Health
ogorzalka Oct 7, 2026
a31ee6f
feat: point to a queue worker when the Laravel scheduler runs WP-Cron
ogorzalka Oct 7, 2026
6be3a46
Merge pull request #430 from Pollora/feat/async-doctor
ogorzalka Oct 7, 2026
72012af
feat: generate #[Async] actions with pollora:make:action --async
ogorzalka Oct 7, 2026
f724590
Merge pull request #431 from Pollora/feat/make-action-async
ogorzalka Oct 7, 2026
b3d76c1
feat: list asynchronous actions with pollora:async:list
ogorzalka Oct 7, 2026
9dc1eb4
Merge pull request #432 from Pollora/feat/async-list
ogorzalka Oct 7, 2026
4bc6db2
chore: release v13.35.2
ogorzalka Oct 7, 2026
9e1148e
Merge pull request #433 from Pollora/release/v13.35.2
ogorzalka Oct 7, 2026
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
20 changes: 19 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<body>`, 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

Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
47 changes: 47 additions & 0 deletions config/hooks.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

return [

/*
|--------------------------------------------------------------------------
| Asynchronous Actions
|--------------------------------------------------------------------------
|
| Action::add(...)->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'),
],

],

];
2 changes: 2 additions & 0 deletions rector.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 17 additions & 8 deletions resources/css/login.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand All @@ -183,15 +192,15 @@ 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);
border-radius: var(--pollora-login-radius-sm);
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);
Expand Down
36 changes: 34 additions & 2 deletions src/Attributes/Action.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<T> $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;
}
}
53 changes: 53 additions & 0 deletions src/Attributes/Async.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

declare(strict_types=1);

namespace Pollora\Attributes;

use Attribute;

/**
* Makes #[Action] methods asynchronous: the handler is queued when its hook
* fires, then run later (Laravel queue, Action Scheduler or WP-Cron).
*
* The counterpart of Action::add(...)->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<int>|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<string> $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 = [],
) {}
}
33 changes: 33 additions & 0 deletions src/Hook/Application/Services/AsyncDeclarationFailures.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace Pollora\Hook\Application\Services;

/**
* The #[Async] declarations discovery could not honour, kept so that
* pollora:doctor can list them instead of leaving them in the log.
*
* Each such method still runs, synchronously: nothing else shows the
* declaration was ignored.
*/
final class AsyncDeclarationFailures
{
/**
* @var array<string, string> 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<string, string> Reason, by method ("Class::method()")
*/
public function all(): array
{
return $this->failures;
}
}
53 changes: 53 additions & 0 deletions src/Hook/Infrastructure/Async/EloquentModelReference.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

declare(strict_types=1);

namespace Pollora\Hook\Infrastructure\Async;

use Illuminate\Database\Eloquent\Model;
use Pollora\Hook\Async\Contracts\ObjectReference;

/**
* Carries Eloquent models by class, key and connection, and reloads them at execution.
*
* The principle of SerializesModels: no stale data and a small payload.
*/
final class EloquentModelReference implements ObjectReference
{
public function name(): string
{
return 'eloquent';
}

public function supports(object $object): bool
{
return $object instanceof Model && $object->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);
}
}
Loading
Loading