codenzia / laravel-superadmin
Protected super admin account for Laravel. Zero-config authorization via Gate::before, defense-in-depth Eloquent observer, optional Filament v4 plugin, vendor-only CLI commands with friction controls. Designed for vendor-deployed applications where customer admins must not accidentally delete the ve
Requires
- php: ^8.3
- illuminate/console: ^12.0 || ^13.0
- illuminate/contracts: ^12.0 || ^13.0
- illuminate/database: ^12.0 || ^13.0
- illuminate/notifications: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
Requires (Dev)
- filament/filament: ^4.0 || ^5.0
- laravel/pint: ^1.29
- mockery/mockery: ^1.6
- orchestra/testbench: ^10.0 || ^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
Suggests
- filament/filament: Required for the optional Filament v4 or v5 panel integration (hides destructive actions on the protected account)
- spatie/laravel-permission: Required if you want the package to auto-assign a 'super_admin' role on install/restore
README
Drop-in protected super-admin account for Laravel. Composer require, run migrate, and you have a working super-admin login. One env var (or one interactive command) overrides the defaults. No friction, no ceremony.
What you get
- A single protected user that is auto-created on first
migrate. Default email derived fromAPP_URL/APP_NAME. Default passwordsuperadmin. - An Eloquent observer that blocks deletion, email changes, unprotect attempts (
true → false), and mass-assignment privilege escalation (false → true) on theis_protectedflag. - A
Gate::beforehook so the super admin authorizes for every ability — works without Spatie, Shield, or any policies wired up. - Late role assignment. Solves the
MigrationsEndedvs Spatie-Role-row race: whenspatie/laravel-permissionis in use, thesuper_adminrole row often doesn't exist yet at auto-install time, so the role would silently fail to attach. A wildcardeloquent.createdlistener retroactively assigns the configured role the moment the row appears in a later seeder run. Idempotent and best-effort; no-ops cleanly when Spatie isn't installed. - A Filament plugin that hides destructive row actions (
delete,suspend,ban,impersonate, …) and disables privileged form fields (roles,status,email, …) on the protected user row — automatically, across every consumer app, with no per-resource code. - A
superadmin:ensurecommand that interactively rotates name + email + password. DB-only — never reads or writes.env. - A
superadmin:statuscommand (with--verbosefor full health diagnostics) so you can verify the install in one shot.
Quick start
composer require codenzia/laravel-superadmin php artisan migrate # ✓ Created protected super admin: superadmin@<your-host> (password: superadmin) # Override defaults in your seeder via SuperAdmin::ensure([...]). Change later with `php artisan superadmin:ensure`.
That's the whole install. The package listens to MigrationsEnded and creates the protected user once, if and only if no protected user exists. Re-running migrate is a no-op.
Override the defaults — two paths
v0.4.0+ keeps identity fields (name / email / password) out of .env and config entirely. Plaintext credentials never live on the host filesystem. Two override paths:
(1) Pin the values in your seeder — runs every migrate:fresh --seed / on first install:
use Codenzia\SuperAdmin\Facades\SuperAdmin; class UserSeeder extends Seeder { public function run(): void { SuperAdmin::ensure([ 'name' => 'Super Admin', 'email' => 'admin@your-app.test', 'password' => 'your-strong-password', ]); } }
Pass any subset of ['name', 'email', 'password']. Omitted keys fall back to package defaults on create; on update they're left unchanged (password specifically — omit to keep the current hash).
(2) Rotate post-install — DB-only artisan command:
php artisan superadmin:ensure # Super admin name [Super Admin]: # Super admin email [admin@your-app.test]: # Super admin password (leave blank to keep current): <new password> # ✓ Updated protected super admin: admin@your-app.test
Non-interactive variant:
php artisan superadmin:ensure --email=admin@your-app.test --password='your-strong-password'
superadmin:ensure never reads or writes .env. Plaintext only lives in the seeder source (committed to your repo with code) or in the operator's terminal during rotation.
Production password warning. The default
superadminis deliberately memorable for local dev and internal use. Always override via the seeder or rotate viasuperadmin:ensurebefore exposing the app to anyone.
Default email resolution
When the seeder doesn't pass email, the package derives one from your host's own config — never a vendor domain:
superadmin@<host>where<host> = parse_url(config('app.url'), PHP_URL_HOST)- else
superadmin@<slug>.localwhere<slug> = Str::slug(config('app.name'))
So APP_URL=https://myshop.com → superadmin@myshop.com. APP_NAME="My Shop" with no URL → superadmin@my-shop.local.
Default role resolution (Filament Shield bridge)
When bezhansalleh/filament-shield is installed, configuredRole() auto-discovers Shield's super-admin role name from filament-shield.super_admin.name. Apps don't need to set the role name in two places. When Shield is not present, the package falls back to the literal 'super_admin'.
How protection works
The package identifies the protected row via the users.is_protected = true DB column. v0.4.0+ removed the secondary email-match path since identity is no longer env-driven — the flag is the single source of truth, set by install() / ensure() and defended by the observer.
Four protection layers — each independent, so tampering with one doesn't silently disable the others:
| Layer | Behavior |
|---|---|
| Eloquent observer | Throws ProtectedAccountException on delete, email change, unprotect (true → false), and promote (false → true outside withoutProtection()). The last is what blocks mass-assignment escalation when a consumer app puts is_protected in $fillable. |
Gate::before |
Returns true for the protected user on every can() / policy / @can check — no Spatie or Shield required |
| Filament plugin (UX layer) | Auto-hides destructive row actions (delete, suspend, ban, impersonate, …) and auto-disables privileged form fields (roles, status, email, is_protected, …) on the protected user row. Zero per-resource code. See Filament below. |
| Late role assignment | Wildcard eloquent.created listener that retroactively assigns the configured role to the protected user the moment the role row exists (typically after migrate --seed). |
The observer is defense-in-depth. Use the facade in your policies for proper HTTP 403s (see UserPolicy below).
App-side defense-in-depth (recommended)
Even with the observer guarding false → true promotion, you should keep is_protected out of the User model's $fillable. The observer only fires on update, and only inside Eloquent — raw DB::table('users')->update(...) calls bypass it. The two-layer pattern:
class User extends Authenticatable { use IsSuperAdmin; // is_protected is intentionally NOT fillable. Only the package's // SuperAdmin::install() / SuperAdmin::ensure() (which wrap the // assignment in SuperAdmin::withoutProtection()) may set it. protected $fillable = ['name', 'email', 'password', 'phone', 'slug']; }
Commands
| Command | Purpose |
|---|---|
superadmin:ensure |
Create or update the protected user. DB-only — never reads or writes .env. Interactive prompts for name / email / password; pass any subset as flags to skip prompts. |
superadmin:status |
Summary of the protected user. Exits non-zero if missing. |
superadmin:status --verbose |
Adds the full health diagnostic matrix (model resolvable, column exists, protection enabled, role assigned, etc.). |
php artisan superadmin:status # +----+--------------------+--------------------------+---+ # | # | Setting | Value | | # +----+--------------------+--------------------------+---+ # | 1 | Email | superadmin@your-app.test | ✓ | # | 2 | is_protected | true | ✓ | # | 3 | Role | super_admin | ✓ | # +----+--------------------+--------------------------+---+ # ✓ Healthy.
Configuration
The package config is small. After php artisan vendor:publish --tag=superadmin-config:
return [ // v0.4.0+: identity (name / email / password / role) is NOT in this // config and NOT in env. See "Override the defaults" above — defaults // are seeder-driven via SuperAdmin::ensure([...]) or derived. 'user_model' => null, // null = resolved from auth.providers 'auto_install' => env('SUPER_ADMIN_AUTO_INSTALL', true), // create user on MigrationsEnded 'authorization' => ['gate_before' => true], // super admin passes every can() 'protection' => ['enabled' => env('SUPER_ADMIN_PROTECTION', true)], 'late_role_assignment' => env('SUPER_ADMIN_LATE_ROLE_ASSIGNMENT', true), // attach role when row appears later 'filament' => [ 'hide_destructive_actions' => true, // master switch for the Filament plugin // Row actions auto-hidden on the protected user row. Apps extend by // merging their own entries — see "Filament" section below. 'hidden_action_names' => [ 'delete', 'forceDelete', 'suspend', 'unsuspend', 'ban', 'unban', 'markEmailVerified', 'verify', 'unverify', 'impersonate', 'demote', ], // Form fields auto-disabled when editing the protected user. 'locked_field_names' => [ 'roles', 'role', 'permissions', 'status', 'is_protected', 'email', 'user_type', ], ], ];
Seeder integration
SuperAdmin::ensure() is the seeder-safe primitive. Two modes:
use Codenzia\SuperAdmin\Facades\SuperAdmin; class DatabaseSeeder extends Seeder { public function run(): void { // (a) No args — idempotent get-or-create. // Returns the existing protected user, or creates one with // defaultName() / defaultEmail() / defaultPassword(). $superAdmin = SuperAdmin::ensure(); // (b) With array — force-applies the supplied fields. Use this // to pin app-specific values that survive every reseed. $superAdmin = SuperAdmin::ensure([ 'name' => 'Super Admin', 'email' => 'admin@your-app.test', 'password' => 'your-strong-password', ]); } }
You don't strictly need the no-args call — the MigrationsEnded auto-install already handles fresh installs. The array form is the recommended pattern when a project wants stable, repo-tracked superadmin credentials across all of its environments.
For raw create/update with explicit credentials, use SuperAdmin::install($password, $email, $name).
Integration patterns
User model trait (optional)
Adds isSuperAdmin() plus two query scopes:
use Codenzia\SuperAdmin\Concerns\IsSuperAdmin; class User extends Authenticatable { use IsSuperAdmin; }
$user->isSuperAdmin(); // bool User::query()->superAdmin()->first(); // WHERE is_protected = true User::query()->exceptSuperAdmin()->get(); // WHERE NOT is_protected
UserPolicy
The observer throws — your policy should return a proper 403 first:
use Codenzia\SuperAdmin\Facades\SuperAdmin; use Illuminate\Auth\Access\Response; class UserPolicy { public function delete(User $actor, User $target): Response { if (SuperAdmin::is($target)) { return Response::deny('The super admin account cannot be deleted.'); } return $actor->can('delete_user') ? Response::allow() : Response::deny(); } public function update(User $actor, User $target): Response { if (SuperAdmin::is($target) && ! SuperAdmin::is($actor)) { return Response::deny('Only the super admin can modify the super admin account.'); } return $actor->can('update_user') ? Response::allow() : Response::deny(); } }
Filament
use Codenzia\SuperAdmin\Filament\SuperAdminPlugin; $panel->plugin(SuperAdminPlugin::make());
The plugin registers three defense-in-depth UX layers on the protected user row, all toggleable via config/superadmin.php and active by default:
DeleteAction/ForceDeleteActionauto-hide — original behavior. Admins never see a button that would only error at the observer layer.- Custom destructive row actions auto-hide. Any
Filament\Actions\ActionwhosegetName()is infilament.hidden_action_namesis hidden on the protected user. The default list catches the verbs we ship across our consumer apps:delete,forceDelete,suspend,unsuspend,ban,unban,markEmailVerified,verify,unverify,impersonate,demote. - Privileged form fields auto-disable. Any
Filament\Forms\Components\FieldwhosegetName()is infilament.locked_field_namesis disabled when the form's record is the super admin. Default list:roles,role,permissions,status,is_protected,email,user_type. Closes the "admin demotes the super admin via the roles Select" loophole.
Apps extend the defaults via config, no code:
// config/superadmin.php 'filament' => [ 'hidden_action_names' => [ ...config('superadmin.filament.hidden_action_names'), 'my_app_specific_destructive_action', ], 'locked_field_names' => [ ...config('superadmin.filament.locked_field_names'), 'my_app_specific_privileged_field', ], ],
Caveat. Filament's
->hidden()and->disabled()setters replace prior conditions (they don't AND/OR). If app code chains an explicit->hidden(false)after construction, the package's auto-hide is overridden. Apps that rely on->visible(fn () => ...)for conditional showing (the common pattern) are unaffected becausevisibleandhiddenare separate fields and an action is hidden when either hides it.
To also hide the protected row from non-super-admin viewers:
public static function getEloquentQuery(): Builder { $query = parent::getEloquentQuery(); if (! auth()->user()?->isSuperAdmin()) { $query->exceptSuperAdmin(); } return $query; }
Authorization modes
| Mode | authorization.gate_before |
Behavior |
|---|---|---|
| Default (zero-config) | true |
Gate::before authorizes the super admin for every ability. Role is also assigned (best-effort, if assignRole() exists on the User model). |
| Role-only | false |
Package only assigns the configured role. Authorization is delegated to your project (typically Filament Shield's own Gate::before). |
The package never creates the role row, defines permissions, or installs Shield — those remain your project's responsibility. In default mode, you don't need any of them: Gate::before covers authorization on its own.
What's new since 0.3.0
0.3.2 (2026-05-22). Adds late role assignment for the MigrationsEnded-vs-Spatie-Role-row race, and Filament auto-lock for the protected user row: every consumer app now auto-hides destructive row actions and auto-disables privileged form fields with no per-resource code. New config keys: late_role_assignment, filament.hidden_action_names, filament.locked_field_names. Tests grew from 84 to 105.
0.3.1 (2026-05-21). Security: the observer now blocks is_protected: false → true promotion via Eloquent update (mass-assignment privilege escalation defense). Previously only the downgrade direction was guarded. Also cleans up three stale protection.block_* config reads that were documented as removed in 0.3.0 but never deleted from the observer code.
See CHANGELOG.md for the full release notes.
Upgrading from 0.3.x to 0.4.0
v0.4.0 moves identity (name / email / password / role) entirely out of .env and config. Per-app upgrade:
composer update codenzia/laravel-superadmin- Move any per-app overrides from
.envinto your seeder:// database/seeders/UserSeeder.php SuperAdmin::ensure([ 'email' => 'admin@your-app.test', // was: SUPER_ADMIN_EMAIL 'password' => 'your-strong-password', // was: SUPER_ADMIN_PASSWORD ]);
- Delete
SUPER_ADMIN_PASSWORD,SUPER_ADMIN_EMAIL,SUPER_ADMIN_ROLE,SUPER_ADMIN_NAMEfrom every.envand.env.example. These env vars are no longer honored — leaving them set is harmless but stale. - If you publish the package config: delete the
email,password,rolekeys fromconfig/superadmin.php. They're no longer read. - Update any callers of
php artisan superadmin:setuptophp artisan superadmin:ensure. The old command name was removed. - If you use Filament Shield: nothing to do —
configuredRole()now auto-discoversfilament-shield.super_admin.name.
Removed in 0.4.0
| Removed | Replacement |
|---|---|
SUPER_ADMIN_PASSWORD env var |
Seeder override: SuperAdmin::ensure(['password' => '...']) |
SUPER_ADMIN_EMAIL env var |
Seeder override: SuperAdmin::ensure(['email' => '...']) |
SUPER_ADMIN_ROLE env var |
Auto-discovered from filament-shield.super_admin.name |
config('superadmin.email' / '.password' / '.role') |
Same — moved into seeder or auto-discovered |
superadmin:setup command |
superadmin:ensure (interactive prompts, but DB-only — no .env writes) |
EnvWriter helper |
Removed entirely — the package never writes to .env now |
Upgrading from 0.2.x
v0.3.0 was a clean break. The vendor-friction model is gone. Per-app upgrade:
composer update codenzia/laravel-superadminphp artisan migrate— auto-installs the protected user if none exists; no-op if one does.- Replace any seeder calls to
SuperAdmin::install(...)withSuperAdmin::ensure()(or keepinstall()if you need explicit credentials). - Delete
.enventries that are no longer recognized (see table below).
Removed in 0.3.0
| Removed | Replacement |
|---|---|
superadmin:install |
superadmin:ensure (or just run migrate for the default install) |
superadmin:reset |
superadmin:ensure |
superadmin:assign-role |
(automatic on install() / ensure()) |
superadmin:doctor |
superadmin:status --verbose |
--confirm flag, typed phrase, VendorCommandInvoked notification |
Removed entirely. No friction layer. |
SUPER_ADMIN_NOTIFY_MAIL / SUPER_ADMIN_NOTIFY_SLACK / SUPER_ADMIN_VENDOR_PHRASE |
Removed entirely. |
vendor_commands.* config |
Removed entirely. |
notifications.* config |
Removed entirely. |
protection.block_delete / block_email_change / block_flag_change |
Collapsed into protection.enabled — all three behaviors fire together. |
Kept
is_protectedcolumn + Eloquent observerGate::beforeauthorization- Filament destructive-action hiding
IsSuperAdmintrait + query scopesSuperAdminfacade —is(),user(),exists(),install(),email(),userModel(),isConfigured(),assignRole(),hasConfiguredRole(),withoutProtection()- Facade methods:
ensure(?array),defaultEmail(),defaultPassword(),defaultName()
Testing
105 Pest tests, 173 assertions. Covers the manager, the observer (delete + email + unprotect + promote-escalation), Gate::before, the MigrationsEnded auto-install hook, the late-role-assignment listener, the setup command, the env writer, and the Filament plugin (DeleteAction / ForceDeleteAction hiding, custom-named-action auto-hide, locked form-field auto-disable, master-switch kill, app-extended allowlists).
composer test
License
MIT © Codenzia. See LICENSE.md.