vimatech / laravel-invitation
Generic email-based invitations for Laravel. Invite anyone to any Eloquent model: teams, projects, workspaces. Fluent API, HMAC tokens, events, queued notifications, i18n.
Requires
- php: ^8.3
- illuminate/contracts: ^11.0|^12.0|^13.0
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/notifications: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- laravel/pint: ^1.29
- orchestra/testbench: ^9.0 || ^10.0 || ^11.0
- pestphp/pest: ^3.0 || ^4.0
- pestphp/pest-plugin-laravel: ^3.0 || ^4.0
- phpstan/phpstan: ^2.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-17 19:52:11 UTC
README
Email invitations to any Eloquent model
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 Invitation?
- 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) 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);
Accepting after registration (new user)
// After user registration (verifies the invitation email matches the user's email): $invitation = Invitations::acceptForNewUser($token, $newUser);
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
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,
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. ],
Authentication and routes
The preview page (GET) is public: anyone with the link can view the invitation details.
The accept route (POST) does not enforce authentication by default. Two common patterns:
- Existing user: Add
authmiddleware, then callInvitations::accept($token, auth()->user()) - New user: Redirect to registration, then call
Invitations::acceptForNewUser($token, $newUser)after signup, which 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 toAPP_KEY, which ties every pending invitation to it: rotatingAPP_KEYstops every outstanding token from matching and holders see "invitation not found". SetINVITATION_TOKEN_HMAC_KEYto decouple them. To adopt one without invalidating tokens already sent, set it to your currentAPP_KEYvalue 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_name' => 'invitations.preview', 'url_generator' => null, 'duplicates' => [ 'allow_pending_for_same_email_and_subject' => false, ], '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 invitationInvitationExpiredException: Invitation has expiredInvitationAlreadyAcceptedException: Already acceptedInvitationCancelledException: Invitation was cancelledInvitationDeclinedException: Invitation was declined by inviteeInvitationAlreadyExistsException: Duplicate pending invitation
Testing
composer test # Pest composer analyse # PHPStan composer format # Pint
Contributing
See CONTRIBUTING.md.
Changelog
Please see CHANGELOG.md for recent changes.
Security
If you discover a security vulnerability, please review our security policy. Do not open a public GitHub issue.
Credits
Built and maintained by Vimatech. Created by Adel Zemzemi.
License
The MIT License (MIT). Please see License File for more information.