---
title: "Join requests — Teams for Laravel | Roundly"
description: "Let users ask to join a team, approve or deny with role fallback, set optional deadlines, and auto-decline stale requests."
url: https://roundly-consulting.com/open-source/docs/teams-for-laravel/join-requests
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)

# Join requests

The inverse of invites: a user asks to join a team and an admin approves or denies. Whether a team takes requests at all is its JoinPolicy setting, not is\_public — is\_public is only a discovery flag for Team::query()->public(), so a private team still accepts requests unless it is invite-only:

```php
use RoundlyConsulting\Teams\Facades\Teams;

$requests = Teams::for($team)->joinRequests();

$request = $requests->open($user, requestedRole: 'member', message: 'Please add me');

$requests->approve($request, by: $admin);                  // Member — adds the requester
$requests->approve($request, by: $admin, role: 'editor');  // override the role
$requests->deny($request, by: $admin);                     // JoinRequest — no membership

$requests->pending();                                      // Collection<JoinRequest>
```

Requesting is idempotent — a second call for the same team and requester returns the existing pending request. JoinRequestCreated fires when a request is created. open() also takes meta and an expiry:

```php
$request = Teams::for($team)->joinRequests()->open(
    $user,
    requestedRole: 'member',
    message: 'Please add me',
    meta: ['source' => 'web'],
);
```

A join request is for newcomers: open() throws TeamsException when the requester already holds an active membership. Role changes go through members()->changeRole().

## Approving and denying

- Approving adds the requester as a member, marks the request Approved, stamps responded\_by and responded\_at, and fires JoinRequestApproved.
- The role resolves from the responder’s override, then the requested role, then the team’s DefaultMemberRole setting, then roles.default (member).
- A requester who has since joined — say, through an invite — keeps their membership unchanged; approving just resolves the request.
- Denying marks the request Denied, stamps the responder and fires JoinRequestDenied — no membership is created.
- Approving or denying another team’s request through Teams::for($team) throws JoinRequestNotFoundException.

A resolved request is final. Approving or denying one that is no longer pending — already approved, denied or expired, including by a concurrent responder — throws JoinRequestNotPendingException and changes nothing, so an old request can never re-add a removed member. The pending → resolved step is a single conditional update, so of two simultaneous responders exactly one wins; an approval refused by the seat cap leaves the request pending:

```php
use RoundlyConsulting\Teams\Exceptions\JoinRequestNotPendingException;
use RoundlyConsulting\Teams\Facades\Teams;

try {
    Teams::for($team)->joinRequests()->approve($request, by: $admin);
} catch (JoinRequestNotPendingException) {
    // "Join request #42 has already been resolved." — nothing changed
}
```

## Join policies

The team’s join policy decides what a new request does: invite-only teams reject it with TeamsException, and the default Request policy leaves it pending. Open teams auto-approve a request into the team’s default role (DefaultMemberRole, then roles.default) — never a role the requester asked for; a request for any other role stays pending for an owner or admin, and so does every request while approval is required. See Team settings.

## Expiring requests

Pass expiresAt to set a deadline; null (the default) never lapses. Teams::joinRequests()->expire() — or teams:join-requests:prune — auto-declines pending requests past their expiry — reusing the Denied status with a null responder (a system decision) and firing JoinRequestExpired:

```php
use RoundlyConsulting\Teams\Facades\Teams;
use RoundlyConsulting\Teams\Models\JoinRequest;

$request = Teams::for($team)->joinRequests()->open($user, requestedRole: 'member', expiresAt: now()->addDays(14));

$request->isExpired();                    // true once the deadline passes
JoinRequest::query()->expiredPending();   // pending requests past their expiry

Teams::joinRequests()->expire();          // int — auto-decline them now
```

```php
use Illuminate\Support\Facades\Schedule;

// routes/console.php
Schedule::command('teams:join-requests:prune')->daily();   // auto-decline + JoinRequestExpired
Schedule::command('model:prune')->daily();                 // hard-delete long-resolved rows
```

JoinRequest is Prunable: php artisan model:prune hard-deletes resolved requests whose last update is older than join\_requests.prune\_after (30 days). Pending requests are never pruned.

## The JoinRequest model

```php
$request->status;           // JoinRequestStatus::Pending | Approved | Denied
$request->isPending();      // bool
$request->requester;        // who asked (MorphTo)
$request->respondedBy;      // who decided (MorphTo) — null for a system decline
$request->responded_at;     // ?Carbon
$request->requested_role;   // ?string
$request->message;          // ?string
$request->meta;             // Collection
```

## The status enum

JoinRequestStatus is a string-backed enum — pending, approved, denied — with enums-for-laravel helpers for selects and validation. An expired request is recorded as Denied, not a separate status:

```php
use RoundlyConsulting\Teams\Enums\JoinRequestStatus;

JoinRequestStatus::options();          // Collection<EnumOption> (value, label, name) for selects
JoinRequestStatus::labels();           // Collection: 'Pending', 'Approved', 'Denied'
JoinRequestStatus::validationRule();   // 'in:pending,approved,denied'
JoinRequestStatus::tryFromLabel('Approved');
```

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