NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Teams for Laravel

Configuration

Once the migrations are published and run, the package works with zero configuration — every key has a default, most of them env-backed. Publish config/teams.php only to change them. The published file in full:

use RoundlyConsulting\Teams\Models\Invite;
use RoundlyConsulting\Teams\Models\JoinRequest;
use RoundlyConsulting\Teams\Models\Member;
use RoundlyConsulting\Teams\Models\Team;
use RoundlyConsulting\Teams\Models\TeamRole;

return [

    'models' => [
        'team' => Team::class,
        'member' => Member::class,
        'invite' => Invite::class,
        'team_role' => TeamRole::class,
        'join_request' => JoinRequest::class,
    ],

    'key_type' => env('TEAMS_KEY_TYPE', 'bigint'),

    'primary_key_type' => env('TEAMS_PRIMARY_KEY_TYPE', 'bigint'),

    'roles' => [
        'provider' => env('TEAMS_ROLES_PROVIDER', 'array'),
        'owner' => 'owner',
        'admin' => 'admin',
        'default' => env('TEAMS_DEFAULT_ROLE', 'member'),
        'per_team' => env('TEAMS_PER_TEAM_ROLES', false),
        'cache' => [
            'enabled' => env('TEAMS_ROLES_CACHE', false),
            'store' => env('TEAMS_ROLES_CACHE_STORE'),
            'key' => env('TEAMS_ROLES_CACHE_KEY', 'teams.roles'),
            'ttl' => env('TEAMS_ROLES_CACHE_TTL', 3600),
        ],
    ],

    'invites' => [
        'expires_after' => env('TEAMS_INVITES_EXPIRES_AFTER', '7 days'),
        'code_length' => env('TEAMS_INVITES_CODE_LENGTH', 32),
    ],

    'members' => [
        'prune_after' => env('TEAMS_MEMBERS_PRUNE_AFTER', '30 days'),
        'expiring_within' => env('TEAMS_MEMBERS_EXPIRING_WITHIN', 7),
        'start_dates' => env('TEAMS_MEMBERS_START_DATES', false),
    ],

    'join_requests' => [
        'prune_after' => env('TEAMS_JOIN_REQUESTS_PRUNE_AFTER', '30 days'),
    ],

    'approvals' => [
        'enabled' => env('TEAMS_APPROVALS', false),
        'rule' => env('TEAMS_APPROVALS_RULE', 'unanimous'),
        'quorum' => env('TEAMS_APPROVALS_QUORUM'),
    ],

    'gate' => [
        'register' => env('TEAMS_REGISTER_GATE', true),
        'prefix' => env('TEAMS_GATE_PREFIX', 'teams'),
        'owner_ability' => env('TEAMS_GATE_OWNER_ABILITY', 'owner'),
    ],

    'notifications' => [
        'queue_connection' => env('TEAMS_NOTIFY_CONNECTION'),
    ],

    'context' => [
        'route_parameter' => env('TEAMS_CONTEXT_ROUTE_PARAMETER', 'team'),
        'deny_status' => env('TEAMS_CONTEXT_DENY_STATUS', 404),
        'recheck_queued_jobs' => env('TEAMS_CONTEXT_RECHECK_QUEUED_JOBS', false),
    ],

    'scoping' => [
        'without_context' => env('TEAMS_SCOPING_WITHOUT_CONTEXT', 'empty'),
        'models' => [],
        'discover' => [app_path('Models')],
    ],

    'nesting' => [
        'enabled' => env('TEAMS_NESTING', false),
        'max_depth' => env('TEAMS_NESTING_MAX_DEPTH', 4),
        'inherit_roles_down' => env('TEAMS_INHERIT_ROLES_DOWN', false),
    ],

    'permissions' => [
        'bridge' => env('TEAMS_PERMISSIONS_BRIDGE', false),
    ],

];

Every key

KeyDefaultEnvPurpose
models.teamTeam::class—Team model. Must extend the packaged Team.
models.memberMember::class—Membership model.
models.inviteInvite::class—Invite model.
models.team_roleTeamRole::class—Per-team role override model.
models.join_requestJoinRequest::class—Join-request model.
key_typebigintTEAMS_KEY_TYPEKey type of the polymorphic columns — bigint, uuid or ulid. Read by the migrations; anything else throws InvalidConfigurationException.
primary_key_typebigintTEAMS_PRIMARY_KEY_TYPEKey type of teams.id and every column pointing at a team — team_id on the package tables, teams.parent_id and every teamScope() column: bigint, uuid or ulid. Read by the migrations; blank reads as bigint, anything else throws InvalidConfigurationException.
roles.providerarrayTEAMS_ROLES_PROVIDERRole storage: array (in code) or database (the team_roles table); anything else throws.
roles.ownerowner—Role given to a team’s creator and to a new owner.
roles.adminadmin—Role the previous owner is demoted to on transfer.
roles.defaultmemberTEAMS_DEFAULT_ROLERole used when none is supplied, e.g. approving a join request.
roles.per_teamfalseTEAMS_PER_TEAM_ROLESEnable per-team role overrides. Off = global roles, zero extra queries.
roles.cache.enabledfalseTEAMS_ROLES_CACHECache the database role map; flushed on every role change.
roles.cache.storenullTEAMS_ROLES_CACHE_STORECache store; null = the default store. Tags are used when supported.
roles.cache.keyteams.rolesTEAMS_ROLES_CACHE_KEYCache key for the role map.
roles.cache.ttl3600TEAMS_ROLES_CACHE_TTLCache lifetime in seconds, 1–31536000.
invites.expires_after7 daysTEAMS_INVITES_EXPIRES_AFTERDefault invite expiry when none is supplied, also used on resend — a positive interval such as 7 days or P7D.
invites.code_length32TEAMS_INVITES_CODE_LENGTHLength of the random invite code, 8–128 (URL-safe alphanumerics).
members.prune_after30 daysTEAMS_MEMBERS_PRUNE_AFTERZero or positive interval after a membership’s expiry before teams:members:prune or model:prune deletes it.
members.expiring_within7TEAMS_MEMBERS_EXPIRING_WITHINDefault window in days (1–3660) for teams:members:expiring and MembershipExpiringSoon.
members.start_datesfalseTEAMS_MEMBERS_START_DATESMembership start dates: members()->add(..., startsAt:) books a membership that grants nothing until then. Needs the starts_at migration; while off the column is never read or written and a startsAt throws.
join_requests.prune_after30 daysTEAMS_JOIN_REQUESTS_PRUNE_AFTERZero or positive interval after a resolved request’s last update before model:prune deletes it.
approvals.enabledfalseTEAMS_APPROVALSRoute join requests through approvals-for-laravel for multi-admin sign-off.
approvals.ruleunanimousTEAMS_APPROVALS_RULEDefault ApprovalRule when the handle sets none: unanimous, quorum, any or weighted. Anything else throws InvalidConfigurationException.
approvals.quorumnullTEAMS_APPROVALS_QUORUMDefault quorum (a positive integer) when the join-request handle sets none.
gate.registertrueTEAMS_REGISTER_GATERegister the Gate hook and the Blade directives.
gate.prefixteamsTEAMS_GATE_PREFIXAbility prefix, e.g. teams.manage-billing.
gate.owner_abilityownerTEAMS_GATE_OWNER_ABILITYShort ability that resolves to ownership: teams.owner.
notifications.queue_connectionnullTEAMS_NOTIFY_CONNECTIONQueue connection for the publishable event subscriber stub.
context.route_parameterteamTEAMS_CONTEXT_ROUTE_PARAMETERRoute parameter the teams.context middleware reads; override per route with teams.context:<name>.
context.deny_status404TEAMS_CONTEXT_DENY_STATUSWhat a non-member, an expired member, a guest and a missing team all get: 404 or 403; anything else throws.
context.recheck_queued_jobsfalseTEAMS_CONTEXT_RECHECK_QUEUED_JOBSA queued job whose context was set through a membership check checks the membership again before it runs, and fails without a retry when it was revoked after dispatch.
scoping.without_contextemptyTEAMS_SCOPING_WITHOUT_CONTEXTA ScopedToTeam query without a team context returns no rows (empty) or throws TeamContextException (throw) — never every team’s rows.
scoping.models[]—Scoped models teams:doctor always checks, e.g. outside app/Models.
scoping.discover[app_path('Models')]—Directories teams:doctor searches for models using ScopedToTeam; files are read, never included.
nesting.enabledfalseTEAMS_NESTINGAllow a parent team. Off: setting a parent throws, ancestors() and descendants() are empty and parent_id is never read.
nesting.max_depth4TEAMS_NESTING_MAX_DEPTHHow far below its root a team may sit, 1–100; the root is depth 0, so 2 allows company → department → team.
nesting.inherit_roles_downfalseTEAMS_INHERIT_ROLES_DOWNA role held on a team also applies on every team below it — only with nesting on.
permissions.bridgefalseTEAMS_PERMISSIONS_BRIDGECount permissions-for-laravel grants made inside a team in its checks, while the membership is active. Needs permissions-for-laravel ^2.1 with assignments on, otherwise the boot fails with InvalidConfigurationException.

Environment

The common switches are env-driven, so you rarely need the published file at all:

TEAMS_KEY_TYPE=uuid                  # set before you migrate
TEAMS_PRIMARY_KEY_TYPE=ulid          # set before you migrate
TEAMS_ROLES_PROVIDER=database
TEAMS_ROLES_CACHE=true
TEAMS_DEFAULT_ROLE=member
TEAMS_PER_TEAM_ROLES=true
TEAMS_INVITES_EXPIRES_AFTER="14 days"
TEAMS_MEMBERS_EXPIRING_WITHIN=14
TEAMS_MEMBERS_START_DATES=true
TEAMS_APPROVALS=true
TEAMS_REGISTER_GATE=true
TEAMS_CONTEXT_RECHECK_QUEUED_JOBS=true
TEAMS_NESTING=true
TEAMS_PERMISSIONS_BRIDGE=true

Strict values

The config file passes env values through raw, and the package reads them strictly. The bool keys take env strings as they come: true/1/on/yes switch a flag on and false/0/off/no switch it off; the int keys take integer strings such as "3600". A key that is not set — absent, null, or blank ('' or whitespace, as a bare TEAMS_APPROVALS= line gives) — takes its default. Anything else throws an InvalidConfigurationException naming the key instead of quietly reading as a default:

  • a mistyped switch (TEAMS_APPROVALS=disabled), role provider (databse) or approval rule;
  • an integer that is not one (abc, 7.5) or is out of range — an invite code shorter than 8 or longer than 128, for example;
  • an interval Carbon cannot parse (seven days) or that is negative — invites.expires_after must also be above zero;
  • a non-string role key, cache store or key, gate prefix or owner ability.

php artisan about shows such a value as INVALID instead of failing.

The interval error is worded in the current locale (teams::errors.invalid_config_value, English and Slovak). The boolean, integer, choice and string errors come from the toolkit’s Config readers and stay English.

Morph key type

key_type is read by the migrations, so set it before you migrate. It types the polymorphic owner, member, invited_by, requester and responded_by columns — use uuid or ulid when the models behind them use UUID or ULID primary keys. Your morph targets must share one key type; any other value throws an InvalidConfigurationException when the migrations run.

Team key type

primary_key_type keys the teams themselves: teams.id and every column pointing at a team — team_id on team_invites, team_members, team_join_requests and team_role_overrides, teams.parent_id, and the $table->teamScope() column of your own scoped tables. On uuid or ulid the packaged Team mints its own key, a time-ordered UUID (v7) or a lowercase ULID. It is not key_type, which types the columns pointing at your models; a host whose users and teams are all ULID-keyed sets both:

TEAMS_KEY_TYPE=ulid           # owner, member, inviter, requester columns → your users
TEAMS_PRIMARY_KEY_TYPE=ulid   # teams.id and every team_id / parent_id

Set it before you migrate — the schema is fixed once the migrations ran. Other packages whose polymorphic columns point at a team need the team’s key type too, such as permissions-for-laravel’s permissions.assignments.scope_key_type. A Team subclass that uses HasUlids or HasUuids itself keeps working and its key wins for teamScope(); set primary_key_type to match it, because php artisan teams:doctor fails while they disagree.

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.