---
title: "Time-boxed memberships — Teams for Laravel | Roundly"
description: "Give members an end date, renew in place, report who lapses soon with optional reminder events, and prune expired rows."
url: https://roundly-consulting.com/open-source/docs/teams-for-laravel/time-boxed-memberships
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Teams for Laravel](https://roundly-consulting.com/open-source/docs/teams-for-laravel.md)

# 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:

```php
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();            // expires_at null or in the future
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. The row itself stays for audits until pruned, so hasMember() and belongsToTeam() still match it — check a role or permission, not bare membership, when gating access.

## Renewing

```php
// 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:

```bash
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:

```php
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();
```

```php
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:

```php
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](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/teams-for-laravel.md)
