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

Models & schema

Six tables back six models. All six are soft-deletable, and Team, Member, Invite and JoinRequest cast meta to a Collection; mass assignment is open because the facade and its actions are the intended write path:

TableModelKey columns
teamsTeamname, is_public, owner morph (nullable), meta json
team_membersMemberteam_id, member morph, role, accepted_invite_id (null on invite delete), meta, expires_at (indexed) — unique on (team_id, member morph), soft-deleted rows included
team_invitesInviteteam_id, code (unique), role, email (indexed), invited_by morph, meta, uses, max_uses, expires_at
team_join_requestsJoinRequestteam_id, requester morph, requested_role, status (indexed), message, meta, responded_by morph, responded_at, expires_at
team_rolesRoleDefinitionkey (unique), name, permissions json, description — the database role driver
team_role_overridesTeamRoleteam_id, key, name, permissions json, description — unique on (team_id, key)

Every table referencing teams cascades when a team row is force-deleted. The morph columns follow key_type — see Configuration.

Swapping a model

Point a teams.models.* key at your own subclass — every relation and action resolves the class through config. The class must be the packaged model or a subclass of it; anything else throws an InvalidConfigurationException naming the key — it is never silently replaced by the packaged one:

namespace App\Models;

use Illuminate\Database\Eloquent\Relations\HasMany;
use RoundlyConsulting\Teams\Models\Team as BaseTeam;

class Team extends BaseTeam
{
    public function projects(): HasMany
    {
        return $this->hasMany(Project::class);
    }
}
// config/teams.php
'models' => [
    'team' => App\Models\Team::class,
    'member' => RoundlyConsulting\Teams\Models\Member::class,
    'invite' => RoundlyConsulting\Teams\Models\Invite::class,
    'team_role' => RoundlyConsulting\Teams\Models\TeamRole::class,
    'join_request' => RoundlyConsulting\Teams\Models\JoinRequest::class,
],

A custom role provider

To load roles from somewhere else — an external service, say — rebind the RoleProvider contract after the package registers, for example in your AppServiceProvider’s register(). The package binds it scoped; bind yours scoped too, so a provider that memoises its role map is rebuilt for every request and queued job:

// app/Providers/AppServiceProvider.php → register()
use RoundlyConsulting\Teams\Roles\Contracts\RoleProvider;

// Scoped, like the package's own binding: a fresh provider per request or queued job.
$this->app->scoped(RoleProvider::class, fn () => new \App\Teams\MyRoleProvider());
namespace RoundlyConsulting\Teams\Roles\Contracts;

interface RoleProvider
{
    /**
     * Define a role — an upsert on $key: registering a key again replaces its
     * name, permissions and description, whichever provider backs it.
     *
     * @param  list<string|Permission>  $permissions
     */
    public function register(string $key, string $name, array $permissions = [], string $description = ''): Role;

    public function find(string $key): ?Role;

    /** @return array<string, Role> */
    public function all(): array;
}

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.