---
url: /getting-started/roles.md
---
# Roles

Every panel user has exactly one role. The ladder from top to bottom:

| Role | Sees and edits |
|---|---|
| developer | everything an admin can, plus the technical settings (environment type, debug, banner, mail safety, preview protection, module switches) and the devtools (env editor, routes, blueprints, logs, going live checklist) |
| admin | the whole panel: content, leads, users, settings (general, mail, tracking, video, packages, health), activity, health monitoring |
| content_manager | pages, navigation, media, forms, sections, tags; the cache screen |
| crm_manager | leads, on top of what a content manager sees for the content screens they are allowed into |
| event_manager | events and participants |

Only a developer can hand out the developer role. The role select in the user and invite forms hides it from admins, and the server refuses the assignment should the form be tampered with.

## Who is a developer

New installs seed the first user as developer. Existing installs get the role with the 2.7.0 migration, which hands it to every login on the `racerfish.com` domain; anyone else is promoted from the panel by a developer.

The intent is a plain one: the person who deploys and configures a site is the developer, the client's webmaster is the admin. An admin cannot accidentally switch a staging site to live, turn off mail safety, or disable a module; a developer can, and the activity log records who did.

## Restricting panel areas&#x20;

Which roles reach the media library, the AI editor and each module is configured under `evolve.roles` in `config/evolve.php`. The defaults keep the behaviour of earlier releases.

```php [config/evolve.php]
'roles' => [
    'media' => ['developer', 'admin', 'content_manager'],
    'editor-ai' => ['developer', 'admin', 'content_manager'],
    'modules' => [
        // '*' => ['developer', 'admin', 'content_manager'],
        // 'blog' => ['developer', 'admin', 'content_manager', 'editor'],
    ],
],
```

| Key | Default | Controls |
|---|---|---|
| `roles.media` | `developer`, `admin`, `content_manager` | the media library routes and its sidebar entry |
| `roles.editor-ai` | `developer`, `admin`, `content_manager` | the AI editor routes |
| `roles.modules.<name>` | none | every panel route a module registers with `Route::evolveModule()`, and its sidebar entry |
| `roles.modules.*` | none | fallback for every module without its own entry |

How module access resolves: the module's own entry wins, otherwise `*`, otherwise the module is open to every logged-in panel user (the behaviour before 2.12). A user without one of the listed roles gets a 403 on the module's routes, and the sidebar hides the module.

There is no env variable for these lists; they are project code and belong in the published `config/evolve.php`.

### Example: an editorial role for the blog

A club site where volunteers write blog posts and upload their photos, but must not touch pages, other modules or settings. The role is called `redaktionsmitglied`.

1. Create the role in a project migration:

   ```php [database/migrations/2026_09_24_124138_create_redaktionsmitglied_role.php]
   use Illuminate\Database\Migrations\Migration;
   use Spatie\Permission\Models\Role;

   return new class extends Migration
   {
       public function up(): void
       {
           Role::findOrCreate('redaktionsmitglied', 'web');
       }

       public function down(): void
       {
           Role::query()->where('name', 'redaktionsmitglied')->where('guard_name', 'web')->delete();
       }
   };
   ```

2. Give it the blog and the media library, and close every other module to it:

   ```php [config/evolve.php]
   'roles' => [
       'media' => ['developer', 'admin', 'content_manager', 'redaktionsmitglied'],
       'editor-ai' => ['developer', 'admin', 'content_manager', 'redaktionsmitglied'],
       'modules' => [
           '*' => ['developer', 'admin', 'content_manager'],
           'blog' => ['developer', 'admin', 'content_manager', 'redaktionsmitglied'],
       ],
   ],
   ```

3. Assign the role to the volunteers in the user screen. Every role in the database is assignable there (the developer role only by developers).

The `*` entry is what closes the other modules. Without it, every module without an entry stays open to every logged-in user, including the new role.

::: danger Always list developer and admin
The lists are checked literally with the role middleware. A role that is not listed does not get in, and that includes `developer`: there is no automatic bypass for route access in 2.12. A `*` entry without `developer` locks the developers out of every module it covers. Copy the defaults and add to them.
:::

Core screens that are not modules (pages, navigation, forms, settings, users) keep their built-in role checks and are not configurable here.

### Verifying

Log in as a user with the custom role (or have an admin assign it to a test account): the sidebar shows only the permitted modules, and opening another module's URL directly, for example `/evolve/event`, answers 403. On the server, `php artisan route:list --path=evolve/blog -v` shows the `role:...` middleware each module route carries.

## Permissions&#x20;

2.12 adds a catalogue of permissions (spatie/laravel-permission, guard `web`) and grants it to the built-in roles, so that access can later be granted per ability instead of per role name. **In 2.12 nothing is decided by these permissions yet.** Routes, views and `evolve.roles` still check role names. A later release switches the checks to permissions (`can:` middleware and policies), shared by the panel, the MCP tools and the API.

The catalogue:

| Permission | Granted to by default |
|---|---|
| `pages.edit`, `pages.create`, `pages.delete`, `pages.publish` | developer, admin, content_manager |
| `pages.set-home` | developer, admin |
| `navigation.edit` | developer, admin, content_manager |
| `navigation.manage` | developer, admin |
| `sections.edit` | developer, admin, content_manager |
| `sections.manage` | developer, admin |
| `forms.manage`, `static-strings.manage`, `redirects.manage`, `languages.manage`, `regions.manage` | developer, admin, content_manager |
| `regions.switch` | developer, admin |
| `cache.manage`, `panel.search` | developer, admin, content_manager |
| `leads.view` | developer, admin, content_manager, crm_manager |
| `users.manage`, `tags.manage`, `activity.view`, `health.view`, `settings.manage`, `system.info`, `system.update` | developer, admin |
| `media.manage` | the roles in `evolve.roles.media` |
| `media.submit`, `media.approve`, `media.edit-rights`, `media.view-restricted` | developer, admin, `media.submit` also to every role with `media.manage`; only with the [Media Governance](/modules/media-governance) module (2.22) |
| `editor-ai.use` | the roles in `evolve.roles.editor-ai` |
| `modules.<name>.access` | the roles `evolve.roles.modules` resolves for the module, or all built-in roles when it has no entry |
| `settings.technical`, `devtools.access`, `roles.assign-developer` | developer only |

How the grants are applied:

* The migration `2026_09_26_000000_add_panel_permissions` creates missing permissions and grants them to the roles named above **that exist** in the database. Fresh installs get the same grants from the roles seeder.
* It only adds, never revokes, and leaves manual grants alone. Running it twice changes nothing.
* The media, AI and module grants are read from `evolve.roles` **at the time the migration runs**. A project with a customised `evolve.roles` should have that config deployed before or together with the migration. A custom role listed there (like `redaktionsmitglied` above) receives those grants when it exists by then; custom roles get nothing else.
* A `Gate::before` hook lets developers pass every Evolve ability and admins every one except the developer-only three. Abilities a project defines itself are left to its own gates and policies.

To check the grants of a role on a server:

```bash
php artisan permission:show web
```
