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
- dev-main
- v1.3.0
- v1.2.1
- v1.2.0
- v1.1.0
- v1.0.0
- dev-docs/audit-2026-10
- dev-security/bind-acceptance-to-invited-email
- dev-fix/conditional-status-transitions
- dev-docs/banniere-et-titre
- dev-chore/align-composer-description
- dev-docs/name-consistency
- dev-chore/contact-email-to-com
- dev-fix/token-key-and-shared-builder-state
- dev-chore/packagist-readiness
This package is auto-updated.
Last update: 2026-10-04 23:13:27 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 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
authmiddleware if you want a hard redirect to login regardless of the route's own guest handling, then callInvitations::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 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_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 invitationInvitationEmailMismatchException: ExtendsInvitationNotFoundException. The given user's email does not match the invited addressInvitationExpiredException: Invitation has expiredInvitationAlreadyAcceptedException: Already acceptedInvitationCancelledException: Invitation was cancelledInvitationDeclinedException: Invitation was declined by inviteeInvitationAlreadyExistsException: Duplicate pending invitationInvitationConfigurationException: 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.