Skip to content

Membership & Provisioning ​

Most starter kits assume a consumer / self-serve model: anyone registers, gets their own personal team, becomes its owner, and grows it by emailing peer invitations that the recipient self-accepts.

Many real projects need the opposite — an admin-provisioned / directory model: an organization admin adds people, those people do not own anything and cannot create their own team; they only activate their account by setting a password. Registration is closed; the only way in is provisioning.

Kinetix Membership provides that second model as a drop-in alternative to the starter kit's InviteMemberModal / TeamInvitation flow, and it composes cleanly with the Kinetix Roles & Permissions module.

Read this first — where the endpoints live

The module registers its own endpoints and <KinetixMemberList> calls them itself. With teams on they live under:

{current_team}/{kinetix.route_prefix}/members/...   →  e.g. /acme/_kinetix/members

Do not register your own {current_team}/members controller expecting the component to hit it — it never will. You register only the Inertia page route. Run php artisan kinetix:routes members to see the resolved URIs, methods and middleware.


1. Two onboarding models ​

AspectStarter-kit Invitation (self-serve)Kinetix Provisioning (admin-controlled)
Who initiatesAnyone with invitation:createAn admin with members.provision
Account creationRecipient self-registersAdmin pre-registers email + role
ActivationRecipient accepts an invite linkMember sets a password via signed link
Personal teamAuto-created on register (is_personal=true, Owner)None — member joins the org team only
Role sourceFixed pivot enum (Owner/Admin/Member)Dynamic Kinetix role (spatie), from a curated allow-list
Public registrationOpen (Features::registration())Closed
Mental model"Invite a collaborator""Add an employee to the directory"

They are not competing implementations of the same idea — they are different onboarding philosophies, which is why this is a substitute component, not a new option on the same button.


2. Installation ​

Publish the provisions migration and run it:

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

Enable the module in config/kinetix.php:

php
'membership' => [
    // Enable the provisioning module (opt-in)
    'enabled'           => env('KINETIX_MEMBERSHIP_ENABLED', false),

    // Scope provisioning to the active team: true/false wins, null (default)
    // inherits the global `kinetix.teams` switch. Keep it in agreement with
    // `permissions.teams` — provisions and the roles they assign should live
    // in the same tenancy model (kinetix:doctor warns when they disagree).
    'teams'             => env('KINETIX_MEMBERSHIP_TEAMS'),

    // The host's User model, created on activation
    'user_model'        => env('KINETIX_MEMBERSHIP_USER_MODEL', 'App\\Models\\User'),

    // Roles an admin is allowed to assign while provisioning. Anything outside
    // this list is rejected server-side — this is how you guarantee
    // "I add members but they never become admin". A static array, a
    // [Class, 'method'] pair, or an invokable class-string (see "Dynamic
    // allow-list" below for the recommended DB-backed resolver).
    'assignable_roles'  => ['editor', 'viewer'],

    // Hours an activation link stays valid
    'activation_expiry' => env('KINETIX_MEMBERSHIP_ACTIVATION_HOURS', 72),

    // Inertia page rendered for the set-password screen
    'activation_view'   => env('KINETIX_MEMBERSHIP_ACTIVATION_VIEW', 'Kinetix/MemberActivation'),

    // Where to send a member after a successful activation
    'redirect_after'    => env('KINETIX_MEMBERSHIP_REDIRECT', '/'),

    // Optional callables to (de)attach the activated user to the host's own team
    // pivot — Kinetix never touches it directly.
    // Signature: fn ($user, MemberProvision $provision) => void
    'attach_member'     => null,
    'detach_member'     => null,
],

Authorization for the management endpoints flows through Laravel's Gate exactly like roles.manage. With the Roles & Permissions module enabled, just grant the members.* abilities to a role. Without it, define those gates yourself.

attach_member is required in a teams app

Kinetix assigns the Kinetix role and never touches your teams pivot — the host owns that row. So with team scoping on and attach_member left at null, activation succeeds, the member gets their role… and belongs to no team, which in a team-routed app means they can't reach a single page. Kinetix logs a warning at boot when it detects that combination:

Kinetix: membership team scoping is on but `kinetix.membership.attach_member`
is null — activated members will NOT be linked to any team.

Point it at a callable array — see step 3 and the callback forms below:

php
'attach_member' => [\App\Kinetix\SyncProvisionedMember::class, 'attach'],

Mail prerequisites ​

The activation link travels by email (MemberActivationNotification, an on-demand mail notification), so provisioning silently depends on two things the module can't set up for you:

  1. A working mailer — the usual MAIL_* env block. In local dev, point it at Mailpit/log to see the links.
  2. A running queue worker. The notification implements ShouldQueue: with QUEUE_CONNECTION=database (the starter-kit default) and no worker running, no email is ever sent — while the admin sees a perfectly successful "Invitation sent" toast. If you don't run workers, set QUEUE_CONNECTION=sync.

The email subject/body use the kinetix.member_activation_* translation keys (all locales), so brand or reword it by overriding those keys in your published lang files. For a fully custom email, extend Happones\Kinetix\Membership\MemberActivationNotification and rebind it in the container.

2.1 Callback options (config:cache-safe) ​

Four options here take a callback: attach_member, detach_member, assignable_roles, and (in Permissions) owner_bypass. Never write them as a closure. A closure in a config file makes the config uncacheable, so the app cannot be deployed with config:cache:

Your configuration files could not be serialized because the value at
"kinetix.membership.attach_member" is non-serializable.

Kinetix accepts two serializable forms instead, both resolved through the container (an instance method gets a container-resolved instance, so you can inject dependencies):

php
// 1. A callable array — [class-string, method]. The recommended form.
'attach_member'     => [\App\Kinetix\SyncProvisionedMember::class, 'attach'],
'detach_member'     => [\App\Kinetix\SyncProvisionedMember::class, 'detach'],

// 2. An invokable class-string, when the class does one thing.
'assignable_roles'  => \App\Kinetix\AssignableRoles::class,   // __invoke($teamId): array
php
namespace App\Kinetix;

use App\Models\Team;
use Happones\Kinetix\Membership\MemberProvision;
use Illuminate\Database\Eloquent\Model;

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

    public function detach(?Model $user, MemberProvision $provision): void
    {
        if ($user !== null && $provision->team_id !== null) {
            Team::find($provision->team_id)?->users()->detach($user->getKey());
        }
    }
}

A closure still works in development (and is fine in a service provider that assigns config at runtime), but the callable array is the only form that survives config:cache — so it is what the guides use.

Dynamic allow-list ​

A config array covers a fixed catalog. If your teams create their own roles in the Roles UI, point assignable_roles at the built-in resolver — the invite picker then offers exactly what the Roles screen shows, the current team's roles PLUS global (team-NULL) ones, minus the protected roles:

php
'assignable_roles' => \Happones\Kinetix\Permissions\AssignableRoles::class,

To withhold more names (e.g. keep admin promotion out of the invite flow), wrap it in your own invokable:

php
namespace App\Kinetix;

use Happones\Kinetix\Permissions\AssignableRoles as KinetixAssignableRoles;

class AssignableRoles
{
    public function __invoke(int|string|null $teamId): array
    {
        return KinetixAssignableRoles::names($teamId, except: ['admin']);
    }
}

Rolling your own query? Don't drop the global roles

A hand-written where('team_id', $teamId) silently excludes team_id IS NULL — which is exactly where KinetixRolesSeeder's admin/editor/viewer presets live (seeded from the console = no team = global). The result is a role that's fully manageable in the Roles UI and invisible in the invite picker. The correct scope is the one AssignableRoles::query() gives you:

php
->where(fn ($q) => $q->whereNull('team_id')->orWhere('team_id', $teamId))

The callback is resolved on every request that lists or validates roles, so a role created a second ago is immediately assignable — and it is still enforced twice (provision + activation), the security boundary is unchanged. At activation the callback receives the provision's team, since the signed activation URL carries no {current_team} segment.

A list of role names is still just a list

An array is read as a callback only when it is a [class-string, method] pair, so ['editor', 'viewer'] keeps meaning exactly what it looks like.


2.5 Provisioning modes (members with no email address) ​

Plenty of businesses employ people who simply do not have an email address — so there is nowhere to send an activation link. Two config axes cover that, and both default to exactly the behavior above.

php
'membership' => [
    // 'activation' (default) | 'direct'
    'provisioning' => env('KINETIX_MEMBERSHIP_PROVISIONING', 'activation'),
    // 'mail' (default) | 'manual'
    'delivery'     => env('KINETIX_MEMBERSHIP_DELIVERY', 'mail'),
    // 'email' (default) | 'username' | 'phone'
    'identifier'   => env('KINETIX_MEMBERSHIP_IDENTIFIER', 'email'),
],
bash
php artisan vendor:publish --tag=kinetix-membership-migrations
php artisan migrate
activation (default)direct
When the User existsafter they set their own passwordimmediately
What they receivea signed activation linka temporary password
Provision status after store()pendingactive
Needs a delivery channelyes, unless delivery: manualno

delivery: manual sends nothing and hands the credential back to the admin once, to pass on in person. It applies to both modes: an activation link in activation, a temporary password in direct.

php
'delivery'                => 'sms',
'identifier'              => 'phone',
'sms_channel'             => 'vonage',                 // the channel YOU registered
'activation_notification' => \App\Notifications\SmsActivation::class,

Kinetix deliberately does not pick an SMS provider. Vonage, Twilio and the local gateways each register their own notification channel and their own message object, they are mutually incompatible, and which one is right is a business decision about coverage and price in your country — in Mexico, for instance, almost nobody uses Vonage.

So Kinetix routes over whatever channel you name and ships the message text; your provider's message object is a few lines:

php
use Happones\Kinetix\Membership\MemberActivationNotification;

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

smsContent() is the translated one-liner (kinetix.member_activation_sms), kept short on purpose: a signed URL is already long, and every character before it pushes the message towards a second, billable segment. If your links are close to the limit, put a short-link service in front of $this->activationUrl.

A link that can't be sent is never silently dropped

Laravel resolves toVonage() by name at send time, so a missing method throws inside a queued job — where nobody sees it and the member never gets their link. Kinetix checks the method exists before sending, and falls back to handing the link to the admin. Same for a member with no phone number on file. kinetix:doctor reports both, because the config would otherwise claim something is being texted when nothing is.

The trade direct makes ​

The default exists so that no password-less accounts pile up — nobody has an account until they prove they wanted one. direct gives that up: the account exists from the moment an admin creates it, whether or not the person ever signs in. That is the price of working with no delivery channel, and it is the right price to pay when the alternative is not being able to onboard your staff at all. Revoking still runs detach_member and removes the role.

The credential is shown exactly once ​

jsonc
// POST /_kinetix/members  →  201
{
  "id": 12, "identifier": "juan.perez", "status": "active", "role": "editor",
  "credential": { "type": "password", "value": "Xk7...", "expiresAt": "..." }
}

It is hashed on the way into the user record, so there is no reading it back — only issuing a new one, which the dedicated endpoint does:

POST {prefix}/members/{provision}/credential

That endpoint always regenerates; the previous credential stops working. It sits behind its own members.credentials ability, because handing someone a credential means being able to become that person — a bigger privilege than adding them to the directory. Every issue is written to the Activity log with who did it; the credential itself never is.

direct needs the Credentials module

The forced first-login change that makes a temporary password safe lives in Credentials. With credentials.enabled off, the password an admin chose keeps working forever — Kinetix logs a warning at boot and kinetix:doctor reports it.

Identifiers other than email ​

membership.identifier must be one of credentials.identity.fields, or a member could be provisioned under something nobody can then sign in with; Kinetix falls back to email rather than let that happen. Values are normalized through the same resolver the login uses, so a phone typed 55 1234 5678 and +52 55 1234 5678 cannot become two provisions. See Credentials §5.

The API and the shipped components use identifier for display — whichever field carries it — while email, username and phone travel alongside it. <KinetixMemberProvisioner> takes an identifier-type prop mirroring the server setting, so the field gets the right label and keyboard.


3. Lifecycle: provision → activate ​

The module uses pre-registration + activation: the admin stores the email and role as a pending provision; no User is created until the person activates. This avoids orphaned, password-less accounts.

State of a single provision:


4. Data model ​

A lightweight pending-provision table (kinetix_member_provisions); the User is only created on activation. It carries no foreign-key constraints because the host's teams/users schema is unknown to the package.

ColumnNotes
team_idNullable — the module works without teams
emailUnique per team (unique(team_id, email))
nameCollected at activation
roleA Kinetix role name
invited_byThe provisioning admin
user_idSet on activation
statuspending · active · revoked
expires_at / activated_atActivation window + completion

The activation token is not stored. The link is a Laravel temporarySignedRoute, so validity is the signature plus the provision's pending, unexpired status — single-use without a bespoke token column.


5. Backend surface ​

The module registers a members feature with the permission registry, so its abilities show up in the permission matrix and kinetix:permissions:sync:

  • members.viewAny — View members
  • members.provision — Add / invite members
  • members.update — Change a member's role
  • members.revoke — Remove members

Endpoints (mounted under the Kinetix route prefix — the same pattern as the permission routes). {prefix} is {current_team}/_kinetix with teams on, _kinetix without; php artisan kinetix:routes members prints the resolved URIs:

MethodEndpointAbilityDescription
GET{prefix}/membersmembers.viewAnyList provisions + the assignable-role allow-list
POST{prefix}/membersmembers.provisionProvision an email + role
POST{prefix}/members/{provision}/resendmembers.provisionRe-send the activation link
PUT{prefix}/members/{provision}members.updateChange a member's role
DELETE{prefix}/members/{provision}members.revokeRevoke a member
GET/members/activate/{provision}— (signed)Public set-password page
POST/members/activate/{provision}— (signed)Complete activation

The allow-list check is the security boundary for your requirement: even a user who can members.provision cannot escalate someone to admin if admin is not in membership.assignable_roles. It is re-checked at activation time too, in case config changed between provisioning and the click.

Revoking from code ​

Outside the members screen (an admin panel, a console command, a job), revoke with KinetixMembership::revoke(). It does what the DELETE endpoint does: the member's role in the provision's team goes, your detach_member callback runs, and the provision is kept as revoked for the audit trail. It checks no ability, so gate it yourself. Revoking a revoked member changes nothing.

php
use Happones\Kinetix\Membership\KinetixMembership;

KinetixMembership::revoke($provision);

6. Adapting the starter kit ​

To switch a starter-kit app from the invitation model to this one, four changes are needed:

  1. Skip personal-team creation. In app/Actions/Fortify/CreateNewUser.php the starter kit calls CreateTeam::handle(..., isPersonal: true). Gate that behind a config flag (or remove it) so provisioned members don't get a personal team.
  2. Close public registration. Drop Features::registration() from config/fortify.php; the only entry point becomes the activation link.
  3. Attach members to your team — mandatory with teams on, or activated members belong to nothing. Point membership.attach_member at a callback that writes your team pivot; Kinetix assigns the Kinetix role, the host owns the membership row. Use the callable-array form so config:cache keeps working (see §2.1 for the class):
    php
    'attach_member' => [\App\Kinetix\SyncProvisionedMember::class, 'attach'],
  4. Swap the UI. Replace InviteMemberModal with <KinetixMemberList>.

7. Frontend (Vue / Inertia) ​

All components publish like the rest of Kinetix and read the same reactive kinetix_permissions prop, so you gate them with the same {feature}.{ability} keys. The role dropdown only ever offers roles in the server-enforced allow-list (with the AssignableRoles resolver: the team's roles plus global ones, minus protected roles).

7.1 Members directory ​

<KinetixMemberList> is the drop-in screen: it embeds the provisioning form and lists members (pending / active / revoked) with search, per-row pending states, a confirm dialog on Remove, and role change / resend / revoke. Revoked rows show the role as read-only history — re-provision to bring someone back (the server rejects role changes and resends on revoked provisions with a 422).

Register only the Inertia page route (the data flows through the built-in endpoints, exactly like the roles pages):

php
// routes/web.php — plain:
Route::middleware(['auth'])->get('members', fn () => inertia('Members/Index'))
    ->name('members.index');

// or team-routed:
Route::middleware(['auth'])->get('{current_team}/members', fn () => inertia('Members/Index'))
    ->name('members.index');
vue
<!-- resources/js/pages/Members/Index.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>
Member list with provisioning formMember list with provisioning form

Need a custom layout? Compose the presentational <KinetixMemberProvisioner :assignable-roles="..." @submit="..."> form with the useKinetixMembers() composable (load, provision, resend, updateRole, revoke, plus the reactive provisions / assignableRoles).

7.2 Activation page ​

<KinetixMemberActivation> is the public set-password screen. Create the page file at resources/js/pages/Kinetix/MemberActivation.vue (that's what the default membership.activation_view resolves to — or change the config to any page you already have), forwarding the props the controller passes — identifier (the email, username or phone the member is known by), email, team (the name of the business they are joining, null without teams) and the signed action URL:

vue
<script setup lang="ts">
import KinetixMemberActivation from "@/components/kinetix/KinetixMemberActivation.vue";

defineProps<{
  identifier?: string | null;
  email?: string | null;
  team?: string | null;
  action: string;
}>();
</script>

<template>
  <KinetixMemberActivation
    :identifier="identifier"
    :email="email"
    :team="team"
    :action="action"
  />
</template>

The page says who is inviting them ("You were invited to join Acme Coffee") — a set-password form that names no business reads like any phishing page.

Member activation — set passwordMember activation — set password

Email is fixed (it came from the provision); the member only sets a name and password, and the form posts back to the signed action URL.


8. How it composes with Roles & Permissions ​

The two modules are independent but complementary:

  • Teams answer where — the tenancy boundary a member belongs to.
  • Roles/Permissions answer what — the abilities a member has.
  • Provisioning sits at the intersection — a single action that adds a user to a team and assigns a Kinetix role, with the assignable set policed by config.

You can adopt either module alone:

  • Permissions without provisioning — keep the starter kit's invitations, but resolve abilities through Kinetix roles instead of the pivot enum.
  • Provisioning without permissions — provision members and gate the endpoints with your own members.* gates; you lose the dynamic role matrix but the onboarding flow stands on its own.

Assigning a role to a user who already exists ​

Kinetix ships no UI for "give this existing user a role" outside the members list — if you keep the starter kit's invitation flow, assign the role yourself where the user joins the team. With teams active, pin spatie's team id first: assignRole() stamps whatever the registrar currently holds, and in a listener/job that's undefined:

php
use Spatie\Permission\PermissionRegistrar;

$registrar = app(PermissionRegistrar::class);
$previous  = $registrar->getPermissionsTeamId();
$registrar->setPermissionsTeamId($team->getKey());

try {
    $user->assignRole('editor');   // resolves team + global roles by name
} finally {
    $registrar->setPermissionsTeamId($previous);
}

(That try/finally is exactly what the built-in provisioning endpoints do internally on activation, role change and revoke.)

Used together, you get the full B2B story: a closed directory where admins add people and hand them exactly the capabilities you allow — never owner, never admin, never a stray personal team.


9. Security considerations ​

  • Signed, expiring, single-use activation links (Laravel signed URLs); never a plain incrementing id — validity is the signature plus the pending status.
  • Don't leak existence — provisioning re-uses updateOrCreate, so a repeat on a known email looks the same as a fresh one.
  • Rate-limit the activation and resend endpoints in your app — the route names are kinetix.membership.activate.show / kinetix.membership.activate (public, signed) and kinetix.membership.resend; e.g. a RateLimiter::for() keyed by IP applied via ->middleware('throttle:...') in a route group that wraps them, or globally with kinetix.middleware.
  • State machine is enforced — a revoked provision rejects role changes and resends (422; re-provision deliberately instead), and resend on an active member is a 422 too (an activation link for an existing user would try to create a duplicate).
  • The allow-list is enforced twice — at provision time and again at activation, so a later config change can't be exploited by an old link.
  • Audit who provisioned/revoked whom (invited_by, timestamps) — this is a privileged, organization-shaping action.

Released under the MIT License.