Imported from offload-project/laravel-waitlist (
skills/SKILL.md). Install upstream withnpx skills add offload-project/laravel-waitlist --skill skills. Copyright stays with the author.
Context
offload-project/laravel-waitlist is a Laravel 11/12/13 package (PHP 8.3+) for managing one or many waitlists. It ships:
- A
WaitlistEloquent model (a named waitlist with aslug) and aWaitlistEntrymodel (a person waiting on a list). - A
WaitlistService(resolved via theWaitlistfacade) withfor(),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: callingWaitlist::invite()creates a realInvitation(token, expiration, events) and persists the FK onWaitlistEntry::$invitation_id. - Two notifications:
WaitlistInvited(opt-in viaauto_send_invitation) andVerifyWaitlistEmail. - Lifecycle events in
OffloadProject\Waitlist\Events:WaitlistCreated,WaitlistEntryAdded,WaitlistEntryVerified,WaitlistEntryInvited,WaitlistEntryRejected, plusWaitlistEntrySubscribed,WaitlistEntryUnsubscribed, andMailingListSyncFailed. - A mailing list integration (
MailingListfacade,MailingListManager) that syncs entries to Mailchimp, Kit (ConvertKit) or Audienceful in a queued job, withlogandarraydrivers and anextend()hook for others. - Typed exceptions:
UnverifiedEntryException(invite attempted on an unverified entry while verification gating is on) andMailingListException(driver/credential/list-id/API failures).
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 or MailingList facades, or waitlist flows in this package.
Rules
Facade usage
- Use the
Waitlistfacade (OffloadProject\Waitlist\Facades\Waitlist) — do not instantiateWaitlistServicedirectly. The facade is the supported entry point. - To target a specific waitlist, chain
Waitlist::for($slugOrIdOrModel)->.... Withoutfor(...), calls operate on the default waitlist (auto-created on first use viagetDefault()). Waitlist::for(...)mutates internal state on a singleton service. For long-running processes (queue workers, Octane), callfor(...)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). Theslugis the canonical identifier — that's whatfor(...)andfind(...)expect. - Add entries via
Waitlist::for($slug)->add($name, $email, $metadata = []). Don't callWaitlistEntry::create([...])directly when you want the verification flow to run —add()automatically triggerssendVerification()whenwaitlist.verification.enabledis true. - The unique constraint on
waitlist_entriesis['waitlist_id', 'email'], not justemail. 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 anInvitationrecord —invite()creates thelaravel-invite-onlyInvitation, links it viainvitation_id, and marks the entry invited. $optionsflows through toInviteOnly::invite(...). Common keys:'invited_by'(Model or int — falls back toauth()->user()),'role','metadata','expires_at'. Don't duplicate keys you've set inwaitlist.invitable.metadata_mapper— the explicit$optionswin viaarray_merge.- If
waitlist.verification.enabledis true andwaitlist.verification.require_before_inviteis true, callinginvite()on an unverified entry throwsUnverifiedEntryException. Catch it explicitly in user-facing flows; don't bury it under a generic\Throwable. - The
WaitlistInvitednotification is opt-in (waitlist.auto_send_invitationdefaults tofalse). The invitation notification fromlaravel-invite-onlyis sent regardless. Enableauto_send_invitationonly 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 sendsVerifyWaitlistEmail. Don't roll your own token generation; use the package's so the verify route keeps working. - Confirm tokens via
Waitlist::verify($token). Returns theWaitlistEntryon success,nullon unknown token. After verification the token is cleared (single-use). - Customize the verification notification via
waitlist.verification.notificationconfig; it receives theWaitlistEntryin its constructor. Read the token from$entry->verification_tokenand callroute('waitlist.verify', ['token' => $entry->verification_token]). - The package's verify route is mounted under
waitlist.routes.prefix(defaultwaitlist) withwaitlist.routes.middleware(default['web']). To use your own controller, setwaitlist.routes.enabled => falseand callWaitlist::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_atandverification_token. Check viaisVerified()andisPendingVerification(); don't compare raw timestamps. - To "block until verified" UI gating, use
isPendingVerification()(token set, not yet verified).isVerified()alone returnsfalsefor 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.phpunderinvitable:invitable.model— class string; the package calls::first()on it. Use this only for single-tenant apps.invitable.resolver— closurefn(WaitlistEntry $entry) => Model|nullfor the multi-tenant case. Pull the tenant ID from$entry->metadataor another column.invitable.metadata_mapper— closurefn(WaitlistEntry $entry) => arrayto 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
resolverclosure with a discriminator inmetadata.
Events
- Hook into the lifecycle with the package's own events rather than wrapping the facade or polling the table. They live in
OffloadProject\Waitlist\Eventsand each carries the model as a readonly property ($event->entry,$event->waitlist). - The lifecycle events fire from the models (
WaitlistEntryAddedvia$dispatchesEventson create; the rest frommarkAsInvited()/markAsRejected()/markAsVerified()), so listeners still run for code that bypasses the facade. - Use
Event::fake([SpecificEvent::class])in tests, not a bareEvent::fake()— a blanket fake also swallowsWaitlistEntryAdded, which stops the mailing list listener from ever running.
Mailing list sync
- Turn it on with
waitlist.mailing_list.enabledand pick a driver (mailchimp,kit,audienceful,log,array). Connect a waitlist to a list withWaitlist::for($slug)->connectMailingList($listId, $driver = null)— the list id is a Mailchimp audience id, a Kit form id (or tag id whenlist_typeistag), or an Audienceful publication id (or tag name whenlist_typeistag). Waitlists with no list of their own fall back to the driver's configuredlist_id. - Don't build your own "subscribe on sign up" listener. Subscribing is automatic and follows the verification setting: with
waitlist.verification.enabledoff the entry syncs onadd(), with it on the entry syncs afterWaitlist::verify(). That ordering is deliberate — an unconfirmed address must never reach the newsletter. - For anything beyond subscribing — tagging on invite, removing on reject, moving between lists — listen for the lifecycle events and call
MailingList::tagEntry($entry, [...])orWaitlist::unsubscribeFromMailingList($entry). Don't add those side effects inside the host app's controllers. - Syncing runs through queued jobs (
SyncEntryToMailingList,UnsubscribeEntryFromMailingList). Keepmailing_list.queue.enabledon in production so sign ups never block on the provider's API. Backfill existing rows withphp artisan waitlist:sync-mailing-list [slug] [--all] [--force]orWaitlist::for($slug)->syncMailingList(). - Add a service the package doesn't ship by implementing
OffloadProject\Waitlist\Contracts\MailingListDriverand registering it withMailingList::extend('name', fn (array $config) => new YourDriver(...))from a service provider. Don't fork the shipped drivers. - In tests use
MailingList::fake(), which swaps in the in-memoryArrayDriver, runs syncs inline, and exposeshasSubscriber(),subscribers(), andtagsFor(). Reach forHttp::fake()only when asserting the exact request a real driver sends.
Don'ts
- Don't run lifecycle changes via direct
update()calls ($entry->update(['status' => 'invited'])). UsemarkAsInvited()/markAsRejected()/markAsVerified()so casts, side effects (timestamps, token clearing), and events 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
WaitlistorWaitlistEntry; both arefinal. Add behavior on the host-app side via listeners on the package's 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
// config/waitlist.php
'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');
// Verification email sent automatically.
try {
Waitlist::invite($entry);
} catch (UnverifiedEntryException) {
return back()->withErrors(['email' => 'Please verify your email first.']);
}
Wiring an invitable model (team invitations)
// config/waitlist.php
'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);
}
}
// config/waitlist.php
'verification' => [
'enabled' => true,
'notification' => \App\Notifications\CustomVerifyWaitlistEmail::class,
],
Syncing sign-ups to Mailchimp
// config/waitlist.php
'mailing_list' => [
'enabled' => true,
'default' => 'mailchimp',
'drivers' => [
'mailchimp' => [
'key' => env('MAILCHIMP_API_KEY'), // suffix carries the data centre, e.g. -us14
'list_id' => env('MAILCHIMP_LIST_ID'),
],
],
],
// One audience per waitlist (optional — otherwise the config list_id is used).
Waitlist::for('beta')->connectMailingList('a1b2c3d4e5');
// Subscribed automatically, on add or on verification depending on the config.
Waitlist::for('beta')->add('Jane Smith', 'jane@example.com');
Reacting to the lifecycle
use Illuminate\Support\Facades\Event;
use OffloadProject\Waitlist\Events\WaitlistEntryInvited;
use OffloadProject\Waitlist\Events\WaitlistEntryRejected;
use OffloadProject\Waitlist\Facades\MailingList;
use OffloadProject\Waitlist\Facades\Waitlist;
Event::listen(fn (WaitlistEntryInvited $event) => MailingList::tagEntry($event->entry, ['invited']));
Event::listen(fn (WaitlistEntryRejected $event) => Waitlist::unsubscribeFromMailingList($event->entry));
Testing a mailing list flow
use OffloadProject\Waitlist\Facades\MailingList;
$mailingList = MailingList::fake();
Waitlist::add('John Doe', 'john@example.com');
expect($mailingList->hasSubscriber('john@example.com'))->toBeTrue();
Disabling package routes (own controller)
// config/waitlist.php
'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. UseWaitlist::add(...)so the verification flow fires when enabled. - ❌
$entry->update(['status' => 'invited'])instead ofWaitlist::invite($entry). The direct update skips thelaravel-invite-onlyinvitation, the token, the notification, and the FK linkage. - ❌ Catching
\Throwableor\ExceptionaroundWaitlist::invite(). CatchUnverifiedEntryException(and the invite-only typed exceptions) so each failure mode produces a tailored response. - ❌ Toggling
waitlist.auto_send_invitationtotruewithout also customizingwaitlist.notification. By default bothWaitlistInvitedand the invite-only invitation notification will fire — two emails per invite. - ❌ Subclassing
WaitlistorWaitlistEntry. Both arefinal; extend behavior via the package's events or a custom service binding. - ❌ Calling a mailing list API from a controller after
Waitlist::add(...). Subscribing is already automatic — a manual call double-subscribes and skips the queue. - ❌ Subscribing unverified entries when verification is on (e.g. by listening for
WaitlistEntryAddedyourself). Listen forWaitlistEntryVerifiedinstead, or just let the package do it. - ❌ Writing a mailing list
list_idintowaitlist_entries.metadata. Lists belong to the waitlist — store them withconnectMailingList(), which lives in thewaitlists.settingscolumn. - ❌ Putting a closure in
mailing_list.attributesand then runningphp artisan config:cache. Config files containing closures cannot be cached. - ❌ Hard-coding
'invited_by' => auth()->user()at every call site. Omit it and letWaitlistServicefall back toauth()->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 viaconfig/waitlist.php. - ❌ Sharing one email between waitlists via a single global
emailunique 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
- Repository: https://github.com/offload-project/laravel-waitlist
- README: https://github.com/offload-project/laravel-waitlist/blob/main/README.md
- Companion package — Laravel Invite Only: https://github.com/offload-project/laravel-invite-only