| name | Laravel Waitlist |
| description | Conventions and APIs for the offload-project/laravel-waitlist package — multiple waitlists, entry status tracking, optional email verification, and bridge into laravel-invite-only. |
| compatible_agents | ["Claude Code","Cursor"] |
| tags | ["laravel","php","waitlist","eloquent","notifications","invitations"] |
Context
offload-project/laravel-waitlist is a Laravel 11/12/13 package (PHP 8.3+) for managing one or many waitlists. It ships:
- A
Waitlist Eloquent model (a named waitlist with a slug) and a WaitlistEntry model (a person waiting on a list).
- A
WaitlistService (resolved via the Waitlist facade) with for(), create(), add(), invite(), reject(), sendVerification(), verify(), and query/count helpers.
- Optional email verification flow with a published
/waitlist/verify/{token} route.
- Optional bridge into
offload-project/laravel-invite-only: calling Waitlist::invite() creates a real Invitation (token, expiration, events) and persists the FK on WaitlistEntry::$invitation_id.
- Two notifications:
WaitlistInvited (opt-in via auto_send_invitation) and VerifyWaitlistEmail.
- A typed
UnverifiedEntryException thrown when invite is attempted on an unverified entry while verification gating is on.
Apply this skill when working in a Laravel app that has offload-project/laravel-waitlist in composer.json, or when the user asks for help with Waitlist, WaitlistEntry, the Waitlist facade, or waitlist flows in this package.
Rules
Facade usage
- Use the
Waitlist facade (OffloadProject\Waitlist\Facades\Waitlist) — do not instantiate WaitlistService directly. The facade is the supported entry point.
- To target a specific waitlist, chain
Waitlist::for($slugOrIdOrModel)->.... Without for(...), calls operate on the default waitlist (auto-created on first use via getDefault()).
Waitlist::for(...) mutates internal state on a singleton service. For long-running processes (queue workers, Octane), call for(...) for every operation rather than relying on a previous context call sticking around.
Waitlists vs. entries
- Create waitlists with
Waitlist::create(string $name, string $slug, ?string $description = null, bool $isActive = true). The slug is the canonical identifier — that's what for(...) and find(...) expect.
- Add entries via
Waitlist::for($slug)->add($name, $email, $metadata = []). Don't call WaitlistEntry::create([...]) directly when you want the verification flow to run — add() automatically triggers sendVerification() when waitlist.verification.enabled is true.
- The unique constraint on
waitlist_entries is ['waitlist_id', 'email'], not just email. The same person can join multiple waitlists.
Inviting
- Invite via
Waitlist::invite($entryOrId, $options = []). Do not call $entry->markAsInvited() by itself when you want notifications and an Invitation record — invite() creates the laravel-invite-only Invitation, links it via invitation_id, and marks the entry invited.
$options flows through to InviteOnly::invite(...). Common keys: 'invited_by' (Model or int — falls back to auth()->user()), 'role', 'metadata', 'expires_at'. Don't duplicate keys you've set in waitlist.invitable.metadata_mapper — the explicit $options win via array_merge.
- If
waitlist.verification.enabled is true and waitlist.verification.require_before_invite is true, calling invite() on an unverified entry throws UnverifiedEntryException. Catch it explicitly in user-facing flows; don't bury it under a generic \Throwable.
- The
WaitlistInvited notification is opt-in (waitlist.auto_send_invitation defaults to false). The invitation notification from laravel-invite-only is sent regardless. Enable auto_send_invitation only when you want a second waitlist-branded email on top.
Verification
- Trigger verification through
Waitlist::sendVerification($entry) — it generates a fresh token (generateVerificationToken() overwrites any existing one) and sends VerifyWaitlistEmail. Don't roll your own token generation; use the package's so the verify route keeps working.
- Confirm tokens via
Waitlist::verify($token). Returns the WaitlistEntry on success, null on unknown token. After verification the token is cleared (single-use).
- Customize the verification notification via
waitlist.verification.notification config; it receives the WaitlistEntry in its constructor. Read the token from $entry->verification_token and call route('waitlist.verify', ['token' => $entry->verification_token]).
- The package's verify route is mounted under
waitlist.routes.prefix (default waitlist) with waitlist.routes.middleware (default ['web']). To use your own controller, set waitlist.routes.enabled => false and call Waitlist::verify($token) from your action.
Status & checks
- Entry statuses are the string literals
pending, invited, rejected. Prefer $entry->isPending(), isInvited(), isRejected() over raw string comparisons.
- Verification state lives on
verified_at and verification_token. Check via isVerified() and isPendingVerification(); don't compare raw timestamps.
- To "block until verified" UI gating, use
isPendingVerification() (token set, not yet verified). isVerified() alone returns false for entries that never started verification — those two states are different.
Invitable wiring
- When the host app is inviting people to a specific entity (Team, Organization, Project), configure it once in
config/waitlist.php under invitable:
invitable.model — class string; the package calls ::first() on it. Use this only for single-tenant apps.
invitable.resolver — closure fn(WaitlistEntry $entry) => Model|null for the multi-tenant case. Pull the tenant ID from $entry->metadata or another column.
invitable.metadata_mapper — closure fn(WaitlistEntry $entry) => array to translate entry metadata into invitation metadata (e.g. ['role' => 'beta-tester']).
- Don't hard-code an invitable per call site. If different flows need different invitables, use the
resolver closure with a discriminator in metadata.
Don'ts
- Don't run lifecycle changes via direct
update() calls ($entry->update(['status' => 'invited'])). Use markAsInvited() / markAsRejected() / markAsVerified() so casts and side effects (timestamps, token clearing) stay consistent. Better still: drive everything through the facade.
- Don't edit the published migrations to add columns — write a follow-up migration in the host app. The package may add columns in future releases and will assume the published schema.
- Don't subclass
Waitlist or WaitlistEntry; both are final. Add behavior on the host-app side via event listeners on the underlying laravel-invite-only events, or by extending the service via a custom binding in your app's container.
Examples
Single waitlist (no config)
use OffloadProject\Waitlist\Facades\Waitlist;
$entry = Waitlist::add('John Doe', 'john@example.com', ['source' => 'landing-page']);
Waitlist::invite($entry, [
'invited_by' => auth()->user(),
'expires_at' => now()->addDays(14),
]);
Multiple waitlists
Waitlist::create('Beta Program', 'beta');
Waitlist::create('VIP Access', 'vip');
Waitlist::for('beta')->add('Jane Smith', 'jane@example.com');
Waitlist::for('vip')->add('Bob Wilson', 'bob@example.com');
$pendingBeta = Waitlist::for('beta')->getPending();
$vipCount = Waitlist::for('vip')->count();
Verification flow
'verification' => [
'enabled' => true,
'require_before_invite' => true,
'notification' => \OffloadProject\Waitlist\Notifications\VerifyWaitlistEmail::class,
],
use OffloadProject\Waitlist\Exceptions\UnverifiedEntryException;
use OffloadProject\Waitlist\Facades\Waitlist;
$entry = Waitlist::add('John Doe', 'john@example.com');
try {
Waitlist::invite($entry);
} catch (UnverifiedEntryException) {
return back()->withErrors(['email' => 'Please verify your email first.']);
}
Wiring an invitable model (team invitations)
'invitable' => [
'model' => null,
'resolver' => fn (\OffloadProject\Waitlist\Models\WaitlistEntry $entry) =>
\App\Models\Team::find($entry->metadata['team_id'] ?? null),
'metadata_mapper' => fn (\OffloadProject\Waitlist\Models\WaitlistEntry $entry) => [
'role' => $entry->metadata['role'] ?? 'member',
],
],
Then:
Waitlist::for('beta')->add('Jane', 'jane@example.com', [
'team_id' => $team->id,
'role' => 'admin',
]);
When you later call Waitlist::invite($entry), the resulting laravel-invite-only invitation is scoped to that team with role=admin.
Custom verification notification
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use OffloadProject\Waitlist\Models\WaitlistEntry;
class CustomVerifyWaitlistEmail extends Notification
{
public function __construct(public WaitlistEntry $entry) {}
public function via($notifiable): array
{
return ['mail'];
}
public function toMail($notifiable): MailMessage
{
$url = route('waitlist.verify', ['token' => $this->entry->verification_token]);
return (new MailMessage)
->subject('Confirm your spot on the waitlist')
->greeting("Hi {$this->entry->name}!")
->action('Verify Email', $url);
}
}
'verification' => [
'enabled' => true,
'notification' => \App\Notifications\CustomVerifyWaitlistEmail::class,
],
Disabling package routes (own controller)
'routes' => ['enabled' => false],
Route::get('/welcome/{token}', function (string $token) {
$entry = Waitlist::verify($token);
return $entry === null
? redirect('/')->withErrors(['token' => 'Invalid or expired link.'])
: redirect('/welcome')->with('entry', $entry);
})->name('waitlist.verify');
Anti-patterns
- ❌
WaitlistEntry::create([...]) for new sign-ups when verification should run. Use Waitlist::add(...) so the verification flow fires when enabled.
- ❌
$entry->update(['status' => 'invited']) instead of Waitlist::invite($entry). The direct update skips the laravel-invite-only invitation, the token, the notification, and the FK linkage.
- ❌ Catching
\Throwable or \Exception around Waitlist::invite(). Catch UnverifiedEntryException (and the invite-only typed exceptions) so each failure mode produces a tailored response.
- ❌ Toggling
waitlist.auto_send_invitation to true without also customizing waitlist.notification. By default both WaitlistInvited and the invite-only invitation notification will fire — two emails per invite.
- ❌ Subclassing
Waitlist or WaitlistEntry. Both are final; extend behavior via events on laravel-invite-only or a custom service binding.
- ❌ Hard-coding
'invited_by' => auth()->user() at every call site. Omit it and let WaitlistService fall back to auth()->user() automatically. Pass it explicitly only when you need a different actor (admin acting on behalf, console command, etc.).
- ❌ Editing files inside
vendor/offload-project/laravel-waitlist. All extension points are exposed via config/waitlist.php.
- ❌ Sharing one email between waitlists via a single global
email unique constraint. The package already supports a person on multiple waitlists — the constraint is ['waitlist_id', 'email']. Don't add app-level deduplication that fights this.
References