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

Time-boxed memberships

Pass expiresAt to grant temporary access — contractors, trials. Once it passes, the member resolves no role and therefore no permissions, so every Gate and Blade permission check fails closed:

use RoundlyConsulting\Teams\Facades\Teams;
use RoundlyConsulting\Teams\Models\Member;

Teams::for($team)->members()->add($contractor, 'editor', expiresAt: now()->addDays(30));

$member = Teams::for($team)->members()->find($contractor);
$member->isExpired();   // true once expires_at has passed
$member->role();        // null once expired — no role, no permissions

Member::query()->active();            // not expired — and, with start dates on, started
Member::query()->unexpired();         // not expired, started or not — every taken or booked seat
Member::query()->upcoming();          // booked to start later (none while start dates are off)
Member::query()->expired();           // expires_at set and in the past
Member::query()->expiringWithin(7);   // active, lapsing within 7 days

Once expired, can('teams.…'), @teamPermission, @teamRole, memberHasRole(), hasTeamRole(), teamsWithRole() and teamRole() all fail. So do hasActiveMember(), members()->hasActive(), isActiveMemberOf(), isAccessibleBy(), the teams.context middleware, Teams::switchTo() and the queued-job membership re-check. The row itself stays for audits until pruned, so the row checks — the deprecated hasMember() and members()->has(), and belongsToTeam() — still match it.

Start dates

Opt in with teams.members.start_dates (TEAMS_MEMBERS_START_DATES=true) after the starts_at migration ran — see Installation. A startsAt books a membership that begins later:

use RoundlyConsulting\Teams\Facades\Teams;

// .env → TEAMS_MEMBERS_START_DATES=true (after the starts_at migration ran)
Teams::for($team)->members()->add($hire, 'member', startsAt: now()->addWeek());
$team->addMember($hire, 'member', startsAt: $monday, expiresAt: $monday->copy()->addMonths(6));

$member = Teams::for($team)->members()->find($hire);
$member->hasStarted();                          // false until starts_at
$member->isActive();                            // false — no access, role or permission yet
Teams::for($team)->members()->hasActive($hire); // false until then
  • Until starts_at the member gets no access, role or permission — every check listed above fails closed exactly as for an expired membership. From starts_at on (a start in the past or exactly now counts) it is active; no command needs to run.
  • A booked membership holds its seat from the moment it is added, so MaxSeats counts it.
  • The start must be before the expiry, otherwise TeamsException; on a re-add the check uses the expiry the membership keeps. Re-adding moves the start only when you pass a new startsAt; reviving a removed or expired membership without one starts it at once.
  • With the option off, passing startsAt throws TeamsException instead of quietly making the member active at once — and nothing reads or writes the column, so hosts without the migration keep working.
  • TeamMemberAdded fires when the membership is booked; no event fires when it starts. Invites and join requests have no start date — they add the member at once.

Renewing

// While the membership is active, a new expiresAt extends it in place.
Teams::for($team)->members()->add($contractor, 'editor', expiresAt: now()->addDays(30));

// Re-adding an active member without expiresAt keeps the current expiry.
Teams::for($team)->members()->add($contractor, 'editor');

// Once it has expired, re-adding revives it as a fresh membership: role, meta and
// expiry come from this call (no expiresAt = permanent), and TeamMemberAdded fires.
Teams::for($team)->members()->add($contractor, 'editor', expiresAt: now()->addDays(30));

Who lapses soon

teams:members:expiring lists active memberships lapsing within a window, so you can send renewal reminders before access ends. It’s read-only by default — MembershipExpiringSoon fires only with --notify. Already-expired and never-expiring memberships are excluded:

php artisan teams:members:expiring                    # read-only table, window from config
php artisan teams:members:expiring --days=3           # narrow the window
php artisan teams:members:expiring --days=3 --notify  # fire MembershipExpiringSoon per member

The same from your own scheduled job — no Artisan needed — or query directly; then turn the event into a notification:

use RoundlyConsulting\Teams\Facades\Teams;
use RoundlyConsulting\Teams\Models\Member;

Teams::members()->expiring(days: 7);         // Collection<Member> — read-only
Teams::members()->notifyExpiring(days: 7);   // fires MembershipExpiringSoon per member

// or query directly:
Member::query()->expiringWithin(7)->get();
use Illuminate\Support\Facades\Event;
use RoundlyConsulting\Teams\Events\MembershipExpiringSoon;

Event::listen(function (MembershipExpiringSoon $event): void {
    // $event->member->member — whose access lapses
    // $event->member->expires_at — when
});

Pruning lapsed memberships

teams:members:prune (or Teams::members()->prune()) force-deletes memberships whose expiry passed more than members.prune_after (30 days) ago and fires MembershipExpired per row. Member is also Prunable with the same window, so php artisan model:prune takes the same rows down the same path — MembershipExpired fires either way:

use Illuminate\Support\Facades\Schedule;

// routes/console.php
Schedule::command('teams:members:expiring --notify')->daily();   // renewal reminders
Schedule::command('teams:members:prune')->daily();               // force-delete + MembershipExpired

// or from your own job:
Teams::members()->prune();   // int — the same force-delete + MembershipExpired

// php artisan model:prune collects the same rows and fires MembershipExpired too.

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.