PHPackages                             taldres/laravel-waitlist - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Utility &amp; Helpers](/categories/utility)
4. /
5. taldres/laravel-waitlist

ActiveLibrary[Utility &amp; Helpers](/categories/utility)

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.

02↑2900%PHP

Since Jun 13Pushed todayCompare

[ Source](https://github.com/Taldres/laravel-waitlist)[ Packagist](https://packagist.org/packages/taldres/laravel-waitlist)[ RSS](/packages/taldres-laravel-waitlist/feed)WikiDiscussions main Synced today

READMEChangelog (1)DependenciesVersions (1)Used By (0)

Laravel Waitlist
================

[](#laravel-waitlist)

> **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](https://camo.githubusercontent.com/d83d77752c1a3f0521f72bca80873a0feafa0b281e210497e398fa95794ae120/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f74616c647265732f6c61726176656c2d776169746c6973742f74657374732e796d6c3f6c6162656c3d7465737473)](https://camo.githubusercontent.com/d83d77752c1a3f0521f72bca80873a0feafa0b281e210497e398fa95794ae120/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f74616c647265732f6c61726176656c2d776169746c6973742f74657374732e796d6c3f6c6162656c3d7465737473)[![PHPStan](https://camo.githubusercontent.com/ff3c7f8c8667ce643f47e74532748f673482a5f95d7d4269f925f2eebbe5117e/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f5048505374616e2d6c6576656c253230382d627269676874677265656e)](https://camo.githubusercontent.com/ff3c7f8c8667ce643f47e74532748f673482a5f95d7d4269f925f2eebbe5117e/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f5048505374616e2d6c6576656c253230382d627269676874677265656e)[![Laravel](https://camo.githubusercontent.com/42e62a9adb05b6cb16993782fd4b04b64a76be3ff5704d170001885eb70c8448/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c61726176656c2d313225323025374325323031332d726564)](https://camo.githubusercontent.com/42e62a9adb05b6cb16993782fd4b04b64a76be3ff5704d170001885eb70c8448/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c61726176656c2d313225323025374325323031332d726564)[![PHP](https://camo.githubusercontent.com/c8d8dad6beb757a2b8acba331d16140813699543b88a37af0a81f20bd35f61de/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f5048502d382e332532422d626c7565)](https://camo.githubusercontent.com/c8d8dad6beb757a2b8acba331d16140813699543b88a37af0a81f20bd35f61de/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f5048502d382e332532422d626c7565)[![License](https://camo.githubusercontent.com/be07a68b57a673af622198c336264f89d82bf4cd5d87bc0cb3f7b6ae47cc43ea/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d6c6967687467726579)](https://camo.githubusercontent.com/be07a68b57a673af622198c336264f89d82bf4cd5d87bc0cb3f7b6ae47cc43ea/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d6c6967687467726579)

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?
-----------------

[](#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
--------------------

[](#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
------------

[](#requirements)

- PHP 8.3+
- Laravel 12 or 13

Installation
------------

[](#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
----------

[](#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
```

### Sending the confirmation mail (your job, by design)

[](#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](docs/welcome-mail-after-confirmation.md)for the full flow and [Token lifecycle](#token-lifecycle) for what is stored when.

Recipes
-------

[](#recipes)

Complete, copy-pastable integrations live in [docs](docs):

- [SPA confirmation flow](docs/spa-confirmation-flow.md): point confirm links at your Inertia/Nuxt/Next.js frontend
- [Confirmation mail with Resend](docs/confirmation-mail-with-resend.md): queued listener + Mailable
- [Welcome mail after confirmation](docs/welcome-mail-after-confirmation.md): `EntryConfirmed` delivers the unsubscribe link ready-made
- [Sync to your email provider](docs/sync-to-email-provider.md): keep Brevo/Mailcoach/etc. in sync, including erasure
- [Custom model and macros](docs/custom-model-and-macros.md): extra columns and your own API methods
- [Securing the endpoints](docs/securing-the-endpoints.md): direct vs. backend-proxied, CORS, auth, redirects, bot protection

GDPR
----

[](#gdpr)

Privacy is the core design constraint, and each GDPR requirement maps to a concrete feature:

GDPR requirementHow it's coveredConsent proof (Art. 7)`consent_text` + `consented_at` snapshot per entry, captured at subscribe timeRight 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 systemsData 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

[](#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

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

[](#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',
]);
```

2. Typed columns via your own model, see [Custom model and macros](docs/custom-model-and-macros.md).

Statuses
--------

[](#statuses)

`pending` → `confirmed`, plus `unsubscribed`. Product-specific statuses like `invited` or `converted` are intentionally out of scope.

Lists
-----

[](#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
-------------

[](#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
---------------

[](#token-lifecycle)

Confirm tokenManage tokenPurposeComplete the double opt-inUnsubscribe link in every mailIssuedOn subscribe (and re-subscribe)On subscribe (and re-subscribe)Plain formOnly in the `EntrySubscribed` payloadEvent payloads, `manageToken()`, `unsubscribeUrl()`Stored asSHA-256 hashSHA-256 hash + encrypted copy (`APP_KEY`)LifetimeOptional TTL while pendingUntil re-subscribeAfter useStays valid as an idempotent status linkUnsubscribing is idempotentTwo 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
--------------

[](#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:

MethodURIBehaviorPOST`/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](docs/securing-the-endpoints.md) for the full architecture guide (direct vs. backend-proxied, CORS, auth, bot protection).

Commands
--------

[](#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
---------

[](#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](docs/securing-the-endpoints.md) for a full Turnstile example.

Both `WaitlistManager` and `ScopedWaitlist` are macroable:

```
Waitlist::macro('confirmedCount', fn (string $list) => /* ... */);
```

API overview
------------

[](#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
------

[](#events)

EventFired whenPayload`EntrySubscribed`New/renewed subscriptionentry, plain tokens, confirm/unsubscribe URLs, `requiresConfirmation`, `isNewEntry``EntryConfirmed`Double opt-in completedentry, plain manage token, `unsubscribeUrl``EntryUnsubscribed`Unsubscribe via token or APIentry`EntryForgotten`Erasure executedentry id + list + email (scalars only; the entry is already gone)Testing
-------

[](#testing)

```
composer test      # Pest
composer analyse   # PHPStan (level 8)
composer format    # Pint
```

License
-------

[](#license)

MIT. See [LICENSE.md](LICENSE.md).

###  Health Score

21

—

LowBetter than 18% of packages

Maintenance65

Regular maintenance activity

Popularity3

Limited adoption so far

Community2

Small or concentrated contributor base

Maturity11

Early-stage or recently created project

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

### Community

Maintainers

![](https://www.gravatar.com/avatar/447342a2b11f3ca024a0b1e864b83cdf00b943f5a054c4c4f6b3140c00d13d82?d=identicon)[Taldres](/maintainers/Taldres)

### Embed Badge

![Health badge](/badges/taldres-laravel-waitlist/health.svg)

```
[![Health](https://phpackages.com/badges/taldres-laravel-waitlist/health.svg)](https://phpackages.com/packages/taldres-laravel-waitlist)
```

###  Alternatives

[pragmatic-modules/magento2-module-system-configuration-toolkit

System Configuration Toolkit is a Magento 2 module that shows sort order of system configuration's tabs, sections, groups, and fields. It also helps you to see full field paths, so no more looking for those.

3212.0k](/packages/pragmatic-modules-magento2-module-system-configuration-toolkit)[dfridrich/czech-data-box

Knihovna pro komunikaci s datovou schránkou v PHP.

2910.6k1](/packages/dfridrich-czech-data-box)[mohammad-mahdy/yii2-jdate

Jalali date &amp; time.

148.5k5](/packages/mohammad-mahdy-yii2-jdate)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
