Search by

vimatech / laravel-invitation

adelzemzemi

Generic email-based invitations for Laravel. Invite anyone to any Eloquent model: teams, projects, workspaces. Fluent API, HMAC tokens, events, queued notifications, i18n.

v1.3.0 2026-09-27 15:50 UTC

README

Laravel Invitations

Email invitations to any Eloquent model

CI Latest Version on Packagist Total Downloads License

Generic email-based invitations for Laravel. Invite anyone to join, access, or accept an action related to any Eloquent model: Organization, Team, Project, Workspace, Document, and more.

Why Laravel Invitations?

  • Invite users to any Eloquent model: not just teams
  • Secure token-based workflow (HMAC by default)
  • Framework-agnostic: no dependency on Jetstream, Breeze, or any starter kit
  • Extensible acceptance handlers and custom notifications
  • Production-ready with queued emails, i18n, and rate-limited routes

Quick start

// 1. Send an invitation
$invitation = Invitations::to('john@example.com')
    ->for($project)
    ->invitedBy(auth()->user())
    ->send();

// 2. Accept the invitation (via token from email)
// Refuses if auth()->user()->email does not match the invited address.
Invitations::accept($token, auth()->user());
Invite → Email sent → User clicks link → Accept → Event dispatched

Subject: The model being invited to (Project, Team, Organization, Workspace, etc.). Set via ->for($model). An invitation without a subject is a "global" invitation.

Requirements

  • PHP 8.3+
  • Laravel 11, 12 or 13

Installation

composer require vimatech/laravel-invitation

The Packagist name is singular (vimatech/laravel-invitation) while the repository is plural. The published name cannot change without breaking existing installs, so it stays as it is: install the singular, read the plural.

Publish the configuration file (optional)

php artisan vendor:publish --tag="invitation-config"

Publish and run migrations

php artisan vendor:publish --tag="invitation-migrations"
php artisan migrate

Publish views (optional)

php artisan vendor:publish --tag="invitation-views"

Usage

Basic invitation

use Vimatech\Invitation\Facades\Invitations;

$invitation = Invitations::to('john@example.com')->send();

Invitation to a User model

If you already have the user model, you can pass it directly, the email will be extracted automatically:

$invitation = Invitations::toUser($user)
    ->for($project)
    ->send();

// Or via the HasInvitations trait:
$project->inviteUser($user)->send();

Invitation linked to a model

$invitation = Invitations::to('john@example.com')
    ->for($project)
    ->invitedBy($currentUser)
    ->expiresInDays(7)
    ->withMeta(['role' => 'admin'])
    ->send();

Using the HasInvitations trait

use Illuminate\Database\Eloquent\Model;
use Vimatech\Invitation\Concerns\HasInvitations;

class Project extends Model
{
    use HasInvitations;
}

// Then:
$project->invite('john@example.com')
    ->invitedBy($user)
    ->expiresInDays(10)
    ->withMeta(['role' => 'member'])
    ->send();

// List invitations
$project->invitations;
$project->pendingInvitations;

Accepting an invitation

$invitation = Invitations::accept($token, $user);

When $user is given, accept() compares its email to the invited address (trimmed, case-insensitive) and throws InvitationEmailMismatchException on a mismatch, so accepting with the wrong account fails instead of silently granting whatever the invitation grants. Set invitation.accept.require_matching_email to false only if an application intentionally lets one account accept an invitation addressed to another. Neither this check nor acceptForNewUser() verifies that the user's email address is actually owned by them: require a verified address (MustVerifyEmail plus the verified middleware) before calling either method if that matters. accept($token) with no user is unchanged and is never checked.

Accepting after registration (new user)

// After user registration:
$invitation = Invitations::acceptForNewUser($token, $newUser);

acceptForNewUser() always compares the invitation email to $newUser's email, whatever invitation.accept.require_matching_email says, and throws InvitationEmailMismatchException on a mismatch.

Cancelling an invitation

Invitations::cancel($invitation);

Declining an invitation (by invitee)

The invitee can actively refuse an invitation:

Invitations::decline($token);

Resending an invitation

Resend generates a new token and resets the expiration. Only pending or expired invitations can be resent. Accepted and cancelled invitations will throw an exception.

Invitations::resend($invitation);

Querying invitations

use Vimatech\Invitation\Models\Invitation;

Invitation::pending()->get();
Invitation::accepted()->get();
Invitation::expired()->get();
Invitation::declined()->get();
Invitation::cancelled()->get();
Invitation::forEmail('john@example.com')->get();
Invitation::forSubject($project)->get();
Invitation::invitedBy($user)->get();

Metadata

Store any custom data with an invitation:

$invitation = Invitations::to('john@example.com')
    ->withMeta(['role' => 'editor', 'department' => 'engineering'])
    ->send();

// Access later:
$invitation->meta['role']; // 'editor'

Expiration

Invitations expire based on the expires_after_days config (default: 7 days). You can also set a custom expiration:

Invitations::to('john@example.com')
    ->expiresInDays(30)
    ->send();

// Or with a specific date:
Invitations::to('john@example.com')
    ->expiresAt(now()->addWeeks(2))
    ->send();

No expiration

For use cases like friend requests where invitations should stay active indefinitely:

// Per invitation:
Invitations::to('jane@example.com')
    ->for($user)
    ->neverExpires()
    ->send();

// Or globally via config:
// 'expires_after_days' => null,

Duplicate Policy

By default, sending a second invitation to the same email for the same subject throws an InvitationAlreadyExistsException:

$project->invite('john@example.com')->send(); // ✅
$project->invite('john@example.com')->send(); // ❌ InvitationAlreadyExistsException

To allow duplicate pending invitations, set this in your config:

'duplicates' => [
    'allow_pending_for_same_email_and_subject' => true,
],

Events

The following events are dispatched:

Event When
InvitationCreated Invitation record created
InvitationSent Notification sent
InvitationAccepted Invitation accepted
InvitationDeclined Invitation declined by invitee
InvitationExpired Expired invitation discovered during acceptance
InvitationCancelled Invitation cancelled
InvitationResent Invitation resent with new token

All events contain the $invitation property. InvitationAccepted also contains the $user.

Listening to events

use Vimatech\Invitation\Events\InvitationAccepted;

Event::listen(InvitationAccepted::class, function ($event) {
    $event->invitation->subject->members()->attach($event->user);
});

Custom Acceptance Handler

Via callback

use Vimatech\Invitation\InvitationManager;

InvitationManager::acceptedUsing(function ($invitation, $user) {
    $invitation->subject->members()->attach($user, [
        'role' => $invitation->meta['role'] ?? 'member',
    ]);
});

Via config

Create a class implementing the AcceptsInvitations contract:

use Vimatech\Invitation\Contracts\AcceptsInvitations;
use Vimatech\Invitation\Models\Invitation;
use Illuminate\Database\Eloquent\Model;

class MyAcceptanceHandler implements AcceptsInvitations
{
    public function accept(Invitation $invitation, ?Model $user = null): void
    {
        // Your logic here
    }
}

Then set it in config:

// config/invitation.php
'acceptance_handler' => App\Invitations\MyAcceptanceHandler::class,

Custom Notification

The default notification implements ShouldBeEncrypted, so its queued job is encrypted with APP_KEY and does not carry the plain token or the invitee's address in clear text in the queue backend or in failed_jobs. You can customize the invitation email in several ways:

Extend the default notification

use Vimatech\Invitation\Notifications\InvitationNotification;

class CustomInvitationNotification extends InvitationNotification
{
    protected function getSubjectLine(): string
    {
        return __('Join :team!', ['team' => $this->invitation->subject?->name]);
    }

    protected function getGreetingLine(): string
    {
        return __('You have been invited to collaborate.');
    }

    protected function getActionText(): string
    {
        return __('Accept Invitation');
    }
}

Or create a fully custom notification

// config/invitation.php
'notification' => App\Notifications\CustomInvitationNotification::class,

A notification that does not extend InvitationNotification does not inherit ShouldBeEncrypted: implement it directly if the queued payload should be encrypted.

Your notification will receive the Invitation model and the plain token in its constructor.

Translations

All notification strings use Laravel's __() helper. Add translations via JSON files:

// lang/fr.json
{
    "You have been invited": "Vous avez été invité",
    "View Invitation": "Voir l'invitation",
    "This invitation will expire on :date.": "Cette invitation expirera le :date.",
    "Invited by: :name": "Invité par : :name"
}

Public Routes

When routes.enabled is true (default), the package registers:

Method URI Name
GET /invitations/{token} invitations.preview
POST /invitations/{token}/accept invitations.accept
POST /invitations/{token}/decline invitations.decline

Configure in config/invitation.php:

'routes' => [
    'enabled' => true,
    'prefix' => 'invitations',
    'middleware' => ['web'],
    'throttle' => 'throttle:30,1', // Per-IP rate limit. Set to null to disable.
],

'route_names' => [
    'preview' => 'invitations.preview',
    'accept' => 'invitations.accept',
    'decline' => 'invitations.decline',
],

route_names holds the route names that the preview view's accept and decline forms, and the controller's redirect for a guest, resolve. The package registers its routes under these default names, so change them only if you disable routes.enabled and register your own routes under other names.

Disabling routes.enabled removes these three routes, and it also removes the route that invitation.route_name points to by default. send() and resend() then need invitation.url_generator set to build the invitation link; without it, they now throw InvitationConfigurationException before writing anything, rather than failing later in a queue worker.

Authentication and routes

The preview page (GET) is public: anyone with the link can view the invitation details.

The accept route (POST) does not carry an auth middleware by default, but it does not accept anonymously either: the controller checks for a signed-in user itself. A guest is redirected to the named login route (when one exists; otherwise the request is redirected back with an error), with the preview page stored as the session's intended URL, so a login flow that finishes with redirect()->intended() returns the user to the invitation. The token is not passed in the login URL's query string.

The accept route also inherits the email check described under Accepting an invitation: a signed-in user whose email does not match the invited address gets "This invitation was sent to a different email address." instead of being accepted.

Two common patterns:

  • Existing user: Add auth middleware if you want a hard redirect to login regardless of the route's own guest handling, then call Invitations::accept($token, auth()->user())
  • New user: Redirect to registration, then call Invitations::acceptForNewUser($token, $newUser) after signup, which always verifies the registered email matches the invitation

To require authentication, add auth to the route middleware in config:

'middleware' => ['web', 'auth'],

Database Schema

invitations
├── id
├── uuid
├── email
├── token_hash
├── subject_type / subject_id    (polymorphic, nullable)
├── inviter_type / inviter_id    (polymorphic, nullable)
├── accepted_by_type / accepted_by_id (polymorphic, nullable)
├── status                       (pending, accepted, declined, expired, cancelled)
├── expires_at
├── accepted_at
├── declined_at
├── cancelled_at
├── meta                         (JSON)
└── timestamps

Token Security

  • Tokens are generated using Str::random(64)
  • Tokens are hashed before storage using HMAC (default) or bcrypt
  • HMAC (default): deterministic, allows direct DB lookup (O(1))
  • The HMAC key is invitation.token_hmac_key. Left unset it falls back to APP_KEY, which ties every pending invitation to it: rotating APP_KEY stops every outstanding token from matching and holders see "invitation not found". Set INVITATION_TOKEN_HMAC_KEY to decouple them. To adopt one without invalidating tokens already sent, set it to your current APP_KEY value first: the hashes are byte-identical. Rotate the two independently afterwards.
  • Bcrypt: non-deterministic, requires iterating records (O(n)), resistant to DB leaks
  • The plain token is only available at the moment of creation/sending
  • Token verification uses constant-time comparison
  • Route tokens are validated via regex constraint ([a-zA-Z0-9]{64})

Configuration

Full config options in config/invitation.php:

return [
    'table' => 'invitations',
    'model' => \Vimatech\Invitation\Models\Invitation::class,
    'expires_after_days' => 7, // Set to null for invitations that never expire
    'notification' => \Vimatech\Invitation\Notifications\InvitationNotification::class,
    'acceptance_handler' => null,
    'routes' => [
        'enabled' => true,
        'prefix' => 'invitations',
        'middleware' => ['web'],
        'throttle' => 'throttle:30,1',
    ],
    'route_names' => [
        'preview' => 'invitations.preview',
        'accept' => 'invitations.accept',
        'decline' => 'invitations.decline',
    ],
    'route_name' => 'invitations.preview',
    'url_generator' => null,
    'duplicates' => [
        'allow_pending_for_same_email_and_subject' => false,
    ],
    'accept' => [
        'require_matching_email' => true,
    ],
    'token_strategy' => 'hmac', // 'hmac' (recommended) or 'hash'
    'token_hmac_key' => env('INVITATION_TOKEN_HMAC_KEY'), // min 32 chars; falls back to APP_KEY
];

Exceptions

All exceptions extend InvitationException:

  • InvitationNotFoundException: Token invalid or no matching invitation
  • InvitationEmailMismatchException: Extends InvitationNotFoundException. The given user's email does not match the invited address
  • InvitationExpiredException: Invitation has expired
  • InvitationAlreadyAcceptedException: Already accepted
  • InvitationCancelledException: Invitation was cancelled
  • InvitationDeclinedException: Invitation was declined by invitee
  • InvitationAlreadyExistsException: Duplicate pending invitation
  • InvitationConfigurationException: The package is misconfigured (a dedicated HMAC key shorter than 32 characters, or no way to build the invitation link when sending)

Testing

composer test       # Pest
composer analyse    # PHPStan
composer format     # Pint

Contributing

See CONTRIBUTING.md.

Changelog

Please see CHANGELOG.md for recent changes.

Security Vulnerabilities

If you discover a security vulnerability, please review our security policy. Do not open a public GitHub issue.

License

The MIT License (MIT). Please see License File for more information.

Credits

Built and maintained by Vimatech. Created by Adel Zemzemi.