Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message
kinetix-permissions
kinetix-permissions
happones/kinetix
claude-code
permissions
roles
spatie-permissions
team-scoping
vue-helpers
policy-binding
artisan-commands

Handles role and permission management, configuration, database synchronization, and frontend authorization checking using spatie/laravel-permission. Activates when configuring permissions, writing gates, running permissions sync, or enforcing front-end access check helper.

Install
php artisan boost:add-skill happones/kinetix

Save this content to: .claude/skills/kinetix-permissions/SKILL.md

# Kinetix Permissions Development

## When to Apply

Activate this skill when:
- Registering feature or resource permissions using the `KinetixPermissions` facade.
- Enforcing resource-level permissions by overriding `permissionFeature()` or `registerPermissions()`.
- Running the `php artisan kinetix:permissions:sync` command to synchronize registry definitions to Spatie permissions.
- Gating Vue UI with the shipped helpers (`useKinetixCan`, `<KinetixCan>`, `v-can`).
- Managing roles via `<KinetixRoleManager>` / `KinetixPermissionMatrix` or the role endpoints.
- Implementing multi-tenant/team scoped permissions with the `kinetix.permissions.team` middleware.

## Documentation

For full details, reference `docs/permissions.md` (published at https://happones.github.io/kinetix/permissions).

## Common integration mistakes

> **Diagnose first.** `php artisan kinetix:doctor` reports every silent
> misconfiguration in one shot (prefix, team scoping, teamless roles,
> `attach_member`, config closures, published-file drift, duplicated i18n
> bundles) and exits non-zero on errors.

Check these **first** — they are what actually breaks integrations, and each one
fails silently:

1. **Writing your own controller under the wrong prefix.** Kinetix registers its
   endpoints under `{current_team}/{kinetix.route_prefix}/permissions/…` (e.g.
   `/acme/_kinetix/permissions/roles`) and the components call those URLs
   themselves. A controller of yours at `{current_team}/roles` is never invoked.
   The app registers only the *Inertia page* route. Verify with
   `php artisan kinetix:routes` before writing any endpoint.
2. **Redefining a `kinetix_*` Inertia prop.** `HandleInertiaRequests::share()` is
   merged **over** the package's shared props, so returning your own
   `kinetix_permissions` key silently replaces it and every `can()` turns false.
   Share your data under your own key. (Kinetix logs a warning in `local` when it
   detects the override.)
3. **Assuming the owner bypass exists.** Team ownership is not a role, so no role
   grants it. Set `permissions.owner_bypass => true` (uses the host's
   `$user->ownsTeam($team)`) or pass a callback as a callable array
   (`[OwnerBypass::class, 'check']`). Without it a team owner with no role holds
   **no** permissions. Do **not** hand-write a blanket
   `Gate::before(fn ($user) => $user->ownsTeam(...) ? true : null)`: it also
   short-circuits model policies, so the owner of team A passes `update` on team
   B's records. Kinetix's bypass only grants registry abilities.
4. **Seeding roles without team context.** Under team scoping a role created with
   no team id is *global*: visible in every team, editable by super-admins only.
   Pin the team first (`setPermissionsTeamId($team->id)`).
   `kinetix:permissions:sync` lists teamless roles that aren't protected.
5. **Forgetting the sync.** Enforcement reads the DB; an unsynced catalog makes
   roles look fine while granting nothing. Run it on deploy *and* in test setup.

6. **Writing a config callback as a closure.** `owner_bypass` (and Membership's
   `assignable_roles` / `attach_member` / `detach_member`) as `fn (...) => ...`
   makes `php artisan config:cache` abort ("value at … is non-serializable"), so
   the app can't deploy. Use `[OwnerBypass::class, 'check']` or an invokable
   class-string — both are resolved through the container.

## Policy ↔ permission binding (REQUIRED)

**If `kinetix.permissions.enabled = true`, every model policy MUST delegate
its abilities to the matrix** — `$user->can('{feature}.{ability}')` — instead
of returning static booleans. Kinetix's enforcement flows through Laravel's
Gate: the role matrix only has EFFECT where a policy consults it, so a policy
method hard-coding `return true;` silently ignores every permission the admin
toggles ("permissions don't work"). The standard pattern per ability:

```php
public function update(User $user, Post $post): bool
{
    // Tenancy boundary first, capability second. The owner clause makes the
    // policy correct even when owner_bypass is off.
    return $user->belongsToTeam($post->team)
        && ($user->ownsTeam($post->team) || $user->can('posts.update'));
}
```

- The **policy owns the tenancy boundary** (`belongsToTeam`) — permissions
  never encode which team a record belongs to.
- **`owner_bypass` semantics**: when on, the team OWNER passes
  `$user->can('{feature}.{ability}')` for every REGISTERED ability (a scoped
  `Gate::before` on registry keys) — but **model policies still run**, so the
  bypass can never cross the tenancy boundary. Keep the explicit
  `$user->ownsTeam(...)` clause anyway: it keeps the policy correct with the
  bypass off, and reads as intent.
- Class-level abilities (`viewAny`, `create`) have no record:
  `return $user->can('posts.viewAny');`.
- **Never put a plan or feature-flag check inside a policy.** A policy runs per
  RECORD; a plan is a per-TENANT answer, so you multiply a constant cost by N
  rows. A `bool` also can't produce an upsell, and role / plan / flag invalidate
  on three different clocks. Compose those with an **entitlement**
  (`kinetix-entitlements` skill) and keep the policy to tenancy + capability.
- Policies must compare FOREIGN KEYS, not lazy-load relations
  (`$post->team_id === $user->current_team_id`): a table serializes its record
  actions per row, so a relation load inside a policy is an N+1 per page.
- `kinetix:doctor` flags synced features whose policy still returns static
  `true`s, and registered features whose model has no policy at all.

## Configuration

Ensure the permissions module is enabled in `config/kinetix.php` (opt-in, default off):

```php
'permissions' => [
    'enabled'          => env('KINETIX_PERMISSIONS_ENABLED', false),
    'teams'            => env('KINETIX_PERMISSIONS_TEAMS', false),
    'super_admin_role' => env('KINETIX_SUPER_ADMIN_ROLE', 'super-admin'),
    // Owner-can-do-everything bypass: true (uses $user->ownsTeam($team)),
    // [Class::class, 'method'], an invokable class-string, or null.
    // Grants only REGISTRY abilities — model policies still run.
    'owner_bypass'     => env('KINETIX_PERMISSIONS_OWNER_BYPASS'),
    'guard'            => env('KINETIX_PERMISSIONS_GUARD', 'web'),
    'protected_roles'  => null, // null = protect the super_admin_role; or ['super-admin', 'owner']
    // How `kinetix_permissions` finds abilities the Gate grants with no stored
    // row: 'auto' (default) | 'sweep' | 'off' — see below.
    'dynamic_grants'   => env('KINETIX_PERMISSIONS_DYNAMIC_GRANTS', 'auto'),
],
```

### Dynamic grants (`dynamic_grants`) — do not set this to `sweep` casually

The `kinetix_permissions` prop must include abilities granted by a
`Gate::before` rather than a stored row (a team owner's, above all), or the SPA
hides features the server authorizes. Discovering them by asking the Gate about
EVERY registered ability is quadratic in disguise — spatie's `Gate::before`
scans the user's permission collection per call, and an unsynced ability costs
a thrown exception — measured at **~40ms per full page load on a 280-key
catalog**.

- `auto` (default, ~0.4ms) — the owner bypass answered ONCE (it is scoped to
  registry keys, so it grants all or none) plus abilities the app declared with
  `Gate::define()` (`Gate::has()` is an array lookup). Covers every dynamic
  grant this skill documents.
- `sweep` (~41ms) — the exhaustive check. Needed ONLY when the app registers
  its own `Gate::before` over registry keys — which mistake #3 below tells you
  not to do.
- `off` — stored rows only.

Guessing wrong fails closed: the UI hides something the server would allow,
never the reverse.

### Team owners (`owner_bypass`)

Ownership lives in the host's team schema, not in `model_has_roles` — so it can
never be granted by a role and needs its own `Gate::before`. Kinetix registers it
when `permissions.owner_bypass` is set, resolving the team from the
`{current_team}` segment (falling back to `currentTeam`) and memoizing the verdict
per user × team. The `kinetix_permissions` prop picks these dynamic grants up, so
the SPA matches the server without the owner holding a permission row.

### Teams: `HasTeams` × `HasRoles` trait collision

spatie v8 (Laravel 13) ships a `teams()` on `HasRoles`, so does the starter-kit's
`HasTeams` — using both on the `User` fatals at boot:

```
Trait method App\Concerns\HasTeams::teams has not been applied as
App\Models\User::teams, because of collision with Spatie\Permission\Traits\HasRoles::teams
```

The starter-kit `teams()` (real team membership; `HasTeams` and your app call it)
must win; spatie's is just a convenience relation it never uses internally
(scoping runs off `getPermissionsTeamId()`). `insteadof` alone resolves it:

```php
use HasRoles, HasTeams {
    HasTeams::teams insteadof HasRoles;   // keep starter-kit relation as User::teams()
    // HasRoles::teams as roleTeams;      // optional: spatie's "teams I have roles on"
}
```

Safe with Kinetix — team-scoped permissions bridge through `currentTeam` +
`PermissionRegistrar` (`kinetix.permissions.team` middleware), not `$user->teams()`.
See `docs/permissions.md` §4.

### Teams: the four required steps

Enabling `kinetix.permissions.teams` alone does nothing — spatie ignores the
team id unless its own flag is on (Kinetix logs a boot warning on the mismatch):

1. `config/kinetix.php` → `'permissions' => ['teams' => true]` — or leave it `null` and set the global `'teams' => true`, which every module's flag inherits (v0.82.0).
2. `config/permission.php` → `'teams' => true`.
3. `php artisan vendor:publish --tag=kinetix-permission-team-migrations && php artisan migrate` — Kinetix's **hybrid** teams migration (nullable `team_id` outside the PK → roles can be team-scoped *or* global).
4. Append `kinetix.permissions.team` to the host's `web` middleware group (Kinetix only auto-applies it to its own routes; without this, `hasRole()`/`can()` in app routes have no team context).

### Platform super-admin (teamless)

With spatie teams on, `hasRole()` is team-scoped. The Kinetix `Gate::before`
additionally honors a **teamless** assignment — assign with a NULL team id for
a platform-wide super-admin:

```php
app(\Spatie\Permission\PermissionRegistrar::class)->setPermissionsTeamId(null);
$user->assignRole('super-admin');   // bypasses gates inside every team
```

A super-admin assigned inside a team keeps the bypass only in that team.

### Sync on deploy and in tests

`kinetix:permissions:sync` must run **after migrations on every deploy** and in
test setup (`beforeEach`/`setUp`) — an empty `permissions` table makes roles
appear to work while carrying no permissions.

### Role-endpoint guardrails (v0.104.0)

The `{prefix}/permissions/roles` endpoints stop a `roles.manage` holder from
escalating past their own level — **all bypassed for a super-admin**:

1. **Allowlist** — `permissions.*` is validated against the registry, so unknown
   keys are rejected (422).
2. **Grant only what you hold — in both directions** — the guard checks the
   permission DELTA (added + removed) against what the manager holds (403
   otherwise); a rename with no permission changes stays allowed. Behavior
   change: previously any `roles.manage` holder could grant anything. Give role
   admins the seeded `admin` role (or super-admin) to manage the full catalog.
3. **Protected roles & self-lockout** — roles in `permissions.protected_roles`
   (default: the super-admin role) can't be created/renamed/edited/deleted here,
   and any change that would revoke the actor's own `roles.manage` is rolled back
   (403). This runs inside a DB transaction.
4. **Roles in use can't be deleted** — a role with members (counted in the
   current team + global assignments; `usersCount` is team-scoped the same way)
   422s on delete; the delete dialog shows the count.
5. **Global roles** — a super-admin can create one from the editor's
   "Global role (all teams)" toggle (`global: true` on POST; 403 for anyone
   else). A role's team never changes after creation. For invite pickers use
   `Happones\Kinetix\Permissions\AssignableRoles` (team + global roles, minus
   protected), so membership and the Roles UI never disagree.

### What each bypass actually bypasses (REQUIRED before answering "can X do Y?")

They are NOT weaker/stronger versions of the same thing. Pinned by
`tests/Feature/BypassPrecedenceTest.php`:

| | Super admin | Team owner (`owner_bypass`) |
| --- | --- | --- |
| Registered ability | ✅ | ✅ |
| Ability outside the Kinetix registry | ✅ | ❌ |
| **Model policy** (`update` + record) | ✅ **short-circuited** | ❌ policy runs |
| Crosses the tenancy boundary | ✅ by design | ❌ never |
| Plan capability / usage limit | ❌ | ❌ |
| Feature flag | ❌ | ❌ |
| Entitlement `->permission()` layer | ✅ | ✅ |
| Entitlement `->plan()`/`->limit()`/`->flag()` | ❌ | ❌ |
| Needs a resolvable team | ❌ | ✅ grants nothing without one |

- **Nobody bypasses billing.** The plan helpers never consult the Gate — a plan
  is a commercial boundary, not an authorization one. A super-admin on the free
  plan gets the free plan, and `EnforcesPlanLimits` blocks them at the cap. To
  exempt staff, model it as a plan or a flag, NEVER as a role.
- **A super-admin's `Gate::before` is blanket** — it short-circuits model
  policies and therefore the tenancy boundary. The owner bypass is scoped to
  registry keys and cannot.
- **Impersonation** replaces the authenticated user (`auth()->login($target)`),
  so every bypass is re-evaluated for the TARGET.

### Super-admin parity on the frontend

A super-admin holds the *role*, not the permissions. The `kinetix_permissions`
prop carries an `isSuperAdmin` flag and `useKinetixCan().can()` / `<KinetixCan>`
honor it, so a super-admin is **not** shown a permission-gated UI as denied.
`useKinetixCan` also exposes `isSuperAdmin`.

---

## Backend Usage

### 1. Resource CRUD Permissions
Enable CRUD permissions on a Kinetix Resource by returning the feature name:

```php
namespace App\Kinetix\Resources;

use Happones\Kinetix\Resources\Resource;
use Happones\Kinetix\Permissions\PermissionRegistry;

class PostResource extends Resource
{
    public static function permissionFeature(): ?string
    {
        return 'posts';
    }

public static function registerPermissions(PermissionRegistry $registry): void
    {
        $registry->feature('posts')
            ->crud()
            ->softDeletes()
            ->ability('publish', 'Publish posts');
    }
}
```

### 2. Custom Explicit Feature Permissions
For standalone modules or custom sections, register them in your `AppServiceProvider` via the facade:

```php
use Happones\Kinetix\Permissions\KinetixPermissions;

KinetixPermissions::feature('billing')
    ->label('Billing Management')
    ->abilities([
        'manage' => 'Manage subscription plans',
        'view-invoices' => 'View invoices',
    ]);
```

### 3. Syncing Permissions to Spatie
Run the sync command to persist permissions to your database:

```bash
# Standard sync
php artisan kinetix:permissions:sync

# Sync and prune obsolete permissions
php artisan kinetix:permissions:sync --prune
```

---

## Frontend Usage

Kinetix shares the user's resolved permissions/roles via the `kinetix_permissions`
Inertia prop automatically — **do not** hand-edit `HandleInertiaRequests`. Gate UI
with the shipped helpers (all reactive, keyed by `{feature}.{ability}`).

### 1. `useKinetixCan` composable

```vue
<script setup lang="ts">
import { useKinetixCan } from '@/composables/useKinetixCan'

const { can, canAny, canAll, hasRole } = useKinetixCan()
</script>

<template>
  <button v-if="can('posts.create')">Create Post</button>
</template>
```

### 2. `<KinetixCan>` component (supports a `#denied` slot, `require-all`, `role`)

```vue
<KinetixCan permission="posts.update">
  <EditButton />
  <template #denied>Read only</template>
</KinetixCan>
```

### 3. `v-can` directive

Register once: `app.use(KinetixPermissions)` (from `@/plugins/kinetixPermissions`), then:

```html
<button v-can="'posts.create'">Create</button>
```

### 4. Role management UI

Drop in `<KinetixRoleManager>` (grouped checkbox lists) or `<KinetixRoleMatrix>` (role cards with member counts + a module × ability grid editor — canonical CRUD columns, custom abilities appended, click a module to toggle its row), both gated behind `roles.manage`. They use the endpoints
`{prefix}/permissions/{features,roles}` (CRUD, gated by `roles.manage`) — where
`{prefix}` is `{current_team}/_kinetix` with teams on, **never a path of your
own**; `php artisan kinetix:routes` prints the resolved URIs. For custom
flows compose `KinetixPermissionMatrix` (`v-model` of permission keys) with
`useKinetixRoles`. Seed starter roles with `KinetixRolesSeeder`.

```vue
<KinetixCan permission="roles.manage">
  <KinetixRoleManager />
</KinetixCan>
```

## UUID / ULID Host Models

`kinetix-permission-team-migrations` adds `team_id` columns (typed by
`Happones\Kinetix\Support\HostKeys` after the app's Team model) AND real
foreign keys to spatie/laravel-permission's pivot tables. On a UUID/ULID app,
first apply spatie's own UUID guidance to the permission tables (their
`model_morph_key` config + retyped pivots); the `team_id` type then follows
detection or a pinned `kinetix.key_types.team`. General recipe: the
`kinetix-boost` skill, section "UUID / ULID Host Models".

Kinetix Permissions Development

When to Apply

Activate this skill when:

  • Registering feature or resource permissions using the KinetixPermissions facade.
  • Enforcing resource-level permissions by overriding permissionFeature() or registerPermissions().
  • Running the php artisan kinetix:permissions:sync command to synchronize registry definitions to Spatie permissions.
  • Gating Vue UI with the shipped helpers (useKinetixCan, <KinetixCan>, v-can).
  • Managing roles via <KinetixRoleManager> / KinetixPermissionMatrix or the role endpoints.
  • Implementing multi-tenant/team scoped permissions with the kinetix.permissions.team middleware.

Documentation

For full details, reference docs/permissions.md (published at https://happones.github.io/kinetix/permissions).

Common integration mistakes

Diagnose first. php artisan kinetix:doctor reports every silent misconfiguration in one shot (prefix, team scoping, teamless roles, attach_member, config closures, published-file drift, duplicated i18n bundles) and exits non-zero on errors.

Check these first — they are what actually breaks integrations, and each one fails silently:

  1. Writing your own controller under the wrong prefix. Kinetix registers its endpoints under {current_team}/{kinetix.route_prefix}/permissions/… (e.g. /acme/_kinetix/permissions/roles) and the components call those URLs themselves. A controller of yours at {current_team}/roles is never invoked. The app registers only the Inertia page route. Verify with php artisan kinetix:routes before writing any endpoint.

  2. Redefining a kinetix_* Inertia prop. HandleInertiaRequests::share() is merged over the package's shared props, so returning your own kinetix_permissions key silently replaces it and every can() turns false. Share your data under your own key. (Kinetix logs a warning in local when it detects the override.)

  3. Assuming the owner bypass exists. Team ownership is not a role, so no role grants it. Set permissions.owner_bypass => true (uses the host's $user->ownsTeam($team)) or pass a callback as a callable array ([OwnerBypass::class, 'check']). Without it a team owner with no role holds no permissions. Do not hand-write a blanket Gate::before(fn ($user) => $user->ownsTeam(...) ? true : null): it also short-circuits model policies, so the owner of team A passes update on team B's records. Kinetix's bypass only grants registry abilities.

  4. Seeding roles without team context. Under team scoping a role created with no team id is global: visible in every team, editable by super-admins only. Pin the team first (setPermissionsTeamId($team->id)). kinetix:permissions:sync lists teamless roles that aren't protected.

  5. Forgetting the sync. Enforcement reads the DB; an unsynced catalog makes roles look fine while granting nothing. Run it on deploy and in test setup.

  6. Writing a config callback as a closure. owner_bypass (and Membership's assignable_roles / attach_member / detach_member) as fn (...) => ... makes php artisan config:cache abort ("value at … is non-serializable"), so the app can't deploy. Use [OwnerBypass::class, 'check'] or an invokable class-string — both are resolved through the container.

Policy ↔ permission binding (REQUIRED)

If kinetix.permissions.enabled = true, every model policy MUST delegate its abilities to the matrix — $user->can('{feature}.{ability}') — instead of returning static booleans. Kinetix's enforcement flows through Laravel's Gate: the role matrix only has EFFECT where a policy consults it, so a policy method hard-coding return true; silently ignores every permission the admin toggles ("permissions don't work"). The standard pattern per ability:

public function update(User $user, Post $post): bool
{
    // Tenancy boundary first, capability second. The owner clause makes the
    // policy correct even when owner_bypass is off.
    return $user->belongsToTeam($post->team)
        && ($user->ownsTeam($post->team) || $user->can('posts.update'));
}
  • The policy owns the tenancy boundary (belongsToTeam) — permissions never encode which team a record belongs to.
  • owner_bypass semantics: when on, the team OWNER passes $user->can('{feature}.{ability}') for every REGISTERED ability (a scoped Gate::before on registry keys) — but model policies still run, so the bypass can never cross the tenancy boundary. Keep the explicit $user->ownsTeam(...) clause anyway: it keeps the policy correct with the bypass off, and reads as intent.
  • Class-level abilities (viewAny, create) have no record: return $user->can('posts.viewAny');.
  • Never put a plan or feature-flag check inside a policy. A policy runs per RECORD; a plan is a per-TENANT answer, so you multiply a constant cost by N rows. A bool also can't produce an upsell, and role / plan / flag invalidate on three different clocks. Compose those with an entitlement (kinetix-entitlements skill) and keep the policy to tenancy + capability.
  • Policies must compare FOREIGN KEYS, not lazy-load relations ($post->team_id === $user->current_team_id): a table serializes its record actions per row, so a relation load inside a policy is an N+1 per page.
  • kinetix:doctor flags synced features whose policy still returns static trues, and registered features whose model has no policy at all.

Configuration

Ensure the permissions module is enabled in config/kinetix.php (opt-in, default off):

'permissions' => [
    'enabled'          => env('KINETIX_PERMISSIONS_ENABLED', false),
    'teams'            => env('KINETIX_PERMISSIONS_TEAMS', false),
    'super_admin_role' => env('KINETIX_SUPER_ADMIN_ROLE', 'super-admin'),
    // Owner-can-do-everything bypass: true (uses $user->ownsTeam($team)),
    // [Class::class, 'method'], an invokable class-string, or null.
    // Grants only REGISTRY abilities — model policies still run.
    'owner_bypass'     => env('KINETIX_PERMISSIONS_OWNER_BYPASS'),
    'guard'            => env('KINETIX_PERMISSIONS_GUARD', 'web'),
    'protected_roles'  => null, // null = protect the super_admin_role; or ['super-admin', 'owner']
    // How `kinetix_permissions` finds abilities the Gate grants with no stored
    // row: 'auto' (default) | 'sweep' | 'off' — see below.
    'dynamic_grants'   => env('KINETIX_PERMISSIONS_DYNAMIC_GRANTS', 'auto'),
],

Dynamic grants (dynamic_grants) — do not set this to sweep casually

The kinetix_permissions prop must include abilities granted by a Gate::before rather than a stored row (a team owner's, above all), or the SPA hides features the server authorizes. Discovering them by asking the Gate about EVERY registered ability is quadratic in disguise — spatie's Gate::before scans the user's permission collection per call, and an unsynced ability costs a thrown exception — measured at ~40ms per full page load on a 280-key catalog.

  • auto (default, ~0.4ms) — the owner bypass answered ONCE (it is scoped to registry keys, so it grants all or none) plus abilities the app declared with Gate::define() (Gate::has() is an array lookup). Covers every dynamic grant this skill documents.
  • sweep (~41ms) — the exhaustive check. Needed ONLY when the app registers its own Gate::before over registry keys — which mistake #3 below tells you not to do.
  • off — stored rows only.

Guessing wrong fails closed: the UI hides something the server would allow, never the reverse.

Team owners (owner_bypass)

Ownership lives in the host's team schema, not in model_has_roles — so it can never be granted by a role and needs its own Gate::before. Kinetix registers it when permissions.owner_bypass is set, resolving the team from the {current_team} segment (falling back to currentTeam) and memoizing the verdict per user × team. The kinetix_permissions prop picks these dynamic grants up, so the SPA matches the server without the owner holding a permission row.

Teams: HasTeams × HasRoles trait collision

spatie v8 (Laravel 13) ships a teams() on HasRoles, so does the starter-kit's HasTeams — using both on the User fatals at boot:

Trait method App\Concerns\HasTeams::teams has not been applied as
App\Models\User::teams, because of collision with Spatie\Permission\Traits\HasRoles::teams

The starter-kit teams() (real team membership; HasTeams and your app call it) must win; spatie's is just a convenience relation it never uses internally (scoping runs off getPermissionsTeamId()). insteadof alone resolves it:

use HasRoles, HasTeams {
    HasTeams::teams insteadof HasRoles;   // keep starter-kit relation as User::teams()
    // HasRoles::teams as roleTeams;      // optional: spatie's "teams I have roles on"
}

Safe with Kinetix — team-scoped permissions bridge through currentTeam + PermissionRegistrar (kinetix.permissions.team middleware), not $user->teams(). See docs/permissions.md §4.

Teams: the four required steps

Enabling kinetix.permissions.teams alone does nothing — spatie ignores the team id unless its own flag is on (Kinetix logs a boot warning on the mismatch):

  1. config/kinetix.php → 'permissions' => ['teams' => true] — or leave it null and set the global 'teams' => true, which every module's flag inherits (v0.82.0).
  2. config/permission.php → 'teams' => true.
  3. php artisan vendor:publish --tag=kinetix-permission-team-migrations && php artisan migrate — Kinetix's hybrid teams migration (nullable team_id outside the PK → roles can be team-scoped or global).
  4. Append kinetix.permissions.team to the host's web middleware group (Kinetix only auto-applies it to its own routes; without this, hasRole()/can() in app routes have no team context).

Platform super-admin (teamless)

With spatie teams on, hasRole() is team-scoped. The Kinetix Gate::before additionally honors a teamless assignment — assign with a NULL team id for a platform-wide super-admin:

app(\Spatie\Permission\PermissionRegistrar::class)->setPermissionsTeamId(null);
$user->assignRole('super-admin');   // bypasses gates inside every team

A super-admin assigned inside a team keeps the bypass only in that team.

Sync on deploy and in tests

kinetix:permissions:sync must run after migrations on every deploy and in test setup (beforeEach/setUp) — an empty permissions table makes roles appear to work while carrying no permissions.

Role-endpoint guardrails (v0.104.0)

The {prefix}/permissions/roles endpoints stop a roles.manage holder from escalating past their own level — all bypassed for a super-admin:

  1. Allowlist — permissions.* is validated against the registry, so unknown keys are rejected (422).
  2. Grant only what you hold — in both directions — the guard checks the permission DELTA (added + removed) against what the manager holds (403 otherwise); a rename with no permission changes stays allowed. Behavior change: previously any roles.manage holder could grant anything. Give role admins the seeded admin role (or super-admin) to manage the full catalog.
  3. Protected roles & self-lockout — roles in permissions.protected_roles (default: the super-admin role) can't be created/renamed/edited/deleted here, and any change that would revoke the actor's own roles.manage is rolled back (403). This runs inside a DB transaction.
  4. Roles in use can't be deleted — a role with members (counted in the current team + global assignments; usersCount is team-scoped the same way) 422s on delete; the delete dialog shows the count.
  5. Global roles — a super-admin can create one from the editor's "Global role (all teams)" toggle (global: true on POST; 403 for anyone else). A role's team never changes after creation. For invite pickers use Happones\Kinetix\Permissions\AssignableRoles (team + global roles, minus protected), so membership and the Roles UI never disagree.

What each bypass actually bypasses (REQUIRED before answering "can X do Y?")

They are NOT weaker/stronger versions of the same thing. Pinned by tests/Feature/BypassPrecedenceTest.php:

Super admin Team owner (owner_bypass)
Registered ability ✅ ✅
Ability outside the Kinetix registry ✅ ❌
Model policy (update + record) ✅ short-circuited ❌ policy runs
Crosses the tenancy boundary ✅ by design ❌ never
Plan capability / usage limit ❌ ❌
Feature flag ❌ ❌
Entitlement ->permission() layer ✅ ✅
Entitlement ->plan()/->limit()/->flag() ❌ ❌
Needs a resolvable team ❌ ✅ grants nothing without one
  • Nobody bypasses billing. The plan helpers never consult the Gate — a plan is a commercial boundary, not an authorization one. A super-admin on the free plan gets the free plan, and EnforcesPlanLimits blocks them at the cap. To exempt staff, model it as a plan or a flag, NEVER as a role.
  • A super-admin's Gate::before is blanket — it short-circuits model policies and therefore the tenancy boundary. The owner bypass is scoped to registry keys and cannot.
  • Impersonation replaces the authenticated user (auth()->login($target)), so every bypass is re-evaluated for the TARGET.

Super-admin parity on the frontend

A super-admin holds the role, not the permissions. The kinetix_permissions prop carries an isSuperAdmin flag and useKinetixCan().can() / <KinetixCan> honor it, so a super-admin is not shown a permission-gated UI as denied. useKinetixCan also exposes isSuperAdmin.


Backend Usage

1. Resource CRUD Permissions

Enable CRUD permissions on a Kinetix Resource by returning the feature name:

namespace App\Kinetix\Resources;

use Happones\Kinetix\Resources\Resource;
use Happones\Kinetix\Permissions\PermissionRegistry;

class PostResource extends Resource
{
    public static function permissionFeature(): ?string
    {
        return 'posts';
    }

public static function registerPermissions(PermissionRegistry $registry): void
    {
        $registry->feature('posts')
            ->crud()
            ->softDeletes()
            ->ability('publish', 'Publish posts');
    }
}

2. Custom Explicit Feature Permissions

For standalone modules or custom sections, register them in your AppServiceProvider via the facade:

use Happones\Kinetix\Permissions\KinetixPermissions;

KinetixPermissions::feature('billing')
    ->label('Billing Management')
    ->abilities([
        'manage' => 'Manage subscription plans',
        'view-invoices' => 'View invoices',
    ]);

3. Syncing Permissions to Spatie

Run the sync command to persist permissions to your database:

# Standard sync
php artisan kinetix:permissions:sync

# Sync and prune obsolete permissions
php artisan kinetix:permissions:sync --prune

Frontend Usage

Kinetix shares the user's resolved permissions/roles via the kinetix_permissions Inertia prop automatically — do not hand-edit HandleInertiaRequests. Gate UI with the shipped helpers (all reactive, keyed by {feature}.{ability}).

1. useKinetixCan composable

<script setup lang="ts">
import { useKinetixCan } from '@/composables/useKinetixCan'

const { can, canAny, canAll, hasRole } = useKinetixCan()
</script>

<template>
  <button v-if="can('posts.create')">Create Post</button>
</template>

2. <KinetixCan> component (supports a #denied slot, require-all, role)

<KinetixCan permission="posts.update">
  <EditButton />
  <template #denied>Read only</template>
</KinetixCan>

3. v-can directive

Register once: app.use(KinetixPermissions) (from @/plugins/kinetixPermissions), then:

<button v-can="'posts.create'">Create</button>

4. Role management UI

Drop in <KinetixRoleManager> (grouped checkbox lists) or <KinetixRoleMatrix> (role cards with member counts + a module × ability grid editor — canonical CRUD columns, custom abilities appended, click a module to toggle its row), both gated behind roles.manage. They use the endpoints {prefix}/permissions/{features,roles} (CRUD, gated by roles.manage) — where {prefix} is {current_team}/_kinetix with teams on, never a path of your own; php artisan kinetix:routes prints the resolved URIs. For custom flows compose KinetixPermissionMatrix (v-model of permission keys) with useKinetixRoles. Seed starter roles with KinetixRolesSeeder.

<KinetixCan permission="roles.manage">
  <KinetixRoleManager />
</KinetixCan>

UUID / ULID Host Models

kinetix-permission-team-migrations adds team_id columns (typed by Happones\Kinetix\Support\HostKeys after the app's Team model) AND real foreign keys to spatie/laravel-permission's pivot tables. On a UUID/ULID app, first apply spatie's own UUID guidance to the permission tables (their model_morph_key config + retyped pivots); the team_id type then follows detection or a pinned kinetix.key_types.team. General recipe: the kinetix-boost skill, section "UUID / ULID Host Models".

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
robertmarney/lara-hierarchial-collections
simonschaufi/rule-doc-generator
emissaoja/emissaoja
blackbricksoftware/gravityforms-civicrm-form-processor
al-rahabi/fancy-crud
jinas/dhivatar-component
mltstephane/laravel-quality
bernskiold/laravel-talentlms
bernskioldmedia/laravel-form-architect