mrgoodbytes8667/discord-interaction-bundle
A Symfony bundle for handling Discord interactions in your PHP application. Register slash commands, route interaction payloads, and respond to Discord requests through a structured, framework-integrated setup, keeping Discord-specific plumbing and signature verification out of your controllers.
Install and enable. The bundle is a standard Symfony bundle, so the first hour is Composer plus registration:
composer require mrgoodbytes8667/discord-interaction-bundle
// config/bundles.php
return [
// ...
MrGoodBytes8667\DiscordInteractionBundle\DiscordInteractionBundle::class => ['all' => true],
];
As of v0.1.0 the package advertises Symfony 6 test coverage, so a Symfony 6.x app is now a reasonable bet — pin the exact version and verify the constraint resolves cleanly in your app:
composer why-not symfony/framework-bundle 7 # still expect trouble here
composer show mrgoodbytes8667/discord-interaction-bundle
There is still no evidence of Symfony 7 support, and this is still a 0.x release (v0.0.3 → v0.1.0), so treat minor bumps as potentially breaking. On a Symfony 7 app you will likely need a fork or --ignore-platform-req hacks — neither is a good day-one decision.
Config you must supply. Every Discord interactions integration needs three values, and the bundle will read them from your config/env:
DISCORD_APPLICATION_ID=1234567890
DISCORD_PUBLIC_KEY=abcdef... # the Ed25519 public key from the app's General Information page
DISCORD_BOT_TOKEN=... # only needed if you push command definitions or send follow-ups
The public key is not the bot token and not the client secret. Mixing these up is the single most common first-day failure.
Where to look first. The bundle is small — read vendor/mrgoodbytes8667/discord-interaction-bundle/src/ end to end before writing code. You are looking for three things:
/discord/interaction).ApplicationCommandEvent or similar).Confirm the wiring from the console rather than from the README:
bin/console debug:router | grep -i discord
bin/console debug:event-dispatcher | grep -i discord
bin/console debug:config discord_interaction
On Symfony 6 note that any @Route/@Required annotations in the bundle source are deprecated but still functional through the 6.x line — that is a deprecation to log, not a blocker.
First use case: a signed /ping round trip. In the Discord Developer Portal, set Interactions Endpoint URL to your route's public HTTPS URL. Discord immediately sends a type-1 PING; your endpoint must reply {"type":1} or the portal refuses to save the URL. That handshake is your smoke test — get it passing before touching command logic.
Then register /ping as an application command (via the Discord API, not via Symfony routing — the bundle handles incoming interactions, not command definitions), and add a listener:
#[AsEventListener(event: ApplicationCommandEvent::class)]
final class PingCommand
{
public function __invoke(ApplicationCommandEvent $event): void
{
if ($event->getCommandName() !== 'ping') {
return;
}
$event->setResponse(InteractionResponse::message('Pong.'));
}
}
Treat class and method names as illustrative until you have read src/ — the pattern (listener receives a payload wrapper, mutates a response object, transport serialises it) is what matters.
One listener per command, dispatched from a name registry. The highest-leverage structure is a thin router: a single high-priority listener reads data.name, looks it up in a Symfony tagged-iterator or service locator of command handlers, and delegates. This keeps each command testable in isolation and makes /help trivial to generate from the registry. Do not let 40 slash commands live in one 900-line switch.
# config/services.yaml
_instanceof:
App\Discord\CommandInterface:
tags: ['app.discord_command']
#[AsEventListener(event: ApplicationCommandEvent::class, priority: 100)]
public function route(ApplicationCommandEvent $event): void
{
$handler = $this->locator->get($event->getCommandName()) ?? $this->locator->get('unknown');
$handler($event);
}
Symfony 6's autoconfiguration with #[AsEventListener] attributes means you can skip the YAML tag entirely if you prefer; the registry pattern is unchanged either way.
Deferred responses for anything that touches the database or an HTTP API. Discord's hard deadline is 3 seconds wall-clock. The reliable pattern is: acknowledge immediately with a deferred response, then edit the original via the @original webhook using application_id + token.
// 1. Immediately
$event->setResponse(InteractionResponse::deferred());
// 2. Later, out of band (async message / queued job)
$http->patch("https://discord.com/api/v10/webhooks/{$appId}/{$token}/messages/@original", [
'json' => ['content' => 'Report ready.'],
]);
A Symfony Messenger handler keyed on the interaction token is the cleanest way to do this, and it survives worker restarts. Note the token is valid for 15 minutes and is single-purpose — log it, don't store it long-term. On Symfony 6 the Messenger async transport config and messenger:consume workflow are stable; no surprises there.
Keep signature verification on the raw body. Verification is sodium_crypto_sign_verify_detached(sig, timestamp . rawBody, publicKey). If any kernel listener (JsonRequestListener, an API platform middleware, a body-parsing firewall) consumes php://input first, the check silently fails. If the bundle does not already guard this, add a listener at very high priority that reads $request->getContent() once and stashes it on the request attributes for later reuse. Symfony 6.3+ has Request::getContent() behaviour that is generally safe here, but custom middleware is still the usual culprit.
Push command definitions from a console command, not by hand. The bundle's job is inbound; you still need an outbound PUT /applications/{id}/commands (global) or PUT /applications/{id}/guilds/{guild}/commands (instant, per-guild). Write an app:discord:sync-commands command that serialises a PHP array of definitions and PUTs it, and run it in your deploy pipeline. Global commands take up to an hour to propagate; guild commands are immediate — use the latter during development to avoid the wait. On Symfony 6, inject HttpClientInterface normally; no bundle coupling required.
Test with replayed payloads. Capture one real interaction body and signature from your logs, commit it as a fixture, and write a functional test that POSTs it to the route. The Symfony\Bundle\FrameworkBundle\Test\WebTestCase route-tester API is stable across 6.x. For local iteration you will want a config flag (e.g. discord_interaction.skip_signature_verification: true in config/packages/test/) — but make sure it is impossible to enable in prod, since an unverified endpoint lets anyone invoke your commands.
Respond to components and modals in the same place. If the bundle covers component interactions (type 3), route on data.custom_id exactly as you route on data.name for commands. Naming convention feature:action:id (ticket:close:4412) pays for itself the moment you have nested buttons. The v0.1.0 release does not change this — component and modal support still depends on whether the DTO tolerates the extra fields.
The 3-second deadline is not negotiable and not extendable. Deferred responses are the only escape hatch. A cold Symfony container, a slow Redis connect, or a synchronous Sentry flush can blow this on its own — measure your endpoint's p50 latency including framework boot.
Returning 200 with an empty body is treated as failure. Discord expects the interaction response JSON. Any uncaught exception in your listener should map to a logged error and still return a valid response type, or users see the generic "This interaction failed."
PING must be answered before anything else. If your listener chain throws before the PING check, the Developer Portal cannot validate and will not let you save the endpoint URL. Put the PING short-circuit at the top of the highest-priority listener, or verify the bundle does it for you in the controller.
The Discord API surface is still far ahead of the bundle. v0.1.0 is "Symfony 6 testing" only — no feature work — so the core logic still reflects a late-2021 understanding of Discord interactions. That means it almost certainly predates autocomplete (type 8, added 2022), modals (type 9, 2022), user-installable apps, and Components V2. If your roadmap includes any of those, either verify the bundle passes unknown fields through untouched (so you can hand-roll them) or budget for a fork. Check the event class: if it deserialises the payload into a rigid DTO, unknown fields will be dropped and you will be stuck.
Symfony version drift is now a narrower risk — but not zero. The v0.1.0 release adds Symfony 6 test coverage, which materially reduces the risk that the event-dispatcher and ContainerBuilder signatures break on 6.x. It does not tell you anything about Symfony 7, and there is still no guarantee the bundle handles 6.4 deprecation-free. Pin the exact version in composer.json, add composer why-not symfony/framework-bundle 7 to CI, and if you must run Symfony 7, plan to vendor-fork.
This is still a 0.x package with a single maintainer. v0.1.0 is a minor bump on a pre-1.0 project, so nothing about it promises backwards compatibility — the only substantive change is CI coverage of a newer framework, and dependency-constraint widening is exactly the kind of change that can surface new deprecations at runtime. Treat vendor/ as read-only source you may need to patch, and vendor-fork it under repositories: { type: path } rather than relying on upstream fixes. The license is NOASSERTION; get a human to confirm you're allowed to ship it before it goes anywhere near a commercial deployment. Given the core logic is roughly 100–150 lines of signature verification plus event dispatch, the honest fallback is a small in-house controller — that removes the compatibility and license questions entirely and costs about a day. The Symfony 6 support does not change that calculus; it just shortens the odds on the compatibility half.
Config cache quirks. If the bundle registers its route or public key via a compiler pass, bin/console cache:clear must run on deploy; a stale compiled container will keep an old public key after you rotate it in the Developer Portal, producing 401s that look like code bugs.
Rotation and secret handling. The Ed25519 public key rotates when you regenerate it in the portal — plan for a brief window where old signed requests fail. The bot token grants full API access, so it belongs in a secrets manager, not .env, and definitely not in the same file as the public key where a careless commit leaks both.
Discord retries. Failed deliveries are retried, so handlers must be idempotent on the interaction id. Store processed IDs (Redis SETNX with a 10-minute TTL is enough) before doing any side-effecting work.
How can I help you explore Laravel packages today?