Laravel Waitlist
SkillCommunicationConventions and APIs for the offload-project/laravel-waitlist package — multiple waitlists, entry status tracking, optional email verification, lifecycle events, mailing list sync (Mailchimp/Kit/Audienceful), and bridge into laravel-invite-only.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Laravel Waitlist skill
What this skill tells your AI
The instructions your AI receives, as published by offload-project/laravel-waitlist in skills/SKILL.md and read by ahel’s review.
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
Signals
- GitHub stars
- 57
- Forks
- 2
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
laravel-waitlist- Source
- github.com/offload-project/laravel-waitlist