承接 taldres/laravel-waitlist 相关项目开发

从需求分析到上线部署,全程专人跟进,保证项目质量与交付效率

邮箱:yvsm@zunyunkeji.com | QQ:316430983 | 微信:yvsm316

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.

Tests PHPStan Laravel PHP License

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:

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',
]);
  1. Typed columns via your own model, see Custom model and macros.

Statuses

pendingconfirmed, 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_KEY rotation: hash lookups survive (sent links keep working), but the encrypted copy becomes unreadable — the next manageToken() call then mints a fresh token, invalidating previously sent manage links. Use Laravel's APP_PREVIOUS_KEYS for 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\ConfirmationUrlGenerator builds confirm/unsubscribe URLs, e.g. pointing at your SPA. The default uses the package routes when enabled, otherwise the WAITLIST_CONFIRM_URL / WAITLIST_UNSUBSCRIBE_URL patterns ({token} is replaced).
  • Taldres\Waitlist\Contracts\EmailNormalizer normalizes addresses; the default lowercases and trims.
  • Taldres\Waitlist\Contracts\SpamProtector guards the public subscribe endpoint (Turnstile, reCAPTCHA, honeypot). The default accepts everything; HTTP layer only.
  • waitlist.model swaps in your own model extending WaitlistEntry.

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
  • 点击次数: 4
  • 依赖项目数: 0
  • 推荐数: 0

GitHub 信息

  • Stars: 0
  • Watchers: 0
  • Forks: 0
  • 开发语言: PHP

其他信息

  • 授权协议: MIT
  • 更新时间: 2026-06-13