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:
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:
$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:
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:
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 nowuse 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 rowsJoinRequest 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
$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; // CollectionThe 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:
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 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.