Skip to content

feat(middleware)!: guard a component the way a command is guarded - #22

Merged
mikield merged 1 commit into
masterfrom
feat/component-middleware
Sep 3, 2026
Merged

feat(middleware)!: guard a component the way a command is guarded#22
mikield merged 1 commit into
masterfrom
feat/component-middleware

Conversation

@mikield

@mikield mikield commented Sep 3, 2026

Copy link
Copy Markdown
Member

Middleware landed for commands in #21. A button could not use it, so the check stayed at the top of the handler — and "may this person do this" is one question whether it arrives as a subcommand or as the button that does the same thing.

#[Button(id: 'petition.accept.{petition}', middleware: [ModerationOnly::class])]
public function accept(ButtonInteraction $button, string $petition): void
{
    // reached only by moderation; the status check that is actually
    // domain logic stays here
}

#[Button], #[SelectMenu] and #[ModalSubmit] now take middleware, compiled and validated by the same rule the command path uses — the validator moved into DeclaredMiddleware so a button and a subcommand are held to the same rule and refused in the same words.

ComponentDispatcher runs the chain before resolving arguments, and hands the middleware the interaction in the shape that kind of component is answered with — ButtonInteraction, ComponentInteraction, ModalSubmitInteraction. It builds that itself rather than taking it from the handler's arguments: a guard has to be able to refuse even when the handler it guards never asked for an interaction at all.

Middleware runs before the handler, so nothing has been deferred yet and a refusal answers with an ordinary ephemeral reply. The defer() stays in the handler, where the slow work is.

Breaking

Middleware::__invoke takes a union now:

public function __invoke(
    CommandInteraction|ButtonInteraction|ComponentInteraction|ModalSubmitInteraction $interaction,
    callable $next,
): void;

A middleware written against 0.14 widens its parameter and nothing else changes — the interaction it receives for a command is the same object as before.

A union rather than a shared interface is a deliberate trade: the four interaction classes have no common ancestor, and giving them one means changing discord-php too. Worth revisiting if the union grows.

Docs

tools/guides/09-middleware.md, with every example pulled from a fixture the suite compiles and exercises — including SuggestionCommand, the mixed-audience case that is the whole reason this exists, and a test asserting the guard lands on one subcommand and not the other.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MQ51VhJ8GmnJdfHrj6Mz7D

The same question — may this person do this — is asked by a subcommand and by
the button that does the same thing, and until now only the subcommand could
answer it with middleware. A button restated the check at the top of its
handler, which is how the two answers drift apart.

#[Button], #[SelectMenu] and #[ModalSubmit] now take middleware, compiled and
validated by the same rule the command path uses, and ComponentDispatcher
runs the chain before resolving anything. The middleware is handed the
interaction in the shape that kind of component is answered with, built by
the dispatcher rather than taken from the handler's arguments — a guard has
to be able to refuse even when the handler it guards never asked for an
interaction at all.

Also documents the whole thing, with every example pulled from a fixture the
suite compiles and exercises.

BREAKING CHANGE: Middleware::__invoke now takes
CommandInteraction|ButtonInteraction|ComponentInteraction|ModalSubmitInteraction
rather than CommandInteraction. A middleware typed for the old signature has
to widen its parameter; nothing else about it changes, and the interaction it
receives for a command is the same object as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mikield
mikield merged commit b1f6e4e into master Sep 3, 2026
3 checks passed
@mikield
mikield deleted the feat/component-middleware branch September 3, 2026 11:43
github-actions Bot pushed a commit that referenced this pull request Sep 3, 2026
## [1.0.0](v0.14.0...v1.0.0) (2026-09-03)

### ⚠ BREAKING CHANGES

* **middleware:** guard a component the way a command is guarded (#22)

### Features

* **middleware:** guard a component the way a command is guarded ([#22](#22)) ([b1f6e4e](b1f6e4e))
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

🎉 This PR is included in version 1.0.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant