Integrate taldres/laravel-waitlist in a Laravel application: define purposes, lists and fields, build the signup form, send the confirmation mail from events, add a preference page, and keep consent, retention and encryption intact.
php artisan boost:add-skill taldres/laravel-waitlist
Save this content to: .claude/skills/laravel-waitlist-development/SKILL.md
# 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.
```bash
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`). For several projects, `waitlist:install --projects` writes
a provider with named projects instead; bind a `ProjectResolver` before the
package routes take signups, they answer `422` until then. An existing provider
is never overwritten. With double opt-in, `waitlist:install --mail` also writes the
confirmation mailable, its view and a queued `EntrySubscribed` listener into the app
(existing files are left alone). Schedule `waitlist:prune` yourself,
`Schedule::command('waitlist:prune')->daily()` in `routes/console.php`, and keep
Laravel's scheduler running.
### 2. Define purposes, lists, fields and pages
Describe the waitlist in `boot()` of the published provider; `config/waitlist.php`
keeps only the technical settings.
```php
// 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.
`->publicCount()` and `->publicPosition()` publish the list's rounded count and a
person's place in line (by unsubscribe token) over HTTP; both are off by default
and stay off on a list without double opt-in.
- Read the environment through `config()`, never `env()`: with a cached config,
`env()` returns `null` outside 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 under
`metadata` and refuses the rest with a `422`; without fields it refuses all
metadata. `add()` trusts its caller: in your own controllers validate with
`Waitlist::for($list)->fields()`.
- Several products in one app: one `Waitlist::define('<key>', ...)` per product with
its own purposes, lists, fields and pages, and use
`Waitlist::project('<key>')->for($list)`. Without `project()` the facade always
means the default project, never all of them; an unknown project throws
`UnknownProjectException`.
For projects kept in a database, bind your own `ProjectCatalog` in a provider's
`register()`: it supplies lists, fields, wording versions and frontend URL
patterns per project. Over HTTP, the bound `ProjectResolver` picks the project of
a signup, e.g. `PublishableKeyResolver` from a publishable key, which is public
and authorizes nothing; never put that check into
`routes.middleware`, which also guards the token links in mails. Checks for
the forms only, such as CSRF or an origin check, go into
`routes.group_middleware.signup`.
- Frontend pages per project in `$project->urls(...)`: `confirm`, `unsubscribe`,
`manage` (mail links, `{token}` replaced) and `confirmed`, `expired`, `invalid`,
`unsubscribed`, `erased` (where a browser lands after posting).
- Rate limits per group, each through a fixed limiter name: `signup` → `waitlist`
(per IP), `links` → `waitlist-links` (per token). Tune `routes.rate_limits`, or
replace a group's limiter by defining that name with `RateLimiter::for()` in a
service provider; returning `Limit::none()` from it turns the group's limit off.
No setting turns a limit off. An own limiter decides the key (`->by()`), the
limits, exemptions (`Limit::none()`), per-route rules (`$request->routeIs()`) and
the response; `rate_limits` does not apply to it.
Never key `links` by 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 by
`authentication.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' => ...]`. Render
`Waitlist::purposes($list, locale: $locale)` (or `GET /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=true` requires it
for every consent newly recorded, not for keeping or withdrawing one.
- Wording written in the frontend or CMS: bind `ProjectCatalog` to
`StoredWordingCatalog` and register it on deploy with
`php artisan waitlist:wording wording.json` (purpose => version => text; first
`--from-definitions` to carry over what `purpose()` holds), or
`Waitlist::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`, event `WordingRegistered`). Other text for a known
version is a 422; guests sending text get a 403 (gate action `RegisterWording`).
### 3. Signup
Render the wording from the server and post back the versions shown:
```php
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 none is registered, so every
URL under the prefix is `404`; 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:
```php
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, 'confirm-mail@2026-10');
}
```
- `$event->confirmUrl` comes from the `confirm` page of the project's `urls()` or,
with `WAITLIST_ROUTES_ENABLED=true`, the package route. With neither it is
`null`: 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) throws `InvalidConfigurationException` and 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')` and
`Waitlist::listUnsubscribeHeaders($entry, 'newsletter')`.
- Queued listeners implement `ShouldBeEncrypted`: payloads hold tokens and, for
`EntryForgotten`, 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`.
```php
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 (and read the
place in line where a list publishes it). The preference
page needs a short-lived manage token, mailed on request: listen to
`ManageLinkRequested` and send `$event->manageUrl` to `$event->entry->email`.
Requests: `POST /waitlist/unsubscribe/{token}/manage-link` (the unsubscribe
token) or `POST /waitlist/manage-link` with `email`, `Waitlist::requestManageLink($token)`,
`Waitlist::for($list)->requestManageLink($email)`.
- With routes on, the page uses `GET /waitlist/manage/{token}`, `PUT .../purposes`,
`POST .../data`, `POST .../unsubscribe`, and `POST .../erase` with
`{"confirm": true}`; `410` once the link expired.
- On request: `Waitlist::allProjects()->personalData($email)`,
`Waitlist::allProjects()->forget($email)` (every project; one controller),
`Waitlist::for($list)->withdraw($email, $purpose)`, and
`Waitlist::for($list)->forgetAll()` once the list's purpose is fulfilled.
## Rules, References, and Templates
- Events: `EntrySubscribed`, `EntryConfirmed`, `EntryImported`, `ConsentGranted`,
`ConsentWithdrawn`, `EntryUnsubscribed`, `SubscriptionExpired`, `ReconfirmationRequested`,
`SubscriptionLapsed`, `EntryForgotten`, `ManageLinkRequested`; all dispatch after commit.
- Config is read where it is used and refused when it does not read, with an
`InvalidConfigurationException` that names the key. `.env` takes `true`/`false`,
`on`/`off`, `yes`/`no`, `1`/`0` and whole numbers (`05` is 5); anything else is
refused, an empty `KEY=` line included: delete the line to keep the default.
`null` says never or off (`double_opt_in.token_ttl`, retention periods, cooldowns,
caps); 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: write `null`. A
cron expression that can never run and a route `prefix` with `{}` 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 `encrypted` casts. When
rotating `APP_KEY`, keep the old key in `APP_PREVIOUS_KEYS`, then run
`php artisan waitlist:rekey` before retiring it.
- The package's models follow Laravel's encrypter, `Model::encryptUsing()`
included. A key for the package only goes in a service provider's `boot()`:
`Waitlist::encryptUsing($encrypter)` with any `Illuminate\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 container
bindings (`ProjectCatalog`, `LinkGenerator`, `ProjectResolver`,
`EmailNormalizer`, `SpamProtector`), the `useWaitlist` gate, events and macros.
Bind a contract with `$this->app->bind()` in a provider's `register()`: the
package binds its defaults with `bindIf()` first, so a `bindIf()` of yours is
ignored. Remove `catalog`, `url_generator`, `project_resolver`,
`email_normalizer` and `spam_protector` from a published config: one that names
a class other than the default makes resolving its contract throw until the
contract is bound, and `waitlist:check` fail until it is removed. 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 with `new`.
Guide: https://github.com/Taldres/laravel-waitlist/blob/main/docs/extending.md
- `php artisan waitlist:privacy` prints 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:privacy` is 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 checks
- `laravel-waitlist-mail`: confirmation, preference link, welcome, launch and newsletter mails
- `laravel-waitlist-provider-sync`: Brevo, Mailchimp, Mailcoach in step, both ways
- `laravel-waitlist-projects`: several products in one app, resolvers, a central API
- `laravel-waitlist-reporting`: daily series, totals, the confirmed count over time
- `laravel-waitlist-launch`: invitations in batches, launch mail, erasing the list afterwards
- `laravel-waitlist-privacy-operations`: access and erasure requests, retention, key rotation, go-live check
- `laravel-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, call `add()` with the versions shown,
and send the confirmation mail from an `EntrySubscribed` listener.
- "Sync confirmed signups to Brevo": listen to `EntryConfirmed`, `ConsentGranted`,
`ConsentWithdrawn`, `EntryUnsubscribed` and `EntryForgotten`, and use
`$entry->purposes` to pick provider lists.
- Testing: `Event::fake([EntrySubscribed::class])`, subscribe, then
`Event::assertDispatched(EntrySubscribed::class)`.
## Anti-patterns
- Writing to `waitlist_subscriptions`, `waitlist_consents` or `waitlist_activity` directly.
- Posting consent text from the client, or bundling two purposes into one checkbox.
- Mailing for a purpose without `recipients()`, `whereConsentedTo()` or `hasConsentFor()`.
- Changing state on GET or HEAD; confirm, unsubscribe, export and erase are POST.
- Querying `email` in SQL; addresses are encrypted, use `forEmail()`.
- Putting a manage link (`Waitlist::manageLink()`) into ordinary mails; they carry the
unsubscribe link, and the manage link is mailed only on request.
Use this skill when a Laravel application collects email addresses for a waitlist,
early access or launch notifications with taldres/laravel-waitlist.
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). For several projects, waitlist:install --projects writes
a provider with named projects instead; bind a ProjectResolver before the
package routes take signups, they answer 422 until then. An existing provider
is never overwritten. With double opt-in, waitlist:install --mail also writes the
confirmation mailable, its view and a queued EntrySubscribed listener into the app
(existing files are left alone). Schedule waitlist:prune yourself,
Schedule::command('waitlist:prune')->daily() in routes/console.php, and keep
Laravel's scheduler running.
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');
});
$project->list('*', purpose: ...) covers any
other list name. ->doubleOptIn(false) turns double opt-in off for one list.
->publicCount() and ->publicPosition() publish the list's rounded count and a
person's place in line (by unsubscribe token) over HTTP; both are off by default
and stay off on a list without double opt-in.config(), never env(): with a cached config,
env() returns null outside the config files. The callback runs when the
project is first needed, and defining a project again replaces it.$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 under
metadata and refuses the rest with a 422; without fields it refuses all
metadata. add() trusts its caller: in your own controllers validate with
Waitlist::for($list)->fields().Waitlist::define('<key>', ...) per product with
its own purposes, lists, fields and pages, and use
Waitlist::project('<key>')->for($list). Without project() the facade always
means the default project, never all of them; an unknown project throws
UnknownProjectException.
For projects kept in a database, bind your own ProjectCatalog in a provider's
register(): it supplies lists, fields, wording versions and frontend URL
patterns per project. Over HTTP, the bound ProjectResolver picks the project of
a signup, e.g. PublishableKeyResolver from a publishable key, which is public
and authorizes nothing; never put that check into
routes.middleware, which also guards the token links in mails. Checks for
the forms only, such as CSRF or an origin check, go into
routes.group_middleware.signup.$project->urls(...): confirm, unsubscribe,
manage (mail links, {token} replaced) and confirmed, expired, invalid,
unsubscribed, erased (where a browser lands after posting).signup → waitlist
(per IP), links → waitlist-links (per token). Tune routes.rate_limits, or
replace a group's limiter by defining that name with RateLimiter::for() in a
service provider; returning Limit::none() from it turns the group's limit off.
No setting turns a limit off. An own limiter decides the key (->by()), the
limits, exemptions (Limit::none()), per-route rules ($request->routeIs()) and
the response; rate_limits does not apply to it.
Never key links by 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 by
authentication.client_ip_header, which then counts per visitor too.['en' => ..., 'de' => ...]. Render
Waitlist::purposes($list, locale: $locale) (or GET /waitlist/purposes?locale=)
and post back {version, locale} as served; such a version needs the locale.hash = sha256 of the shown
text; a mismatch is a 422. WAITLIST_REQUIRE_WORDING_HASH=true requires it
for every consent newly recorded, not for keeping or withdrawing one.ProjectCatalog to
StoredWordingCatalog and register it on deploy with
php artisan waitlist:wording wording.json (purpose => version => text; first
--from-definitions to carry over what purpose() holds), or
Waitlist::registerWording($purpose, $version, $wording). Registered text is
immutable: a change needs a new version.$project->wordingFromCallers(); a
signup posts {version, locale?, text} and a new version is registered with the
server (registered_by, event WordingRegistered). Other text for a known
version is a 422; guests sending text get a 403 (gate action RegisterWording).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 none is registered, so every
URL under the prefix is 404; an empty WAITLIST_ROUTES_ENABLED= is a config error,
false turns them off.
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, 'confirm-mail@2026-10');
}
$event->confirmUrl comes from the confirm page of the project's urls() or,
with WAITLIST_ROUTES_ENABLED=true, the package route. With neither it is
null: 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) throws InvalidConfigurationException and undoes the
signup or confirmation: nothing is committed and no event fires.Waitlist::unsubscribeUrl($entry, 'newsletter') and
Waitlist::listUnsubscribeHeaders($entry, 'newsletter').ShouldBeEncrypted: payloads hold tokens and, for
EntryForgotten, the address in plain text.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');
ManageLinkRequested and send $event->manageUrl to $event->entry->email.
Requests: POST /waitlist/unsubscribe/{token}/manage-link (the unsubscribe
token) or POST /waitlist/manage-link with email, Waitlist::requestManageLink($token),
Waitlist::for($list)->requestManageLink($email).GET /waitlist/manage/{token}, PUT .../purposes,
POST .../data, POST .../unsubscribe, and POST .../erase with
{"confirm": true}; 410 once the link expired.Waitlist::allProjects()->personalData($email),
Waitlist::allProjects()->forget($email) (every project; one controller),
Waitlist::for($list)->withdraw($email, $purpose), and
Waitlist::for($list)->forgetAll() once the list's purpose is fulfilled.EntrySubscribed, EntryConfirmed, EntryImported, ConsentGranted,
ConsentWithdrawn, EntryUnsubscribed, SubscriptionExpired, ReconfirmationRequested,
SubscriptionLapsed, EntryForgotten, ManageLinkRequested; all dispatch after commit.InvalidConfigurationException that names the key. .env takes true/false,
on/off, yes/no, 1/0 and whole numbers (05 is 5); anything else is
refused, an empty KEY= line included: delete the line to keep the default.
null says never or off (double_opt_in.token_ttl, retention periods, cooldowns,
caps); 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: write null. A
cron expression that can never run and a route prefix with {} are refused too.encrypted casts. When
rotating APP_KEY, keep the old key in APP_PREVIOUS_KEYS, then run
php artisan waitlist:rekey before retiring it.Model::encryptUsing()
included. A key for the package only goes in a service provider's boot():
Waitlist::encryptUsing($encrypter) with any Illuminate\Contracts\Encryption\Encrypter.
Its keys also make the lookup hash, so they must stay stable.ProjectCatalog, LinkGenerator, ProjectResolver,
EmailNormalizer, SpamProtector), the useWaitlist gate, events and macros.
Bind a contract with $this->app->bind() in a provider's register(): the
package binds its defaults with bindIf() first, so a bindIf() of yours is
ignored. Remove catalog, url_generator, project_resolver,
email_normalizer and spam_protector from a published config: one that names
a class other than the default makes resolving its contract throw until the
contract is bound, and waitlist:check fail until it is removed. 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 with new.
Guide: https://github.com/Taldres/laravel-waitlist/blob/main/docs/extending.mdphp artisan waitlist:privacy prints the facts for the record of processing.waitlist:privacy is an incomplete technical inventory, not a complete Art. 30
record or compliance check. The package ships no application legal texts.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 testsWaitlist::purposes() in the form, call add() with the versions shown,
and send the confirmation mail from an EntrySubscribed listener.EntryConfirmed, ConsentGranted,
ConsentWithdrawn, EntryUnsubscribed and EntryForgotten, and use
$entry->purposes to pick provider lists.Event::fake([EntrySubscribed::class]), subscribe, then
Event::assertDispatched(EntrySubscribed::class).waitlist_subscriptions, waitlist_consents or waitlist_activity directly.recipients(), whereConsentedTo() or hasConsentFor().email in SQL; addresses are encrypted, use forEmail().Waitlist::manageLink()) into ordinary mails; they carry the
unsubscribe link, and the manage link is mailed only on request.How can I help you explore Laravel packages today?