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:
| Table | Model | Key columns |
|---|---|---|
teams | Team | name, is_public, owner morph (nullable), meta json |
team_members | Member | team_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_invites | Invite | team_id, code (unique), role, email (indexed), invited_by morph, meta, uses, max_uses, expires_at |
team_join_requests | JoinRequest | team_id, requester morph, requested_role, status (indexed), message, meta, responded_by morph, responded_at, expires_at |
team_roles | RoleDefinition | key (unique), name, permissions json, description — the database role driver |
team_role_overrides | TeamRole | team_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 cryptoBy 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.