---
title: "Using Discord roles for Laravel permissions"
description: "How I made Discord the source of truth for our Laravel permissions instead of maintaining the same team roles twice."
canonical: "https://gummibeer.dev/blog/2023/seamless-role-management-with-discord"
---

# Using Discord roles for Laravel permissions

How I made Discord the source of truth for our Laravel permissions instead of maintaining the same team roles twice.

When I built this, we were a team of around 30 people managing a FiveM GTA:RP server.
We had several departments and a roughly three-level hierarchy with head admins, team leads and team members.

Like a lot of gaming communities, Discord was our central place for communication and team management.
So all those departments and hierarchy levels already existed as Discord roles.

And then we had another set of roles for our Laravel backoffice, which happened to be built with Nova.

You can probably guess what happened.

Someone changed departments, got promoted or left a team, the Discord roles were updated and the backoffice was forgotten.
Or the other way around.
Two places describing the same thing will get out of sync.

So I stopped trying to keep both role systems synchronized manually and made Discord the source of truth for the permissions in Laravel instead.

## Discord is the source of truth

I don't need every role from our Discord server inside Laravel.
There are plenty of roles that are only relevant for Discord itself.

The roles the application actually cares about are defined in an enum:

```php
enum DiscordRole: string
{
    case LEAD_ADMINISTRATOR = '100000000000000001';

    case ADMINISTRATOR = '100000000000000002';

    case LEAD_CONCEPTIONER = '100000000000000003';

    case LEAD_DEVELOPER = '100000000000000004';
    case DEVELOPER = '100000000000000005';
    case SCRIPTER = '100000000000000006';
    case MODDER_CLOTHES = '100000000000000007';
    case MODDER_VEHICLES = '100000000000000008';
    case MAPPER = '100000000000000009';
    case FACILITYMANAGER = '100000000000000010';

    case LEAD_SUPPORTER = '100000000000000011';
    case SUPPORT = '100000000000000012';
    case FRACTIONMANAGER = '100000000000000013';
    case COMMUNITYMANAGER = '100000000000000014';

    case LEAD_WHITELIST = '100000000000000015';
    case WHITELIST = '100000000000000016';

    case LAWMAKER = '100000000000000017';
    case WEAZELNEWS = '100000000000000018';
}
```

The values are the Discord role ID Snowflakes. I anonymized them here for obvious reasons.

Using the IDs instead of role names also means Laravel doesn't care if someone renames a role in Discord.
The enum gives me readable names in the application while Discord keeps being responsible for the actual role assignment.

## Getting the Discord roles

The `User` model exposes the relevant Discord roles as an attribute and has a small helper to check them:

```php
/**
 * @return Collection<array-key, DiscordRole>
 */
public function getDiscordRolesAttribute(): Collection
{
    if ($this->discord_id === null) {
        return collect();
    }

    if (in_array($this->role, [UserRole::GUEST(), UserRole::PLAYER()], true)) {
        return collect();
    }

    return rescue(
        callback: fn () => Cache::flexible(
            key: $this->getCacheKey('discord_roles'),
            ttl: [
                CarbonInterval::minute()->totalSeconds,
                CarbonInterval::day()->totalSeconds,
            ],
            callback: function (): Collection {
                $member = app(DiscordConnector::class)->guild()->getGuildMember(
                    guildId: new Snowflake(config('services.discord.guild_id')),
                    userId: new Snowflake($this->discord_id),
                );

                return collect($member->roles)
                    ->map(fn (string $id) => DiscordRole::tryFrom($id))
                    ->filter()
                    ->values();
            }
        ),
        rescue: collect(),
    );
}

public function hasDiscordRole(DiscordRole ...$roles): bool
{
    return $this->discord_roles->contains(fn (DiscordRole $role): bool => in_array($role, $roles, true));
}
```

The `DiscordConnector` in that snippet comes from [my Discord SDK](https://github.com/Astrotomic/discord-sdk) package.
It wraps Discord's HTTP API and is built on top of [Saloon](https://docs.saloon.dev), so for this use case I can simply fetch the guild member without having to run a websocket client.

There are a few details in there that matter.

Users without a connected Discord account obviously don't have Discord roles.
Guests and normal players don't need backoffice permissions either, so I don't even make an API request for them.

For actual team members I fetch the guild member from Discord and map its role IDs through the `DiscordRole` enum.
`tryFrom()` plus `filter()` means all the Discord-only roles simply disappear here.
Laravel only sees roles I explicitly added to the enum.

The result is cached because calling Discord for every policy check would be pretty stupid.
And `rescue()` returns an empty collection if the Discord request fails, so a failing API doesn't accidentally grant permissions.
It can lock somebody out until the data is available again, but for authorization I prefer that direction of failure.

The `hasDiscordRole()` helper is intentionally an **any-of** check.
Pass one role or ten roles and it returns `true` as soon as the user has one of them.

## Policies stay boring

With that in place there isn't anything special left to do in Laravel's authorization layer.
The policies just check the roles they need:

```php
use App\Enums\DiscordRole;
use App\Models\Post;
use App\Models\User as AuthUser;
use Illuminate\Auth\Access\HandlesAuthorization;

class PostPolicy
{
    public function viewAny(AuthUser $auth): bool
    {
        return $auth->hasDiscordRole(
            DiscordRole::LEAD_SUPPORTER,
            DiscordRole::ADMINISTRATOR,
        );
    }

    // ... other methods

    public function delete(AuthUser $auth, Post $post): bool
    {
        return $auth->hasDiscordRole(
            DiscordRole::ADMINISTRATOR,
        );
    }
}
```

In this example lead supporters and administrators can work with posts, but deleting one is restricted to administrators.
Nothing unusual from Laravel's perspective.

Our backoffice happened to use Nova, so that's where I used this first.
But nothing about the approach depends on Nova. The authorization is implemented with normal Laravel policies and gates.
If you're using Filament, another admin panel or something custom on top of Laravel's authorization layer, the same idea applies there as well.

## One source of truth

The useful part of this isn't really Discord.
It's removing a second place where the same information had to be maintained.

Discord already knew which teams someone belonged to and which position they had in those teams.
Laravel didn't need to own another copy of that information just to answer an authorization question.
It only needed to consume the existing source of truth.

The backoffice was the second place that needed every team change - and it was forgotten.
That's not really specific to our team either.
If you maintain the same roles and permissions manually in two different systems, one of them will eventually be forgotten or drift out of sync.
That's why companies centralize this stuff with Active Directory, Entra ID, LDAP or similar systems instead of asking every application to maintain its own organizational structure.

And the same idea applies outside of Discord.
If another system already owns the information your Laravel application needs for an authorization decision, it can often be better to consume that information than to create another model, another admin UI and another process that has to stay synchronized.

So instead of trying to become better at maintaining duplicate data, I removed the duplicate source of truth.

There is one tradeoff worth keeping in mind: the roles are cached.
That's great for not hammering the Discord API, but it also means a permission change isn't necessarily visible in Laravel immediately.
With permissions where revocation has to take effect right now, I would use a shorter stale window or explicitly invalidate that user's cache when the role changes.

For our backoffice that tradeoff was fine.
And compared to maintaining the same organizational structure twice, it was a lot less annoying.

That's it.
Discord already knew who belonged to which team, so Laravel stopped pretending it needed its own version of that information.
