taldres/laravel-waitlist
Composer 安装命令:
composer require taldres/laravel-waitlist
包简介
Privacy-first, headless waitlist package for Laravel: consent logging, GDPR data access and erasure, optional double opt-in, hashed tokens, events, and CSV export. No dashboard, no mail delivery.
README 文档
README
Privacy-first, headless waitlists for Laravel: consent logging, double opt-in, and the right to be forgotten built in.
Bring your own UI. Bring your own mail provider.
Headless Waitlist is an API-first Laravel package for collecting and managing waitlist and early-access entries with consent logging, optional double opt-in, token-based confirmation/unsubscribe flows, events, and CSV export. It ships no dashboard and no mail delivery by default, so applications remain free to use their own frontend, mail provider, and workflow logic.
Why this package?
The focus is on what usually gets bolted on too late: privacy, security, and integration freedom.
- Consent snapshot (wording + timestamp) as first-class columns
- IP / user agent storage is opt-in and off by default
- Confirm tokens are stored hashed (SHA-256); manage tokens additionally encrypted, so unsubscribe links stay retrievable
- Right to erasure (Art. 17) via
waitlist:forget+Waitlist::forget() - Right of access (Art. 15) via
waitlist:show+Waitlist::personalData() - Mail delivery never happens in the package; you listen to events and use your own mailer
- Hardened public endpoints: anti-enumeration responses and rate limiting
- No runtime dependencies beyond
illuminate/*, PHPStan level 8
Honest trade-off: if you want a ready-made invite/notification workflow (invited, rejected, auto-sent mails), this is intentionally not that package. Build it on top via events and macros, or use a package that ships it.
The Headless Promise
This package will never send an email. It ships no mail provider integration and no notification classes, so there is nothing to lock you in.
Everything happens through events. The package stores entries, manages statuses and tokens, and fires events that carry everything a listener needs: the entry, the plain tokens, and ready-made confirm/unsubscribe URLs. Which mailer, which templates, and which queue handle the actual sending is entirely up to your application.
Requirements
- PHP 8.3+
- Laravel 12 or 13
Installation
composer require taldres/laravel-waitlist php artisan vendor:publish --tag=waitlist-migrations php artisan migrate
Optionally publish the config:
php artisan vendor:publish --tag=waitlist-config
Quickstart
Subscribe an email through the action class:
use Taldres\Waitlist\Actions\SubscribeToWaitlist; $entry = app(SubscribeToWaitlist::class)( list: 'beta', email: 'user@example.com', metadata: ['source' => 'landing-page'], consent: ['text' => 'I agree to receive waitlist updates.'], );
Or use the facade with the fluent API:
use Taldres\Waitlist\Facades\Waitlist; Waitlist::for('beta')->add('user@example.com'); Waitlist::for('beta')->has('user@example.com'); // bool Waitlist::for('beta')->count(); Waitlist::for('beta')->entries(); // scoped query builder Waitlist::for('beta')->unsubscribe('user@example.com'); Waitlist::for('beta')->export(storage_path('beta.csv')); Waitlist::for('beta')->forget('user@example.com'); // GDPR erasure Waitlist::for('beta')->personalData('user@example.com'); // GDPR access
Look up entries without touching the model directly:
Waitlist::exists('user@example.com'); // any list Waitlist::exists('user@example.com', list: 'beta'); // specific list Waitlist::findByEmail('user@example.com'); // Collection<WaitlistEntry>
Sending the confirmation mail (your job, by design)
Listen for EntrySubscribed and use your own mailer:
use Taldres\Waitlist\Events\EntrySubscribed; class SendWaitlistConfirmationMail { public function handle(EntrySubscribed $event): void { if (! $event->requiresConfirmation) { return; } Mail::to($event->entry->email)->send( new ConfirmWaitlistMail($event->confirmUrl, $event->unsubscribeUrl) ); } }
Confirm and unsubscribe with the tokens from the event:
Waitlist::confirm($token); // pending → confirmed, fires EntryConfirmed Waitlist::unsubscribe($token); // → unsubscribed, fires EntryUnsubscribed
For mails after the confirm — a welcome mail is the classic case —
EntryConfirmed carries a ready-made unsubscribe link, so that listener is
a one-liner too. And whenever you need a link for an entry you already
hold, any time later:
Waitlist::unsubscribeUrl($entry); // ?string, links from earlier mails stay valid Waitlist::manageToken($entry); // the underlying ManageToken DTO
See Welcome mail after confirmation for the full flow and Token lifecycle for what is stored when.
Recipes
Complete, copy-pastable integrations live in docs:
- SPA confirmation flow: point confirm links at your Inertia/Nuxt/Next.js frontend
- Confirmation mail with Resend: queued listener + Mailable
- Welcome mail after confirmation:
EntryConfirmeddelivers the unsubscribe link ready-made - Sync to your email provider: keep Brevo/Mailcoach/etc. in sync, including erasure
- Custom model and macros: extra columns and your own API methods
- Securing the endpoints: direct vs. backend-proxied, CORS, auth, redirects, bot protection
GDPR
Privacy is the core design constraint, and each GDPR requirement maps to a concrete feature:
| GDPR requirement | How it's covered |
|---|---|
| Consent proof (Art. 7) | consent_text + consented_at snapshot per entry, captured at subscribe time |
| Right of access (Art. 15) | php artisan waitlist:show user@example.com --json (or --pretty) or Waitlist::personalData($email) returning typed PersonalData DTOs (full disclosure, token hashes excluded) |
| Right to erasure (Art. 17) | php artisan waitlist:forget user@example.com or Waitlist::forget($email): hard delete, fires EntryForgotten so you can clean up external systems |
| Data minimization (Art. 5) | IP and user agent are not stored unless you opt in (privacy.store_ip, privacy.store_user_agent) |
| Storage limitation (Art. 5) | php artisan waitlist:prune removes stale pending entries; also works with model:prune if you schedule it (Schedule::command('model:prune') in routes/console.php) |
Programmatic access and erasure
personalData() returns typed, readonly PersonalData DTOs, a stable contract
instead of raw model arrays:
use Taldres\Waitlist\Facades\Waitlist; use Taldres\Waitlist\Support\PersonalData; $data = Waitlist::personalData('user@example.com'); // Collection<PersonalData> foreach ($data as $entry) { $entry->list; // string $entry->status; // EntryStatus enum $entry->consentText; // ?string $entry->consentedAt; // ?Carbon $entry->metadata; // array $entry->toArray(); // stable snake_case array, JSON-ready } $deleted = Waitlist::forget('user@example.com'); // int, fires EntryForgotten per entry
Tokens and token hashes are never part of the disclosure; they are security material, not personal data.
Storing names and extra fields
There are deliberately no first_name/last_name parameters or columns. A
waitlist needs an email, and first-class name fields would nudge
every consumer into collecting more personal data than necessary (Art. 5 data
minimization). Two supported paths instead:
// 1. metadata: flexible, flows through personalData(), CSV export, and forget() Waitlist::for('beta')->add('user@example.com', metadata: [ 'first_name' => 'Jane', ]);
- Typed columns via your own model, see Custom model and macros.
Statuses
pending → confirmed, plus unsubscribed. Product-specific statuses like invited or converted are intentionally out of scope.
Lists
Lists are plain string keys (default, beta, product-42). Restrict accepted keys via the whitelist:
WAITLIST_ALLOWED_LISTS=test,test2
Empty (default) means every key is accepted; otherwise subscribing to an unknown key throws UnknownWaitlistException (HTTP layer: 422).
Double opt-in
Enabled by default. New entries start as pending and become confirmed via a token (TTL configurable, default 7 days). Disable globally or per list:
'double_opt_in' => [ 'enabled' => true, 'token_ttl' => 60 * 24 * 7, // minutes 'lists' => ['beta' => false], ],
Calling subscribe() again for a pending entry regenerates the tokens and
re-fires EntrySubscribed — that is the resend mechanism for "didn't
get the mail" flows, and it also means a double POST fires the event twice.
The package stays unopinionated about whether a mail goes out: the event's
isNewEntry flag (false on renewals) is the hook for your listener to
throttle or skip. The HTTP endpoint's rate limiter covers the
accidental-double-click case.
Token lifecycle
| Confirm token | Manage token | |
|---|---|---|
| Purpose | Complete the double opt-in | Unsubscribe link in every mail |
| Issued | On subscribe (and re-subscribe) | On subscribe (and re-subscribe) |
| Plain form | Only in the EntrySubscribed payload |
Event payloads, manageToken(), unsubscribeUrl() |
| Stored as | SHA-256 hash | SHA-256 hash + encrypted copy (APP_KEY) |
| Lifetime | Optional TTL while pending | Until re-subscribe |
| After use | Stays valid as an idempotent status link | Unsubscribing is idempotent |
Two consequences worth knowing:
- Confirming is idempotent, not single-use. A second click on the confirm link reports "confirmed" instead of a confusing 404. The trade-off: after confirmation the link degrades to a status link — it can never change state again, so a leaked link reveals at most that the address is confirmed. Unsubscribing invalidates it.
APP_KEYrotation: hash lookups survive (sent links keep working), but the encrypted copy becomes unreadable — the nextmanageToken()call then mints a fresh token, invalidating previously sent manage links. Use Laravel'sAPP_PREVIOUS_KEYSfor graceful rotation.
HTTP endpoints
The HTTP API is part of the headless story, since it is what lets any frontend
talk to your waitlist. It still ships disabled, because installing a package
should never silently expose a public write endpoint. Enable it with
WAITLIST_ROUTES_ENABLED=true to get:
| Method | URI | Behavior |
|---|---|---|
| POST | /waitlist |
Subscribe. Always responds 202 with an identical body (anti-enumeration). |
| GET | /waitlist/confirm/{token} |
Confirm (idempotent — a second click reports success). 404 invalid, 410 expired. |
| GET | /waitlist/unsubscribe/{token} |
Unsubscribe. |
Prefix, route names, middleware, and rate limit are configurable. Responses never expose tokens, IPs, or user agents.
Mail links are clicked in browsers. Set the optional redirect URLs and the
confirm/unsubscribe endpoints answer with a 302 to your frontend instead of JSON:
WAITLIST_REDIRECT_CONFIRMED=https://app.example.com/waitlist/thanks WAITLIST_REDIRECT_EXPIRED=https://app.example.com/waitlist/expired WAITLIST_REDIRECT_INVALID=https://app.example.com/waitlist/oops WAITLIST_REDIRECT_UNSUBSCRIBED=https://app.example.com/waitlist/goodbye
Each key is independent; unset keys keep the JSON behavior. Redirects only
apply to browser requests: clients sending Accept: application/json always
get JSON, so server-to-server calls are unaffected — set that header
explicitly when calling these endpoints from code. See
Securing the endpoints for the full
architecture guide (direct vs. backend-proxied, CORS, auth, bot protection).
Commands
php artisan waitlist:show {email} [--list=] [--json|--pretty] # right of access
php artisan waitlist:forget {email} [--list=] # right to erasure
php artisan waitlist:prune [--list=] [--days=] # remove stale pending entries
php artisan waitlist:export {list} [--status=] [--path=] # streaming CSV export
Extending
Bind your own implementations (configured in config/waitlist.php):
Taldres\Waitlist\Contracts\ConfirmationUrlGeneratorbuilds confirm/unsubscribe URLs, e.g. pointing at your SPA. The default uses the package routes when enabled, otherwise theWAITLIST_CONFIRM_URL/WAITLIST_UNSUBSCRIBE_URLpatterns ({token}is replaced).Taldres\Waitlist\Contracts\EmailNormalizernormalizes addresses; the default lowercases and trims.Taldres\Waitlist\Contracts\SpamProtectorguards the public subscribe endpoint (Turnstile, reCAPTCHA, honeypot). The default accepts everything; HTTP layer only.waitlist.modelswaps in your own model extendingWaitlistEntry.
For the spam check there is also a closure shortcut — no class or config change needed, and it takes precedence over the configured protector:
// app/Providers/AppServiceProvider.php Waitlist::verifySpamUsing(fn (Request $request): bool => /* your check */);
See docs/securing-the-endpoints.md for a full Turnstile example.
Both WaitlistManager and ScopedWaitlist are macroable:
Waitlist::macro('confirmedCount', fn (string $list) => /* ... */);
API overview
One convention to know: manager methods that complete a flow take tokens
(confirm, unsubscribe, the links from your mails), while ScopedWaitlist
methods are email-centric and operate on a single list.
// Manager (Waitlist facade) Waitlist::subscribe(string $list, string $email, array $metadata = [], array $consent = []): WaitlistEntry Waitlist::confirm(string $plainToken): WaitlistEntry Waitlist::unsubscribe(string $plainToken): WaitlistEntry Waitlist::manageToken(WaitlistEntry $entry): ManageToken Waitlist::unsubscribeUrl(WaitlistEntry $entry): ?string Waitlist::exists(string $email, ?string $list = null): bool Waitlist::findByEmail(string $email, ?string $list = null): Collection // of WaitlistEntry Waitlist::forget(string $email, ?string $list = null): int Waitlist::personalData(string $email, ?string $list = null): Collection // of PersonalData Waitlist::verifySpamUsing(?Closure $callback): WaitlistManager // Closure(Request): bool, null restores config Waitlist::for(string $list): ScopedWaitlist // ScopedWaitlist Waitlist::for('beta')->add(string $email, array $metadata = [], array $consent = []): WaitlistEntry Waitlist::for('beta')->has(string $email): bool Waitlist::for('beta')->find(string $email): ?WaitlistEntry Waitlist::for('beta')->count(): int Waitlist::for('beta')->entries(): Builder Waitlist::for('beta')->unsubscribe(string $email): ?WaitlistEntry Waitlist::for('beta')->forget(string $email): int Waitlist::for('beta')->personalData(string $email): Collection Waitlist::for('beta')->export(string $path): int
Events
| Event | Fired when | Payload |
|---|---|---|
EntrySubscribed |
New/renewed subscription | entry, plain tokens, confirm/unsubscribe URLs, requiresConfirmation, isNewEntry |
EntryConfirmed |
Double opt-in completed | entry, plain manage token, unsubscribeUrl |
EntryUnsubscribed |
Unsubscribe via token or API | entry |
EntryForgotten |
Erasure executed | entry id + list + email (scalars only; the entry is already gone) |
Testing
composer test # Pest composer analyse # PHPStan (level 8) composer format # Pint
License
MIT. See LICENSE.md.
统计信息
- 总下载量: 0
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 3
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-06-13