goldnead / statamic-teams
Teams for Statamic: workspaces with members, roles per team, invitations by link and join code. A team can hold access and buy on its own billing address.
Package info
github.com/goldnead/statamic-teams
Type:statamic-addon
pkg:composer/goldnead/statamic-teams
Requires
- php: ^8.2
- laravel/framework: ^12.40|^13.0
- statamic/cms: ^6.0
Requires (Dev)
- goldnead/statamic-brand-context: ^1.13
- goldnead/statamic-email-templates: ^2.8
- goldnead/statamic-entitlements: ^1.5
- goldnead/statamic-payments: ^1.27
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0 || ^11.0
- phpunit/phpunit: ^11.0 || ^12.0
Suggests
- goldnead/statamic-activity: Team changes are written to the activity log.
- goldnead/statamic-automations: Every team event (created, member joined, invitation sent ...) becomes an automation trigger.
- goldnead/statamic-brand-context: Makes the invitation lifetime, join-code length and the mail switches editable under Settings, Addon settings.
- goldnead/statamic-email-templates: Every mail of this addon becomes a template an editor can change in the Control Panel.
- goldnead/statamic-entitlements: A team becomes a subject: access granted to the team holds for every member.
- goldnead/statamic-payments: A team can be the buyer, with its own billing address.
- goldnead/statamic-webhook-manager: Every team event becomes a webhook trigger.
Provides
None
Conflicts
None
Replaces
None
README
Workspaces with members. A team is a choir, a company, a household: people are in it with a role that counts in that team only, they come in by invitation link or join code, and access or a purchase can belong to the team instead of one person.
- Teams, memberships with a role and free
metaper member, invitations (token stored only as a hash), join codes that survive being read aloud. - Roles and permissions per team, not global. Statamic roles stay for the Control Panel. Roles are managed in the CP (Teams → Roles, a permission matrix), per team on the team page, and through the facade.
- Middleware
teams.current: which team a request is about, and that the user is in it. 403 for a stranger, 422 for a request that names two different teams. - A team is a subject in statamic-entitlements: access granted to the team holds for every member and ends when they leave.
- A team is the buyer in statamic-payments, with its own billing address.
- Every mail is a template in statamic-email-templates.
- Every event is a trigger in automations and the webhook manager, and lands in the activity log.
- Personal team per user (optional), read-only teams, import with fixed ids for moving an existing app.
Works with Statamic's file users and Eloquent users alike.
Requirements
- PHP 8.2+, Laravel 12.40+ or 13, Statamic 6.
- Users from Statamic's file repository or the Eloquent repository.
- Optional: statamic-brand-context (settings page), statamic-email-templates, statamic-entitlements, statamic-payments, statamic-automations, statamic-webhook-manager, statamic-activity.
Installation
composer require goldnead/statamic-teams php artisan migrate
Optional, when email-templates is installed:
php please teams:mail-templates # writes the four mails into the templates collection
The Control Panel lists teams under Users → Teams. Permissions: view teams, manage teams,
manage teams settings.
Usage
use Goldnead\Teams\Facades\Teams; $choir = Teams::create('Kammerchor Köln', $conductor, ['join_method' => 'join_code']); Teams::invite($choir, 'alto@example.com', 'member', ['voice_part' => 'alto'], actor: $conductor); // in a controller behind `teams.current` $team = Teams::current(); abort_unless(Teams::can($request->user(), $team, 'invite members'), 403);
In Antlers, {{ teams:switch_form }}, {{ teams:members }}, {{ teams:invite_form }} and
{{ teams:join_form }} cover the usual account pages (see "Front end").
Concepts
| Team | Goldnead\Teams\Models\Team: id, uuid, name, type (team, personal, or your own), owner_id, join_method (invitation_only, join_code), join_code, settings, billing. |
| Membership | One user in one team: role, meta (free fields, e.g. a voice part), is_current, joined_at. Users are stored by string key, so UUIDs (file users) and integers (Eloquent) both work. |
| Invitation | Addressed to an email, with a role and meta copied onto the membership. Expires (default 7 days), is bound to its address, works once. Inviting the same address again replaces the link. |
| Role | Global roles start from teams.roles; changes made in the CP (table team_global_roles) win, and each can be reset to the config. A team can add or adjust roles for itself (team_roles); a team role with a global handle replaces it for that team. owner holds every permission and cannot be removed from the last owner. |
Nobody hands out more than they hold. Whoever assigns a role, invites into it or removes someone
holding it must hold every permission of that role; a role with * and the owner role only by an
owner. Changing one's own role, and demoting or removing an owner, is an owner's business. The
last-owner check runs inside the write's transaction with the owner rows locked. The same rule
covers editing someone else's membership fields (updateMemberMeta).
An invitation grants what its sender may still give. On acceptance, the person who invited must
still be in the team and hold every permission of the invited role. If not, the invitation is not
refused (the invitee acted in good faith) but grants only default_role; the team can raise it.
Invitations from the CP or an import (no sender) keep their role.
Managing roles
In the CP, Teams → Roles lists the global roles with one column per permission, and creates,
renames, changes, resets and deletes them (core publish form with a checkbox per permission). The
team page has a Roles panel: every role that applies there, marked Global or This team,
and roles created or adjusted for that team only. Both need the Statamic permission
manage team roles; manage teams is not enough.
The rules hold for every caller, CP and API alike:
*(every permission) belongs to the owner role only. The owner role can be renamed, not narrowed. Neither the owner role nordefault_rolecan be deleted.- A role lists only known permissions:
teams.permissions, plus what code registers withTeams::registerPermission('edit scores', 'Edit scores')(in a service provider'sboot()). Labels come fromteams::permissions.<handle>or the translator. - A role somebody holds (member or open invitation) is deleted only together with a role to move
them to, never into the owner role. Without one the refusal is
role_in_usewith the counts indetails. Holders are counted inside the deleting transaction and again after the delete; a holder that appeared in between rolls the deletion back. Deleting a team's version of a global role moves nobody: they get the global role back. - Global roles are site business: a team member (an actor) is refused.
- A new global role cannot take a handle that teams already use for a role of their own (409
role_handle_in_teams, the teams indetails.teams): it would silently become the role those team roles replace. The same holds for resetting a deleted config role. - A team member changes the roles of their team only with the team permission
manage team roles(not inadminby default), and only within what they hold: every permission they write, and of every role they change, replace, delete or move people into, must be theirs. Their own role is an owner's business. Sochange rolesalone never lets an admin widen a role. Going back from a team's narrower version to the global role grants the global permissions, so it counts too: only owners (or the system) may restore a global role that holds more than the editor.
When a change takes effect. Global and team roles are read once per request or queue job and
kept for its duration; every write through the addon empties that store. A long-running job (one
job that loops for minutes) therefore works with the roles as they were when it read them first.
Call app(\Goldnead\Teams\Support\GlobalRoleStore::class)->flush() and
app(\Goldnead\Teams\Support\TeamRoleStore::class)->flush() where it must see changes made
meanwhile. Only a missing team_global_roles table (migration not run) falls back to the config;
any other database error is thrown, so a deleted or narrowed role never gets its config
permissions back by accident.
Nobody is put into a team without consent: the Control Panel and the front end invite, they do not add.
Teams::addMember() exists for code that has its own consent (an import, a checkout).
Public API
Everything goes through the facade Goldnead\Teams\Facades\Teams (root: Goldnead\Teams\TeamsManager).
These are the operations statamic-app-api exposes as JSON.
Methods that change something take an optional $actor. With an actor (the signed-in user), the
actor's role in the team must allow it. Without an actor the call is trusted (CP, console, import).
A refusal is a Goldnead\Teams\Exceptions\TeamsException with a stable reason and an HTTP status():
| reason | status | |
|---|---|---|
not_member |
403 | actor or user is not in the team |
forbidden |
403 | the role does not allow it |
already_member |
422 | |
invitation_not_found |
404 | |
invitation_expired, invitation_used, invitation_revoked |
410 | |
invitation_wrong_email |
403 | account email differs from the invited one |
join_code_invalid |
404 | |
join_disabled |
403 | team does not accept codes |
unknown_role, last_owner, already_owner, personal_team, team_mismatch, team_required |
422 | |
import_collision |
409 | a fixed id belongs to another team |
read_only |
423 | |
role_exists, role_protected, unknown_permission, wildcard_not_allowed, invalid_role_handle |
422 | role editor |
role_in_use |
409 | details: members, invitations |
role_handle_in_teams |
409 | details.teams: [{id, name}] |
anything a join guard returns, e.g. team_full |
422 |
TeamsException::toArray() (the JSON body) is {reason, message}, plus details where a reason has
them.
use Goldnead\Teams\Facades\Teams; // Teams Teams::create(string $name, $owner = null, array $attributes = []): Team // type, join_method, settings, billing, meta Teams::update(Team $team, array $attributes, $actor = null): Team // name, join_method, settings (merged), billing (merged) Teams::delete(Team $team, $actor = null): void Teams::find(int|string $idOrUuid): ?Team Teams::personalTeam($user): Team // created on first use Teams::regenerateJoinCode(Team $team, $actor = null): string Teams::transferOwnership(Team $team, $to, $actor = null): Team // old owner becomes admin // Members Teams::teamsOf($user): Collection Teams::members(Team $team): Collection // of Membership Teams::addMember(Team $team, $user, ?string $role = null, array $meta = [], $actor = null): Membership Teams::removeMember(Team $team, $user, $actor = null): void // actor === user means leaving Teams::leave(Team $team, $user): void Teams::changeRole(Team $team, $user, string $role, $actor = null): Membership Teams::updateMemberMeta(Team $team, $user, array $meta, $actor = null): Membership // null removes a key // Current team Teams::current($user = null): ?Team // the middleware's team, else the user's current one Teams::currentOrFail($user = null): Team // or TeamsException team_required (422) Teams::switch($user, Team $team): Membership // Invitations and codes Teams::invite(Team $team, string $email, ?string $role = null, array $meta = [], $actor = null): IssuedInvitation // ->invitation, ->token, ->url Teams::invitation(string $token): Invitation // peek, throws if no longer usable Teams::acceptInvitation(string $token, $user): Membership Teams::revokeInvitation(Invitation $invitation, $actor = null): Invitation Teams::resendInvitation(Invitation $invitation, $actor = null): IssuedInvitation Teams::pendingInvitationsFor($user): Collection // addressed to the user's email Teams::pendingInvitationsOf(Team $team): Collection Teams::joinByCode(string $code, $user, array $meta = []): Membership Teams::guardJoining(Closure $guard): void // fn (Team $team, string $userKey, string $via): ?string reason // Roles Teams::can($user, Team $team, string $permission): bool Teams::roleOf($user, Team $team): ?string Teams::roles(?Team $team = null): array // handle => label, permissions, scope (global|team), source, overrides_global Teams::permissions(): array // handle => translated label Teams::registerPermission(string $handle, ?string $label = null): void // Managing roles: without $team a global role (system only), with $team that team's own Teams::createRole(string $handle, string $label, array $permissions = [], ?Team $team = null, $actor = null): array Teams::updateRole(string $handle, array $attributes, ?Team $team = null, $actor = null): array // label, permissions Teams::deleteRole(string $handle, ?Team $team = null, ?string $reassignTo = null, $actor = null): int // members moved Teams::resetRole(string $handle): array // a global role back to teams.roles Teams::roleUsage(string $handle, ?Team $team = null): array // members, invitations // Entitlements and payments Teams::entitlementSubject(Team $team) // SubjectReference('team', id) Teams::entitlementSubjectsFor($user): array // one per team of the user Teams::allows($user, string $product, $personalSubject = null): bool Teams::checkoutBuyer(Team $team, $payer = null): array Teams::checkoutDetails(Team $team, $payer = null): array Teams::checkout(Team $team, string|array $products, $payer = null, ?string $returnUrl = null): ?object // Import Teams::import(array $data): Team
$user is anything that names a user: a Statamic user, an Authenticatable, or its id.
Roles over app-api. statamic-app-api does not expose role management yet. An endpoint for it is
a thin controller in the shape of its TeamController::changeRole(): resolve the team the caller is
a member of, then pass the signed-in user as $actor, so the team permission manage team roles
and the no-escalation rule apply:
// GET /teams/{team}/roles Teams::roles($team) + Teams::permissions() // POST /teams/{team}/roles Teams::createRole($handle, $label, $permissions, $team, $user) // PATCH /teams/{team}/roles/{role} Teams::updateRole($role, $request->only('label', 'permissions'), $team, $user) // DELETE /teams/{team}/roles/{role}?reassign_to=member // Teams::deleteRole($role, $team, $reassignTo, $user)
A TeamsException answers with its status() and toArray(). Global roles are not for app-api:
with an actor, every global call is forbidden.
Team offers hasMember($user), roleOf($user), membershipOf($user), isOwner($user),
isPersonal(), isReadOnly(), allowsJoinCode(), setting($key) and summary() (the fields
events and APIs carry; never the join code).
Current team middleware
Route::middleware(['auth', 'teams.current'])->group(...); // named team, else the user's current one Route::middleware(['auth', 'teams.current:required'])->group(...); // a team must be named: 422 otherwise Route::middleware(['auth', 'teams.current', 'teams.writable'])->group(...); // 423 on writes in a read-only team
The team is read from the X-Team-ID header, team_id in query or body, and the route parameters
{team} / {team_id}, by id or uuid (all configurable under teams.current). Two sources that point
to different teams: 422. A team the user is not in, or one that does not exist: 403. Afterwards
Teams::current() returns the team.
Without a named team, mixed mode falls back to the user's current team. With
teams.current.fallback_to_current = false the request has no team, and Teams::currentOrFail()
answers 422 (team_required).
ChoirLive keeps its API names and behaviour with:
// config/teams.php 'current' => [ 'header' => 'X-Tenant-ID', 'parameter' => 'tenant_id', 'route_parameters' => ['tenant', 'tenant_id'], 'fallback_to_current' => false, // like currentTenantId(): no workspace named, 422 ], 'meta_labels' => ['voice_part' => 'Voice part'],
Front end
Antlers tags, all working on the current team unless team="id or uuid" is given:
{{ teams }}{{ name }} ({{ role_label }}){{ if is_current }} ✓{{ /if }}{{ /teams }}
{{ teams:current }}{{ name }}, {{ member_count }} members{{ /teams:current }}
{{ teams:members }}{{ name }} {{ email }} {{ role_label }} {{ meta:voice_part }}{{ /teams:members }}
{{ teams:invitations }}{{ email }} {{ status }}{{ /teams:invitations }} {{# only for who may invite #}}
{{ teams:my_invitations }}{{ team_name }}{{ /teams:my_invitations }}
{{ teams:roles }}{{ handle }} {{ label }}{{ /teams:roles }}
{{ teams:can do="invite members" }} … {{ /teams:can }}
{{ teams:switch_form redirect="/account" }}<select name="team">{{ teams }}<option value="{{ id }}">{{ name }}</option>{{ /teams }}</select><button>Switch</button>{{ /teams:switch_form }}
{{ teams:create_form }}<input name="name"><button>Create</button>{{ /teams:create_form }}
{{ teams:join_form }}<input name="code"><button>Join</button>{{ /teams:join_form }}
{{ teams:invite_form }}<input name="email"><select name="role">{{ roles }}<option value="{{ handle }}">{{ label }}</option>{{ /roles }}</select><button>Invite</button>{{ /teams:invite_form }}
{{ teams:leave_form }}<button>Leave</button>{{ /teams:leave_form }}
{{ teams:form_session }}{{ success }}{{ errors }}{{ value }}{{ /errors }}{{ /teams:form_session }}
Inside {{ teams:members }}, remove_url and role_url are set only when the signed-in user may
use them (post role to role_url).
The forms post to /!/statamic-teams/… (route names statamic.teams.forms.*). A request that
wants JSON gets JSON with reason on refusal. redirect="…" is followed only for a path on this
site. Joining by code is limited to 10 attempts per hour per account and 30 per address
(teams.routes.join_limits). The link in the invitation mail opens
/teams/invitations/{token} (view teams::invitation, publish with --tag=teams-views): a guest is
sent to teams.invitations.login_url first; accepting is a POST from that page, so a mail scanner
following the link accepts nothing.
Entitlements
A team is the subject team:<id> (Team::MORPH_ALIAS, registered in the morph map unless the host
already maps team). Grant access to a team like to anything else:
Entitlements::grant(Teams::entitlementSubject($team), 'choir-plan', 'manual');
Check a member:
Teams::allows($user, 'choir-plan'); // through any of the user's teams Teams::allows($user, 'lifetime', new SubjectReference('user', $user->id())); // personally or through a team
Teams registers itself with entitlements (Entitlements::extendSubjects(), from entitlements
150b5f2 on) as a subject expander. Then entitlements itself counts a user's teams, for grants and
for limits, without going through Teams::allows():
Entitlements::allows($user, 'choir-plan'); // true while the user is in a team holding it Entitlements::consume($user, 'analyses'); // booked at the team: the holder of the limit Entitlements::remaining(Teams::entitlementSubject($team), 'analyses');
A subject is expanded only when its type names a user: user, the auth model's class (ChoirLive:
App\Models\User) and its morph alias, plus teams.entitlements.user_types. An email or a team
subject is never expanded; a team id is not a user id. Expansion applies to reads only; a refund
against a person never touches the team's grant (entitlements' rule).
Payments
Payments has no customer model: the buyer is email, name, country on the payment, the billing
address sits in payments.meta.address. Teams::checkout() fills exactly those from the team's
billing fields (company, name, email, line1, line2, postal_code, city, country,
vat_id), names the team as $details['for'] (payments ≥ eb8bfb6: the grant, renewals, refunds and
the subscription then belong to the team) and carries meta.team_id, meta.team_uuid, meta.paid_by,
meta.address (as fields) and meta.vat_id. The same details work for Subscriptions::start();
Teams::checkoutBuyer() and Teams::checkoutDetails() return them.
VAT ID. Stored on the team as entered and not verified there (the CP says so). With
statamic-invoices installed, the checkout asks its BuyerAdmission::check() (VIES, cached) and
freezes the answer as meta.vat_id_check, which the invoice prints. Without invoices no check is
claimed.
$result = Teams::checkout($team, 'choir-plan-yearly', auth()->user(), url('/thanks')); return redirect($result->checkoutUrl);
Required: the payer is a member holding manage billing in the team. Otherwise
Teams::checkout() throws TeamsException (not_member or forbidden) and no checkout starts.
Pass the signed-in user as payer; only system code (a CP action, a job) may pass none.
Mails
Key (teams.mail.*) |
Template slug | To | Default |
|---|---|---|---|
invitation |
teams-invitation |
invited address | on |
member_joined |
teams-member-joined |
the team's owners | on |
member_removed |
teams-member-removed |
whoever was removed by someone else | on |
role_changed |
teams-role-changed |
the member | off |
With email-templates, the CP entry wins; without it, or before teams:mail-templates, the shipped
text (lang/*/mail.php) is sent. Variables per mail are listed on the Wiring page.
With an email-templates version that has the template registry, each mail is registered there
(occasion, event, placeholders with label and example, default text): the template list then shows
"Sent on: Teams: …" and Live Preview fills in the examples. Older versions get the defaults through
the email-templates.sources import tag instead.
Events
Every event extends Goldnead\Teams\Events\TeamEvent with a stable handle() and a payload() of ids
and plain fields (no tokens, no join codes):
teams.team.created, teams.team.updated, teams.team.deleted, teams.team.ownership_transferred,
teams.member.joined (via: created, added, invitation, join_code), teams.member.left
(reason: left, removed), teams.member.role_changed, teams.invitation.sent,
teams.invitation.accepted, teams.invitation.revoked, teams.role.created, teams.role.updated
(changes: label, permissions), teams.role.deleted (reassigned_to, reassigned).
Role events carry role (handle, label, permissions, scope) and team, which is null
for a global role (then team_type is null too, a flow filtered on a team type does not fire, and
the activity entry has no subject). Members moved by a deletion each fire
teams.member.role_changed as well.
Every payload carries team_type at the top level (personal, team or a type of your own), the
same value as team.type.
Personal teams are created, not joined. Creating a personal team (on registration with
personal.create_on_registration, or through Teams::personalTeam()) fires teams.team.created
with team_type = personal and no teams.member.joined for its owner. A regular team still
announces its founder as the first member (via = created). Since 0.2.0; before, every
registration on a site with personal teams fired member.joined.
With statamic-automations each is a trigger (group "Teams") with one setting, Team type: empty
fires for every team, a type fires only for teams of that type. In webhook-manager, filter on
team_type in the payload. With statamic-webhook-manager a webhook
trigger (source type team), with statamic-activity an entry (subject team:<id>).
Teams → Wiring in the CP shows, per event, its mail and how many enabled flows and webhooks listen.
Importing teams
Teams::import(array $data) or php please teams:import teams.json [--dry-run] (a JSON list, one
transaction). Idempotent by uuid, no events, no mails.
[{
"id": 42,
"uuid": "0b8a1c7e-2f7e-4a0e-9a54-0d1f5f7f2a11",
"name": "Kammerchor Köln",
"type": "team",
"owner_id": "5",
"join_code": "KAMMER24",
"join_method": "join_code",
"settings": {"read_only": false},
"billing": {"company": "Kammerchor Köln e.V."},
"created_at": "2025-12-04 10:00:00",
"roles": [{"handle": "section_leader", "label": "Section leader", "permissions": ["invite members"]}],
"members": [
{"user_id": "5", "role": "owner", "is_current": true},
{"email": "alto@example.com", "role": "section_leader", "meta": {"voice_part": "alto"}}
],
"invitations": [{"email": "new@example.com", "role": "member", "token": "the-token-from-the-mail", "expires_at": "2026-10-01"}]
}]
A plain token is hashed on the way in, so links already in someone's inbox keep working. An unknown
role stops the import with nothing written.
- Only the same team is updated. A fixed
idheld by a different team stops the import (import_collision). Without auuid, the uuid is derived from theid(UUID v5), so a file with ids only imports the same way twice. An invitation token that belongs to another team is a collision as well.--dry-runchecks every team and lists every problem, then writes nothing. - Invitation
statusis taken over:acceptedsetsaccepted_at(fromaccepted_at,updated_atorcreated_at),declined/revokedsetrevoked_at,expiredsetsexpires_atif it is missing. An accepted or expired invitation never comes back as open; an open one without an end gets the standard lifetime from the day of the import. An unknown status is imported as withdrawn and reported. join_methodother thaninvitation_onlyandjoin_code(ChoirLive'sjoin_request) is imported asinvitation_onlywith a warning in the report; the join code is kept.- On PostgreSQL, reset the
teams_id_seqsequence after importing fixed ids.
Settings
Under Settings → Addon settings (with statamic-brand-context): invitation lifetime, matching email,
join code length, personal team on registration, and the four mail switches. Everything else in
config/teams.php (php artisan vendor:publish --tag=teams-config).
License
Proprietary, part of the goldnead suite license. See LICENSE.md.
