Laravel Waitlist: a headless waitlist package with privacy built in
Double opt-in, consent per purpose, encrypted addresses and erasure for Laravel waitlists. Headless, so your frontend and mail provider stay your own.

On this page
A waitlist looks like the smallest feature a product can have: an email field, a table, a thank-you message. Then the details arrive. The address has to be confirmed. The newsletter needs its own checkbox. People want to leave, see what you stored about them, or have it erased. And someone wants to know how many signed up last month.
I try out and validate a lot of projects, so I needed a waitlist again and again, and I didn't want to solve the same problems for every project. That is why I wrote laravel-waitlist.
Headless on purpose
#The package ships no dashboard and sends no mail. Your application keeps its
own frontend, its own mail provider and its own workflow. The package records
the state and fires events, and you decide what happens on them: a queued
listener for EntrySubscribed sends the confirmation mail, for example.
If the pages live outside Laravel, such as a static landing page or a Next.js site, the package has a JSON API, and laravel-waitlist-js is a typed client for it.
Consent is a record, not a checkbox
#You define what people can agree to, as purposes with versioned wording, and which list asks for what:
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('beta', purpose: 'waitlist')->optional('newsletter');});Early access and the newsletter are two purposes, and the package records each as a consent of its own, so agreeing to one never counts as agreeing to the other. Every consent stores a snapshot of the text the person saw, its version and its locale. A new text is a new version, so the record still shows what this person agreed to after the form has changed. The models guard those columns with laravel-immutable-attributes, which I wrote for this package: attempts to change them throw an exception.
Correct when two requests race
#A signup has moments where two things happen at once. Someone opens the confirmation link twice. Someone unsubscribes, and a moment later a confirmation for the same address arrives.
Every state change is a conditional update that restates the state it expects:
UPDATE waitlist_subscriptions SET confirmed_at = ?WHERE id = ? AND confirmed_at IS NULL AND ended_at IS NULLOnly an update that changed exactly one row fires an event. So when two
requests confirm the same address at the same moment, one of them wins and the
event fires once. And because an unsubscribe sets ended_at, a confirmation
can't overwrite an unsubscribe that landed a moment earlier.
Addresses a dump doesn't reveal
#Addresses, metadata, IP addresses and user agents are always encrypted, with
Laravel's encrypted casts and APP_KEY. IP and user agent aren't stored at
all unless you turn that on.
To find an address without decrypting every row, the package looks it up by a
keyed hash, with a key derived from APP_KEY. A database dump or backup
without the key holds no readable address. Whoever has the key can still test
an address against the hash, so it is pseudonymous, not anonymous.
Keeping the encryption key safe matters just as much as backing up the
database. Store APP_KEY separately from the database backups, and rotate it
as the
documentation
describes. Without the old key, addresses can neither be read nor found by
email.
Reporting that survives an erasure
#"How many confirmed in August" and "how many are on the list right now" are two different questions, and the package answers them from two sources:
Waitlist::for('beta')->report()->between('2026-08-01', '2026-08-31')->totals();Waitlist::for('beta')->snapshot()->active();The first reads an activity log, the second the addresses. When a person is erased, their address, their subscription history and their consents are deleted. Their log rows stay, but lose everything that points to them directly: the references, IP, user agent and the exact time. What remains is the project, the list, the step, the purpose and the date, so last month's numbers don't change.
In a small list, though, the dates that remain can still allow conclusions about individual people.
What stays your job
#The package provides technical features, not legal advice. Whoever runs the application is responsible for the consent flow, the wording, the retention periods and every integration, from scheduling the cleanup to the copies that end up in backups or at the mail provider. The documentation lists what stays your job.
Try it
#The package needs PHP 8.3 or later and Laravel 13, with SQLite, MySQL, MariaDB or PostgreSQL.
The current version is 0.2.0. Until a stable release, breaking changes can happen without a deprecation period, so pin the exact version you install.
composer require taldres/laravel-waitlist:0.2.0php artisan waitlist:installphp artisan migratewaitlist:install writes a WaitlistServiceProvider, where you describe your
waitlist. For a Next.js frontend, add the client:
pnpm add @taldres/laravel-waitlistThe documentation starts with a guide for each setup, and the package ships Laravel Boost skills. Feedback, issues and pull requests are welcome.
composer require taldres/laravel-waitlist