taldres / laravel-waitlist
Privacy-first, headless waitlist package for Laravel: subscription cycles with versioned consent records, atomic state changes, data access and erasure APIs, reporting that survives an erasure, double opt-in, events and CSV export. No dashboard, no mail delivery.
Requires
- php: ^8.3
- laravel/framework: ^13.0
- taldres/laravel-immutable-attributes: ^1.0
Requires (Dev)
- brianium/paratest: <7.26.1
- larastan/larastan: ^3.13
- laravel/pao: ^1.0
- laravel/pint: ^1.29
- orchestra/testbench: ^11.0
- pestphp/pest: ^4.7.3||^5.0
- pestphp/pest-plugin-laravel: ^4.1||^5.0
- pestphp/pest-plugin-type-coverage: ^4.0||^5.0
- phpstan/extension-installer: ^1.4
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v0.1.0
- dev-docs/development-notice
- dev-fix/privacy-auth-middleware
- dev-ci/weekly-run-no-fail-fast
- dev-refactor/console-cleanups
- dev-refactor/report-mail-with-event
- dev-fix/withdraw-sweep-by-hash
- dev-feat/install-projects
- dev-refactor/limiters-and-contracts
- dev-fix/privacy-limiter-origin
- dev-fix/about-robust
- dev-fix/check-prune-hint-never-fails-strict
- dev-ci/newer-databases-lazy-schema
- dev-test/auth-middleware-with-project-resolver
- dev-refactor/remove-unused-internals
- dev-fix/personal-data-date-types
- dev-refactor/no-route-fallbacks
- dev-ci/phpstan-single-process
- dev-fix/prune-needs-the-catalog
- dev-refactor/leave-scheduling-to-the-app
- dev-fix/respect-app-limiters-and-bindings
- dev-fix/mail-links-need-app-url
- dev-docs/agents-laravel-first
- dev-feat/project-origins
- dev-feat/waitlist-check
- dev-docs/upgrading
- dev-docs/config-bounds
- dev-test/docs-name-real-settings
- dev-fix/confirmation-report-identity
- dev-feat/spam-check-error-code
- dev-docs/decision-tables
- dev-feat/project-periods
- dev-feat/confirmation-failed
- dev-feat/manage-link-by-token-route
- dev-refactor/drop-single-use-confirm-links
- dev-feat/privacy-go-live-warnings
- dev-fix/privacy-caller-limits
- dev-ci/type-coverage-only-with-pest-5
- dev-docs/testing-notes
- dev-docs/config-empty-variables
- dev-docs/integration-notes
- dev-fix/config-messages
- dev-fix/null-list-is-default
- dev-ci/keep-paratest-below-7-26-1
This package is auto-updated.
Last update: 2026-10-11 17:24:12 UTC
README
Laravel Waitlist
Headless waitlists for Laravel, built to support GDPR compliance: encrypted addresses, consent records per purpose, configurable retention, and self-service APIs.
Bring your own UI. Bring your own mail provider.
Warning
This package is under active development. Until a stable release it can change at any time, and breaking changes will happen, without a deprecation period. Pin the exact version you install.
An API-first Laravel package for waitlists and early access: double opt-in, consent recorded per purpose, token-based confirm, unsubscribe and preference flows, retention periods, events, CSV export and daily reporting. It ships no dashboard and no mail delivery, so your application keeps its own frontend, mail provider and workflow.
Why this package?
Privacy is the design constraint, not an add-on.
- Required purpose choice. The signup API requires a primary purpose choice; optional purposes such as a newsletter are recorded and withdrawn separately
- Versioned consent records. Each consent stores the registered text, version and locale; your application must display that text and obtain a valid agreement
- Encrypted at rest. Addresses, metadata, IP and user agent are always encrypted with
APP_KEY, or with your own encrypter; lookups run on a keyed hash, so these fields are unreadable in a database dump without the encryption key - Scheduled retention.
waitlist:prunecleans up abandoned signups, people who left and request metadata; your application schedules it, runs and monitors Laravel's scheduler, andwaitlist:privacywarns while nothing runs it - People help themselves. A preference page lets them change purposes, download their data and erase it, without writing to your support. It opens with a short-lived link that is mailed to the address on request; the link in every mail can only remove
- Minimal by default. Double opt-in on, IP and user agent off, no metadata accepted over HTTP unless a project or list defines the fields, with Laravel validation rules
- No built-in mail delivery or outbound requests. Your application controls mail, integrations, exports and other data flows;
waitlist:privacylists registered package-event listeners to help you review them - Correct under load. State changes are conditional updates, so every event fires exactly once, even when two requests race
The application operator is responsible for the lawfulness and security of its processing, including its configuration, consent flow, retention and integrations. The package provides technical features, not legal advice, certification or a warranty of GDPR compliance. It is provided under the MIT License, including its warranty and liability disclaimer, subject to applicable mandatory law. See Operator responsibilities and limitations and GDPR in practice.
Honest trade-off: if you want a ready-made invite workflow (invited,
rejected, auto-sent mails), this is intentionally not that package. Build it on
top via events and macros, see the invite flow example.
Requirements
- PHP 8.3+
- Laravel 13
- SQLite 3.26+, MySQL 5.7+, MariaDB 10.3+ or PostgreSQL 10+
Installation
Choose the setup before you install, by how many projects the waitlist serves:
- One project:
php artisan waitlist:install; the provider describes the default project. - Several projects in one app:
php artisan waitlist:install --projects; the provider starts with aWaitlist::define()per project. Bind aProjectResolverbefore the package routes take signups; until then they answer422. - A central API whose projects manage their own lists and wording:
php artisan waitlist:install, then aProjectCatalogover your database and a resolver that maps each caller to its project, see the example.
Projects, lists and fields compares them. The install never overwrites a provider that exists, so a second run with another option changes nothing.
composer require taldres/laravel-waitlist
php artisan waitlist:install # or: php artisan waitlist:install --projects
php artisan migrate
waitlist:install publishes the migrations, the config and
app/Providers/WaitlistServiceProvider.php, where you describe your waitlist,
and registers that provider.
Addresses are encrypted with APP_KEY: back it up apart from the database, and
keep the old key in APP_PREVIOUS_KEYS when you rotate it. See
Encryption and keys.
Quickstart
Define what people agree to, and which list asks for what:
// app/Providers/WaitlistServiceProvider.php use Taldres\Waitlist\Definitions\ProjectDefinition; use Taldres\Waitlist\Facades\Waitlist; public function boot(): void { 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'); // Pages your mail links point at, which post the token back $project->urls( confirm: 'https://example.com/waitlist/confirm/{token}', unsubscribe: 'https://example.com/waitlist/leave/{token}', ); }); }
Each list has one primary purpose, which is required, so two purposes can never be bundled into one checkbox; the newsletter is chosen on its own. Never edit a version's wording: add a new version. A signup can carry more than the address when a project or list defines the fields, with any Laravel validation rule:
$project->list('teams', purpose: 'waitlist')->fields(fn () => [ 'company' => ['required', 'string', 'max:120'], 'plan' => ['nullable', Rule::enum(Plan::class)], ]);
Render the wording from the package, then post back the versions that were shown:
use Taldres\Waitlist\Facades\Waitlist; Waitlist::purposes('default'); // list<PurposeWording>: purpose, version, locale, text, hash(), required $result = Waitlist::for('default')->add('jane@example.com', [ 'waitlist' => '2026-10', 'newsletter' => '2026-10', // only if the box was ticked ]);
The package sends no mail. Your listener sends the confirmation:
use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Taldres\Waitlist\Events\EntrySubscribed; class SendWaitlistConfirmationMail implements ShouldQueue, ShouldBeEncrypted { public function handle(EntrySubscribed $event): void { if (! $event->requiresConfirmation) { return; } // ConfirmWaitlistMail is yours: see docs/mail.md for one Mail::to($event->entry->email)->send(new ConfirmWaitlistMail( $event->confirmUrl, $event->unsubscribeUrl, $event->subscription->consents->pluck('text')->all(), )); Waitlist::confirmationMailed($event, 'confirm-mail@2026-10'); } }
Your pages post the token back:
Waitlist::confirm($confirmToken); // fires EntryConfirmed Waitlist::withdrawConsent($unsubscribeToken, 'newsletter'); // only the newsletter Waitlist::unsubscribe($unsubscribeToken); // leaves the list
Once confirmed, ask before you send. recipients() streams everyone a mail may
go to, once per address, with the links that mail must carry:
Waitlist::recipients('newsletter')->each(fn (Recipient $recipient) => Mail::to($recipient->email) ->locale($recipient->locale) ->queue(new NewsletterMail($recipient))); // carries $recipient->unsubscribeUrl() and ->listUnsubscribeHeaders()
No Laravel backend for the pages? Enable the JSON API with
WAITLIST_ROUTES_ENABLED=true, see the HTTP API.
Getting started walks through the whole flow both ways.
Which setup do you have?
| You have | Start with |
|---|---|
| One product, pages rendered by Laravel (Blade, Livewire, Inertia) | Getting started |
| One product, a static landing page or SPA | Example: a landing page or SPA |
| Several products in one app | Projects, lists and fields, example |
| A central API serving the waitlists of all your products | Example: a central waitlist API |
One product needs one definition: Waitlist::define() without a name describes
the default project, and the facade acts on it.
Documentation
- Guides: Getting started · Projects, lists and fields · Purposes and wording · Mail · Sync to your email provider · Lifecycle · Reporting · Securing the endpoints · Encryption and keys · Extending
- Privacy: GDPR in practice · Operator responsibilities
- Reference: Configuration · PHP API · HTTP API · Events · Commands
The documentation index lists every page with a line on what it covers.
AI agents
The package ships Laravel Boost skills. Run
php artisan boost:install and select taldres/laravel-waitlist among the
third-party packages; agents such as Claude Code, Codex or Cursor then integrate
the waitlist the way these docs describe. A non-interactive install only picks
up packages listed under packages in boost.json.
| Skill | For |
|---|---|
laravel-waitlist-development |
the integration as a whole |
laravel-waitlist-frontend |
signup form, confirm, unsubscribe and preference pages |
laravel-waitlist-mail |
every mail the flow needs, a sender per project |
laravel-waitlist-provider-sync |
Brevo, Mailchimp, Mailcoach in step, both ways |
laravel-waitlist-projects |
several products in one app |
laravel-waitlist-reporting |
statistics for dashboards and charts |
laravel-waitlist-launch |
invitations, the launch mail, erasing the list afterwards |
laravel-waitlist-privacy-operations |
access and erasure requests, retention, key rotation |
laravel-waitlist-testing |
feature tests for the integration |
Development
Built on the Laravel package skeleton:
composer test # PHPStan level 10, Pint, type coverage, Pest composer lint # Pint composer serve # workbench app with the HTTP API enabled
Concurrency tests act as two clients on two connections against one database. They are skipped on SQLite, where an in-memory database is per connection:
DB_CONNECTION=mysql vendor/bin/pest DB_CONNECTION=mariadb vendor/bin/pest DB_CONNECTION=pgsql vendor/bin/pest
See CONTRIBUTING and the changelog.
License
MIT. See LICENSE.md.
