Skip to content

Invitations

An owner or admin invites an email address, not a user. That is the whole design constraint: the person invited very often has no account yet, and creating one for them before they accept would put an unverified address in your users table.

The flow

  1. An administrator posts an address and a role to organizations.invitations.store.
  2. A row is written with a hashed token, and a temporary signed URL is emailed.
  3. Following the link renders the invitation screen. Accepting posts to a second signed URL.
  4. The user joins, accepted_at is stamped, and the organization becomes their active one.

The same two guards as a magic link, and both have to hold:

  • The URL is a temporary signed route, expiring with the invitation itself. It cannot be forged or replayed past its expiry without your APP_KEY, and an expired link is a 403 at the router before the controller runs.
  • The token is stored hashed. A dump of organization_invitations is not a set of working invitations. The value that was emailed exists in the email and in the MemberInvited event, nowhere else.

Plus one guard a magic link does not need:

  • The address on the invitation is the address that may accept it. An invitation is an offer to one person. Without this, a forwarded email is a way into somebody else's organization.

Somebody who has no account yet

This is the case that shapes the routes. Both invitation routes sit behind signed and auth, and that pairing is what makes it work:

  • A guest following the link hits auth, which remembers the signed URL as their intended destination and sends them to sign in.
  • Whether they log in or register from there, they come back to the same URL and land on the invitation screen.

Nothing about the invitation is parked in the session, so an abandoned sign-up leaves no state behind. Registration honours the pending destination the same way signing in does, which is the one change laraspring/auth needed for this to work.

The addresses have to match

Somebody who registers with a different address than the one invited lands on the invitation screen and is told it was sent to somebody else. Accepting is a 403. This is intentional; the alternative is an invitation that anyone holding the link can redeem.

One pending invitation per address

The unique index on (organization_id, email) enforces it. Inviting an address that already has a row replaces it, which is also how reissuing works: a fresh token, a fresh expiry, and the old link dead immediately.

Two related behaviours follow from the same rule:

  • Revoking deletes the row rather than flagging it. The token has to stop working, and a revoked row left behind would block a later invitation to the same address.
  • Removing a member withdraws any pending invitation to their address. Otherwise the link they were sent earlier walks them straight back in.

Refusals

Expired, revoked, never issued and already accepted all end the same way: a 404 from the accept endpoint, or an invitation screen that says it is no longer valid. A caller learns whether their own link works and nothing more.

Inviting somebody who is already a member is refused with a validation error. The endpoint never reveals whether an address has an account here, because that is not something an organization administrator is entitled to learn by typing it into a form.

Throttling

php
'invitations' => [
    'expires' => 60 * 24 * 7,   // minutes
    'roles' => [Roles::ADMIN, Roles::MEMBER],
    'throttle' => [
        'max_attempts' => 20,
        'decay_seconds' => 3600,
    ],
],

The throttle is per organization rather than per user: it exists to stop a member using your mail reputation to send twenty thousand emails, and switching accounts should not reset it.

roles is what an invitation may offer. Ownership is not on the list and adding it does nothing; ownership is transferred to somebody who is already a member, never handed to an address that has not accepted anything yet.

Sending your own email

The shipped OrganizationInvitationNotification is deliberately plain. To replace it, listen for the event, which carries the plain token precisely because the table does not:

php
use Laraspring\Organizations\Events\MemberInvited;
use Laraspring\Organizations\Support\Invitations;

Event::listen(function (MemberInvited $event) {
    $url = Invitations::acceptanceUrl($event->invitation, $event->token);

    // Send whatever you like to $event->invitation->email.
});

Laraspring is a commercial starter kit. Buying it gets you the source.