Spatie Permissions
Add admin, manager, and user roles. New registrations get user; seed roles before opening registration.
Installation
bash
composer require spatie/laravel-permissionbash
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"bash
php artisan config:clear
php artisan migratebash
php artisan boost:updateUser Model
Add HasRoles to the model from step 03. Keep the split-name methods and auth traits.
File: app/Models/User.php
php
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Laravel\Fortify\Contracts\PasskeyUser;
use Laravel\Fortify\PasskeyAuthenticatable;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Spatie\Permission\Traits\HasRoles;
/**
* @property int $id
* @property string $first_name
* @property string $last_name
* @property string $email
* @property Carbon|null $email_verified_at
* @property string $password
* @property string|null $two_factor_secret
* @property string|null $two_factor_recovery_codes
* @property Carbon|null $two_factor_confirmed_at
* @property string|null $remember_token
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*/
#[Fillable(['first_name', 'last_name', 'email', 'password'])]
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
class User extends Authenticatable implements PasskeyUser
{
/** @use HasFactory<UserFactory> */
use HasFactory, HasRoles, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable;
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'two_factor_confirmed_at' => 'datetime',
];
}
/**
* Get the user's full name.
*/
public function fullName(): string
{
return trim($this->first_name.' '.$this->last_name);
}
/**
* Use the full name in passkey prompts.
*/
public function getPasskeyDisplayName(): string
{
return $this->fullName();
}
/**
* Get the user's initials.
*/
public function initials(): string
{
return Str::upper(Str::substr($this->first_name, 0, 1).Str::substr($this->last_name, 0, 1));
}
}Registration
File: app/Actions/Fortify/CreateNewUser.php
php
<?php
namespace App\Actions\Fortify;
use App\Concerns\PasswordValidationRules;
use App\Concerns\ProfileValidationRules;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\CreatesNewUsers;
class CreateNewUser implements CreatesNewUsers
{
use PasswordValidationRules, ProfileValidationRules;
/**
* Validate and create a newly registered user.
*
* @param array<string, string> $input
*/
public function create(array $input): User
{
Validator::make($input, [
...$this->profileRules(),
'password' => $this->passwordRules(),
])->validate();
$user = User::create([
'first_name' => $input['first_name'],
'last_name' => $input['last_name'],
'email' => $input['email'],
'password' => $input['password'],
]);
$user->assignRole('user');
return $user;
}
}Seeders
Local demo accounts only
admin@example.com and user@example.com share local-only-change-me. Keep the local environment guard; never deploy these accounts.
bash
php artisan make:seeder RoleSeeder
php artisan make:seeder UserSeederFile: database/seeders/RoleSeeder.php
php
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
class RoleSeeder extends Seeder
{
public function run(): void
{
app(PermissionRegistrar::class)->forgetCachedPermissions();
$adminAccess = Permission::firstOrCreate(['name' => 'admin access']);
$managerAccess = Permission::firstOrCreate(['name' => 'manager access']);
$userAccess = Permission::firstOrCreate(['name' => 'user access']);
Role::firstOrCreate(['name' => 'admin'])->syncPermissions([$adminAccess, $managerAccess, $userAccess]);
Role::firstOrCreate(['name' => 'manager'])->syncPermissions([$managerAccess, $userAccess]);
Role::firstOrCreate(['name' => 'user'])->syncPermissions([$userAccess]);
}
}File: database/seeders/UserSeeder.php
php
<?php
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class UserSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
if (! app()->environment('local')) {
return;
}
$users = [
['first_name' => 'Local', 'last_name' => 'Admin', 'email' => 'admin@example.com', 'role' => 'admin'],
['first_name' => 'Regular', 'last_name' => 'User', 'email' => 'user@example.com', 'role' => 'user'],
];
foreach ($users as $userData) {
$role = $userData['role'];
unset($userData['role']);
User::firstOrCreate(['email' => $userData['email']], [
...$userData,
'email_verified_at' => now(),
'password' => Hash::make('local-only-change-me'),
'remember_token' => Str::random(10),
])->assignRole($role);
}
}
}File: database/seeders/DatabaseSeeder.php
php
<?php
namespace Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
use WithoutModelEvents;
/**
* Seed the application's database.
*/
public function run(): void
{
$this->call([
RoleSeeder::class,
UserSeeder::class,
]);
}
}Seed the Database
assignRole('user') fails if the role is missing, after the user has already been inserted. Seed first in every environment.
UserSeeder only runs in local. Reruns preserve existing user fields but still assign the listed roles. RoleSeeder runs everywhere and replaces each role's permissions with the listed set; keep that list current.
bash
php artisan db:seedRegistration Tests
Seed RoleSeeder in tests that create users through registration; a fresh test database has no roles. Keep the feature guards and split-name payload.
File: tests/Feature/Auth/RegistrationTest.php
php
<?php
use Database\Seeders\RoleSeeder;
use Laravel\Fortify\Features;
beforeEach(function () {
$this->skipUnlessFortifyHas(Features::registration());
});
test('registration screen can be rendered', function () {
$response = $this->get(route('register'));
$response->assertOk();
});
test('new users can register', function () {
$this->seed(RoleSeeder::class);
$response = $this->post(route('register.store'), [
'first_name' => 'Test',
'last_name' => 'User',
'email' => 'test@example.com',
'password' => 'password',
'password_confirmation' => 'password',
]);
$response->assertSessionHasNoErrors();
$response->assertRedirect(route('dashboard', absolute: false));
$this->assertAuthenticated();
});Add an assertion that the registered user has the user role; the imported test currently checks authentication and redirect only.
Verify
bash
composer lint
composer ci:checkRerun php artisan db:seed locally; confirm no duplicate users or roles, then register a new account and check its user role.
Commit
bash
git add -A
git commit -m '04 - Spatie Permissions'