Motivation
A closed-beta or seat-controlled product needs more than “send a magic link”. It needs campaigns (a launch wave, an investor list, a conference QR), codes that are multi-use or single-use or vanity, referrals that reward the referrer when the invitee converts, a waitlist with queue-jumping, and an anti-abuse layer so a leaked code can’t be farmed into thousands of free seats — all of it multi-tenant, because two customers may legitimately mint the same human-readable code. AskMyDocs gets this from the standalonepadosoft/laravel-invitations
engine rather than re-implementing it inline. The package is vendor-neutral:
it types against interfaces (TenantResolver, Provisioner, InvitedAccount),
not against App\Models\User or AskMyDocs’s tenant context. The host’s job is to
bind those seams — and to decide what an accepted invite actually grants.
That decision is the interesting part. In AskMyDocs an invite carries a
per-tenant grant: a Spatie role and a set of KB project memberships.
So a single code can onboard a user into the right tenant, with the right role,
scoped to the right projects, in one redemption.
Theory — where the coupling lives
The engine is ~80% domain-agnostic. The only host-specific surface is what happens after a redemption commits — applying the grant. The package isolates that behind two contracts and a tag:TenantResolver—current(): string. Every invite table carriestenant_id; every query is scoped through this resolver. A plain app gets the package’s single-tenant default; a multi-tenant host binds its own.InvitedAccount—getInviteEmail(): ?string+getInviteGuardName(): string. The engine reads only these two account attributes (email for abuse-correlation hashing, guard for role provisioning) so it never couples to a concrete user class.Provisioner(taggedinvitations.provisioners) —provision(Model $account, TenantGrant $grant): void. The package shipsSpatiePermissionProvisioner(grants the role); a host adds more under the same tag. Two invariants the contract mandates: GRANT-never-REVOKE (only ever raise access) and best-effort (a fault is swallowed + logged, never thrown — the redemption is already committed when provisioning runs).
Design
Three things the host wires, all inApp\Providers\AppServiceProvider::boot()
so they win over the package’s packageRegistered() defaults (the same
boot-vs-register ordering AskMyDocs uses for its MCP and evidence-risk-review
adapters):
TenantResolver→App\Support\TenantContext. An anonymous class adapts the host context’scurrent()to the package contract. The package binds its single-tenant default withbindIf(); an explicitbind()inboot()overrides it definitively. Result: every invite read/write is scoped to the tenant the request resolved (R30).App\Invitations\ProjectMembershipProvisioner→ theinvitations.provisionerstag. Appended to the tag alongside the package’sSpatiePermissionProvisioner. The package’s contextualgiveTagged('invitations.provisioners')forAccountProvisioningServiceresolves the tag lazily at redemption time, so both provisioners run: the Spatie one raises the global role, ours raises per-project access.manageInvitationsgate +config/invitations.php. The package routes carry no internal authorization — admin gating is entirely the hostadmin_middlewareconfig (R32). AskMyDocs sets it to its standard admin stack pluscan:manageInvitations.
Data model
The package owns 9 tenant-aware tables (created by its own migrations, R30/R31 enforced in its CI):
The host adds nothing to its own schema — an accepted invite writes into the
existing
project_memberships table (via the provisioner) and assigns an
existing Spatie role.
The grant is a pure value object (TenantGrant): tenantId, role,
projects[], projectRole, scopeAllowlist. An invite can carry several, so
one code provisions across one or more tenants (“teams”) at once.
Decision rationale
The load-bearing choices (ADR-style; cross-links to the ADR index):- Reuse the standalone engine, bind the seams — don’t fork it inline. The invite engine is general-purpose and battle-tested; AskMyDocs is one consumer. Keeping it a package means its concurrency-safety and anti-abuse logic improve for every consumer at once, and AskMyDocs only owns the ~70-line provisioner + the bindings.
- Atomic redemption is the engine’s, and it is not negotiable. Seat-count
safety comes from a single conditional
UPDATE … WHERE current_uses < max_usesplusUNIQUE(code_id, redeemer_id)— never a read-then-write. Two concurrent redemptions of the last seat cannot both win (mirrors AskMyDocs’s own R21 “security invariants are atomic or absent”). - GRANT-never-REVOKE provisioning.
ProjectMembershipProvisionerusesfirstOrCreateon(tenant_id, user_id, project_key). A pre-existing membership at a higher role is never downgraded; an invite can only raise access. This makes redemption safe to replay and safe to over-grant. - Best-effort provisioning. The redemption commits first; provisioning runs after. A provisioning fault is logged (with the exception class for triage) and swallowed — a transient DB hiccup while writing a membership must not roll back a redemption the user already sees as successful.
INVITE_REQUIREDdefaults OFF (R43 both-states). Installing the package does not silently turn AskMyDocs into a closed beta. The signup gate is strict opt-in; both the OFF path (registration unchanged) and the ON path (code required) are covered by tests.- The 9 invite tables stay package-owned. They are not added to the host
TenantIdMandatoryTestenumeration (which only iteratesApp\Models\*) — the package enforces R30/R31 on its own models in its own CI, exactly as theaskmydocs-connector-basetables do.
Worked example — a launch-wave campaign that grants editor + two projects
POST /api/invitations/redeem, the engine claims the seat atomically and then fans the
grant out to both provisioners: the user is assigned the editor Spatie role and
gains member project_memberships rows on hr-portal and engineering —
scoped to the redemption tenant. Re-redeeming (or a code that grants a project
the user already owns at a higher role) changes nothing: GRANT-never-REVOKE.
Read the funnel three ways (R44) — GET /api/admin/invitations/metrics, the
InviteMetricsTool MCP tool, or MetricsService::summary() in PHP.
Admin UI — native, in-app
The admin surface is a native tabbed page at/app/{team}/admin/invitations,
inside the unified admin chrome and team switcher — not a new tab. Every tab
reads the same /api/admin/invitations/* core that the MCP tools and PHP
services use (R44 — one core, many surfaces), so the UI is a thin consumer with
no parallel backend. The X-Tenant-Id header rides the shared SPA client, so
every read is tenant-scoped automatically (R30).
Two deliberate honesties:
- Truncation is visible. The core read surfaces cap at 500 rows with no pagination; when a table hits the cap it shows a “first 500 rows — refine the filters” notice rather than pretending the list is complete (R3/R14).
- The “Advanced” panel link is gated on a server-truthful flag. The
standalone
padosoft/laravel-invitations-adminpanel (campaign builder + multi-tenant grant editor — the parts not yet native) is offered as an “Advanced” launcher only whenINVITATIONS_ADMIN_ENABLED=true. The SPA learns this from the additivefeatures.invitations_adminfield on/api/auth/me(R27); when the mount is OFF the package route is unregistered and the link is hidden, so it never dead-ends on the/admin/invitations404 (R14/R43).
Invite-only sign-up (SPA registration)
Since v8.26 the public auth UI is entirely React:/login, /register,
/forgot-password and /reset-password all render the SPA shell (view('app')
via SpaController) even on a hard page load, so a cache-cleared reload no longer
falls back to the old Blade auth page (those views, and the web
PasswordResetController, were removed).
The /register screen posts to POST /api/auth/register — a guest route in
the web middleware group, throttled 6/min per IP (throttle:register, defined
in AppServiceProvider) so it can’t be used to brute-force invite codes. The
controller is a thin HTTP adapter over the same invite core (R44) and follows a
deliberate order so the invite-only invariant can never leak:
- Pre-validate the code with
CodeValidatorbefore touchingusers— an invalid / expired / exhausted code never mints an orphan account. - Create the account (no role yet).
- Redeem authoritatively with
RedemptionService(the atomic conditionalUPDATE … WHERE current_uses < max_uses+ the tagged Spatie-role / project provisioners). Redeem runs outside any DB transaction by design: on PostgreSQL a UNIQUE-violation aborts the connection for the rest of a transaction, which would poison the package’s compensation follow-ups. On an exhausted-between-checks race the brand-new account is force-deleted. - Floor the account at
viewer(layered on any grant role redeem already provisioned — GRANT-never-revoke), open the SPA session, fireRegistered.
invite_code
(R14 — never a 200 with an empty body) with a localized message
(lang/{en,it}/register.php; the machine-readable RedemptionError stays
English, R24). invite_code is always required at this endpoint regardless of
the INVITE_REQUIRED gate — the host SPA sign-up endpoint is invite-only by
product decision. The INVITE_REQUIRED / invitations.invitation_required flag is
the package-level gate (read by padosoft/laravel-invitations, not by the host
register endpoint), so flipping it does not open a non-invite registration path
on AskMyDocs.
Gotchas
manageInvitationsis super-admin + admin. Issuing access-granting codes is an administrative act; dpo / editor / viewer are excluded. The user redeem surface (/api/invitations/*) only requires authentication — any logged-in account may redeem a code it holds.- Provisioning runs in the redemption tenant and any explicit grant
tenants. A grant’s
tenantIdis authoritative for that slice — a single code can seed memberships in several tenants at once. The host provisioner writes each membership in its grant’s tenant, not the request’s active tenant. - A provisioning failure never fails the redemption. If you don’t see a
membership after a successful redeem, check the logs for
invitations.provision.project_membership_failed(it carries the exception class) — the redemption itself still succeeded by design. - Flipping
INVITE_REQUIRED=trueis a product decision, not a deploy detail. With it on, registration requires a valid code. Verify your invite-issuing flow is live before enabling it, or new signups are blocked. - Campaign creation writes a grant — mind super-admin + immutability. The
campaign builder’s grant editor provisions a Spatie role + KB projects across
one or more tenants on redemption;
super-adminis rejected (client and server) and a campaign’skey+typeare immutable after creation (the update rules don’t accept them). The standalonepadosoft/laravel-invitations-adminpanel remains reachable via the gated “Advanced” launcher (INVITATIONS_ADMIN_ENABLED=true) for parity, but the native tabs now cover the full create/edit/send surface.