Membership lifecycle
Membership lifecycle
Every change to who belongs to an organization goes through the Memberships,
Invitations and Organizations contracts. Each verb enforces the organization's
invariants inside one transaction, records an audit entry and emits a
webhook event — so a host never writes a role or a status
onto a model by hand.
The single-owner rule
An organization always has an owner, and ownership is transferred, never assigned.
MembershipRole::assignable()lists the tiers a console or API may hand out —Owneris not among them. Validate one incoming tier with$role->isAssignable()and route a request forOwnertotransferOwnership()instead.- The last owner cannot be demoted, removed or leave: those raise
LastOwner. The count of owners is taken with the owner rows locked, so two owners stepping down at the same moment cannot both succeed. owners($organizationId)lists the active owners. Organizations created before ownership was transfer-only may hold more than one; treat that as data to reconcile, not a pattern to extend.
The verbs
| Verb | Contract | What it does | Refusals |
|---|---|---|---|
| Invite | Invitations::invite() |
Creates a pending invitation; supersedes an earlier pending one to the same address. | — |
| Accept | Invitations::accept() |
Creates the membership with the invited tier. | InvalidInvitation |
| Revoke invitation | Invitations::revoke() |
Stops a pending invitation's token working. Bound to the organization; idempotent. | — |
| Add | Memberships::add() |
Adds a member directly. | CrossEnvironmentAccess |
| Change tier | Memberships::changeRole() |
Moves a member to another tier. | LastOwner (demoting the only owner) |
| Remove | Memberships::remove() |
An administrator removes a member and every role they held there. | LastOwner |
| Leave | Memberships::leave() |
The member removes themselves, with every role they held there. Idempotent. | LastOwner::leaving() for the only owner |
| Transfer ownership | Memberships::transferOwnership() |
Owner → an existing active member. The new owner becomes owner, the previous one admin. |
OwnershipTransferRefused |
| Rename | Organizations::update() |
Changes the name and/or slug. | SlugAlreadyTaken |
| Archive (operator) | Organizations::archive() |
Terminal status; access revoked everywhere. Idempotent. | — |
| Archive (owner) | Organizations::archiveAsOwner() |
The same archive, on an active owner's say-so. | NotOrganizationOwner |
Leaving
use Cbox\Id\Organization\Contracts\Memberships;
use Cbox\Id\Organization\Exceptions\LastOwner;
try {
app(Memberships::class)->leave($organizationId, $userId);
} catch (LastOwner $e) {
// "You are the only owner of organization [...], so you cannot leave it.
// Transfer ownership to another member first, or archive the organization."
return back()->withErrors(['leave' => $e->getMessage()]);
}
Transferring ownership
use Cbox\Id\Organization\Contracts\Memberships;
use Cbox\Id\Organization\Exceptions\OwnershipTransferRefused;
try {
$newOwner = app(Memberships::class)->transferOwnership($organizationId, $currentOwnerId, $memberId);
} catch (OwnershipTransferRefused $e) {
// $e->reason is an OwnershipTransferRefusal: not_owner, target_not_member,
// target_not_active or same_member — a stable code for an API error body.
}
Both membership rows are read with a row lock, in one statement ordered by id, so the transfer is atomic against a concurrent remove, leave, role change or second transfer, and two transfers crossing between the same people cannot deadlock. The organization is never without an owner at any point in the transaction.
An owner archiving their organization
archiveAsOwner($organizationId, $userId) has exactly archive()'s semantics — the
Deleted status, the cache invalidation, idempotence — plus a check that $userId holds an
active owner membership, taken under a row lock so an ownership transfer cannot slip in
between the check and the archive. The audit entry is attributed to that user.
Isolation
Every guard above binds the organization in its WHERE clause as well as relying on the
tenant scope. Under TenantContext::withoutScope() — a provisioning job, a backfill — a
guard that relied on the scope alone would see every organization's rows: a delete would
remove the person everywhere, the owner count would include other organizations' owners,
and a transfer could promote a member of a different organization.
Events
| Change | Event | Also emitted (legacy name) |
|---|---|---|
| A member joins | membership.created |
organization.member_added |
| A tier changes (incl. a transfer, one per side) | membership.updated (reason: role_changed or ownership_transferred) |
organization.member_role_changed |
| A member is removed or leaves | membership.deleted (reason: removed or left) |
organization.member_removed |
| An invitation is sent | invitation.created |
organization.invitation_created |
| An invitation is accepted | invitation.accepted |
organization.invitation_accepted |
| An invitation is revoked or superseded | invitation.revoked (reason: revoked or superseded) |
— |
| A rename or settings change | organization.updated |
— |
| An archive | organization.deleted |
organization.archived |
The legacy names stay because outbound provisioning and usage metering key off them, and existing webhook endpoints may subscribe to them by name. New integrations subscribe to the first column. Payloads: Webhook events.
The member's tier also travels in tokens as the
org_role claim.