| name | Laravel Invite Only |
| description | Conventions and APIs for the offload-project/laravel-invite-only package — polymorphic invitations, token acceptance, bulk invites, scheduled reminders, and event-driven hooks. |
| compatible_agents | ["Claude Code","Cursor"] |
| tags | ["laravel","php","invitations","eloquent","polymorphic","notifications","events"] |
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
Invitation Eloquent model with status lifecycle (pending, accepted, declined, expired, cancelled) backed by an InvitationStatus enum.
- Two traits:
HasInvitations (for models that issue invitations — Team, Organization, Project) and CanBeInvited (for the User model).
- An
InviteOnly facade 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-reminders Artisan 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
HasInvitations to any model that can issue invitations (Team, Organization, Project, Account). Apply CanBeInvited to 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 deprecated acceptedInvitations() helper on HasInvitations. The deprecated method will be removed in v3.0.
Status & enum
- Use the
InvitationStatus enum (InvitationStatus::Pending, Accepted, Declined, Expired, Cancelled). Do not use the deprecated Invitation::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 the InviteOnly facade. Do not call Invitation::create() directly — the facade handles token generation, expiration defaults, the InvitationCreated event, and the outbound notification.
- Pass the invitable model via the trait method (
$team->invite(...)), or as the second argument to InviteOnly::invite($email, $invitable, $options). For invitations not tied to any model (e.g. open-platform signup), pass null.
- For bulk invites, use
inviteMany() and inspect the returned BulkInvitationResult — $result->successful (Collection of Invitation) 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' => false only when you intentionally want duplicate pending invites.
Accepting / declining / cancelling
- Accept by calling
InviteOnly::accept($token, $user) (or Invitation::accept via facade). Always pass the authenticated User so accepted_by is recorded.
- Catch the typed exceptions individually when handling user-facing flows —
InvitationExpiredException, InvitationAlreadyAcceptedException, InvalidInvitationException — to produce specific error messages. Do not catch the bare base InvitationException unless 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
InvitationAccepted event 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 to null disables that notification. Do not edit the package's notification classes directly.
- Adjust expiration window via
invite-only.expiration.days (default 7). Set invite-only.expiration.enabled to false for non-expiring invitations.
- Configure reminders via
invite-only.reminders.after_days (e.g. [3, 5]) and max_reminders. Reminders only fire if reminders.enabled is true.
- The default routes are mounted at
/invitations with ['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 past expires_at stay in pending status until something else marks them.
Mass assignment / model
- The
Invitation model is final. 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 via update() is allowed but discouraged — prefer the markAs*() 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
use OffloadProject\InviteOnly\Traits\HasInvitations;
class Team extends Model
{
use HasInvitations;
}
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) {
}
foreach ($result->failed as $failure) {
Log::warning('Skipped invite', $failure);
}
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;
$user = $event->user;
$role = $event->invitation->role;
if ($team !== null && $user !== null) {
$team->users()->attach($user->id, ['role' => $role]);
}
});
Custom notification
'notifications' => [
'invitation' => App\Notifications\TeamInvitationSent::class,
'reminder' => App\Notifications\TeamInvitationReminder::class,
'cancelled' => null,
'accepted' => App\Notifications\TeamInvitationAccepted::class,
],
Status checks with the enum
use OffloadProject\InviteOnly\Enums\InvitationStatus;
if ($invitation->status === InvitationStatus::Pending) { }
if ($invitation->status->isTerminal()) {
}
Scheduled reminders + expiration sweep
use Illuminate\Support\Facades\Schedule;
Schedule::command('invite-only:send-reminders --mark-expired')->daily();
Anti-patterns
- ❌
Invitation::create([...]) — bypasses token generation, the InvitationCreated event, and the outbound notification. Always go through the facade or trait method.
- ❌ Using
Invitation::STATUS_PENDING (and the other STATUS_* constants). Deprecated — use InvitationStatus::Pending etc.
- ❌ Using
acceptedInvitations() from HasInvitations. Deprecated — use getAcceptedInvitations().
- ❌ Subclassing
Invitation. The model is final; extend behavior via events or wrapper services.
- ❌ Catching
\Throwable or \Exception around InviteOnly::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
InvitationAccepted listener so manual acceptance, console flows, and webhook acceptance all behave the same.
- ❌ Disabling the
throttle middleware 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 via config/invite-only.php.
References