feat(commands): run middleware around a command - #21
Merged
Conversation
A command may now declare middleware, and so may a subcommand group and a subcommand. They are folded outermost first — the command's, then the group's, then the subcommand's own — and a middleware that answers the interaction instead of calling $next stops the handler from running. Discord's own permission field is a default: a guild administrator can override it in Server Settings, and it is scoped to the whole command, so a command with one subcommand for everybody and another for moderators cannot be described with it at all. RequiresPermissions ships for that, reading the permissions Discord has already computed for the channel. Arguments are resolved inside the chain rather than before it, so a command about to be refused does not pay for the REST call an option can cost. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
github-actions Bot
pushed a commit
that referenced
this pull request
Sep 3, 2026
## [0.14.0](v0.13.0...v0.14.0) (2026-09-03) ### Features * **commands:** run middleware around a command ([#21](#21)) ([270afd4](270afd4))
|
🎉 This PR is included in version 0.14.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
mikield
added a commit
that referenced
this pull request
Sep 3, 2026
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. ```php #[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: ```php 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.com/claude-code) https://claude.ai/code/session_01MQ51VhJ8GmnJdfHrj6Mz7D Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A command may now declare middleware, and so may a subcommand group and a subcommand:
They are folded outermost first — the command's, then the group's, then the subcommand's own — and flattened into the
HandlerDefinitionat discovery time, so nothing at runtime walks the tree again. A middleware that answers the interaction instead of calling$nextstops the handler from running.Both shapes
#[Option(autocomplete:)]already takes are accepted: a class name, built by the container so a middleware may take dependencies, or an object written inline for a check that only needs its own arguments. A class name that is not middleware is refused during discovery, which is start-up — a guard that turns out not to be a guard should stop the bot booting, not surface the first time somebody uses the command it was meant to protect.Why RequiresPermissions ships with it
#[Command(permissions: …)]is a default: a guild administrator can override it in Server Settings, and Discord scopes it to the whole command. A command with one subcommand for everybody and another for moderators cannot be described that way at all.RequiresPermissionschecks at the moment of use, reading the permissions Discord has already computed for the channel — no roles read back, nothing cached.Note on ordering
Arguments are resolved inside the chain rather than before it. Resolving an option can cost a REST call, and a command a middleware is about to refuse should not pay for one.
Not in this change
Components (buttons, select menus, modals) dispatch from a raw
InteractionCreaterather than aCommandInteraction, so guarding those is a separate interface and a separate change.🤖 Generated with Claude Code
https://claude.ai/code/session_01MQ51VhJ8GmnJdfHrj6Mz7D