Laravel Waitlist
Use this skill when a Laravel application collects email addresses for a waitlist,
early access or launch notifications with taldres/laravel-waitlist.
Primary Goal
- apply the package's public API in the smallest correct way, preserving its technical privacy controls: consent records, double opt-in, encryption, retention
Workflow
1. Install
Requires PHP 8.3+ and Laravel 13.
composer require taldres/laravel-waitlist
php artisan waitlist:install # provider, config and migrations; registers the provider
php artisan migrate
waitlist:install publishes app/Providers/WaitlistServiceProvider.php
(waitlist-provider), config/waitlist.php (waitlist-config) and the migrations
(waitlist-migrations). Laravel's scheduler must run: the package schedules
waitlist:prune itself.
2. Define purposes, lists, fields and pages
Describe the waitlist in boot() of the published provider; config/waitlist.php
keeps only the technical settings.
// app/Providers/WaitlistServiceProvider.php
use Taldres\Waitlist\Definitions\ProjectDefinition;
use Taldres\Waitlist\Facades\Waitlist;
Waitlist::define(function (ProjectDefinition $project): void {
$project->purpose('waitlist', ['2026-10' => 'Email me when early access opens.']);
$project->purpose('newsletter', ['2026-10' => 'Also send me the monthly newsletter.']);
$project->list('default', purpose: 'waitlist')->optional('newsletter');
});
- Every list has exactly one required primary purpose; anything else is optional.
- Only defined lists accept signups;
$project->list('*', purpose: ...)covers any other list name.->doubleOptIn(false)turns double opt-in off for one list. - Read the environment through
config(), neverenv(): with a cached config,env()returnsnulloutside the config files. The callback runs when the project is first needed, and defining a project again replaces it. - Fields beyond the address, per project and list:
$project->fields(fn () => [...])for every list,->fields(...)on a list on top (field => validation rules; a closure when the rules are objects). The HTTP signup accepts only these undermetadataand refuses the rest with a422; without fields it refuses all metadata.add()trusts its caller: in your own controllers validate withWaitlist::for($list)->fields(). - Several products in one app: one
Waitlist::define('<key>', ...)per product with its own purposes, lists, fields and pages, and useWaitlist::project('<key>')->for($list). Withoutproject()the facade always means the default project, never all of them; an unknown project throwsUnknownProjectException. For projects kept in a database, bind your ownProjectCatalog(waitlist.catalog): it supplies lists, fields, wording versions and frontend URL patterns per project. Over HTTP, aProjectResolver(waitlist.project_resolver) picks the project of a signup, e.g. from a publishable key; never put that check intoroutes.middleware, which also guards the token links in mails. Checks for the forms only, such as CSRF or an origin check, go intoroutes.group_middleware.signup. - Frontend pages per project in
$project->urls(...):confirm,unsubscribe,manage(mail links,{token}replaced) andconfirmed,expired,invalid,unsubscribed,erased(where a browser lands after posting). - Rate limits per group in
routes.limiters:signup→waitlist(per IP),links→waitlist-links(per token). Tuneroutes.rate_limits, point a group at your ownRateLimiter::for()name (a name nothing defines is refused), or set it tonull. An own limiter decides the key (->by()), the limits, exemptions (Limit::none()), per-route rules ($request->routeIs()) and the response;rate_limitsdoes not apply to it. Never keylinksby IP alone: one-click unsubscribes share mail providers' IPs. A caller acting for a project (HasWaitlistProject) is a server: it is capped as a whole (rate_limits.caller_signup_per_minute) and limits its visitors itself, unless it forwards their address in the header named byauthentication.client_ip_header, which then counts per visitor too. - To change wording, add a new version key. Never edit an existing version's text.
- Several languages: a version holds
['en' => ..., 'de' => ...]. RenderWaitlist::purposes($list, locale: $locale)(orGET /waitlist/purposes?locale=) and post back{version, locale}as served; such a version needs the locale. - A form rendering its own copy of the text (CMS) adds
hash= sha256 of the shown text; a mismatch is a 422.WAITLIST_REQUIRE_WORDING_HASH=truerequires it for every consent newly recorded, not for keeping or withdrawing one. - Wording written in the frontend or CMS: set
waitlist.catalogtoStoredWordingCatalogand register it on deploy withphp artisan waitlist:wording wording.json(purpose => version => text; first--from-definitionsto carry over whatpurpose()holds), orWaitlist::registerWording($purpose, $version, $wording). Registered text is immutable: a change needs a new version. - Wording sent by the project's own servers:
$project->wordingFromCallers(); a signup posts{version, locale?, text}and a new version is registered with the server (registered_by, eventWordingRegistered). Other text for a known version is a 422; guests sending text get a 403 (gate actionRegisterWording).
3. Signup
Render the wording from the server and post back the versions shown:
use Taldres\Waitlist\Facades\Waitlist;
$wording = Waitlist::purposes('default'); // list<PurposeWording>: purpose, version, text, required, locale; hash()
$result = Waitlist::for('default')->add($email, ['waitlist' => '2026-10', 'newsletter' => '2026-10']);
Over HTTP (with WAITLIST_ROUTES_ENABLED=true): GET /waitlist/purposes?list=default,
then POST /waitlist with email, list, and purposes as {purpose: version}.
Fields beyond the address go under metadata, and only those the project or list
defines are accepted. While the routes are off every route answers 404, before
any rate limit or session; an empty WAITLIST_ROUTES_ENABLED= is a config error,
false turns them off.
4. Mails come from your listeners
The package never sends mail. Listen to EntrySubscribed for the confirmation mail:
use Taldres\Waitlist\Events\EntrySubscribed;
public function handle(EntrySubscribed $event): void
{
if (! $event->requiresConfirmation) {
return;
}
Mail::to($event->entry->email)->send(new ConfirmWaitlist(
confirmUrl: $event->confirmUrl,
purposes: $event->subscription->consents,
unsubscribeUrl: $event->unsubscribeUrl,
));
// Record the mail reference alongside the consent wording.
Waitlist::confirmationMailed($event->subscription, 'confirm-mail@2026-10');
}
$event->confirmUrlcomes from theconfirmpage of the project'surls()or, withWAITLIST_ROUTES_ENABLED=true, the package route. With neither it isnull: set one before the first signup, or the mail goes out without a link. A link that cannot be built (the package routes are on but not registered, as after a stale route cache) throwsInvalidConfigurationExceptionand undoes the signup or confirmation: nothing is committed and no event fires.- Every later mail carries a link that withdraws exactly its purpose:
Waitlist::unsubscribeUrl($entry, 'newsletter')andWaitlist::listUnsubscribeHeaders($entry, 'newsletter'). - Queued listeners implement
ShouldBeEncrypted: payloads hold tokens and, forEntryForgotten, the address in plain text.
5. Before sending, check consent
Send to recipients(): only addresses with the purpose in force, once each, with
the right links (unsubscribeUrl(), listUnsubscribeHeaders()) and the consent's
locale.
Waitlist::recipients('newsletter')->each(fn (Recipient $recipient) => ...); // project-wide; links withdraw the purpose (a primary purpose: that list only)
Waitlist::for('beta')->recipients()->each(...); // one list; links leave it
$entry->hasConsentFor('newsletter');
6. Preference page and rights
- Mails carry only the unsubscribe token, which can only remove. The preference
page needs a short-lived manage token, mailed on request: listen to
ManageLinkRequestedand send$event->manageUrlto$event->entry->email. Requests:POST /waitlist/manage-linkwithtoken(the unsubscribe token) oremail,Waitlist::requestManageLink($token),Waitlist::for($list)->requestManageLink($email). - With routes on, the page uses
GET /waitlist/manage/{token},PUT .../purposes,POST .../data,POST .../unsubscribe, andPOST .../erasewith{"confirm": true};410once the link expired. - On request:
Waitlist::allProjects()->personalData($email),Waitlist::allProjects()->forget($email)(every project; one controller),Waitlist::for($list)->withdraw($email, $purpose), andWaitlist::for($list)->forgetAll()once the list's purpose is fulfilled.
Rules, References, and Templates
- Events:
EntrySubscribed,EntryConfirmed,ConsentGranted,ConsentWithdrawn,EntryUnsubscribed,SubscriptionExpired,EntryForgotten,ManageLinkRequested; all dispatch after commit. - Config is read where it is used and refused when it does not read, with an
InvalidConfigurationExceptionthat names the key..envtakestrue/false,on/off,yes/no,1/0and whole numbers (05is 5); anything else is refused, an emptyKEY=line included: delete the line to keep the default.nullsays never or off (double_opt_in.token_ttl, retention periods, cooldowns, caps, a limiter,retention.schedule); a manage link always expires. A confirm or manage link may not end after 2038-01-19, and a retention period or cooldown may not reach back before 1970, so a 36500-day "forever" is refused: writenull. A cron expression that can never run and a routeprefixwith{}are refused too. - Leaving, withdrawing, confirming an issued link, erasing and pruning keep working when a setting they only pass by does not read (privacy, guards or client IP header on token links, the email normalizer sweep over other lists, the catalog behind a redirect, link rate limits, which fall back to 10 and 600): the mistake is reported to the logs instead. The signup, the gate and the reports stay strict.
- Personal data is always encrypted with Laravel's
encryptedcasts. When rotatingAPP_KEY, keep the old key inAPP_PREVIOUS_KEYS, then runphp artisan waitlist:rekeybefore retiring it. - The package's models follow Laravel's encrypter,
Model::encryptUsing()included. A key for the package only goes in a service provider'sboot():Waitlist::encryptUsing($encrypter)with anyIlluminate\Contracts\Encryption\Encrypter. Its keys also make the lookup hash, so they must stay stable. - Supported extension points: your own models, the contracts with their config keys
(
catalog,url_generator,project_resolver,email_normalizer,spam_protector), container bindings, theuseWaitlistgate, events and macros. A binding names a class that implements the contract, or an interface or abstract class your app binds; the contract itself and a class the container cannot build are refused. Other public classes (actions,SubscriptionLifecycle, controllers, the default implementations) are open but promise nothing, and a subclass only takes effect where the package resolves the class from the container or the config, not where it creates it withnew. Guide: https://github.com/Taldres/laravel-waitlist/blob/main/docs/extending.md php artisan waitlist:privacyprints the facts for the record of processing.- The operator is responsible for lawful processing, valid consent, notices, justified retention, infrastructure security and provider agreements. The package offers no legal advice, certification or GDPR compliance warranty; refer to https://github.com/Taldres/laravel-waitlist/blob/main/docs/responsibility.md and the MIT License, subject to mandatory law.
- Retention defaults are not legal recommendations. Confirmed active entries and remaining reporting rows do not expire automatically. Review backups, queues, logs, exports and providers separately; remaining activity is not guaranteed anonymous.
waitlist:privacyis an incomplete technical inventory, not a complete Art. 30 record or compliance check. The package ships no application legal texts.
Related skills
This skill covers the integration as a whole. For a focused task, load:
laravel-waitlist-frontend: signup form, confirm, unsubscribe and preference pages, CORS, bot checkslaravel-waitlist-mail: confirmation, preference link, welcome, launch and newsletter mailslaravel-waitlist-provider-sync: Brevo, Mailchimp, Mailcoach in step, both wayslaravel-waitlist-projects: several products in one app, resolvers, a central APIlaravel-waitlist-reporting: daily series, totals, the confirmed count over timelaravel-waitlist-launch: invitations in batches, launch mail, erasing the list afterwardslaravel-waitlist-privacy-operations: access and erasure requests, retention, key rotation, go-live checklaravel-waitlist-testing: tokens from events, mail assertions, HTTP route tests
Examples
- "Add a waitlist to the landing page": define a list with its primary purpose,
render
Waitlist::purposes()in the form, calladd()with the versions shown, and send the confirmation mail from anEntrySubscribedlistener. - "Sync confirmed signups to Brevo": listen to
EntryConfirmed,ConsentGranted,ConsentWithdrawn,EntryUnsubscribedandEntryForgotten, and use$entry->purposesto pick provider lists. - Testing:
Event::fake([EntrySubscribed::class]), subscribe, thenEvent::assertDispatched(EntrySubscribed::class).
Anti-patterns
- Writing to
waitlist_subscriptions,waitlist_consentsorwaitlist_activitydirectly. - Posting consent text from the client, or bundling two purposes into one checkbox.
- Mailing for a purpose without
recipients(),whereConsentedTo()orhasConsentFor(). - Changing state on GET or HEAD; confirm, unsubscribe, export and erase are POST.
- Querying
emailin SQL; addresses are encrypted, useforEmail(). - Putting a manage link (
Waitlist::manageLink()) into ordinary mails; they carry the unsubscribe link, and the manage link is mailed only on request.