In this lesson, let's talk about structuring projects by areas based on roles. It's a typical scenario for many projects: for example, you have administrator users and regular users.
There could be a more complicated scenario: for instance, in school management software, you might have students, teachers, admins, etc. So, how do we structure such projects?
We have two sub-questions I've seen developers asking:
- Do we define separate Models:
app/Models/Admin.php
,app/Models/User.php
, etc? - Do we define separate namespaces:
app/Http/Controllers/User
,app/Http/Controllers/Admin
, etc? Same with Routes?
The answer to the first question is no. Please, please don't create separate models and DB tables for different user roles. It will bite you in the long run: you will need to repeat the same logic in multiple Models, then. Use one model User
and then roles/permissions DB tables for this: below, you will see a few examples.
The second question can be answered with "it depends on a project" or "it's your personal preference".
Some projects have totally separate admin areas: different designs, different features, separate login areas, etc. Then, of course, it makes sense to separate the Controllers/routes/views into subfolders.
Other projects are just about checking the permission of the users to access the same pages/functionality. In those cases, I would go for the same folder for Controllers/routes/views and check the permissions as needed.
Let's look at examples of this approach from open-source projects.
Example 1. Check Roles/Permissions in Controller.
This approach to structuring admin/user areas is about using roles/permissions and not changing anything about the structure of the folders.
In other words, you have the same Controllers, common for all user roles. Just check the permissions of who can access which route or method.
The example is from ploi/roadmap, an open-source project.
They have a role
column in the users
table, and roles are defined in the enum
class.
2022_06_17_053959_convert_roles_for_users.php:
public function up(){ Schema::table('users', function (Blueprint $table) { $table->string('role')->nullable()->default(UserRole::User->value)->after('password'); }); // ...}
enum UserRole: string{ case Admin = 'admin'; case Employee = 'employee'; case User = 'user';}
Then, a method hasAdminAccess()
in the User Model checks if the user has the role of admin or employee.
class User extends Authenticatable implements FilamentUser, HasAvatar, MustVerifyEmail{ // ... public function hasAdminAccess(): bool { return in_array($this->role, [UserRole::Admin, UserRole::Employee]); } // ...}
Now, this method can be used throughout the whole project to check if the authenticated user has access.
app/Http/Controllers/BoardsController.php:
class BoardsController extends Controller{ public function show(Project $project, Board $board) { abort_if($project->private && !auth()->user()?->hasAdminAccess(), 404); return view('board', [ 'project' => $project, 'board' => $board, ]); }}
Example 2. Check Roles/Permissions in Policies.
The second example is from the laravelio/laravel.io open-source project. The user role is defined similarly to the first example, but instead of the role
column in the user table here, it is called type
, and types are defined as constants in the User Model.
database/migrations/2017_04_08_104959_next_version.php:
public function up(): void{ // ... Schema::table('users', function (Blueprint $table) { $table->string('email')->unique()->change(); $table->string('username', 40)->default(''); $table->string('password')->default(''); $table->smallInteger('type', false, true)->default(1); $table->dateTime('created_at')->nullable()->default(null)->change(); $table->dateTime('updated_at')->nullable()->default(null)->change(); }); // ...}
final class User extends Authenticatable implements MustVerifyEmail{ // ... const DEFAULT = 1; const MODERATOR = 2; const ADMIN = 3; // ...}
Then, there are two methods to check if a user is an admin or moderator.
final class User extends Authenticatable implements MustVerifyEmail{ // ... public function type(): int { return (int) $this->type; } public function isModerator(): bool { return $this->type() === self::MODERATOR; } public function isAdmin(): bool { return $this->type() === self::ADMIN; } // ...}
Then, these methods are used, for example, in Policies.
final class UserPolicy{ const ADMIN = 'admin'; const BAN = 'ban'; const BLOCK = 'block'; const DELETE = 'delete'; public function admin(User $user): bool { return $user->isAdmin() || $user->isModerator(); } public function ban(User $user, User $subject): bool { return ($user->isAdmin() && ! $subject->isAdmin()) || ($user->isModerator() && ! $subject->isAdmin() && ! $subject->isModerator()); } public function block(User $user, User $subject): bool { return ! $user->is($subject) && ! $subject->isModerator() && ! $subject->isAdmin(); } public function delete(User $user, User $subject): bool { return ($user->isAdmin() || $user->is($subject)) && ! $subject->isAdmin(); }}
These policies can now be used in the Controller.
app/Http/Controllers/Admin/UsersController.php:
class UsersController extends Controller{ // ... public function ban(BanRequest $request, User $user): RedirectResponse { $this->authorize(UserPolicy::BAN, $user); $this->dispatchSync(new BanUser($user, $request->get('reason'))); $this->success('admin.users.banned', $user->name()); return redirect()->route('profile', $user->username()); } public function unban(User $user): RedirectResponse { $this->authorize(UserPolicy::BAN, $user); $this->dispatchSync(new UnbanUser($user)); $this->success('admin.users.unbanned', $user->name()); return redirect()->route('profile', $user->username()); } // ...}
And, of course, policies, in this case, are used in Middleware to allow only admins and moderators to access specific routes.
app/Http/Middleware/VerifyAdmins.php:
class VerifyAdmins{ public function handle(Request $request, Closure $next, $guard = null): Response { if (Auth::guard($guard)->user()->can(UserPolicy::ADMIN, User::class)) { return $next($request); } throw new HttpException(403, 'Forbidden'); }}
app/Http/Controllers/Admin/UsersController.php:
class UsersController extends Controller{ public function __construct() { $this->middleware([Authenticate::class, VerifyAdmins::class]); } // ...}
You may also read a few additional tutorials on this topic:
I think that statement says it all. Having been bitten by a production project that is more than four years old I have learnt a lot through mistakes, but still have many lingering questions.
Let me make it clear I'm not worried about name spacing. Name spacing in my opinion is a more general / beginner type question.
Implementing multiple roles if you have a single database column doesn't suffice. It's back to many-to-many relationships either roll your own use Spatie's like so many people do.
The moment you have many-to-many filtering and a User database model it gets tricky.
Let's take this example for a video game.
Role #1 - Administrators - mostly administers the users but can also be a player. So more than one role.
Role #2 - Players
Role #3 Viewers. Only observes games and can like stuff. But can also be players. So again more than one role.
To design this more complex project there will be many hanging questions. Perhaps in your "Game" model there is a GameUser many to many and each line has a pivot column for "role" who created it?
Also, you probably can't have one dashboard. You have to carefully think about when a user with more than one role logs in, what to show them, how to direct them.
Then for your back office. Unless you like pain you're already using either Filament or Nova. In both Filament and Nova it's not so easy to generate resources for the same model but have them "separate". Elegant filtering is not so easy nor readable with many-to-many relationships. Performance might even suffer.
I would like to see a tutorial that handles the more complex cases as mentioned above starting with a user can have more than one roles, redirection, and how to elegancy build the admin back-end without breaking your back.
After having type this huge reply I was wondering if you thought there was ever a use case for this:
I've experimented with it and because of "authenticatable" it's not simple. But somehow I thought that to be a good solution, at one stage.
Wow Eugene, what a long comment. I appreciate you taking the time.
The thing is that your question touches on multiple topics that require multiple DEEP tutorials comparing the approaches. I will try to touch on them in my upcoming course about Roles/Permissions.
But, in short:
Everything else depends on the exact functionality you want to achieve for the exact project. The balance between performance, developer experience and user experience.
Also, one more thing I remembered: Spatie package docs say this, and I would apply it broader: don't be attached to roles, always check for PERMISSION (Gate) in your code, whichever roles have those permission.
A role is just a "parent" concept of "set of permissions", and roles may change quite a bit. But permission names rarely change.