A modern Discord bot framework for PHP built on top of Tempest. Tempcord provides a clean, expressive API for building Discord bots with PHP 8.5+.
- π Modern PHP: Built for PHP 8.5+ with full type safety
- π§© Components: Buttons, select menus and modals routed by custom id
- β‘ Tempest Integration: Leverages the powerful Tempest framework
- π― Discord API: Full Discord API v10 support
- ποΈ Gateway cache: Guilds, channels, roles, members and voice states, read without a round trip
- π§ Developer Friendly: Intuitive API with excellent IDE support
- π¦ Composer Ready: Easy installation and dependency management
- π§ͺ Testing: Built-in testing support with PHPUnit and Pest
- π Documented: Guides and an API reference generated from the framework source
Create a new Tempcord project with Composer:
composer create-project tempcord/tempcord my-discord-bot
cd my-discord-botphp tempcord initinit walks you through everything a first bot needs:
- π asks for your bot token (and writes it to
.envfor you) - β verifies it against Discord and greets you by your bot's name
- π prints a ready-to-use invite link so you can add the bot to a server
- π offers to register slash commands and boot right away
No token yet? Create an application at the Discord Developer Portal, open Bot β Reset Token, and paste it into the wizard.
The starter project ships with a /ping command in app/Commands/PingCommand.php:
<?php
namespace App\Commands;
use Tempcord\Discord\Interaction\CommandInteraction;
use Tempcord\Attributes\Command;
#[Command(description: 'Ping? Pong!')]
final readonly class PingCommand
{
public function __invoke(CommandInteraction $interaction): void
{
$interaction->reply('Pong!');
}
}Invite the bot, then type /ping in your server.
php tempcord boot --register # register slash commands, then run
php tempcord boot # run onlyPrefer to skip the wizard? Copy .env.example to .env, set DISCORD_TOKEN,
and run php tempcord boot --register.
Options are declared as typed parameters. Add #[Option] for a description and
constraints; Discord builds the picker from them.
<?php
namespace App\Commands;
use Tempcord\Discord\Interaction\CommandInteraction;
use Tempcord\Attributes\Command;
use Tempcord\Attributes\Option;
#[Command(description: 'Greet a user')]
final readonly class GreetCommand
{
public function __invoke(
CommandInteraction $interaction,
#[Option(description: 'Who to greet')]
?string $name = null,
): void {
$interaction->reply('Hello, ' . ($name ?? 'world') . '!');
}
}An invokable class with #[Event] listens to a gateway event. The payload arrives
as the single argument, and the class is resolved from the container β so it can take
constructor dependencies such as the Discord client.
<?php
namespace App\Listeners;
use Tempcord\Discord\Constants\Events;
use Tempcord\Discord\Gateway\Events\MessageCreate;
use Tempcord\Attributes\Event;
#[Event(name: Events::MESSAGE_CREATE)]
final readonly class MessageLogger
{
public function __invoke(MessageCreate $message): void
{
if ($message->content === '!hello') {
// handle the message
}
}
}A listener only fires for events your bot has the intent for β set intents in
app/config/tempcord.config.php.MESSAGE_CONTENTis privileged and must also be enabled in the Discord developer portal.
#[Button], #[SelectMenu] and #[ModalSubmit] route a component back to the code
that answers it. Discord hands back nothing but the custom id, so anything the handler
needs to know travels inside it as a {placeholder}:
<?php
namespace App\Components;
use Tempcord\Discord\Interaction\ButtonInteraction;
use Tempcord\Attributes\Button;
#[Button(id: WaveButton::CUSTOM_ID)]
final readonly class WaveButton
{
public const string CUSTOM_ID = 'wave.back.{name}';
public function __invoke(ButtonInteraction $interaction, string $name): void
{
$interaction->update($name . ' waved back π');
}
}Build a matching id from the same pattern when you create the button, so the two cannot drift apart:
use Tempcord\Runtime\CustomId;
new PrimaryButton(
customId: CustomId::compile(WaveButton::CUSTOM_ID)->build(['name' => $name]),
label: 'Wave back',
);The starter ships both halves in app/Commands/WaveCommand.php and
app/Components/WaveButton.php β invite the bot and run /wave.
Every interaction takes a reply directly. Text, an embed, or a fully built response when you need more:
$interaction->reply('Pong!');
$interaction->reply($embed, ephemeral: true); // only the person who triggered it sees it
$interaction->update($embed); // replaces the message a component sits on
$interaction->showModal($modal);
$interaction->defer(); // then editReply() once the work is done
$interaction->followUp('and one more thing');Components go on without building the tree by hand β addButton() fills a row five at a
time, addRow() groups deliberately:
$interaction->reply(
Response::message('Pick one')->addButton($accept)->addButton($reject),
);Response::message(), ::ephemeral(), ::update(), ::modal() and ::defer() return
the underlying builder for anything they do not cover.
An option can suggest values while the user is still typing. The lightest way is a method on the command itself:
#[Autocomplete(option: 'track')]
public function completeTrack(string $typed): array
{
return $this->tracks->matching($typed);
}For suggestions more than one command wants, name a class implementing Autocomplete β
it is built by the container, so it may take dependencies of its own.
The Discord library keeps no state, so without a cache every role check is an HTTP round
trip. Ask the container for Tempcord\Cache\Cache and read guilds, channels, roles,
members and voice states straight out of memory:
$member = $this->cache->member($guildId, $userId);
$blocked = in_array($blockedRoleId, $member?->roles ?? [], true);Reads never touch the network, so a miss returns null rather than quietly becoming a rate-limited request inside a loop.
Your bot is configured in app/config/tempcord.config.php:
<?php
use Tempcord\Discord\Bitwise\Bitwise;
use Tempcord\Discord\Enums\Intent;
use Tempcord\TempcordConfig;
use function Tempest\env;
return new TempcordConfig(
token: env('DISCORD_TOKEN') ?? '',
intents: Bitwise::from(
Intent::GUILDS,
Intent::GUILD_MESSAGES,
Intent::MESSAGE_CONTENT,
),
);More advanced topics β subcommands, autocomplete, localization and plugins β are covered in the framework guides.
Run the test suite:
composer testRun tests with coverage:
composer test-coverageRun static analysis:
composer analyseFormat code:
composer formatIf you discover a security vulnerability within Tempcord, please send an e-mail to the maintainers via mikield@icloud.com. All security vulnerabilities will be promptly addressed.
Tempcord is open-sourced software licensed under the MIT license.