vimatech / laravel-invitation
Generic email-based invitations for Laravel.
Requires
- php: ^8.2
- 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: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- phpstan/phpstan: ^2.0
This package is auto-updated.
Last update: 2026-06-23 11:46:13 UTC
README
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.2+
- Laravel 11+
Installation
composer require vimatech/laravel-invitation
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 — this 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 (recommended): deterministic, allows direct DB lookup (O(1)), relies on
APP_KEY - 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' ];
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
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
MIT