Laravel Invite Only
SkillProductivityConventions and APIs for the offload-project/laravel-invite-only package — polymorphic invitations, token acceptance, bulk invites, scheduled reminders, and event-driven hooks.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Laravel Invite Only skill
What this skill tells your AI
The instructions your AI receives, as published by offload-project/laravel-invite-only in skills/SKILL.md and read by ahel’s review.
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
Signals
- GitHub stars
- 74
- Forks
- 2
- Last commit
- Jun 2026
Advanced
- Catalog kind
- skill
- Gateway key
laravel-invite-only- Source
- github.com/offload-project/laravel-invite-only