Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Waitlist Laravel Package

taldres/laravel-waitlist

taldres/laravel-waitlist is a lightweight Laravel package for adding a waitlist to your app. Let users join, store signups, verify emails, and manage entries with configurable models, migrations, and routes—ideal for launching products with early-access queues.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and publish.

composer require taldres/laravel-waitlist
php artisan vendor:publish --tag=waitlist-migrations
php artisan migrate

If you want the double opt-in confirmation mail, its view and its queued listener scaffolded into your app (so you can style and translate them), run:

php artisan waitlist:install --mail

Read these first, in order:

  1. config/waitlist.php — double opt-in, retention, and defaults. Most features in v0.2.0 are off by default and switched on per list.
  2. The registered waitlist routes — the release gives each mail action one route per method: confirm.store / unsubscribe.store are POST, the GET variants only report. Use the named routes in your own mailables, templates or SPA, never hand-built URLs.
  3. The Waitlist facade / Waitlist::for($list) entrypoint — everything (signups, imports, count, position) hangs off a list.

First use case: put a signup on a list with double opt-in and read the public count.

use Taldres\Waitlist\Facades\Waitlist;

$list = Waitlist::for('early-access')
    ->withDoubleOptIn()
    ->withPublicCount(); // opt in; count is off by default

$list->signup($request->validated('email'), [
    'purpose' => $request->validated('purpose'),
]);

Then wire your form to post to the signup endpoint and let the confirmation mail do the rest. GET /waitlist/count now answers for this list alone.

Implementation Patterns

Form + confirmation loop. Submit via the package endpoint, let the listener send the double opt-in mail, and redirect to a "check your inbox" page. The POST routes act (confirm.store, unsubscribe.store); GET routes only render/report. Links in mails already point at the right verb, so don't rebuild them.

Progressive disclosure of count and place. Count and position are per-list opt-ins and are refused on a list without double opt-in — sensible, since an unverified address has no meaningful place.

Waitlist::for('early-access')->withPublicCount();     // GET /waitlist/count
Waitlist::for('early-access')->withPublicPosition();  // GET /waitlist/unsubscribe/{token}/position

The count rounds confirmed addresses down to ten and returns null below ten; it caches for a minute. Position is looked up only by the token's own address — never by a guessed address — so it's safe to show in an unsubscribe/status page.

Importing an existing list. Use the command for one-offs, the API for tenant-scoped or scripted imports:

php artisan waitlist:import --list=early-access storage/imports/legacy.csv
Waitlist::for('early-access')->import($csvPath, confirmed: true);

confirmed: true skips the mail and records the original time of consent (EntryImported); otherwise the address enters the normal confirmation flow. Preserve the source of consent in your CSV — this is the audit trail you'll want if anyone asks how an address got on the list.

Retention / reconfirmation. Set retention.reconfirm_after_days to re-ask addresses that have gone quiet; waitlist:prune fires ReconfirmationRequested, and after reconfirm_within_days (default 30) with no answer the subscription ends as lapsed. Leave it unset if you don't want the extra mail cycle. Schedule prune on the same cadence as your other hygiene jobs, with --isolated so overlapping runs don't fight:

$schedule->command('waitlist:prune --isolated')->daily();

--isolated also exists on waitlist:rekey and waitlist:wording.

Spam protection without a third party. Bind the time-trap protector in a service provider and put its hidden field in your form:

$this->app->bind(\Taldres\Waitlist\Contracts\SpamProtector::class,
    \Taldres\Waitlist\Support\TimeTrapSpamProtector::class);

For short, inline rules, ClosureSpamProtector wraps a closure. This replaces the removed Waitlist::verifySpamUsing() and Waitlist::spamProtector() — migrate those calls to a container binding.

Browser pages with an API key. If a separate front end posts signups, let PublishableKeyResolver map the X-Waitlist-Key header to a project instead of hand-resolving keys per request. Keep that key in your front-end env, not in the bundle.

Authorisation. ListPolicy::doubleOptIn() is now a method — call it, don't read a property.

Gotchas and Tips

Upgrade checklist for v0.2.0:

php artisan vendor:publish --tag=waitlist-migrations
php artisan migrate
php artisan route:cache   # only if you cache routes

Publishing all migrations again also drops copies of the first five; they skip existing tables and can be deleted. The two you actually need are add_reconfirmation_to_waitlist_subscriptions_table and widen_waitlist_wordings_table.

The widening migration shrinks first on MySQL/MariaDB. widen_waitlist_wordings_table reduces waitlist_wordings.project to 100 characters before widening, and it fails if any stored project name is longer. Query for CHAR_LENGTH(project) > 100 before running it in production.

Breaking changes that will bite silently:

  • Waitlist::verifySpamUsing() / spamProtector() are gone — a call site that still uses them throws, but a config that set them may just stop protecting you. Grep your providers.
  • ConfirmationUrlGenerator → LinkGenerator, DefaultConfirmationUrlGenerator → DefaultLinkGenerator. Your own container binding works if you rename it; otherwise the default is used and your customisation is lost without an error.
  • ListPolicy::doubleOptIn is now doubleOptIn(). A stale property access returns null (falsy) rather than throwing — check any gate that reads it.
  • ActivityType, EndReason, WaitlistAction gained cases (Imported, reconfirmation steps, Lapsed, ViewCount). Exhaustive match expressions over them will now throw UnhandledMatchError — add arms, or give the match a default.
  • Models now declare #[UseFactory]. Any model of yours extending a package model needs its own factory, or test setup will fail confusingly.

JS client version pin. Count, place in line, challenge from GET /purposes, and answer_by require @taldres/laravel-waitlist 0.2.0+. On older clients, leave reconfirm_after_days unset and do not bind TimeTrapSpamProtector on pages that use the client — older JS won't render the hidden field and every submission will look like spam.

Count semantics. Below ten confirmed addresses the endpoint answers null, not 0, and values are floored to the nearest ten with a one-minute cache. Don't build a "we have N people" widget that assumes exact numbers or a non-null body — handle the null as "not enough yet".

Position is token-only. There is no lookup by email, by design. If you want a "where am I?" page for logged-out users, the token from the mail is the only key — treat it as a bearer secret and don't log it.

--isolated needs a cache lock store. It runs the command under a cache lock, so a file cache on multiple hosts won't actually isolate. Use Redis/Memcached/database in production.

Missing arguments prompt only in a terminal. waitlist:show, waitlist:export and waitlist:import ask interactively, which means a cron job or CI step with a missing argument will fail rather than prompt. Always pass arguments explicitly in automation.

Resend edge case fixed in this release: a signup for an address with a pending cycle now re-signs up if waitlist:prune ended or erased that cycle between lookup and resend, where it previously stored nothing or answered 422. If you had a workaround (retry-on-422, or a manual "resend" endpoint), it can likely be removed.

Column budgets. project is 100 chars in waitlist_wordings; purposes and versions go up to 255. Validate at the boundary if you let users name projects.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core
nodisrupt/laravel-agent
sierratecnologia/media-manager