Imported from offload-project/laravel-invite-only (
skills/SKILL.md). Install upstream withnpx skills add offload-project/laravel-invite-only --skill skills. Copyright stays with the author.
Context
offload-project/laravel-invite-only is a Laravel 11/12/13 package (PHP 8.2+) for managing user invitations against any model via polymorphic relationships. It ships:
- An
InvitationEloquent model with status lifecycle (pending,accepted,declined,expired,cancelled) backed by anInvitationStatusenum. - Two traits:
HasInvitations(for models that issue invitations — Team, Organization, Project) andCanBeInvited(for the User model). - An
InviteOnlyfacade that wraps token generation, event dispatch, and notification sending. - Events for every lifecycle transition:
InvitationCreated,InvitationAccepted,InvitationDeclined,InvitationCancelled,InvitationExpired. - Structured exceptions:
InvalidInvitationException,InvitationAlreadyAcceptedException,InvitationExpiredException. - A
invite-only:send-remindersArtisan command for scheduled reminder emails and expiration sweeps.
Apply this skill when working in a Laravel app that has offload-project/laravel-invite-only in composer.json, or when the user asks for help with InviteOnly, HasInvitations, CanBeInvited, the Invitation model, or invitation flows in this package.
Rules
Trait usage
- Apply
HasInvitationsto any model that can issue invitations (Team, Organization, Project, Account). ApplyCanBeInvitedto the User model that receives them. - If a single model both sends and receives invitations (e.g. user-to-user friend invites), use both traits with PHP trait conflict resolution — the two traits each define an
acceptedInvitations()method that collides. See the example below. - Prefer
getAcceptedInvitations()over the deprecatedacceptedInvitations()helper onHasInvitations. The deprecated method will be removed in v3.0.
Status & enum
- Use the
InvitationStatusenum (InvitationStatus::Pending,Accepted,Declined,Expired,Cancelled). Do not use the deprecatedInvitation::STATUS_*string constants; they are kept only for backwards compatibility and will be removed. - Check terminal states via
$status->isTerminal()rather than chaining||against individual cases.
Creating invitations
- Create invitations through
$invitable->invite()/$invitable->inviteMany()(preferred) or theInviteOnlyfacade. Do not callInvitation::create()directly — the facade handles token generation, expiration defaults, theInvitationCreatedevent, and the outbound notification. - Pass the invitable model via the trait method (
$team->invite(...)), or as the second argument toInviteOnly::invite($email, $invitable, $options). For invitations not tied to any model (e.g. open-platform signup), passnull. - For bulk invites, use
inviteMany()and inspect the returnedBulkInvitationResult—$result->successful(Collection ofInvitation) and$result->failed(Collection of['email' => ..., 'reason' => ...]). It supports partial failure; do not wrap it in a try/catch expecting an exception. inviteMany()deduplicates against existing pending invitations by default. Pass'skip_duplicates' => falseonly when you intentionally want duplicate pending invites.
Accepting / declining / cancelling
- Accept by calling
InviteOnly::accept($token, $user)(orInvitation::acceptvia facade). Always pass the authenticatedUsersoaccepted_byis recorded. - Catch the typed exceptions individually when handling user-facing flows —
InvitationExpiredException,InvitationAlreadyAcceptedException,InvalidInvitationException— to produce specific error messages. Do not catch the bare baseInvitationExceptionunless you intentionally want to collapse all failure modes. - Wire the actual "do something on acceptance" logic (attaching a user to a team, granting a role, etc.) in an
InvitationAcceptedevent listener, not inline at every call site. The facade fires the event for you.
Configuration & customization
- Customize notifications by overriding the
invite-only.notifications.{invitation,reminder,cancelled,accepted}config entries. Setting any of them tonulldisables that notification. Do not edit the package's notification classes directly. - Adjust expiration window via
invite-only.expiration.days(default 7). Setinvite-only.expiration.enabledtofalsefor non-expiring invitations. - Configure reminders via
invite-only.reminders.after_days(e.g.[3, 5]) andmax_reminders. Reminders only fire ifreminders.enabledis true. - The default routes are mounted at
/invitationswith['web', 'throttle:60,1']middleware. Keep the throttle (or stricter) — invitation tokens are otherwise susceptible to brute force. Disable the package routes (routes.enabled => false) only if you're providing your own.
Scheduling
-
Schedule the bundled command to run daily so reminders go out and expired invitations get marked:
Schedule::command('invite-only:send-reminders --mark-expired')->daily();Without
--mark-expired, pending invitations pastexpires_atstay inpendingstatus until something else marks them.
Mass assignment / model
- The
Invitationmodel isfinal. To extend behavior, listen to events or wrap calls — do not try to subclass it. - All migration columns are in
$fillable. Setting lifecycle fields (accepted_at,accepted_by,declined_at,cancelled_at,last_sent_at,reminder_count) directly viaupdate()is allowed but discouraged — prefer themarkAs*()helpers so casts and side effects stay consistent. - Use
$invitation->isValid()(pending and not expired) when gating "can this token still be used" checks.isPending()alone is not sufficient.
Examples
Basic setup
// app/Models/Team.php
use OffloadProject\InviteOnly\Traits\HasInvitations;
class Team extends Model
{
use HasInvitations;
}
// app/Models/User.php
use OffloadProject\InviteOnly\Traits\CanBeInvited;
class User extends Authenticatable
{
use CanBeInvited;
}
Model that both sends and receives invitations
use OffloadProject\InviteOnly\Traits\CanBeInvited;
use OffloadProject\InviteOnly\Traits\HasInvitations;
class User extends Authenticatable
{
use HasInvitations, CanBeInvited {
CanBeInvited::acceptedInvitations insteadof HasInvitations;
HasInvitations::acceptedInvitations as acceptedInvitationsToModel;
}
}
In v3.0 HasInvitations::acceptedInvitations() will be removed in favour of getAcceptedInvitations(), eliminating the conflict — at which point the insteadof / as clauses can be dropped.
Sending invitations
$invitation = $team->invite('user@example.com', [
'role' => 'member',
'invited_by' => auth()->user(),
'metadata' => ['source' => 'team-settings'],
]);
Bulk invitations with partial-failure handling
$result = $team->inviteMany(
['one@example.com', 'two@example.com', 'bad-email'],
['role' => 'member', 'invited_by' => auth()->user()],
);
foreach ($result->successful as $invitation) {
// send to UI, log, etc.
}
foreach ($result->failed as $failure) {
Log::warning('Skipped invite', $failure); // ['email' => ..., 'reason' => ...]
}
Accepting an invitation
use OffloadProject\InviteOnly\Exceptions\InvalidInvitationException;
use OffloadProject\InviteOnly\Exceptions\InvitationAlreadyAcceptedException;
use OffloadProject\InviteOnly\Exceptions\InvitationExpiredException;
use OffloadProject\InviteOnly\Facades\InviteOnly;
try {
$invitation = InviteOnly::accept($token, auth()->user());
} catch (InvitationExpiredException) {
return redirect()->route('login')->withErrors(['invite' => 'This invitation has expired.']);
} catch (InvitationAlreadyAcceptedException) {
return redirect()->route('dashboard');
} catch (InvalidInvitationException $e) {
return redirect()->route('login')->withErrors(['invite' => $e->getMessage()]);
}
Wiring side effects via the event
use Illuminate\Support\Facades\Event;
use OffloadProject\InviteOnly\Events\InvitationAccepted;
Event::listen(InvitationAccepted::class, function (InvitationAccepted $event): void {
$team = $event->invitation->invitable; // Team|Organization|null
$user = $event->user;
$role = $event->invitation->role;
if ($team !== null && $user !== null) {
$team->users()->attach($user->id, ['role' => $role]);
}
});
Custom notification
// config/invite-only.php
'notifications' => [
'invitation' => App\Notifications\TeamInvitationSent::class,
'reminder' => App\Notifications\TeamInvitationReminder::class,
'cancelled' => null, // disabled
'accepted' => App\Notifications\TeamInvitationAccepted::class,
],
Status checks with the enum
use OffloadProject\InviteOnly\Enums\InvitationStatus;
if ($invitation->status === InvitationStatus::Pending) { /* ... */ }
if ($invitation->status->isTerminal()) {
// accepted | declined | expired | cancelled
}
Scheduled reminders + expiration sweep
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('invite-only:send-reminders --mark-expired')->daily();
Anti-patterns
- ❌
Invitation::create([...])— bypasses token generation, theInvitationCreatedevent, and the outbound notification. Always go through the facade or trait method. - ❌ Using
Invitation::STATUS_PENDING(and the otherSTATUS_*constants). Deprecated — useInvitationStatus::Pendingetc. - ❌ Using
acceptedInvitations()fromHasInvitations. Deprecated — usegetAcceptedInvitations(). - ❌ Subclassing
Invitation. The model isfinal; extend behavior via events or wrapper services. - ❌ Catching
\Throwableor\ExceptionaroundInviteOnly::accept(). Catch the typed exceptions so each failure mode produces a tailored response. - ❌ Doing "attach user to team / grant role" work inline at the acceptance route. Move it to an
InvitationAcceptedlistener so manual acceptance, console flows, and webhook acceptance all behave the same. - ❌ Disabling the
throttlemiddleware on package routes. Invitation tokens are 64-char hex; without a throttle they are still brute-forceable at high rates. - ❌ Editing files inside
vendor/offload-project/laravel-invite-only. All extension points (notifications, expiration, routes, redirects) are exposed viaconfig/invite-only.php.
References
- Repository: https://github.com/offload-project/laravel-invite-only
- Getting Started: https://github.com/offload-project/laravel-invite-only/blob/main/docs/getting-started.md
- API Reference: https://github.com/offload-project/laravel-invite-only/blob/main/docs/reference.md
- Concepts (lifecycle, architecture): https://github.com/offload-project/laravel-invite-only/blob/main/docs/concepts.md
- How-to: Team Invitations — https://github.com/offload-project/laravel-invite-only/blob/main/docs/howto/team-invitations.md
- How-to: Custom Notifications — https://github.com/offload-project/laravel-invite-only/blob/main/docs/howto/custom-notifications.md
- How-to: Handling Errors — https://github.com/offload-project/laravel-invite-only/blob/main/docs/howto/handling-errors.md