Invitations
Let a privileged operator invite a new user by email — issue a single-use link, the invitee sets a password and lands in the app — instead of leaving self-service registration open to the world.
Why this exists
Invitations let a privileged operator bring users into an app that has no open /register page. The module is a package-owned InvitationManager plus a public accept screen, wired through the same RegistersUsers pipeline the rest of authentication uses.
Invite-only onboarding (agencies, internal tools, B2B admin panels, anything with a closed user base) is a different shape: a privileged operator decides who gets in, the invitee never chooses their own email, and the whole thing has to be safe against token-guessing and link-sharing.
Two-layer guard
Invitations are opt-in. Two independent controls decide whether the feature does anything:
- Master switch —
martis.invitations.enabled(defaultfalse). SetMARTIS_INVITATIONS_ENABLED=truein the env to turn it on. While off, the accept routes always 503 and the resource scaffolded by the generator (see below) hides itself from navigation and routing. - Authorisation gate —
martis-invite. The package registers a deny-by-default definition (Gate::define('martis-invite', fn () => false)) so an unconfigured install cannot issue invitations by accident. Define your own inApp\Providers\AuthServiceProvider::boot()to decide who may invite:
use Illuminate\Support\Facades\Gate; Gate::define('martis-invite', function ($user) { return $user?->hasRole('admin'); // Or: $user?->is_admin; // Or: $user?->email && str_ends_with($user->email, '@yourcompany.com');});The gate only guards the privileged issue action (wherever you call InvitationManager::invite() from — typically the InviteUser action the generator scaffolds). Accepting an invitation is token-authorized, not gate-authorized: the invitee has no account yet, so neither martis.auth nor martis-invite apply to the public accept endpoints.
Configuration
// config/martis.php'invitations' => [ 'enabled' => env('MARTIS_INVITATIONS_ENABLED', false), 'expires_after_hours' => (int) env('MARTIS_INVITATIONS_TTL_HOURS', 72), 'single_use' => true, 'resend_throttle_seconds' => (int) env('MARTIS_INVITATIONS_RESEND_THROTTLE', 60), 'login_after_accept' => env('MARTIS_INVITATIONS_LOGIN_AFTER_ACCEPT', true), 'redirect_after_accept' => env('MARTIS_INVITATIONS_REDIRECT', null), 'signup_fields' => ['name', 'password'], 'mark_email_verified_on_accept' => true,], 'audit' => [ // ... other audit toggles 'invitations' => env('MARTIS_AUDIT_INVITATIONS', true),],| Key | Default | Purpose |
|---|---|---|
enabled | false | Master switch. Every accept endpoint 503s while off. |
expires_after_hours | 72 | TTL applied when an invitation is minted. accept() rejects an expired token the same way it rejects an unknown one. |
single_use | true | Documents the invariant, not a toggle — read by no code path. Single-use is enforced structurally by the atomic pending→accepted claim in accept() (see Security posture), so there is nothing to turn off. |
resend_throttle_seconds | 60 | Minimum gap between two resend() calls on the same invitation, keyed off updated_at. Prevents notification spam from a mis-clicking operator. |
login_after_accept | true | When true, accept() logs the new user in and regenerates the session before redirecting. When false, the invitee is sent to /login instead. |
redirect_after_accept | null | Where a freshly logged-in invitee lands. null falls back to the resource index (route('martis.index')). |
signup_fields | ['name', 'password'] | Whitelist of client-supplied fields accept() will read. email is never in this list — it always comes from the invitation record, never from the request. Add/remove fields to match what your createUser() override expects. |
mark_email_verified_on_accept | true | When true and the user model's table has an email_verified_at column, it is stamped at accept time (an emailed, single-use link is itself a verification step). |
audit.invitations | true | Toggles whether the lifecycle events also write a martis_action_events row (see Events + audit). The events themselves keep firing either way. |
Upgrading from before this key shipped? The base config stub carries
audit.invitations, so a freshvendor:publish --tag=martis-configis covered. If yourconfig/martis.phpwas published earlier and you runconfig:cache(the common production and Docker path), the cached snapshot is built from your published file and Laravel's package-config merge is skipped, so a missingaudit.invitationsleavesMARTIS_AUDIT_INVITATIONSinert. Runningphp artisan martis:invitationsbackfills the key into your existingauditarray (idempotent — a second run is a no-op); or add'invitations' => env('MARTIS_AUDIT_INVITATIONS', true),to the array by hand.
Lifecycle
An invitation moves through a small state machine: pending → accepted or revoked. There is no manual expired transition — expiry is evaluated at read time (isExpired()) and enforced at claim time in accept().
Issue
use Martis\Invitations\InvitationManager; $invitation = app(InvitationManager::class)->invite( email: 'new.hire@example.com', role: 'editor', // optional; assigned to the user on accept if the model supports assignRole() metadata: [], // optional opaque bag — see below); // $invitation->rawToken holds the plain-text token, in memory only,// for exactly this request — build the accept URL and send it now.invite() generates a CSPRNG token, persists only its hash, sets expires_at from expires_after_hours, records invited_by from the currently authenticated guard user, and fires InvitationCreated.
Accept
$user = app(InvitationManager::class)->accept($rawToken, [ 'name' => 'Jane Doe', 'password' => '...', 'password_confirmation' => '...',]);accept() runs inside a single DB transaction:
- Atomically claims the invitation (
pending→accepted, compare-and-setUPDATE). If nothing was claimed — unknown token, already accepted, revoked, or expired — it throwsInvalidInvitationExceptionand the transaction rolls back. - Rejects if the invitation's email already belongs to a registered user (anti-takeover).
- Filters the incoming signup payload down to
signup_fieldsand calls thecreateUser()override seam. - Assigns the invitation's
role, if any, viaassignRole()when the resulting user model supports it (Spatie soft-dependency). - Optionally stamps
email_verified_at. - Fires
InvitationAcceptedonly after the transaction actually commits (DB::afterCommit), so a rolled-back claim never emits the event.
A validation failure inside createUser() (short password, mismatched confirmation, …) propagates out of accept() unchanged as a ValidationException — the transaction rolls back and the invitation returns to pending, so the invitee can simply retry with a fixed payload.
Resend
app(InvitationManager::class)->resend($invitation);// $invitation->rawToken now holds a fresh plain-text token; the old one stops working.Only works on a still-pending invitation, and is throttled by resend_throttle_seconds against the row's updated_at. Both failure modes throw InvalidInvitationException.
Revoke
app(InvitationManager::class)->revoke($invitation);Flips a still-pending invitation to revoked, permanently blocking its accept link, and fires InvitationRevoked. Revoking a non-pending invitation throws InvalidInvitationException.
Security posture
- Hashed at rest. The plain-text token is a 32-byte CSPRNG value (
random_bytes(32), base64url-encoded); only its SHA-256 hash is ever written to thetokencolumn. The plain value lives on$invitation->rawToken— an in-memory-only property, never fillable, never cast — for exactly the request that minted or resent it. - Atomic single-use. The pending→accepted transition is a single compare-and-set
UPDATE …WHERE status = 'pending', not a read-then-write. The row lock it takes serializes concurrent accept attempts on the same token, so a token cannot be claimed twice even under a race. - Out of the request line. The emailed link is
/invitations/accept#token=…(v2.6.0): the token is in the URL fragment, which the browser never sends, so proxy and web-server access logs never record it. A link mailed before v2.6.0 (/invitations/accept/{token}) is redirected to that form. See authentication.md § One-time links keep the token out of the request line. - Enumeration-neutral.
GET /invitations/acceptnever sees the token and always returns the same200+ SPA shell, and the legacyGET /invitations/accept/{token}answers the same redirect whether the token is valid, unknown, expired, revoked, or already used — the server never signals validity from the page load. ThePOSTaccept endpoint collapses every unacceptable state (unknown, expired, revoked, used, email-already-registered) into the same genericInvalidInvitationExceptionmessage, so probing the endpoint cannot distinguish "no such token" from "already claimed". - Signup whitelist.
accept()only reads keys listed insignup_fields(pluspassword_confirmation) from the client payload.emailis never client-controlled — it always comes from the invitation row. - Existing-email guard. An invitation whose email already belongs to a registered user is rejected (and the claim rolled back) rather than silently overwriting or duplicating an account.
- The role is checked where the invitation is issued. The scaffolded
InviteUservalidates the role on the server against the list the picker shows (no SSO-managed role, and a delegate only the roles they hold), so a forgedfields.rolecannot mint an invitation for a role the inviter may not grant. See The invite role picker.invite()itself stores the role it is given: if you call it from your own code, check the role there.
The InvitationManager API
| Method | Signature | Notes |
|---|---|---|
invite | invite(string $email, ?string $role = null, array $metadata = []): Invitation | Mints and persists a new pending invitation. Fires InvitationCreated. |
findByRawToken | findByRawToken(string $rawToken): ?Invitation | Hashes and looks up. Used by the accept screen's GET to exercise the same code path for any token state without revealing which. |
accept | accept(string $rawToken, array $signup): Authenticatable | See Lifecycle → Accept. Throws InvalidInvitationException or ValidationException. |
resend | resend(Invitation $invitation): void | See Lifecycle → Resend. Throws InvalidInvitationException. |
revoke | revoke(Invitation $invitation): void | See Lifecycle → Revoke. Throws InvalidInvitationException. |
createUser | protected createUser(Invitation $invitation, array $signup): Authenticatable | The single override seam. See below. |
The override seam: createUser()
InvitationManager::createUser() is the only method meant to be overridden. Its default implementation builds an in-memory Request (name/password from the whitelisted signup payload, email authoritative from the invitation) and delegates to the same Martis\Contracts\RegistersUsers binding self-service registration uses — one registration pipeline, one place to change validation, hashing, and default-role assignment app-wide.
Subclass the manager when an invitation needs to shape the user differently from a self-registered one — for example, carrying arbitrary data through metadata and writing it onto the new user:
namespace App\Invitations; use Illuminate\Contracts\Auth\Authenticatable;use Martis\Invitations\Invitation;use Martis\Invitations\InvitationManager; class AppInvitationManager extends InvitationManager{ protected function createUser(Invitation $invitation, array $signup): Authenticatable { $user = parent::createUser($invitation, $signup); // $invitation->metadata is whatever opaque bag invite() was called with. if ($departmentId = $invitation->metadata['department_id'] ?? null) { $user->forceFill(['department_id' => $departmentId])->save(); } return $user; }}Rebind it in your service provider so the package resolves your subclass everywhere:
$this->app->bind(InvitationManager::class, AppInvitationManager::class);Do not reach for this seam for app-wide registration changes — rebind RegistersUsers instead (see authentication.md). Override createUser() only for behaviour specific to the invitation flow (reading metadata, skipping a step self-registration always does, etc).
Events + audit
Three events, one per lifecycle transition, all carrying the Invitation:
| Event | Fired by | Extra payload |
|---|---|---|
Martis\Invitations\Events\InvitationCreated | invite() | — |
Martis\Invitations\Events\InvitationAccepted | accept(), after commit | Authenticatable $user |
Martis\Invitations\Events\InvitationRevoked | revoke() | — |
The package's own Martis\Invitations\Listeners\RecordInvitation subscribes to all three and writes a martis_action_events row per transition (invitation.created / invitation.accepted / invitation.revoked), skipping silently when the audit table is missing. Toggle the row write off per-environment with MARTIS_AUDIT_INVITATIONS=false — the events still fire, so any listener you attach keeps firing; only the built-in audit row is suppressed.
Attach your own listeners the same way you would for any Laravel event:
use Illuminate\Support\Facades\Event;use Martis\Invitations\Events\InvitationAccepted; Event::listen(InvitationAccepted::class, function (InvitationAccepted $e) { \App\Notifications\SecurityWebhook::send([ 'invited_email' => $e->invitation->email, 'user_id' => $e->user->getAuthIdentifier(), ]);});The accept-URL seam (InvitationUrl)
The URL an invite notification points at is built through a static, overridable seam, mirroring Laravel's own Illuminate\Auth\Notifications\ResetPassword::createUrlUsing():
use Martis\Invitations\Invitation;use Martis\Invitations\InvitationUrl; InvitationUrl::createUrlUsing(function (Invitation $invitation, string $rawToken): string { return 'https://accounts.example.com/invite#token='.rawurlencode($rawToken);});MartisServiceProvider::boot() seeds the package default (TokenLink::url('martis.invitations.accept', ['token' => $rawToken]): /{martis-path}/invitations/accept#token=… on APP_URL) automatically, but only when martis.invitations.enabled is true and no callback is already registered — so a consumer's own registration (in AppServiceProvider::boot(), run before the package's) always wins. Reach for this when the accept link needs to point somewhere other than the bundled SPA route: an off-platform signup page, a deep link into a mobile app, or a URL that needs extra query parameters. Keep the token itself in the fragment (or the body of a POST), never in the path or the query string, so access logs never record it.
Pass null to reset to the package default.
Public accept surface
Two routes, always registered regardless of the master switch (so the route table stays predictable across environments) — both 503 while martis.invitations.enabled is false:
| Method | Path | Handler |
|---|---|---|
GET | /{martis-path}/invitations/accept | Renders the SPA shell (no-store, Referrer-Policy: no-referrer). The token is in the URL fragment, so this request never carries it; a ?token= query string is redirected to the fragment form. |
GET | /{martis-path}/invitations/accept/{token} | A link mailed before v2.6.0: redirected to /invitations/accept#token=…, the same answer for any token state. Removed in v3.0.0. |
POST | /{martis-path}/api/invitations/accept | Validates the signup payload, delegates to InvitationManager::accept(), logs the invitee in (unless login_after_accept is false), and returns { ok, redirect, user? } (JSON) or a redirect (non-JSON). |
token, plus each configured signup_fields entry, plus password (always confirmed, and your password rules: Password::defaults(), else Password::min(8)) are the only fields the POST endpoint validates. The password rules are checked once: by the default RegistersUsers the accept hands the signup to, or by the endpoint itself when you bind a registrar of your own, which may not check them (so uncompromised() asks Have I Been Pwned once per accept). An InvalidInvitationException from the manager becomes a neutral 422 { message, errors: { token: [message] } } (JSON) or a redirect back to /login with a flashed error (non-JSON) — the same shape regardless of which invalid state it was.
React accept screen
The package ships a working accept screen — resources/js/pages/InvitationAccept.tsx — mounted at /invitations/accept. It reads the token from the URL fragment (useAuthLinkParams()), drops it from the address bar, and renders the set-password form optimistically (the GET never reveals whether the token is valid) and only learns the token was bad from the POST response's errors.token key, at which point it swaps to a neutral "invitation link invalid" state. Every other 422 field error stays inline so the invitee can fix and retry. On success it follows the JSON envelope's redirect with a full page navigation (the accept call just changed session state, so a hard navigation is what picks up the new auth state).
Only the default signup_fields (name, password) render — matching the controller's default validation. Ships translated in en, pt_PT, and pt_BR.
Overriding the screen
Register a replacement under the auth:invitation-accept key from your consumer extension bundle:
import { componentRegistry } from '@martis/runtime'import { MyInvitationAccept } from './components/MyInvitationAccept' componentRegistry.register('auth:invitation-accept', MyInvitationAccept)The replacement reads the token with useAuthLinkParams() from @martis/runtime (since v2.6.0 it is in the URL fragment, not a route parameter) and posts it to /api/invitations/accept:
import { useAuthLinkParams } from '@martis/runtime' export function MyInvitationAccept() { const { token } = useAuthLinkParams() // ... POST { token, name, password, password_confirmation }}The router resolves this key the same way it resolves auth:login, auth:register, and the other auth-page slots — a missing registration falls back to the bundled default. There is no dedicated martis:component --type= scaffold for this slot yet; the registry key works for any string, scaffolded or not, so registering it directly (as above) is the supported path.
Copy overrides
Two of the same two paths every other auth screen supports (see authentication.md § Customising the auth copy):
- Path 1 (lang files) —
invitation_accept_title/invitation_accept_subkeys in the publishedauth.phplang file. - Path 2 (config) —
config('martis.auth.copy.invitation_accept.title')/.subtitle, string orarray<locale, string>.
The martis:invitations generator
php artisan martis:invitationsEverything above ships inside the package. What cannot live there — because it belongs in your application's own namespace and is meant to be edited — is scaffolded by this one-shot generator:
| Generated | Path (default) | Purpose |
|---|---|---|
InvitationResource | app/Martis/Resources/InvitationResource.php | Index of issued invitations in the System sidebar group. Hides from navigation and routing while invitations are disabled. |
InviteUser | app/Martis/Resources/Actions/InviteUser.php | Standalone action — email + optional role picker, calls InvitationManager::invite(), sends the notification. Gated on martis-invite; the role is checked on the server (see The invite role picker). |
ResendInvitation | app/Martis/Resources/Actions/ResendInvitation.php | Row action — calls InvitationManager::resend() and re-sends the notification for each selected pending invitation. |
RevokeInvitation | app/Martis/Resources/Actions/RevokeInvitation.php | Row action — calls InvitationManager::revoke() for each selected pending invitation. |
InvitationPolicy | app/Policies/InvitationPolicy.php | Admin-only policy, auto-registered in AuthServiceProvider::boot(). create() always returns false — invitations are only ever issued through InviteUser, never the generic resource create form. |
UserInvitation | app/Notifications/UserInvitation.php | The invite email, delivered via an on-demand mail route (the invitee has no account to notify yet). Built from InvitationUrl::url(). |
It also publishes the create_invitations_table migration (a portable, key-type-aware stub: invited_by and accepted_user_id reference the table of the Martis guard's users, users on a default install, and match its primary-key shape), runs it, and, if you published config/martis.php before the invitations block existed, inserts the block for you. Every step is idempotent; re-running the command is a no-op once the app is set up, and nothing is overwritten without --force. The migration is dated after the newest migration already in database/migrations.
Options:
| Option | Default | Purpose |
|---|---|---|
--user= | App\Models\User | Fully-qualified user model class, used to type-hint the generated policy. |
--namespace= | App\Martis\Resources | Namespace for the generated resource + actions. |
--no-install | — | Skip the advisory note about the optional spatie/laravel-permission role picker. |
--no-migrate | — | Skip running migrations after publishing them. |
--no-publish | — | Skip publishing the invitations migration. |
--force | — | Overwrite existing resource / action / policy / notification files. |
After the command finishes: set MARTIS_INVITATIONS_ENABLED=true, define the martis-invite gate, and visit the System sidebar group.
The invite role picker
Accepting an invitation assigns the invitation's role (assignRole()), so the role is the sensitive input of the InviteUser action. InvitationManager::invite() stores whatever role it is given, as a primitive should: deciding who may hand out which role is the caller's job, and the scaffolded action does it on the server (v2.4.0+), not only in the picker. A fields.role the picker never offered answers 422 on the field, mints no invitation and sends no mail.
One private method of the generated action, assignableRoles(), holds the list, and the picker, the Select's validateAgainstOptions() rule and a second check in handle() all read it:
- SSO-managed roles (a non-null
provider_group_name) are never offered or accepted: group sync owns them and would overwrite them at the next sign-in. - Who may invite into which role. An admin (the
hasRole('admin')checkInvitationPolicymakes) or anyone who passes themartis-invite-any-roleability may invite into every remaining role. Anyone else is a delegate and may only hand out the roles they hold themselves. So when you delegatemartis-inviteto a non-admin role, that role cannot be used to mint an invitation for a role above its own, which would let the delegate invite a second address as an admin and accept it. - Granting a wider list on purpose is one Gate definition, next to
martis-invite(below). - No roles package, or no role chosen. Without
spatie/laravel-permissionthe list is empty and an invitation carries no role; leaving the picker empty always works.
// App\Providers\AuthServiceProvider::boot()Gate::define('martis-invite', fn ($user) => $user->hasRole('hr-manager'));Gate::define('martis-invite-any-role', fn ($user) => $user->hasRole('hr-manager'));Without the second line an hr-manager can invite into hr-manager only. Edit mayInviteIntoAnyRole() in the action if your app delegates in another way (a permission, a per-role ability).
An InviteUser generated before v2.4.0 keeps the old behaviour (any role name was accepted). Regenerate it with php artisan martis:invitations --force after merging your edits, or take the three changes by hand: build the list once in assignableRoles(), add ->validateAgainstOptions() to the Select, and refuse a role outside the list in handle().
Recipes
Consumer-land recipe (not shipped in the package)
Multi-tenant SaaS admin: every invitation belongs to a tenant, the invited user must land inside that tenant, and only tenant admins may invite within their own tenant. None of this is package behaviour — it's composed entirely from the seams above.
1. Carry the tenant on the invitation and read it back in createUser():
namespace App\Invitations; use Illuminate\Contracts\Auth\Authenticatable;use Martis\Invitations\Invitation;use Martis\Invitations\InvitationManager; class TenantInvitationManager extends InvitationManager{ protected function createUser(Invitation $invitation, array $signup): Authenticatable { $user = parent::createUser($invitation, $signup); $user->forceFill([ 'tenant_id' => $invitation->metadata['tenant_id'] ?? null, ])->save(); return $user; }}// App\Providers\AppServiceProvider::register()$this->app->bind( \Martis\Invitations\InvitationManager::class, \App\Invitations\TenantInvitationManager::class,);Issue an invitation carrying the current operator's tenant:
app(InvitationManager::class)->invite( email: $email, role: $role, metadata: ['tenant_id' => auth()->user()->tenant_id],);2. Scope who may invite to their own tenant:
// App\Providers\AuthServiceProvider::boot()Gate::define('martis-invite', fn ($user) => $user?->tenant_id !== null && $user->hasRole('admin'));3. Scope the generated resource to the current tenant (edit the generated InvitationResource):
public static function indexQuery(Request $request, Builder $query): Builder{ return $query->where( 'metadata->tenant_id', $request->user()->tenant_id, );}4. Restrict the roles to the ones the current tenant actually uses (edit assignableRoles() in the generated InviteUser action). The picker, the Select's validation and the check in handle() all read that method, so one clause restricts all three:
$query = \Spatie\Permission\Models\Role::query() ->where('tenant_id', $inviter?->tenant_id);Nothing here required forking the package: one manager subclass, one gate definition, one indexQuery() override, and one query clause.
Tests
Behaviour-level coverage lives across tests/Feature/Invitation*Test.php: InvitationModelTest, InvitationConfigTest, InvitationManagerTest, InvitationControllerTest, InvitationAuditTest, InvitationsMigrationTest, InvitationsScaffoldCommandTest — the full state-machine, security-invariant, and generator-idempotency matrix. The React accept screen has its own coverage in resources/js/pages/InvitationAccept.test.tsx.