Skip to content

feat(commands): run middleware around a command - #21

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

feat(commands): run middleware around a command#21
mikield merged 1 commit into
masterfrom
feat/command-middleware

Conversation

@mikield

@mikield mikield commented Sep 3, 2026

Copy link
Copy Markdown
Member

A command may now declare middleware, and so may a subcommand group and a subcommand:

#[Command(name: 'petition', description: '')]
final readonly class PetitionCommand
{
    #[Subcommand(name: 'submit', description: 'Anybody may.')]
    public function submit(CommandInteraction $interaction): void { /* … */ }

    #[Subcommand(
        name: 'panel',
        description: 'Moderation only.',
        middleware: [new RequiresPermissions([Permission::MANAGE_GUILD])],
    )]
    public function panel(CommandInteraction $interaction): void { /* … */ }
}

They are folded outermost first — the command's, then the group's, then the subcommand's own — and flattened into the HandlerDefinition at discovery time, so nothing at runtime walks the tree again. A middleware that answers the interaction instead of calling $next stops 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. RequiresPermissions checks 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 InteractionCreate rather than a CommandInteraction, so guarding those is a separate interface and a separate change.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MQ51VhJ8GmnJdfHrj6Mz7D

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>
@mikield
mikield merged commit 270afd4 into master Sep 3, 2026
3 checks passed
@mikield
mikield deleted the feat/command-middleware branch September 3, 2026 10:19
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))
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

🎉 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>
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