Skip to content
Skillv1.0.0

Laravel Invite Only

Conventions and APIs for the offload-project/laravel-invite-only package — polymorphic invitations, token acceptance, bulk invites, scheduled reminders, and event-driven hooks.

by offload-project(0) 0 installs
Free
Sign in to install

Free account. Installing gives you the manifest plus copy-paste snippets.

See reviews

About

Imported from offload-project/laravel-invite-only (skills/SKILL.md). Install upstream with npx 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 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

  1. Apply HasInvitations to any model that can issue invitations (Team, Organization, Project, Account). Apply CanBeInvited to the User model that receives them.
  2. 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.
  3. Prefer getAcceptedInvitations() over the deprecated acceptedInvitations() helper on HasInvitations. The deprecated method will be removed in v3.0.

Status & enum

  1. 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.
  2. Check terminal states via $status->isTerminal() rather than chaining || against individual cases.

Creating invitations

  1. 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.
  2. 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.
  3. 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.
  4. inviteMany() deduplicates against existing pending invitations by default. Pass 'skip_duplicates' => false only when you intentionally want duplicate pending invites.

Accepting / declining / cancelling

  1. Accept by calling InviteOnly::accept($token, $user) (or Invitation::accept via facade). Always pass the authenticated User so accepted_by is recorded.
  2. 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.
  3. 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

  1. 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.
  2. Adjust expiration window via invite-only.expiration.days (default 7). Set invite-only.expiration.enabled to false for non-expiring invitations.
  3. Configure reminders via invite-only.reminders.after_days (e.g. [3, 5]) and max_reminders. Reminders only fire if reminders.enabled is true.
  4. 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

  1. 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

  1. The Invitation model is final. To extend behavior, listen to events or wrap calls — do not try to subclass it.
  2. 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.
  3. 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, 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

Use it

Copy one of these into your project. Installing also returns the manifest and these snippets.

yaml
targets:
  - https://api.opensmartroute.ai/api/v1/registry/offload-project-laravel-invite-only-skills/manifest   # or paste the manifest below

Manifest

An Open Capability Manifest: the router reads it to know what this does, what it costs and when to pick it.

offload-project-laravel-invite-only-skills.ocm.jsonjson
{
  "ocm": "1",
  "id": "offload-project-laravel-invite-only-skills",
  "kind": "skill",
  "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.",
  "publisher": "offload-project",
  "version": "1.0.0",
  "capabilities": {
    "domains": [
      "general"
    ],
    "tags": [
      "skill-md",
      "laravel",
      "php",
      "invitations",
      "eloquent",
      "polymorphic",
      "notifications",
      "events",
      "github"
    ],
    "languages": [
      "en"
    ]
  },
  "quality_prior": 0.6,
  "examples": [
    "Conventions and APIs for the offload-project/laravel-invite-only package — polymorphic invitations, token acceptance, bulk invites, scheduled reminders, and event-driven hooks."
  ],
  "primary": false,
  "metadata": {
    "source": {
      "provider": "github",
      "repository": "https://github.com/offload-project/laravel-invite-only",
      "path": "skills/SKILL.md",
      "ref": "437c6035bec9fb89466fcd20eb2f19c12374492b",
      "url": "https://github.com/offload-project/laravel-invite-only/blob/437c6035bec9fb89466fcd20eb2f19c12374492b/skills/SKILL.md",
      "key": "offload-project/laravel-invite-only/skills/SKILL.md"
    }
  },
  "instructions": "## Context\n\n`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:\n\n- An `Invitation` Eloquent model with status lifecycle (`pending`, `accepted`, `declined`, `expired`, `cancelled`) backed by an `InvitationStatus` enum.\n- Two traits: `HasInvitations` (for models that issue invitations — Team, Organization, Project) and `CanBeInvited` (for the User model).\n- An `InviteOnly` facade that wraps token generation, event dispatch, and notification sending.\n- Events for every lifecycle tra",
  "cost": {
    "context_tokens": 2825
  }
}

Fetch it by URL: GET /api/v1/registry/offload-project-laravel-invite-only-skills/manifest?version=1.0.0

Reviews

Star ratings from people who tried it. One review per account; edit yours any time.

No reviews yet. Install it, try it, and be the first to rate it.