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):| Table | Holds |
|---|---|
invite_campaigns | a named wave with a default grant |
invite_codes | a code (multi-use / single-use / vanity) with current_uses / max_uses and its own grant override |
invitations | a direct, addressed invitation (high-entropy link token) |
invite_redemptions | the claim ledger — UNIQUE(code_id, redeemer_id) is the idempotency + concurrency anchor |
invite_referrals | referrer → invitee links for the K-factor loop |
invite_rewards | the reward ledger (states + reversal) |
invite_waitlist | waitlist entries with queue-jump on referral |
invite_abuse_signals | rolling fraud signals (HMAC’d subjects, never plaintext) |
invite_analytics_events | funnel + virality events |
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).
| Tab | What it shows / does | Core endpoint(s) |
|---|---|---|
| Overview | all 11 live funnel KPIs (k-factor, acceptance / conversion rate, time-to-redeem p50/p90, distinct referrers…) + a proportional acquisition funnel; campaign + window filters | GET …/metrics |
| Campaigns | list + create/edit with the multi-tenant grant editor (a Spatie role + KB projects across one or more tenants); key + type are immutable on edit; super-admin is rejected client- and server-side | GET/POST …/campaigns, PATCH …/campaigns/{id}, GET …/tenants |
| Codes | inventory + batch generation (count 1–1000, max-uses, length, expiry) with Copy-all + CSV export, and inline-confirm revoke | GET/POST …/codes, POST …/codes/{id}/revoke |
| Invite | send a direct invitation (recipient / channel / role / context); session-scoped sent list, since the core API has no list endpoint | POST …/invitations |
| Referrals / Rewards / Waitlist / Anti-abuse | filtered read tables; the waitlist email is masked and anti-abuse subjects stay server-hashed | GET …/{referrals,rewards,waitlist,abuse-signals} |
- 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.