givanov95 / laravel-roles-permissions-crud
Admin CRUD for spatie/laravel-permission roles & permissions on Laravel + Inertia, with a matching Vue 3 frontend (@givanov95/vue-roles-permissions-crud).
Package info
github.com/givanov95/laravel-roles-permissions-crud
pkg:composer/givanov95/laravel-roles-permissions-crud
Requires
- php: ^8.3
- givanov95/laravel-data-table: ^4.1
- illuminate/contracts: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/routing: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- illuminate/validation: ^12.0|^13.0
- inertiajs/inertia-laravel: ^3.0
- spatie/laravel-permission: ^6.0|^7.0|^8.0
Requires (Dev)
- givanov95/laravel-git-hooks: ^1.1
- larastan/larastan: ^3.0
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^12.0|^13.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v7.1.2
- v7.1.1
- v7.1.0
- v7.0.0
- v6.3.0
- v6.2.0
- v6.1.0
- v6.0.0
- v5.1.0
- v5.0.0
- v4.0.1
- v4.0.0
- v3.0.0
- v2.0.2
- v2.0.1
- v2.0.0
- v1.1.2
- v1.1.1
- v1.1.0
- v1.0.1
- v1.0.0
- dev-fix/58-lazy-own-access
- dev-feat/56-custom-primary-keys
- dev-fix/55-wildcard-lock-out
- dev-fix/53-modal-max-width
- dev-fix/52-native-dialog-modal
- dev-fix/51-modal-focus-return
- dev-fix/50-lock-delete-modal
- dev-chore/npm-trusted-publishing
This package is auto-updated.
Last update: 2026-10-31 11:17:22 UTC
README
Admin CRUD for spatie/laravel-permission roles & permissions on Laravel + Inertia, with a matching Vue 3 frontend.
The PHP package (givanov95/laravel-roles-permissions-crud) ships the controllers,
form requests and a route macro. The npm package (@givanov95/vue-roles-permissions-crud)
ships the Vue components you drop into thin page shells inside your own admin layout.
Why thin shells?
Full Inertia pages cannot be resolved from a package (the Inertia resolver only globs
the host app's resources/js/pages). So — exactly like @givanov95/vue-data-table —
the package ships components; each app keeps a ~10-line page shell that wraps the
component in its own layout. The shared logic lives in the package; only the layout
stays local.
Install
composer require givanov95/laravel-roles-permissions-crud npm install @givanov95/vue-roles-permissions-crud @givanov95/vue-data-table @tanstack/vue-table
The lists are paged, searched and sorted on the server with
givanov95/laravel-data-table, which composer
installs; @givanov95/vue-data-table and @tanstack/vue-table are its frontend side.
Local development (path / file: link)
To work on the package alongside an application, link both from the application instead of installing the released versions.
composer.json:
"repositories": [ { "type": "path", "url": "../laravel-roles-permissions-crud" } ], "require": { "givanov95/laravel-roles-permissions-crud": "*" }
package.json:
"dependencies": { "@givanov95/vue-roles-permissions-crud": "file:../laravel-roles-permissions-crud" }
Vite must bundle the package source (it ships .ts/.vue, not a build):
// vite.config.ts — ssr.noExternal noExternal: ['@givanov95/vue-roles-permissions-crud', '@givanov95/vue-data-table']
Tailwind must scan the package's components for their classes (v4 skips node_modules):
/* resources/css/app.css */ @source '../../node_modules/@givanov95/vue-roles-permissions-crud/resources/js';
Backend wiring
-
(Optional) publish & tune the config:
php artisan vendor:publish --tag=roles-permissions-crud-config
Keys:
guard,authorize_permissions,prevent_privilege_escalation,protected_roles,protected_permissions,allow_delete_in_use,prefix,route_name_prefix,middleware,page_prefix. Defaults gate the admin area by four permissions (/admin/roles, route namesadmin.roles.*, Inertia pagesadmin/roles/*):view-rolesandview-permissionsto look,manage-rolesandmanage-permissionsto change. Seed them and grant them to your admin role; whoever edits needs both theview-*and themanage-*permission. -
Register the routes in
routes/web.php:Route::rolesPermissionsCrud();
This creates
admin.roles.*andadmin.permissions.*resource routes (minusshow) under the configured prefix and middleware, with per-resourcepermission:middleware fromauthorize_permissions. -
Your own models are used. The controllers create, update and delete roles and permissions through the classes in
config/permission.php(models.role,models.permission), so a model that extends spatie's (observers, overriddendelete(), …) sees every change made in these screens. Each write, such as creating a role together with its permissions, runs in one database transaction.A model with another primary key works too, such as spatie's UUID set-up (
HasUuidsand$primaryKey = 'uuid', with UUID columns in the pivot tables). The screens read the key from the model, the role form sends permission keys as strings when the key is not an integer (validated asuuidorulidfor a key made byHasUuidsorHasUlids, else asstring), and theidfields of the pages and rows carry that key. Route binding finds a record by the configured model's key because the package binds spatie'sRoleandPermissionclasses in the container to the configured ones, soapp(\Spatie\Permission\Models\Role::class)gives your model. -
The lists come from the server.
index()sends onedataTableprop, the payload of laravel-data-table'sDataTable::toArray(): the rows of the page,meta(page, total),columnsand the appliedstate.List Search Sort Row fields roles namename,users_countid,name,users_count,others_count,permissions,is_protected,can_manage,can_delete,delete_refusalpermissions namename,roles_countid,name,roles_count,direct_count,is_protected,can_manage,can_delete,delete_refusalThey are sorted by name by default, and the page size is
default_per_pageinconfig/data-table.php.users_countcounts only the guard's user model. To change the query, the columns or the rows, extendGivanov95\RolesPermissionsCrud\Lists\RoleList(orPermissionList) and bind it:$this->app->bind(RoleList::class, AppRoleList::class);
An application with its own table component can render the
dataTableprop itself, withuseLaravelDataTable()from@givanov95/vue-data-table/tanstack, instead ofRolesManager.Every page also gets the package's own data under one
rolesPermissionsCrudprop, so it cannot shadow a prop your application shares:routeNamePrefix(route_name_prefix) and, on the lists,canCreate(whether the user may add).can_manageon a row says whether the user may open, edit and delete it: themanage-*permission and, while escalation is prevented, a record within their reach.can_deleteadds what a delete checks: not protected, and not in use unlessallow_delete_in_use(others_count, models other than users that have the role, anddirect_count, users given the permission straight, show what keeps a record in use).delete_refusalsays why, when the user may manage the record but a delete would be refused: the translated message the delete itself would show (protected, in use, or the user would lose access they need to manage access), andnullotherwise. Show it as the hint on a disabled Delete rather than working the reason out from the counts, which cannot tell, for example, a role in use from one whose delete would lock the user out. The components read these from the page, so the shells do not forward them: they show Edit and Delete only where they lead somewhere, and a refused Delete greyed out, with the reason as its hint and in a dialog on a click (a hint alone does not show on a touch screen or from the keyboard).
Security
What an application gets without configuring anything:
- The routes sit behind the configured
middleware(web,auth,verifiedby default). Listing needsview-roles/view-permissions; opening a form or changing anything needsmanage-roles/manage-permissions(authorize_permissions, see below). The form requests and the controllers check the same permissions themselves on every action, so routes you register by hand instead of with the macro are covered too. permissionis a middleware alias that spatie/laravel-permission does not register for you. Register it in your application (Laravel 11+:$middleware->alias(['permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class])inbootstrap/app.php), otherwise the routes cannot resolve it and requests fail.authorize_permissionstakes, per resource, a['view' => ..., 'manage' => ...]pair (the default), a string (one permission for reading and writing, as before they were split) ornull. A null part is not checked, andnulldrops the check everywhere (route middleware, form requests and controllers), which leaves onlymiddleware; do that only when you guard the routes yourself.- Nobody can hand out more than they hold (
prevent_privilege_escalation, on by default). A permission can be added to a role only by a user who holds it; a role can be opened for editing, edited or deleted only by a user who holds every permission it carries; the forms offer only the permissions the user holds, and the roles list says which roles are theirs to change (can_manage). A user counts as holding what they can do, so a super admin let through byGate::beforeholds everything. - A permission can be renamed or deleted only by a user who holds it, or when no role or user has it: otherwise the change would reach roles and users beyond them. A permission in use can be renamed only to a name the user can use, since the rename hands that name to everyone who has the permission.
- Nobody locks themselves out. While escalation is prevented, a user cannot take away from their own role, or delete with it, a permission from
authorize_permissionsthat they hold through nothing else. With spatie's wildcard permissions on, a wildcard counts by spatie's rules on both sides (*orroles.*keepsroles.manage). AGate::beforedoes not count: the package cannot tell whether it lets the user in because of that very role. - Nothing in use is deleted by accident (
allow_delete_in_use, false by default). A role assigned to anyone, or a permission given to a role or straight to a user, is not deleted; the screen says so. Set it to true to delete them together with their assignments. - A permission the user does not hold and an id that does not exist get the same validation error (
The selected permission is invalid.onpermissions), without names, so the form cannot be used to find out which ids exist or what they are called. protected_roles(empty by default; list the roles your application's access depends on, e.g.['Administrator']) cannot be renamed or deleted and, while escalation is prevented, cannot lose permissions.protected_permissions(empty by default) lists permissions that cannot be renamed or deleted. The ones named inauthorize_permissionsare always protected, because deleting one would lock out whoever manages access, and so, while escalation is prevented, are the permissions a protected role holds. Add the permissions your own routes check.- The screens list and change only the roles and permissions of the configured
guard. One of another guard is a 404 on edit, update and delete, a name only has to be unique within the guard, and a permission given to a role must exist for the guard (one from another guard is a validation error). - Spatie teams (
permission.teams): the screens work in the current team (setPermissionsTeamId(), set by your middleware beforeSubstituteBindings). They list the team's roles and the global ones (no team); a role of another team is a 404, a new role belongs to the team, a name has to be free among the team's and the global roles (a global role's among every role), the names inprotected_rolesare reserved, and the counts are the team's. What every team shares is read-only inside a team: the global roles and all permissions (spatie's permissions have no team), since changing one would change the other teams' access. They are changed outside any team (no current team). There spatie finds no team-bound roles for a user, so in practice that is a super admin let through byGate::before, as spatie's teams docs suggest.
Frontend wiring
-
Register the runtime config once in your app entrypoint:
import { RolesPermissionsPlugin, RolesPlugin, PermissionsPlugin } from '@givanov95/vue-roles-permissions-crud'; app.use(RolesPermissionsPlugin, { translator: (key, replacements) => (window as any).__(key, replacements), route: (...args) => (window as any).route(...args), routeNamePrefix: 'admin.', // fallback: every page of the screens sends route_name_prefix itself }); // Optional global $hasRole / $can helpers: app.use(RolesPlugin).use(PermissionsPlugin);
-
Add thin page shells under
resources/js/pages/admin/(the controllers render{page_prefix}/roles/Indexand so on, andpage_prefixisadminby default; the names are case-sensitive):<!-- admin/roles/Index.vue --> <script setup lang="ts"> import { RolesManager } from '@givanov95/vue-roles-permissions-crud'; import AdminLayout from '@/layouts/AdminLayout.vue'; defineProps<{ dataTable: any }>(); </script> <template> <AdminLayout :title="__('Roles')"> <template #header><h1 class="text-xl font-semibold text-gray-800">{{ __('Roles') }}</h1></template> <RolesManager :data-table="dataTable" /> </AdminLayout> </template>
Components & the props each shell forwards:
Page Component Props admin/roles/IndexRolesManagerdataTableadmin/roles/CreateRoleFormpermissionsadmin/roles/EditRoleFormrole,permissionsadmin/permissions/IndexPermissionsManagerdataTableadmin/permissions/CreatePermissionForm— admin/permissions/EditPermissionFormpermission
Tests
composer test # PHPUnit on Orchestra Testbench composer analyse # PHPStan (Larastan)
TestCase switches the middleware, authorize_permissions and prevent_privilege_escalation off so the controllers can be tested on their own; DefaultsTestCase runs with the package defaults (access control, validation, protected roles, mass assignment, the permission cache). CI runs both commands on PHP 8.3 and 8.4 for every push to main and every pull request, and publishing to npm waits for it. It uses the newest Laravel that composer.json allows; Laravel 12 is not tested separately.
License
MIT © Georgi Ivanov