Skip to content

feat: make #[Action] methods asynchronous with #[Async] - #428

Merged
ogorzalka merged 1 commit into
developfrom
feat/async-attribute
Oct 7, 2026
Merged

ogorzalka merged 1 commit into
developfrom
feat/async-attribute

Conversation

@ogorzalka

Copy link
Copy Markdown
Member

Async Actions, phase 3 step 2: the #[Async] attribute (design doc, "The #[Async] attribute").

#[Action('save_post_event')]
#[Async(delay: 60, unique: true, tries: 3)]
public function syncToCrm(int $postId): void {}

#[Action('transition_post_status')]
#[Async(capture: 'captureTransition', when: 'isPublished')]
public function notify(string $new, string $old, AsyncContext $context): void {}

Pollora\Attributes\Async

Method or class target. Every option of ->async() is a named parameter:

  • delay (seconds or DateInterval), via, onQueue;
  • unique (true or the lock duration), tries, backoff, asUser;
  • capture and when (names of public methods of the class);
  • keepMissing, except.

Registration (AsyncAttributeRegistrar)

One place for both registration paths: HookDiscovery and Action::handle() (used by the post type and taxonomy discoveries).

  • #[Async] is read on the method, otherwise on the class. A method's own #[Async] replaces the class's.
  • Several #[Action] on a method: all are asynchronous, except the hooks in except.
  • A declaration that cannot be honoured is logged, and the action runs synchronously, so the work still happens. Cases:
    • a hook in except that the #[Action] attributes do not declare (checked against the whole class for a class-level #[Async]);
    • a capture or when method that is missing or not public;
    • tries < 1, an invalid backoff, or unique < 1, all checked before registration so nothing is half-applied.
  • #[Async] on a #[Filter] is logged, and the filter still registers (a filter returns a value and cannot be deferred). #[Async] without #[Action] is logged too: HookDiscovery now collects these methods.

Fix on the way

HookServiceProvider did not pass a logger to HookDiscovery, so registration errors (existing and new) went nowhere. It now receives the application's logger. This is in the CHANGELOG under Fixed.

Tests

  • Feature (AsyncAttributeTest, 15 tests): options applied (checked with Async::fake()), except, capture/when, no #[Async], class versus method, the six refused declarations, discovery (filter, orphan), Action::handle() with and without a logger, class context.
    • The fixtures live in Fixtures/AsyncAttributeFixtures.php, excluded from Rector: its dead-code rules had removed the empty, unused or private methods that carry the attributes under test.
    • Full suite: 1,870 passed.
  • E2E (new async.spec.ts, phase 3 exit criterion: "an action declared with attributes runs in a Laravel worker"):
    • the e2e-features plugin gets an #[Action] + #[Async(via: 'queue')] handler, fired from admin-ajax;
    • the spec checks that nothing ran in the request, runs artisan queue:work --once --stop-when-empty, then checks that the handler received its argument and its AsyncContext, ran in the console, and had a LoggerInterface injected by the container;
    • new artisan() helper in support/site.ts (E2E_ARTISAN, ddev exec php artisan by default).
  • Rector, Pint and PHPStan pass.

@ogorzalka
ogorzalka merged commit 5dbe9c8 into develop Oct 7, 2026
11 checks passed
@ogorzalka
ogorzalka deleted the feat/async-attribute branch October 7, 2026 16:33
@ogorzalka ogorzalka mentioned this pull request Oct 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant