laravel-team maintained by labrodev
labrodev/laravel-team
Teams, memberships, invitations, roles and plan/billing mechanics for Laravel SaaS applications — the Labrodev multi-tenancy core.
The package owns the mechanism: teams with owner/admin/member roles, invitation
flow, a per-team subscription-plan caps snapshot (-1 = unlimited), plan checkout /
swap / cancel orchestration behind a gateway contract, and a team morph alias so
polymorphic rows (e.g. a billing provider's billable_type) never depend on class FQCNs.
The application owns the policy: its plan enum (cases, pricing, cap keys), the rules
that consume caps, and all UI. Payment providers are adapter packages implementing the
BillingClient contract — see labrodev/laravel-team-paddle.
Installation
composer require labrodev/laravel-team
php artisan vendor:publish --tag=team-config
php artisan vendor:publish --tag=team-migrations
php artisan migrate
Wiring the application
1. User model
use Labrodev\Team\Concerns\HasTeams;
use Labrodev\Team\Contracts\TeamMember;
final class User extends Authenticatable implements TeamMember
{
use HasTeams;
}
The users table needs a nullable current_team_id column.
2. Plan enum
Define a string-backed enum implementing Labrodev\Team\Contracts\PlanContract and point
config/team.php at it:
enum Plan: string implements PlanContract
{
case Free = 'free';
case Pro = 'pro';
public function label(): string { /* ... */ }
public function isPaid(): bool { /* ... */ }
/** @return array<string, mixed> */
public function caps(): array
{
return match ($this) {
self::Free => ['monitors' => 3, 'seats' => 1],
self::Pro => ['monitors' => -1, 'seats' => -1],
};
}
}
// config/team.php
'user_model' => App\Models\User::class,
'plan_enum' => App\Enums\Plan::class,
'free_plan' => 'free',
TeamCreate snapshots the free plan's caps() onto the team's subscription_plans row,
so later plan-definition changes never retro-affect existing teams. Read caps via
$team->subscriptionPlan->cap('monitors').
Applications that prefer typed cap columns can add them in their own migration on top of
the published subscription_plans table and bind their own snapshot logic.
3. Exception rendering
Package exceptions never construct a ValidationException. They implement one of two
contracts; register render mappings once (e.g. bootstrap/app.php):
use Labrodev\Team\Contracts\ProvidesFieldError;
use Labrodev\Team\Contracts\ProvidesFlashError;
$exceptions->render(fn (ProvidesFieldError $e, Request $r) => $r->expectsJson()
? response()->json(['message' => $e->userMessage(), 'errors' => [$e->field() => [$e->userMessage()]]], 422)
: redirect()->back()->withErrors([$e->field() => $e->userMessage()]));
$exceptions->render(fn (ProvidesFlashError $e, Request $r) => $r->expectsJson()
? response()->json(['message' => $e->userMessage()], 422)
: redirect()->back()); // + your flash-toast mechanism
4. Billing gateway
Bind an adapter implementing Labrodev\Team\Contracts\BillingClient (or install
labrodev/laravel-team-paddle). With team.sandbox enabled, StartPlanCheckout
bypasses the gateway and applies the target plan directly — for local development
without a configured payment provider.
Usage
// Create a team (owner membership + plan snapshot + current-team switch, in one transaction)
$team = app(TeamCreate::class)(user: $user, teamCreateData: new TeamCreateData(name: 'Acme'));
// Invite / accept
app(TeamInvitationCreate::class)(team: $team, user: $inviter, teamInvitationCreateData: $data);
app(TeamInvitationAcceptedStatusUpdate::class)(teamInvitation: $invitation, user: $user);
// Plans
app(StartPlanCheckout::class)(team: $team, plan: Plan::Pro, customerName: ..., customerEmail: ..., returnUrl: ...);
Authorization: TeamPolicy ships with view/update/delete/member-management/manageBilling
abilities driven by TeamRole/TeamPermission.
Testing
composer check # pint --test, phpstan (level 7), rector --dry-run, pest
License
MIT — see LICENSE.md.