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-membership
kinetix-membership
happones/kinetix
claude-code
provisioning
activation
credentials
identifiers
sms
composables

Handles admin-provisioned membership — adding members by email + role, password activation via signed links, an assignable-role allow-list, and the members directory UI. An alternative to self-serve team invitations. Activates when provisioning members, wiring activation, gating member endpoints, or rendering the membership UI.

Install
php artisan boost:add-skill happones/kinetix

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

# Kinetix Membership Development

## When to Apply

Activate this skill when:
- Enabling the admin-provisioned onboarding model instead of self-serve team invitations.
- Provisioning members (email + role) and wiring the password-activation flow.
- Gating the `members.*` endpoints/UI, or constraining which roles a provisioner may assign.
- Rendering the membership UI (`<KinetixMemberList>`, `<KinetixMemberProvisioner>`, `<KinetixMemberActivation>`) or using the `useKinetixMembers` composable.
- Attaching activated users to the host app's own team pivot via `attach_member` / `detach_member`.

## Provisioning modes (members with no email address) — REQUIRED reading

Two axes, BOTH defaulting to today's behavior:

```php
'provisioning' => 'activation',  // | 'direct'
'delivery'     => 'mail',        // | 'manual'
'identifier'   => 'email',       // | 'username' | 'phone'
```

- **`activation`** (default) — no `User` until the person sets their own
  password. The invariant: no password-less accounts pile up.
- **`direct`** — the `User` is created immediately with a temporary password.
  GIVES UP that invariant in exchange for working with NO delivery channel,
  which is the point when staff have no email address. Provision goes straight
  to `active`.
- **`manual`** delivery sends nothing and returns the credential ONCE.
- **`sms`** delivery texts the link to the member's phone over
  `membership.sms_channel`.

### Rules

- **The credential is shown exactly once.** It is hashed into the user record;
  there is no reading it back. `POST {prefix}/members/{provision}/credential`
  always REGENERATES (the old one stops working). Never add a "reveal" that
  retrieves.
- **`members.credentials` is a separate ability** from `members.provision` —
  handing over a credential means being able to become that person.
- **`direct` requires `credentials.enabled`**, or the forced first-login change
  never happens and the admin-chosen password works forever. Boot warning +
  `kinetix:doctor`.
- **`membership.identifier` must be in `credentials.identity.fields`** or
  Kinetix falls back to `email` — provisioning someone under an identifier
  nobody can sign in with is a directory entry, not an account.
- **Identifiers are normalized** through the same resolver the login uses
  (`KinetixIdentity::normalize`), so one phone number cannot become two
  provisions.
- **`store()` stays `updateOrCreate`** whatever the mode: a repeat on a known
  identifier must look identical, or provisioning becomes a membership oracle.
- **Use `identifier` for display**, not `email` — it is whichever field carries
  it. `email`/`username`/`phone` travel alongside.

### SMS delivery

NEVER hardcode an SMS provider. Vonage/Twilio/local gateways each define their
own channel AND their own message object; the choice is a business decision
about coverage and price. Kinetix routes over `membership.sms_channel` and ships
the TEXT (`smsContent()`); the host subclasses the notification and points
`membership.activation_notification` at it:

```php
class SmsActivation extends MemberActivationNotification
{
    public function toVonage(object $notifiable): VonageMessage
    {
        return (new VonageMessage)->content($this->smsContent());
    }
}
```

A missing `to{Channel}()` would throw inside a QUEUED job at send time — nobody
sees it and the member never gets their link. Kinetix checks the method exists
first and falls back to handing the link over; `kinetix:doctor` errors on it.
Same fallback when the member has no phone on file. Keep `smsContent()` short:
a signed URL is long and every extra character risks a second billable segment.

## Documentation

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

## 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.** The module's endpoints
   live at `{current_team}/{kinetix.route_prefix}/members/…` (e.g.
   `/acme/_kinetix/members`) and `<KinetixMemberList>` calls them itself. A
   controller of yours at `{current_team}/members` is never invoked — the app
   registers only the *Inertia page* route. Verify with
   `php artisan kinetix:routes members` before writing any endpoint.
2. **Leaving `attach_member` at `null` in a teams app.** Kinetix assigns the role
   and never touches the host's team pivot, so activation succeeds and the member
   belongs to **no team** — in a team-routed app they can't reach any page. Kinetix
   logs a boot warning for exactly this combination.
3. **Redefining a `kinetix_*` Inertia prop.** `HandleInertiaRequests::share()` is
   merged **over** the package's shared props, so your own `kinetix_permissions`
   key silently replaces Kinetix's and the `members.*` gating in the UI collapses.
   Share your data under your own key.
4. **Seeding the assignable roles without team context.** Under team scoping a
   role created with no team id is global (visible in every team, super-admin
   editable only), so it won't behave like the team role you expected.
   `kinetix:permissions:sync` lists teamless roles.

5. **Writing a config callback as a closure.** `assignable_roles`,
   `attach_member`, `detach_member` and `assign_role` as `fn (...) => ...` make
   `php artisan config:cache` abort ("value at … is non-serializable"), i.e. the
   app can't deploy. Always use the serializable forms:
   `[SyncProvisionedMember::class, 'attach']` or an invokable class-string.
6. **A fixed `assignable_roles` list in a published config.** Configs published
   before v0.262 carry `['editor', 'viewer']`, which does NOT follow the Roles
   screen: roles created there never reach the member picker. Set it to `null`
   (the default) to link them; `kinetix:doctor` reports which one is in use.
7. **Trying to make a member super-admin.** The protected roles
   (`permissions.protected_roles`, `super-admin` by default) belong to the team
   owner and are stripped from the allow-list whatever it says — provisioning or
   changing a member to them is a 422. Don't work around it.

## Configuration

Enable the module in `config/kinetix.php` (opt-in, default off) and publish + run the migration:

```bash
php artisan vendor:publish --tag=kinetix-membership-migrations
php artisan migrate
```

```php
'membership' => [
    'enabled'           => env('KINETIX_MEMBERSHIP_ENABLED', false),
    'teams'             => env('KINETIX_MEMBERSHIP_TEAMS', false),
    'user_model'        => env('KINETIX_MEMBERSHIP_USER_MODEL', 'App\\Models\\User'),
    // The ONLY roles a provisioner may assign — the guard that keeps added
    // members from ever becoming admin. null (default) = the Roles screen's
    // roles for the team (team + global), else a fixed array or an invokable
    // class-string (config:cache-safe). Entries may be names or
    // ['name' => …, 'label' => …, 'description' => …]. Protected roles
    // (super-admin) are always removed.
    'assignable_roles'  => null,
    'activation_expiry' => env('KINETIX_MEMBERSHIP_ACTIVATION_HOURS', 72),
    'activation_view'   => env('KINETIX_MEMBERSHIP_ACTIVATION_VIEW', 'Kinetix/MemberActivation'),
    'redirect_after'    => env('KINETIX_MEMBERSHIP_REDIRECT', '/'),
    // Optional callables: fn ($user, MemberProvision $provision) => void
    'attach_member'     => null,
    'detach_member'     => null,
    // Roles from the app's own logic, not spatie: [Class, 'method'] receiving
    // ($user, ?string $to, ?string $from, MemberProvision $provision).
    // null $to removes, null $from grants. null (default) = spatie roles.
    'assign_role'       => null,
],
```

### Roles that don't come from Roles & Permissions

The member picker does NOT require the Roles screen. When the app already
decides what a member is (a pivot `role` column, a flag, another package),
point `assignable_roles` at an invokable returning
`[['name' => 'cashier', 'label' => __('Cashier'), 'description' => __('…')], …]`
and `assign_role` at the method that writes it. Kinetix then never calls
spatie for members (activation, direct provisioning, role change, revoke all
go through the callback), still enforces the allow-list server-side, and
still strips the protected roles.

---

## Invitation vs Provisioning

This module is an **alternative** to the starter-kit's self-serve team invitations,
not an extension of them. Use provisioning when admins should add people who never
own anything and never create a personal team — they only activate by setting a
password. Registration stays closed; the only way in is provisioning.

---

## Backend Usage

The module registers a `members` feature with the permission registry, so its
abilities appear in the permission matrix and `kinetix:permissions:sync`:
`members.viewAny`, `members.provision`, `members.update`, `members.revoke`.

Management endpoints, gated through Laravel's Gate like `roles.manage`. `{prefix}`
is `{current_team}/_kinetix` with teams on, `_kinetix` without — **never a path of
your own** (`php artisan kinetix:routes members` prints the resolved URIs):

| Method | Endpoint | Ability |
|---|---|---|
| `GET` | `{prefix}/members` | `members.viewAny` |
| `POST` | `{prefix}/members` | `members.provision` |
| `POST` | `{prefix}/members/{provision}/resend` | `members.provision` |
| `PUT` | `{prefix}/members/{provision}` | `members.update` |
| `DELETE` | `{prefix}/members/{provision}` | `members.revoke` |

Outside the members screen (admin panel, console, job), revoke with
`KinetixMembership::revoke($provision)`: role removed in the provision's team,
`detach_member` runs, provision kept as `revoked`. It checks no ability.

Activation endpoints are public but protected by a temporary signed URL (GET shows
the page, POST completes it — both share the same path so one signature covers both):

| Method | Endpoint |
|---|---|
| `GET` | `/members/activate/{provision}` (signed) |
| `POST` | `/members/activate/{provision}` (signed) |

**The allow-list is the security boundary.** A user who can `members.provision`
still cannot assign a role outside `membership.assignable_roles` — it is enforced
at provision **and** activation time (at activation against the *provision's*
team, since the signed URL carries no `{current_team}` segment). Kinetix assigns
the dynamic Kinetix role; it never touches the host's team pivot, so wire
`attach_member` — **required** once team scoping is on:

Teams that create their own roles in the Roles UI also need a **dynamic**
allow-list — a callback receiving the provision's team key instead of a static
array. **Write both as callable arrays, never closures**: a closure in a config
file makes `php artisan config:cache` fail, so a closure-based config is not
deployable.

```php
// config/kinetix.php → membership
'attach_member'    => [\App\Kinetix\SyncProvisionedMember::class, 'attach'],
'detach_member'    => [\App\Kinetix\SyncProvisionedMember::class, 'detach'],
'assignable_roles' => [\App\Kinetix\AssignableRoles::class, 'forTeam'],
```

```php
class SyncProvisionedMember
{
    public function attach(Model $user, MemberProvision $provision): void
    {
        if ($provision->team_id !== null) {
            Team::find($provision->team_id)?->users()->attach($user->getKey());
        }
    }
}

// Prefer the built-in resolver — it matches the Roles UI's visibility rule
// (the team's roles PLUS global team-NULL ones, minus protected roles):
//   'assignable_roles' => \Happones\Kinetix\Permissions\AssignableRoles::class,
// To withhold extra names, wrap it:
class AssignableRoles
{
    public function __invoke(int|string|null $teamId): array
    {
        return \Happones\Kinetix\Permissions\AssignableRoles::names($teamId, except: ['admin']);
    }
}
```

NEVER hand-write `where('team_id', $teamId)` here: it drops global (team-NULL)
roles — exactly where `KinetixRolesSeeder`'s presets live — leaving the invite
picker empty. The correct scope is `whereNull('team_id')->orWhere('team_id', $teamId)`
(what `AssignableRoles::query()` returns). An array is read as a callback only
when it is a `[class-string, method]` pair, so `['editor', 'viewer']` still
means a list of role names.

State machine: `update`/`resend` on a REVOKED provision and `resend` on an
ACTIVE one are 422s — re-provision deliberately. The activation mail is
`ShouldQueue`: without a running worker (or `QUEUE_CONNECTION=sync`) no invite
email is ever sent while the UI reports success.

---

## Frontend Usage

Components publish with `--tag=kinetix-components` and read the reactive
`kinetix_permissions` prop, so gate them with the same `{feature}.{ability}` keys.

### 1. Members directory (drop-in)

`<KinetixMemberList>` embeds the provisioning form (identifier field from
`membership.identifier`, optional name, role picker showing what the role
grants and that super-admin is the owner's) and lists members with joined /
invited / expiry dates, status filters with counts, role change (a role that
is no longer assignable stays listed, locked), resend, new link / new password
(the credential shown once, to copy) and revoke. Every action shows only when
the viewer holds its `members.*` ability:

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

const { can } = useKinetixCan()
</script>

<template>
  <KinetixMemberList v-if="can('members.provision')" />
</template>
```

For a custom layout, compose the presentational
`<KinetixMemberProvisioner :assignable-roles :roles :reserved-roles :identifier-type @submit>`
(`submit(identifier, role, name?)`) with the `useKinetixMembers()` composable
(`load`, `provision(identifier, role, name?)` → the provision with its one-time
`credential`, `issueCredential`, `resend`, `updateRole`, `revoke`, plus reactive
`provisions`, `assignableRoles`, `roles`, `reservedRoles`, `identifierField`,
`can`).

### 2. Activation page

Render `<KinetixMemberActivation :email="email" :action="action">` from the page
named by `membership.activation_view` (default `Kinetix/MemberActivation`),
forwarding the `email` and `action` props the controller passes. Email is fixed
(from the provision); the member only sets a name + password, posting back to the
signed `action` URL.

---

## Composing with Permissions

Membership and [Permissions](https://happones.github.io/kinetix/permissions) are
independent but complementary: **teams = where**, **roles/permissions = what**,
**provisioning = the intersection** (adds a user to a team AND assigns a role).
With Permissions enabled, grant the `members.*` abilities to a role; without it,
define those gates in your app.

## UUID / ULID Host Models

This feature's migration builds `user_id`, `team_id` and `invited_by` with
`Happones\Kinetix\Support\HostKeys`, which types each column after YOUR model
at migrate time from its primary key (`HasUlids` -> ulid, `HasUuids` -> uuid, string `$keyType` ->
string, else bigint). Pin `kinetix.key_types.user|team` when detection cannot
see the setup; morph ids follow `kinetix.key_types.morph` (default bigint) —
set it when the referenced models use UUIDs/ULIDs. Apps migrated on an older
Kinetix have bigint columns on disk and need their own ALTER migration. Full
recipe: the `kinetix-boost` skill, section "UUID / ULID Host Models".

Kinetix Membership Development

When to Apply

Activate this skill when:

  • Enabling the admin-provisioned onboarding model instead of self-serve team invitations.
  • Provisioning members (email + role) and wiring the password-activation flow.
  • Gating the members.* endpoints/UI, or constraining which roles a provisioner may assign.
  • Rendering the membership UI (<KinetixMemberList>, <KinetixMemberProvisioner>, <KinetixMemberActivation>) or using the useKinetixMembers composable.
  • Attaching activated users to the host app's own team pivot via attach_member / detach_member.

Provisioning modes (members with no email address) — REQUIRED reading

Two axes, BOTH defaulting to today's behavior:

'provisioning' => 'activation',  // | 'direct'
'delivery'     => 'mail',        // | 'manual'
'identifier'   => 'email',       // | 'username' | 'phone'
  • activation (default) — no User until the person sets their own password. The invariant: no password-less accounts pile up.
  • direct — the User is created immediately with a temporary password. GIVES UP that invariant in exchange for working with NO delivery channel, which is the point when staff have no email address. Provision goes straight to active.
  • manual delivery sends nothing and returns the credential ONCE.
  • sms delivery texts the link to the member's phone over membership.sms_channel.

Rules

  • The credential is shown exactly once. It is hashed into the user record; there is no reading it back. POST {prefix}/members/{provision}/credential always REGENERATES (the old one stops working). Never add a "reveal" that retrieves.
  • members.credentials is a separate ability from members.provision — handing over a credential means being able to become that person.
  • direct requires credentials.enabled, or the forced first-login change never happens and the admin-chosen password works forever. Boot warning + kinetix:doctor.
  • membership.identifier must be in credentials.identity.fields or Kinetix falls back to email — provisioning someone under an identifier nobody can sign in with is a directory entry, not an account.
  • Identifiers are normalized through the same resolver the login uses (KinetixIdentity::normalize), so one phone number cannot become two provisions.
  • store() stays updateOrCreate whatever the mode: a repeat on a known identifier must look identical, or provisioning becomes a membership oracle.
  • Use identifier for display, not email — it is whichever field carries it. email/username/phone travel alongside.

SMS delivery

NEVER hardcode an SMS provider. Vonage/Twilio/local gateways each define their own channel AND their own message object; the choice is a business decision about coverage and price. Kinetix routes over membership.sms_channel and ships the TEXT (smsContent()); the host subclasses the notification and points membership.activation_notification at it:

class SmsActivation extends MemberActivationNotification
{
    public function toVonage(object $notifiable): VonageMessage
    {
        return (new VonageMessage)->content($this->smsContent());
    }
}

A missing to{Channel}() would throw inside a QUEUED job at send time — nobody sees it and the member never gets their link. Kinetix checks the method exists first and falls back to handing the link over; kinetix:doctor errors on it. Same fallback when the member has no phone on file. Keep smsContent() short: a signed URL is long and every extra character risks a second billable segment.

Documentation

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

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. The module's endpoints live at {current_team}/{kinetix.route_prefix}/members/… (e.g. /acme/_kinetix/members) and <KinetixMemberList> calls them itself. A controller of yours at {current_team}/members is never invoked — the app registers only the Inertia page route. Verify with php artisan kinetix:routes members before writing any endpoint.

  2. Leaving attach_member at null in a teams app. Kinetix assigns the role and never touches the host's team pivot, so activation succeeds and the member belongs to no team — in a team-routed app they can't reach any page. Kinetix logs a boot warning for exactly this combination.

  3. Redefining a kinetix_* Inertia prop. HandleInertiaRequests::share() is merged over the package's shared props, so your own kinetix_permissions key silently replaces Kinetix's and the members.* gating in the UI collapses. Share your data under your own key.

  4. Seeding the assignable roles without team context. Under team scoping a role created with no team id is global (visible in every team, super-admin editable only), so it won't behave like the team role you expected. kinetix:permissions:sync lists teamless roles.

  5. Writing a config callback as a closure. assignable_roles, attach_member, detach_member and assign_role as fn (...) => ... make php artisan config:cache abort ("value at … is non-serializable"), i.e. the app can't deploy. Always use the serializable forms: [SyncProvisionedMember::class, 'attach'] or an invokable class-string.

  6. A fixed assignable_roles list in a published config. Configs published before v0.262 carry ['editor', 'viewer'], which does NOT follow the Roles screen: roles created there never reach the member picker. Set it to null (the default) to link them; kinetix:doctor reports which one is in use.

  7. Trying to make a member super-admin. The protected roles (permissions.protected_roles, super-admin by default) belong to the team owner and are stripped from the allow-list whatever it says — provisioning or changing a member to them is a 422. Don't work around it.

Configuration

Enable the module in config/kinetix.php (opt-in, default off) and publish + run the migration:

php artisan vendor:publish --tag=kinetix-membership-migrations
php artisan migrate
'membership' => [
    'enabled'           => env('KINETIX_MEMBERSHIP_ENABLED', false),
    'teams'             => env('KINETIX_MEMBERSHIP_TEAMS', false),
    'user_model'        => env('KINETIX_MEMBERSHIP_USER_MODEL', 'App\\Models\\User'),
    // The ONLY roles a provisioner may assign — the guard that keeps added
    // members from ever becoming admin. null (default) = the Roles screen's
    // roles for the team (team + global), else a fixed array or an invokable
    // class-string (config:cache-safe). Entries may be names or
    // ['name' => …, 'label' => …, 'description' => …]. Protected roles
    // (super-admin) are always removed.
    'assignable_roles'  => null,
    'activation_expiry' => env('KINETIX_MEMBERSHIP_ACTIVATION_HOURS', 72),
    'activation_view'   => env('KINETIX_MEMBERSHIP_ACTIVATION_VIEW', 'Kinetix/MemberActivation'),
    'redirect_after'    => env('KINETIX_MEMBERSHIP_REDIRECT', '/'),
    // Optional callables: fn ($user, MemberProvision $provision) => void
    'attach_member'     => null,
    'detach_member'     => null,
    // Roles from the app's own logic, not spatie: [Class, 'method'] receiving
    // ($user, ?string $to, ?string $from, MemberProvision $provision).
    // null $to removes, null $from grants. null (default) = spatie roles.
    'assign_role'       => null,
],

Roles that don't come from Roles & Permissions

The member picker does NOT require the Roles screen. When the app already decides what a member is (a pivot role column, a flag, another package), point assignable_roles at an invokable returning [['name' => 'cashier', 'label' => __('Cashier'), 'description' => __('…')], …] and assign_role at the method that writes it. Kinetix then never calls spatie for members (activation, direct provisioning, role change, revoke all go through the callback), still enforces the allow-list server-side, and still strips the protected roles.


Invitation vs Provisioning

This module is an alternative to the starter-kit's self-serve team invitations, not an extension of them. Use provisioning when admins should add people who never own anything and never create a personal team — they only activate by setting a password. Registration stays closed; the only way in is provisioning.


Backend Usage

The module registers a members feature with the permission registry, so its abilities appear in the permission matrix and kinetix:permissions:sync: members.viewAny, members.provision, members.update, members.revoke.

Management endpoints, gated through Laravel's Gate like roles.manage. {prefix} is {current_team}/_kinetix with teams on, _kinetix without — never a path of your own (php artisan kinetix:routes members prints the resolved URIs):

Method Endpoint Ability
GET {prefix}/members members.viewAny
POST {prefix}/members members.provision
POST {prefix}/members/{provision}/resend members.provision
PUT {prefix}/members/{provision} members.update
DELETE {prefix}/members/{provision} members.revoke

Outside the members screen (admin panel, console, job), revoke with KinetixMembership::revoke($provision): role removed in the provision's team, detach_member runs, provision kept as revoked. It checks no ability.

Activation endpoints are public but protected by a temporary signed URL (GET shows the page, POST completes it — both share the same path so one signature covers both):

Method Endpoint
GET /members/activate/{provision} (signed)
POST /members/activate/{provision} (signed)

The allow-list is the security boundary. A user who can members.provision still cannot assign a role outside membership.assignable_roles — it is enforced at provision and activation time (at activation against the provision's team, since the signed URL carries no {current_team} segment). Kinetix assigns the dynamic Kinetix role; it never touches the host's team pivot, so wire attach_member — required once team scoping is on:

Teams that create their own roles in the Roles UI also need a dynamic allow-list — a callback receiving the provision's team key instead of a static array. Write both as callable arrays, never closures: a closure in a config file makes php artisan config:cache fail, so a closure-based config is not deployable.

// config/kinetix.php → membership
'attach_member'    => [\App\Kinetix\SyncProvisionedMember::class, 'attach'],
'detach_member'    => [\App\Kinetix\SyncProvisionedMember::class, 'detach'],
'assignable_roles' => [\App\Kinetix\AssignableRoles::class, 'forTeam'],
class SyncProvisionedMember
{
    public function attach(Model $user, MemberProvision $provision): void
    {
        if ($provision->team_id !== null) {
            Team::find($provision->team_id)?->users()->attach($user->getKey());
        }
    }
}

// Prefer the built-in resolver — it matches the Roles UI's visibility rule
// (the team's roles PLUS global team-NULL ones, minus protected roles):
//   'assignable_roles' => \Happones\Kinetix\Permissions\AssignableRoles::class,
// To withhold extra names, wrap it:
class AssignableRoles
{
    public function __invoke(int|string|null $teamId): array
    {
        return \Happones\Kinetix\Permissions\AssignableRoles::names($teamId, except: ['admin']);
    }
}

NEVER hand-write where('team_id', $teamId) here: it drops global (team-NULL) roles — exactly where KinetixRolesSeeder's presets live — leaving the invite picker empty. The correct scope is whereNull('team_id')->orWhere('team_id', $teamId) (what AssignableRoles::query() returns). An array is read as a callback only when it is a [class-string, method] pair, so ['editor', 'viewer'] still means a list of role names.

State machine: update/resend on a REVOKED provision and resend on an ACTIVE one are 422s — re-provision deliberately. The activation mail is ShouldQueue: without a running worker (or QUEUE_CONNECTION=sync) no invite email is ever sent while the UI reports success.


Frontend Usage

Components publish with --tag=kinetix-components and read the reactive kinetix_permissions prop, so gate them with the same {feature}.{ability} keys.

1. Members directory (drop-in)

<KinetixMemberList> embeds the provisioning form (identifier field from membership.identifier, optional name, role picker showing what the role grants and that super-admin is the owner's) and lists members with joined / invited / expiry dates, status filters with counts, role change (a role that is no longer assignable stays listed, locked), resend, new link / new password (the credential shown once, to copy) and revoke. Every action shows only when the viewer holds its members.* ability:

<script setup lang="ts">
import { useKinetixCan } from '@/composables/useKinetixCan'
import KinetixMemberList from '@/components/kinetix/KinetixMemberList.vue'

const { can } = useKinetixCan()
</script>

<template>
  <KinetixMemberList v-if="can('members.provision')" />
</template>

For a custom layout, compose the presentational <KinetixMemberProvisioner :assignable-roles :roles :reserved-roles :identifier-type @submit> (submit(identifier, role, name?)) with the useKinetixMembers() composable (load, provision(identifier, role, name?) → the provision with its one-time credential, issueCredential, resend, updateRole, revoke, plus reactive provisions, assignableRoles, roles, reservedRoles, identifierField, can).

2. Activation page

Render <KinetixMemberActivation :email="email" :action="action"> from the page named by membership.activation_view (default Kinetix/MemberActivation), forwarding the email and action props the controller passes. Email is fixed (from the provision); the member only sets a name + password, posting back to the signed action URL.


Composing with Permissions

Membership and Permissions are independent but complementary: teams = where, roles/permissions = what, provisioning = the intersection (adds a user to a team AND assigns a role). With Permissions enabled, grant the members.* abilities to a role; without it, define those gates in your app.

UUID / ULID Host Models

This feature's migration builds user_id, team_id and invited_by with Happones\Kinetix\Support\HostKeys, which types each column after YOUR model at migrate time from its primary key (HasUlids -> ulid, HasUuids -> uuid, string $keyType -> string, else bigint). Pin kinetix.key_types.user|team when detection cannot see the setup; morph ids follow kinetix.key_types.morph (default bigint) — set it when the referenced models use UUIDs/ULIDs. Apps migrated on an older Kinetix have bigint columns on disk and need their own ALTER migration. Full 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