Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message
laravel-waitlist-development
laravel-waitlist-development
taldres/laravel-waitlist
claude-code
waitlist
double-opt-in
consent
rate-limiting
localization
multi-project
wording-versions

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.

Install
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.

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). 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.

// 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:

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:

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.

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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox