dainc007 / laravel-discord-server
Manage a Discord server from Laravel: config, a test fake, rate limits shared through the cache, and a log of every change with Discord audit reasons.
Package info
github.com/Dainc007/laravel-discord-server
pkg:composer/dainc007/laravel-discord-server
Requires
- php: ^8.3
- dainc007/discord-rest-client: ^0.1
- guzzlehttp/guzzle: ^7.8
- illuminate/cache: ^12.0 || ^13.0
- illuminate/contracts: ^12.0 || ^13.0
- illuminate/database: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
- spatie/laravel-package-tools: ^1.92
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.20
- orchestra/testbench: ^10.0 || ^11.0
- pestphp/pest: ^3.0 || ^4.0 || ^5.0
- pestphp/pest-plugin-laravel: ^3.0 || ^4.0 || ^5.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Manage a Discord server from Laravel with a bot token: give and take roles, set nicknames, time out, kick and ban members, create and edit roles and channels. Every change shows up in Discord's own audit log with who made it and why, and is written to a change log table, also when Discord refuses it. Built on dainc007/discord-rest-client.
Install
composer require dainc007/laravel-discord-server
php artisan vendor:publish --tag="discord-server-migrations"
php artisan migrate
Then add to .env:
DISCORD_BOT_TOKEN=your-bot-token DISCORD_GUILD_ID=your-server-id
Requires PHP 8.3+ and Laravel 12 or 13. To change the defaults, publish the config: php artisan vendor:publish --tag="discord-server-config".
Creating the bot, inviting it with the right permissions and finding the server id are described in the client's setup guide. The step that trips most people: the bot's role must sit above every role it should give or edit.
Check the setup
php artisan discord-server:check
php artisan discord-server:check --role=123456789012345678 # a role your app gives
It reads the server with your token and reports: whether the bot reaches it, which permissions this package uses are missing, and which roles sit at or above the bot's highest role (the bot cannot give or edit those). It changes nothing. The bot's user id is read from the token; pass --bot-id= if that fails. It exits with an error when the server cannot be read or a --role is out of the bot's reach, so it also works in a deploy script.
Usage
use Dainc007\LaravelDiscordServer\Facades\DiscordServer; DiscordServer::actingAs($request->user()) ->because('Linked Discord account') ->addRole($discordUserId, $playerRoleId);
Discord's audit log then shows "My App: Anna: Linked Discord account" (the prefix is APP_NAME), and a row lands in discord_changes.
actingAs(), because() and forGuild() each return a copy, so a configured instance can be kept:
use Dainc007\DiscordRestClient\Enums\ChannelType; $discord = DiscordServer::actingAs($admin)->because('Season 3 start'); $discord->createRole('Season 3'); $discord->createChannel('season-3', ChannelType::GuildText);
| Members | Roles | Channels |
|---|---|---|
addRole, removeRole, syncRoles, setNickname, timeout, kick, ban, unban |
createRole, editRole, deleteRole |
createChannel, editChannel, deleteChannel |
Reads go straight to the client and skip the change log:
$roles = DiscordServer::client()->guilds()->roles(DiscordServer::guildId()); $members = DiscordServer::client()->members()->search(DiscordServer::guildId(), 'anna');
Keeping roles in step with your app
syncRoles() gives a member every role in wanted, and takes away each role listed in managed but missing from wanted. Roles outside both lists, such as ones a moderator gave by hand, stay.
DiscordServer::syncRoles($discordUserId, wanted: $user->isCaptain() ? [$playerRoleId, $captainRoleId] : [$playerRoleId], managed: [$playerRoleId, $captainRoleId], ); // ['added' => [...], 'removed' => [...]]
Your app decides who gets which role and which Discord account belongs to which user, and passes both in.
Your own changes
For a write without its own method (reordering, permission overwrites), wrap it in record() to get the audit reason and the log entry:
use Dainc007\DiscordRestClient\Contracts\ClientContract; use Dainc007\LaravelDiscordServer\Enums\ChangeTarget; DiscordServer::actingAs($admin)->record( 'roles.reorder', fn (ClientContract $client, string $guildId, string $auditReason) => $client->roles()->reorder($guildId, $positions, $auditReason), ChangeTarget::Guild, );
To keep working with the log row the change wrote (show it, link to it, add to it), use recordChange(). It returns the change's result and that exact row, even when other changes are logged at the same moment:
$recorded = DiscordServer::actingAs($admin)->recordChange('roles.reorder', $change, ChangeTarget::Guild); $recorded->result; // what the closure returned $recorded->log; // the DiscordChange row, or null when the change log is off
Who made the change
Any Eloquent model can be the actor. Discord's audit log shows its name attribute. To choose another name, implement NamedDiscordActor:
use Dainc007\LaravelDiscordServer\Contracts\NamedDiscordActor; class Admin extends Model implements NamedDiscordActor { public function discordActorName(): string { return $this->display_name; } }
Call Discord from a queued job
A call can take a while: up to 10 seconds per try, a few retries, and a wait when Discord's rate limit says so (up to 60 seconds by default). Inside a web request, that is a page that hangs. Make changes from a queued job:
use Dainc007\DiscordRestClient\Exceptions\MissingPermissionException; use Dainc007\LaravelDiscordServer\Facades\DiscordServer; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; final class GiveDiscordRole implements ShouldQueue { use Queueable; public int $tries = 3; public int $backoff = 60; public function __construct(public User $user, public string $roleId) {} public function handle(): void { try { DiscordServer::actingAs($this->user) ->because('Linked Discord account') ->addRole($this->user->discord_id, $this->roleId); } catch (MissingPermissionException $exception) { $this->fail($exception); // retrying will not fix the role order } } } GiveDiscordRole::dispatch($user, $playerRoleId);
If a call must run during a request, publish the config and lower max_retries and max_retry_after_seconds, so a busy Discord fails fast.
When Discord says no
A refused change is logged with succeeded = false, Discord's HTTP status, its error code and message. Then the client's exception is thrown again unchanged, so your app decides what to show: MissingPermissionException (403, often the role order), UnknownResourceException (404), RateLimitedException, InvalidTokenException, TransportException (network). All extend DiscordException. See the client's error list.
A missing token or server id throws DiscordServerNotConfigured before any request.
The change log
Every logged change fires Dainc007\LaravelDiscordServer\Events\DiscordChangeRecorded, also when Discord refused it ($event->change->succeeded tells which), so an app can notify someone or react. This event is the only way to reach the row of a refused change: recordChange() throws Discord's exception instead of returning. An error thrown by a listener is reported (report()) and does not stop the change or replace Discord's error:
use Dainc007\LaravelDiscordServer\Events\DiscordChangeRecorded; use Illuminate\Support\Facades\Event; Event::listen(function (DiscordChangeRecorded $event): void { if (! $event->change->succeeded) { // tell the admins } });
Dainc007\LaravelDiscordServer\Models\DiscordChange, one row per change:
| Column | |
|---|---|
actor_type, actor_id |
who (actor() relation), null for the console or a job |
guild_id, action |
where and what, e.g. member.add_role |
target_type, target_id, target_name |
the member, role or channel |
details |
JSON: role id, nickname, the reason, ... |
succeeded, http_status, discord_code, error |
the outcome |
Turn it off with change_log.enabled = false, or rename the table with change_log.table before migrating.
Entries older than change_log.keep_days (90 by default; null keeps everything) are deleted by Laravel's model:prune. The model lives outside app/Models, so name it when you schedule the command:
use Dainc007\LaravelDiscordServer\Models\DiscordChange; use Illuminate\Support\Facades\Schedule; Schedule::command('model:prune', ['--model' => [DiscordChange::class]])->daily();
Rate limits
Discord's rate-limit buckets are kept in the Laravel cache, so web requests and queue workers know about each other's limits. This is best effort: two workers can read "one request left" at the same moment, and one of them then gets a 429, waits and retries. Set DISCORD_RATE_LIMIT_STORE to pick a store; the array store keeps them per process. The client's rate-limit notes still apply: the 50-requests-a-second count is per process.
The client is bound as a scoped service, so under Octane each request gets its own.
Testing your app
use Dainc007\DiscordRestClient\Testing\RecordedRequest; use Dainc007\LaravelDiscordServer\Facades\DiscordServer; $discord = DiscordServer::fake([null]); // one queued response: 204 for addRole $this->post('/link-discord')->assertOk(); $discord->assertSent('PUT /guilds/{guild}/members/{user}/roles/{role}', fn (RecordedRequest $r) => $r->parameter('role') === $playerRoleId);
The fake makes no HTTP calls and the change log keeps working. When no server id is configured, the fake fills one in. Queue a DiscordException to test a refusal. The fake stays in place when Laravel resets per-request services, as a queue worker does between jobs.
Planned
A queued version of the change methods. A Filament plugin with pages for roles, channels, members and the change log comes as a separate package.
Package tests
composer test
composer analyse
Changelog and security
See CHANGELOG.md. To report a security problem, see SECURITY.md.
Licence
MIT. See LICENSE.md.