Search by

givanov95 / laravel-roles-permissions-crud

givanov95

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

Statistics

Installs: 1 297

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 5

v7.1.2 2026-10-28 13:21 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

  1. (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 names admin.roles.*, Inertia pages admin/roles/*): view-roles and view-permissions to look, manage-roles and manage-permissions to change. Seed them and grant them to your admin role; whoever edits needs both the view-* and the manage-* permission.

  2. Register the routes in routes/web.php:

    Route::rolesPermissionsCrud();

    This creates admin.roles.* and admin.permissions.* resource routes (minus show) under the configured prefix and middleware, with per-resource permission: middleware from authorize_permissions.

  3. 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, overridden delete(), …) 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 (HasUuids and $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 as uuid or ulid for a key made by HasUuids or HasUlids, else as string), and the id fields of the pages and rows carry that key. Route binding finds a record by the configured model's key because the package binds spatie's Role and Permission classes in the container to the configured ones, so app(\Spatie\Permission\Models\Role::class) gives your model.

  4. The lists come from the server. index() sends one dataTable prop, the payload of laravel-data-table's DataTable::toArray(): the rows of the page, meta (page, total), columns and the applied state.

    List Search Sort Row fields
    roles name name, users_count id, name, users_count, others_count, permissions, is_protected, can_manage, can_delete, delete_refusal
    permissions name name, roles_count id, name, roles_count, direct_count, is_protected, can_manage, can_delete, delete_refusal

    They are sorted by name by default, and the page size is default_per_page in config/data-table.php. users_count counts only the guard's user model. To change the query, the columns or the rows, extend Givanov95\RolesPermissionsCrud\Lists\RoleList (or PermissionList) and bind it:

    $this->app->bind(RoleList::class, AppRoleList::class);

    An application with its own table component can render the dataTable prop itself, with useLaravelDataTable() from @givanov95/vue-data-table/tanstack, instead of RolesManager.

    Every page also gets the package's own data under one rolesPermissionsCrud prop, so it cannot shadow a prop your application shares: routeNamePrefix (route_name_prefix) and, on the lists, canCreate (whether the user may add). can_manage on a row says whether the user may open, edit and delete it: the manage-* permission and, while escalation is prevented, a record within their reach. can_delete adds what a delete checks: not protected, and not in use unless allow_delete_in_use (others_count, models other than users that have the role, and direct_count, users given the permission straight, show what keeps a record in use). delete_refusal says 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), and null otherwise. 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, verified by default). Listing needs view-roles / view-permissions; opening a form or changing anything needs manage-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.
  • permission is 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]) in bootstrap/app.php), otherwise the routes cannot resolve it and requests fail.
  • authorize_permissions takes, per resource, a ['view' => ..., 'manage' => ...] pair (the default), a string (one permission for reading and writing, as before they were split) or null. A null part is not checked, and null drops the check everywhere (route middleware, form requests and controllers), which leaves only middleware; 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 by Gate::before holds 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_permissions that they hold through nothing else. With spatie's wildcard permissions on, a wildcard counts by spatie's rules on both sides (* or roles.* keeps roles.manage). A Gate::before does 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. on permissions), 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 in authorize_permissions are 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 before SubstituteBindings). 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 in protected_roles are 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 by Gate::before, as spatie's teams docs suggest.

Frontend wiring

  1. 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);
  2. Add thin page shells under resources/js/pages/admin/ (the controllers render {page_prefix}/roles/Index and so on, and page_prefix is admin by 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/Index RolesManager dataTable
    admin/roles/Create RoleForm permissions
    admin/roles/Edit RoleForm role, permissions
    admin/permissions/Index PermissionsManager dataTable
    admin/permissions/Create PermissionForm —
    admin/permissions/Edit PermissionForm permission

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