My User model has role in $fillable. It has to — my seeders create admin accounts and the admin panel updates roles. So mass-assignment protection does not save me here. Anyone can POST a role field to my register endpoint, and Eloquent would happily accept it — if I ever passed request data straight through.
I don't. The register method hardcodes it:
public function register(RegisterRequest $request): JsonResponse
{
$user = User::create([
'name' => $request->validated('name'),
'email' => $request->validated('email'),
'password' => $request->validated('password'),
'role' => 'user', // never from the request
]);
$token = $user->createToken('auth-token')->plainTextToken;
return response()->json([
'user' => new UserResource($user),
'token' => $token,
], 201);
}
Two layers, both deliberate:
1. The FormRequest has no role rule. RegisterRequest validates name, email (unique) and password (min 8, confirmed). A role key in the payload never even survives validation — validated() drops it.
2. The controller sets the value explicitly. Even if validation passed it through, the create call ignores the request for this column. There is exactly one place in the codebase that decides a new signup's role, and it is that one line.
Why both? Defence in depth sounds like a slogan until someone refactors the FormRequest six months later and adds a field. The controller line is the real lock; the FormRequest is the habit that keeps the payload surface small.
Elevated roles get created by seeders and the admin panel only (the admin update route validates role against in:admin,user and is itself guarded by the role middleware plus an isAdmin() authorisation check). Registration creates customers. That rule is enforced in code, not in documentation.
This is one of the small security decisions baked into the Laravel + React SaaS Starter Kit — Sanctum auth, role middleware and the user management loop come wired this way out of the box: https://kamranofficial.gumroad.com/l/mhekoig
Top comments (4)
Nice, the hardcoded line is the part that survives a refactor.
One more field on that endpoint worth treating the same way is email. The unique rule compares the string as sent, so on Postgres Foo@Example.com and foo@example.com both get through (MySQL's default collation hides this). Lowercasing it in prepareForValidation() fixes that. And if the kit ever gives new accounts free credits, Gmail ignores dots and anything after a +, so one inbox can register many times unless you also store a normalised copy with its own unique index.
This is a sharp catch. I've only ever run this stack on MySQL, so the collation hid the case-sensitivity hole from me, and lowercasing in prepareForValidation() is exactly the right home for the fix. The Gmail dot/plus trick is the nastier one because it sails through even a correct unique index, so I'll add a normalized email column with its own unique index when the kit gets free credits. Thanks for flagging it.
Glad it helped. Two small things when you add it: only drop the dots for gmail.com and googlemail.com, since most other providers treat a dot as a real part of the address. And check existing users for collisions on the normalised value first, because any that already exist will break the backfill or the unique index.
Good call on googlemail.com, I would have forgotten that one, and you're right that blanket dot-stripping would corrupt addresses for providers that treat the dot as significant. I'll scope it to a provider check in the normaliser. And the collision point is the one that would have bitten me during the backfill, so I'll run a query for duplicate normalised values before the migration touches anything. Cheap check, expensive to skip.