Build the pages of a taldres/laravel-waitlist integration: the signup form with the registered wording, and the confirm, unsubscribe and preference pages, either in Laravel controllers (Blade, Livewire, Inertia) or in an SPA or static site over the package's JSON API, including CORS and bot protection.
php artisan boost:add-skill taldres/laravel-waitlist
Save this content to: .claude/skills/laravel-waitlist-frontend/SKILL.md
# Laravel Waitlist: frontend pages
Use this skill when building or changing the signup form, the confirmation page,
the unsubscribe page or the preference page for `taldres/laravel-waitlist`.
## Primary Goal
- forms show exactly the registered wording and post back what they showed; pages
behind mail links never change state on GET
## Workflow
### 1. Pick the architecture
- **Laravel renders the pages** (Blade, Livewire, Inertia): call the `Waitlist`
facade from controllers. The package routes can stay off, unless mails should
carry RFC 8058 one-click `List-Unsubscribe` headers, which need them; the pages
still render the UI either way.
- **SPA or static site without a Laravel backend for the pages**: set
`WAITLIST_ROUTES_ENABLED=true` and call the JSON API under `/waitlist`.
Point the mail links at the app's own pages either way, in the project's
definition (`app/Providers/WaitlistServiceProvider.php`). Read the environment
through `config()`, never `env()`:
```php
$project->urls(
confirm: 'https://example.com/waitlist/confirm/{token}',
unsubscribe: 'https://example.com/waitlist/leave/{token}',
manage: 'https://example.com/waitlist/preferences/{token}',
);
```
The same call takes `confirmed`, `expired`, `invalid`, `unsubscribed` and `erased`,
the pages a browser lands on after posting to the package routes.
### 2. Signup form
Render the wording from the package, never type it into the template:
```php
return view('waitlist.signup', [
'purposes' => Waitlist::purposes('default', locale: app()->getLocale()),
]);
```
```blade
@foreach ($purposes as $wording)
<input type="hidden" name="purposes[{{ $wording->purpose }}][version]" value="{{ $wording->version }}">
<input type="hidden" name="purposes[{{ $wording->purpose }}][locale]" value="{{ $wording->locale }}">
<input type="hidden" name="purposes[{{ $wording->purpose }}][hash]" value="{{ $wording->hash() }}">
@if ($wording->required)
<input type="hidden" name="agreed[]" value="{{ $wording->purpose }}">
<p>{{ $wording->text }}</p>
@else
<label><input type="checkbox" name="agreed[]" value="{{ $wording->purpose }}"> {{ $wording->text }}</label>
@endif
@endforeach
```
```php
use Illuminate\Support\Arr;
use Taldres\Waitlist\Exceptions\WaitlistException;
use Taldres\Waitlist\Http\Rules\PurposeChoice;
use Taldres\Waitlist\Support\RequestContext;
$validated = $request->validate([
'email' => ['required', 'email:filter', 'max:255'],
'purposes' => ['required', 'array'],
'purposes.*' => ['required', new PurposeChoice],
'agreed' => ['required', 'array'],
'agreed.*' => ['string'],
]);
try {
Waitlist::for('default')->add($validated['email'], Arr::only($validated['purposes'], $validated['agreed']), context: RequestContext::fromRequest($request));
} catch (WaitlistException $exception) {
return back()->withErrors(['purposes' => $exception->getMessage()]);
}
return view('waitlist.check-your-inbox'); // the same answer for new and known addresses
```
Over HTTP: `GET /waitlist/purposes?list=default&locale=de`, then
`POST /waitlist` with `email`, `list` and `purposes` as
`{purpose: {version, locale, hash}}`. Post back the `locale` the response served,
which may be a fallback. Answers: `202` for every address, `422` names the field.
Calling from your own server instead (Next.js Server Actions, a BFF with a token
per project): keep the token server-side and limit each visitor there, since the
API sees only your server. With `wordingFromCallers()` on the project the server
may post `{version, locale?, text}` with the exact text the form showed instead of
rendering it from the API.
More than the address goes under `metadata`, and only for fields the project or
list defines (`$project->fields(...)`, `->fields(...)` on a list); anything else is
a `422`. In your own controllers validate with `Waitlist::for('default')->fields()`.
### 3. Confirm page
The mail link opens the page; the page posts the token back.
```php
use Taldres\Waitlist\Exceptions\ExpiredTokenException;
use Taldres\Waitlist\Exceptions\InvalidTokenException;
try {
Waitlist::confirm($token);
} catch (ExpiredTokenException) {
return view('waitlist.expired');
} catch (InvalidTokenException) {
abort(404);
}
```
Over HTTP: `POST /waitlist/confirm/{token}` with `Accept: application/json`
answers `{"data": {..., "status": "confirmed"}}`, `404` unknown, `410` expired;
a page that first asks with `GET` must still `POST` when `data.answer_by` is set:
the address was asked to confirm again (`ReconfirmationRequested`) and only the
post keeps it on the list, although `status` already reads `confirmed` (the JS
client reads it as `entry.answerBy` since 0.2.0);
the package's own refusals name an `error`, e.g. `invalid_token`, and a `404`
without one means a wrong URL, routes that are off or a `useWaitlist` gate that
keeps the caller out.
### 4. Unsubscribe page
```php
// POST from the page; a link for one purpose carries ?purpose=
$purpose = $request->query('purpose');
is_string($purpose)
? Waitlist::withdrawConsent($token, $purpose) // only that purpose
: Waitlist::unsubscribe($token); // leaves the list
Waitlist::requestManageLink($token); // "manage preferences instead": mails a manage link
```
Both throw `InvalidTokenException` for an unknown token.
Over HTTP: `POST /waitlist/unsubscribe/{token}[?purpose=]` and
`POST /waitlist/unsubscribe/{token}/manage-link`. A project without a
preference page sets `$project->manageLinks(false)`: then offer no "manage
preferences" step, as the request answers `404` with `manage_links_disabled`.
### 5. Preference page
Behind the manage token from the mailed link (expires after
`waitlist.manage.token_ttl` minutes, 60 by default, at least 1, never unlimited):
`GET /waitlist/manage/{token}` (status, purposes in force),
`PUT .../purposes` with the complete wanted set (the primary purpose must stay),
`POST .../data` (JSON copy), `POST .../unsubscribe`, `POST .../erase` with
`{"confirm": true}`. On `410`, offer to send a new link. Ask for an explicit
confirmation before erasing. In PHP: `Waitlist::findByManageToken($token)`,
`Waitlist::grantConsent($token, $purpose, $version, $locale)`.
Shape the `purposes` of that `PUT` like the signup's. With
`WAITLIST_REQUIRE_WORDING_HASH=true`, only a purpose being added needs its `hash`;
keeping or dropping one does not.
### 5b. Count and place in line
Both are off until the list opts in, and only work on a list with double opt-in:
`$project->list('beta', purpose: 'waitlist')->publicCount()->publicPosition()`.
- `GET /waitlist/count?list=beta` answers `{"data": {"list", "count"}}`: the
confirmed addresses, rounded down to a multiple of 10, `null` below 10, cached
for 60 seconds. Show your own wording for `null` ("Be one of the first"), never
`0`. It runs through the resolver, the origins and the `useWaitlist` gate
(`WaitlistAction::ViewCount`) like the wording; a list that does not publish it
answers `404` with `count_disabled`.
- `GET /waitlist/unsubscribe/{token}/position` with the unsubscribe token answers
`{"data": {"project", "list", "status", "position"}}`: the place among the
confirmed addresses in the order they confirmed, `null` while pending or after
leaving, cached for a minute. Never by address. `404` with `position_disabled` where the list does
not publish positions, `invalid_token` for an unknown token.
- A server calling for its project is capped per server and endpoint
(`caller_signup_per_minute`), so a server-rendered page caches the count
rather than fetching it on every view.
- The JS client has `count({ list })` and `position(token)` since 0.2.0.
- In PHP, without the switches: `Waitlist::for('beta')->publicCount()` and
`Waitlist::for('beta')->position($entry)`.
### 6. Protect the public endpoints
- Optional bot check on the signup and manage links by address (the default
`NullSpamProtector` accepts every request), e.g. Cloudflare Turnstile:
```php
$this->app->bind(SpamProtector::class, fn () => new ClosureSpamProtector(
fn (Request $request): bool => Http::asForm()
->post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [
'secret' => config('services.turnstile.secret'),
'response' => (string) ($request->header('X-Waitlist-Challenge') ?? $request->input('turnstile_token')),
'remoteip' => $request->ip(),
])->json('success') === true,
));
```
It guards the package routes only; in own controllers, check it yourself.
Without an outside service, bind `Taldres\Waitlist\Support\TimeTrapSpamProtector`
as the `SpamProtector`: `GET /waitlist/purposes` then also answers a `challenge`,
which the form sends back in `X-Waitlist-Challenge` between 2 seconds and an
hour later (on `spam_check_failed`, fetch the wording again), and a hidden
`website` field must stay empty. Servers calling for their project are not
checked; token links never are. The JS client returns the challenge from
`purposes()` since 0.2.0 and sends it with `{ challenge }`; it has no `website`
field, so through it only the time is checked. A plain HTML form without
JavaScript cannot pass this check (header only). Do not let a CDN cache `/purposes`.
- Frontend on another origin: allow it in `config/cors.php` (`paths` with
`'waitlist', 'waitlist/*'`, the site in `allowed_origins`, `Retry-After` in
`exposed_headers`). `$project->origins(['https://site.example'])` in the definition
additionally refuses other websites' browsers with `403` for that project (a
request without an `Origin` header, as from a server, is not affected; it keeps
other websites out, not bots).
- Several projects called from the browser: with `PublishableKeyResolver` bound,
the page sends its project's `publishableKey()` in `X-Waitlist-Key` (add it to
`allowed_headers` in `config/cors.php`); a missing or unknown key answers `403`.
The key is public and only picks the project: pair it with `origins()`, and
never put a secret token in a page.
- Behind a proxy or CDN: configure trusted proxies, or all visitors share one rate
limit bucket.
- Routes that are off (the default) are not registered: every URL under the prefix
is Laravel's `404`. An empty `WAITLIST_ROUTES_ENABLED=` is a config error that
registers no route; `false` turns the routes off. After a change of a `routes.*`
setting, build the route cache again (`route:cache`, or `route:clear` when they
go off). A setting that does not read is reported to the logs and leaves out its
routes only; `waitlist:check` names it. `routes.enabled=false` does not stop routes
a route cache holds, so deploy with `waitlist:check --strict` before and after
`route:cache`; the second run compares the registered routes with the config.
## Rules, References, and Templates
- Optional purposes are separate, unticked checkboxes; the primary purpose is
agreed to by sending the form.
- Validation errors from the package routes are always `422` JSON.
- Set `Accept: application/json` on API calls, or browsers are redirected to the
pages in the project's `urls()`.
- Full guides: https://github.com/Taldres/laravel-waitlist/blob/main/docs/getting-started.md,
https://github.com/Taldres/laravel-waitlist/blob/main/docs/examples/landing-page-spa.md,
https://github.com/Taldres/laravel-waitlist/blob/main/docs/securing-the-endpoints.md
## Examples
- "Add a waitlist form to the Blade landing page": steps 2 to 4 in controllers.
- "Our Nuxt site needs a waitlist": `WAITLIST_ROUTES_ENABLED=true`, CORS, then the
HTTP variants of steps 2 to 5.
- "Add Turnstile to the waitlist form": step 6, and send the token in the
`X-Waitlist-Challenge` header, or as `turnstile_token` from a plain form.
## Anti-patterns
- Confirming or unsubscribing on GET; mail scanners follow every link.
- Typing the consent text into the template, or posting text instead of versions.
- Telling apart new and known addresses in the response.
- Putting authentication into `waitlist.routes.middleware`: it also guards the
token links and one-click unsubscribes. Use a `ProjectResolver` or own
controllers for closed signups, and `waitlist.routes.group_middleware.signup`
for checks on the forms only, such as CSRF or an origin check.
- A pre-ticked checkbox for an optional purpose, or one checkbox for two purposes.
- Showing a place in line looked up by address, or computing the count from
`snapshot()` on a public page: use the rounded `publicCount()` and the token.
Use this skill when building or changing the signup form, the confirmation page,
the unsubscribe page or the preference page for taldres/laravel-waitlist.
Waitlist
facade from controllers. The package routes can stay off, unless mails should
carry RFC 8058 one-click List-Unsubscribe headers, which need them; the pages
still render the UI either way.WAITLIST_ROUTES_ENABLED=true and call the JSON API under /waitlist.Point the mail links at the app's own pages either way, in the project's
definition (app/Providers/WaitlistServiceProvider.php). Read the environment
through config(), never env():
$project->urls(
confirm: 'https://example.com/waitlist/confirm/{token}',
unsubscribe: 'https://example.com/waitlist/leave/{token}',
manage: 'https://example.com/waitlist/preferences/{token}',
);
The same call takes confirmed, expired, invalid, unsubscribed and erased,
the pages a browser lands on after posting to the package routes.
Render the wording from the package, never type it into the template:
return view('waitlist.signup', [
'purposes' => Waitlist::purposes('default', locale: app()->getLocale()),
]);
@foreach ($purposes as $wording)
<input type="hidden" name="purposes[{{ $wording->purpose }}][version]" value="{{ $wording->version }}">
<input type="hidden" name="purposes[{{ $wording->purpose }}][locale]" value="{{ $wording->locale }}">
<input type="hidden" name="purposes[{{ $wording->purpose }}][hash]" value="{{ $wording->hash() }}">
@if ($wording->required)
<input type="hidden" name="agreed[]" value="{{ $wording->purpose }}">
<p>{{ $wording->text }}</p>
@else
<label><input type="checkbox" name="agreed[]" value="{{ $wording->purpose }}"> {{ $wording->text }}</label>
@endif
@endforeach
use Illuminate\Support\Arr;
use Taldres\Waitlist\Exceptions\WaitlistException;
use Taldres\Waitlist\Http\Rules\PurposeChoice;
use Taldres\Waitlist\Support\RequestContext;
$validated = $request->validate([
'email' => ['required', 'email:filter', 'max:255'],
'purposes' => ['required', 'array'],
'purposes.*' => ['required', new PurposeChoice],
'agreed' => ['required', 'array'],
'agreed.*' => ['string'],
]);
try {
Waitlist::for('default')->add($validated['email'], Arr::only($validated['purposes'], $validated['agreed']), context: RequestContext::fromRequest($request));
} catch (WaitlistException $exception) {
return back()->withErrors(['purposes' => $exception->getMessage()]);
}
return view('waitlist.check-your-inbox'); // the same answer for new and known addresses
Over HTTP: GET /waitlist/purposes?list=default&locale=de, then
POST /waitlist with email, list and purposes as
{purpose: {version, locale, hash}}. Post back the locale the response served,
which may be a fallback. Answers: 202 for every address, 422 names the field.
Calling from your own server instead (Next.js Server Actions, a BFF with a token
per project): keep the token server-side and limit each visitor there, since the
API sees only your server. With wordingFromCallers() on the project the server
may post {version, locale?, text} with the exact text the form showed instead of
rendering it from the API.
More than the address goes under metadata, and only for fields the project or
list defines ($project->fields(...), ->fields(...) on a list); anything else is
a 422. In your own controllers validate with Waitlist::for('default')->fields().
The mail link opens the page; the page posts the token back.
use Taldres\Waitlist\Exceptions\ExpiredTokenException;
use Taldres\Waitlist\Exceptions\InvalidTokenException;
try {
Waitlist::confirm($token);
} catch (ExpiredTokenException) {
return view('waitlist.expired');
} catch (InvalidTokenException) {
abort(404);
}
Over HTTP: POST /waitlist/confirm/{token} with Accept: application/json
answers {"data": {..., "status": "confirmed"}}, 404 unknown, 410 expired;
a page that first asks with GET must still POST when data.answer_by is set:
the address was asked to confirm again (ReconfirmationRequested) and only the
post keeps it on the list, although status already reads confirmed (the JS
client reads it as entry.answerBy since 0.2.0);
the package's own refusals name an error, e.g. invalid_token, and a 404
without one means a wrong URL, routes that are off or a useWaitlist gate that
keeps the caller out.
// POST from the page; a link for one purpose carries ?purpose=
$purpose = $request->query('purpose');
is_string($purpose)
? Waitlist::withdrawConsent($token, $purpose) // only that purpose
: Waitlist::unsubscribe($token); // leaves the list
Waitlist::requestManageLink($token); // "manage preferences instead": mails a manage link
Both throw InvalidTokenException for an unknown token.
Over HTTP: POST /waitlist/unsubscribe/{token}[?purpose=] and
POST /waitlist/unsubscribe/{token}/manage-link. A project without a
preference page sets $project->manageLinks(false): then offer no "manage
preferences" step, as the request answers 404 with manage_links_disabled.
Behind the manage token from the mailed link (expires after
waitlist.manage.token_ttl minutes, 60 by default, at least 1, never unlimited):
GET /waitlist/manage/{token} (status, purposes in force),
PUT .../purposes with the complete wanted set (the primary purpose must stay),
POST .../data (JSON copy), POST .../unsubscribe, POST .../erase with
{"confirm": true}. On 410, offer to send a new link. Ask for an explicit
confirmation before erasing. In PHP: Waitlist::findByManageToken($token),
Waitlist::grantConsent($token, $purpose, $version, $locale).
Shape the purposes of that PUT like the signup's. With
WAITLIST_REQUIRE_WORDING_HASH=true, only a purpose being added needs its hash;
keeping or dropping one does not.
Both are off until the list opts in, and only work on a list with double opt-in:
$project->list('beta', purpose: 'waitlist')->publicCount()->publicPosition().
GET /waitlist/count?list=beta answers {"data": {"list", "count"}}: the
confirmed addresses, rounded down to a multiple of 10, null below 10, cached
for 60 seconds. Show your own wording for null ("Be one of the first"), never
0. It runs through the resolver, the origins and the useWaitlist gate
(WaitlistAction::ViewCount) like the wording; a list that does not publish it
answers 404 with count_disabled.GET /waitlist/unsubscribe/{token}/position with the unsubscribe token answers
{"data": {"project", "list", "status", "position"}}: the place among the
confirmed addresses in the order they confirmed, null while pending or after
leaving, cached for a minute. Never by address. 404 with position_disabled where the list does
not publish positions, invalid_token for an unknown token.caller_signup_per_minute), so a server-rendered page caches the count
rather than fetching it on every view.count({ list }) and position(token) since 0.2.0.Waitlist::for('beta')->publicCount() and
Waitlist::for('beta')->position($entry).Optional bot check on the signup and manage links by address (the default
NullSpamProtector accepts every request), e.g. Cloudflare Turnstile:
$this->app->bind(SpamProtector::class, fn () => new ClosureSpamProtector(
fn (Request $request): bool => Http::asForm()
->post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [
'secret' => config('services.turnstile.secret'),
'response' => (string) ($request->header('X-Waitlist-Challenge') ?? $request->input('turnstile_token')),
'remoteip' => $request->ip(),
])->json('success') === true,
));
It guards the package routes only; in own controllers, check it yourself.
Without an outside service, bind Taldres\Waitlist\Support\TimeTrapSpamProtector
as the SpamProtector: GET /waitlist/purposes then also answers a challenge,
which the form sends back in X-Waitlist-Challenge between 2 seconds and an
hour later (on spam_check_failed, fetch the wording again), and a hidden
website field must stay empty. Servers calling for their project are not
checked; token links never are. The JS client returns the challenge from
purposes() since 0.2.0 and sends it with { challenge }; it has no website
field, so through it only the time is checked. A plain HTML form without
JavaScript cannot pass this check (header only). Do not let a CDN cache /purposes.
config/cors.php (paths with
'waitlist', 'waitlist/*', the site in allowed_origins, Retry-After in
exposed_headers). $project->origins(['https://site.example']) in the definition
additionally refuses other websites' browsers with 403 for that project (a
request without an Origin header, as from a server, is not affected; it keeps
other websites out, not bots).PublishableKeyResolver bound,
the page sends its project's publishableKey() in X-Waitlist-Key (add it to
allowed_headers in config/cors.php); a missing or unknown key answers 403.
The key is public and only picks the project: pair it with origins(), and
never put a secret token in a page.404. An empty WAITLIST_ROUTES_ENABLED= is a config error that
registers no route; false turns the routes off. After a change of a routes.*
setting, build the route cache again (route:cache, or route:clear when they
go off). A setting that does not read is reported to the logs and leaves out its
routes only; waitlist:check names it. routes.enabled=false does not stop routes
a route cache holds, so deploy with waitlist:check --strict before and after
route:cache; the second run compares the registered routes with the config.422 JSON.Accept: application/json on API calls, or browsers are redirected to the
pages in the project's urls().WAITLIST_ROUTES_ENABLED=true, CORS, then the
HTTP variants of steps 2 to 5.X-Waitlist-Challenge header, or as turnstile_token from a plain form.waitlist.routes.middleware: it also guards the
token links and one-click unsubscribes. Use a ProjectResolver or own
controllers for closed signups, and waitlist.routes.group_middleware.signup
for checks on the forms only, such as CSRF or an origin check.snapshot() on a public page: use the rounded publicCount() and the token.How can I help you explore Laravel packages today?