nuewire/newsletter
A Laravel newsletter package for managing subscribers, mailing lists, and email campaigns. It provides simple tools to collect signups, organize audiences, and send newsletters from your application, with an emphasis on straightforward integration into existing Laravel projects.
Install and publish everything in one pass. The package ships four publishable assets — config, migrations, mail views, and (per the description) the subscriber/list schema — so publish by tag rather than guessing:
composer require nuewire/newsletter
php artisan vendor:publish --provider="Nuewire\Newsletter\NewsletterServiceProvider"
php artisan migrate
Check composer.json constraints resolve against your Laravel/PHP versions before anything else; an unresolvable constraint ends the evaluation in thirty seconds.
Where to look first. There is no README-level API reference in the release record — the 1.0.0 notes are a changelog link only. So read the source in this order:
config/newsletter.php (after publishing) — this is the de facto API doc: model class bindings, table names, double opt-in toggle, signed-link TTL, from-address, queue connection/name.vendor/nuewire/newsletter/src/ — the service provider first (what it binds, what it registers), then the fluent entry point (facade or injectable service), then Models/Subscriber.database/migrations/* — the actual truth about columns, indexes, and whether consent provenance is recorded.resources/views/vendor/newsletter/ — confirmation and welcome templates you'll inevitably restyle.Treat the description's feature list as a contract to verify, not documentation. Write down the real method signatures before you write application code against them.
First use case: the footer signup. The smallest end-to-end loop is signup → confirmation email → confirm → welcome. Build it as a controller action plus two routes (confirm, unsubscribe), both signed-URL protected. Get this working against a throwaway mailbox and a sync queue connection before you introduce a worker; then flip to a real queue and confirm the same flow once more. If that loop is clean, everything else in the package is variation on it.
Pin the version in composer.json ("nuewire/newsletter": "1.0.0" or ^1.0 if you accept the risk). A single-maintainer package with an opaque changelog is not something to float on a caret constraint unexamined.
Signup controller — validate, normalize, subscribe. The email normalization step matters more than it looks; it's the difference between a working uniqueness index and silent duplicates.
public function store(SubscribeRequest $request, Newsletter $newsletter)
{
$email = Str::lower(trim($request->validated('email')));
$subscriber = $newsletter
->subscribe($email)
->withList('product-updates') // verify actual list API in src/
->withTags(['source:footer', 'plan:free'])
->sendConfirmation(); // double opt-in; no-op if already confirmed
return back()->with('status', 'Check your inbox to confirm.');
}
Keep the request validation honest (email:rfc,dns is usually too slow and too strict for a footer form — email is fine) and return the same message whether the email is new or already subscribed. That's both a UX nicety and a small enumeration defense.
Double opt-in: let the package sign, but own the route. Signed confirmation links mean you should be using Laravel's signed middleware, not hand-rolled tokens:
Route::get('/newsletter/confirm/{subscriber}', [NewsletterController::class, 'confirm'])
->name('newsletter.confirm')
->middleware('signed');
Inside, call the package's confirm method and handle "already confirmed" as success, not an error — people click links twice, and mail scanners prefetch them.
Unsubscribe: idempotent, GET-safe, and persistent. The most common real-world bug in a self-hosted newsletter is an unsubscribe link that renders a success page but doesn't write. Make the handler:
subscribers.unsubscribed_at (or the package's equivalent) actually changed.Because email clients and security scanners prefetch links, consider routing the signed link to a one-button confirmation page that POSTs the unsubscribe. It costs a click and eliminates the "our scanner unsubscribed your whole list" class of incident.
Segmentation should be Eloquent, not a package feature. The description promises lists and tags, which get you coarse grouping. The real power is that subscribers live in your database — so express segments as queries against your own models:
$recipients = User::query()
->whereHas('subscription', fn ($q) => $q->whereNull('unsubscribed_at'))
->where('plan', 'pro')
->where('activated_at', '>=', now()->subDays(30))
->lazyById(500);
Join on the subscriber's email (or a user_id if the package's model supports it) rather than maintaining a parallel segmentation system. When product asks "send to paying customers who haven't used feature X," the answer is a query, not a tag taxonomy.
Broadcasts: chunk, queue, and log. The package gives you Mailables and queue integration; it does not give you blast orchestration. Do it yourself with lazyById + a job per chunk, so a failure retries a batch rather than the whole send:
User::query()->...->lazyById(500)->each(function ($chunk) use ($campaign) {
SendCampaignChunk::dispatch($campaign->id, $chunk->pluck('email')->all());
});
Inside the job, Mail::to($chunk)->queue(new CampaignMailable($campaign)). Set an explicit queue name (mail or newsletter) so a 20k-send doesn't starve your password-reset jobs. Record a row per subscriber-per-campaign on success; that's your idempotency key and your audit trail for compliance questions.
Welcome email on confirm — via the event, not inline. The package emits lifecycle events (per the description); listen rather than hand-calling from the confirm controller:
Event::listen(SubscriberConfirmed::class, SendWelcomeEmail::class);
Event::listen(SubscriberUnsubscribed::class, RecordUnsubscribeReason::class);
Same hook point is where you push to CRM, emit analytics, or write an audit row. Register listeners in a dedicated EventServiceProvider block so the integration surface is greppable and removable.
Preference center as a first-class page. Once you have lists and tags, give subscribers a signed, no-login page to toggle lists and pick a frequency. This is cheap to build on the package's list API and it materially reduces spam complaints, which is the metric that actually hurts your sending domain.
Testing. Mail::fake() and Event::fake() cover most of it. The tests worth writing: (1) signup sends exactly one confirmation regardless of repeat submissions; (2) confirm transitions state and fires the event; (3) unsubscribe persists; (4) a campaign job sends once per subscriber after a forced retry.
The API surface is unverified. 1.0.0 shipped with release notes that are just a changelog URL. Every method name above is inferred from the package description — treat the source as authoritative and keep a short internal notes file with the real signatures. Budget an hour for this; it prevents a day of BadMethodCallException archaeology mid-sprint.
Re-subscription and the confirmation race. A confirmed subscriber who submits the form again should not get another confirmation email, and an unconfirmed subscriber who submits twice should get one, not two. Check the current state before calling sendConfirmation(), or make the call conditional on confirmed_at === null. Duplicate confirmation emails are the fastest way to get flagged as a spammer by your own users.
Normalize email at every entry point. Lowercase and trim before insert. If the schema's unique index is on the raw email column, [email protected] and [email protected] become two subscribers, two confirmation emails, and a double-send. If the package doesn't normalize for you, normalize in one place — a custom cast or a repository method — and never bypass it.
Consent provenance is your problem, not the package's. The description mentions double opt-in but says nothing about recording where consent came from. GDPR-adjacent questions ("prove this person opted in, and when, and from which form") need source, ip, consented_at, and confirmation_sent_at. Add these columns in your own migration on top of the published schema rather than hoping they're shipped. Read the published migration before assuming.
Published migrations are now yours. You own the schema; upgrades become a diff-and-reconcile exercise. Never edit a published migration after it has run in any shared environment — add a bridge migration instead. And note that rollback on a published migration typically drops the subscriber table; add a guard or make destructive rollbacks explicit in your deploy runbook.
Signed-link TTL is a compliance surface. If confirmation links expire too fast, real subscribers bounce and you lose them silently. If unsubscribe links expire, you have a legal problem, not a UX one. Check the config for the TTL and make unsubscribe links effectively permanent — or make the unsubscribe route accept an expired signature deliberately.
Queue worker prerequisites you'll forget. Confirmation emails, welcome emails, and campaign Mailables all queue. That means a running worker, a named queue, and a failed_jobs table you're actually watching. In local dev, QUEUE_CONNECTION=sync will hide retry/idempotency bugs that only appear in production — test at least one campaign flow with a real worker and --tries=1.
Idempotency on partial failure. Queue jobs retry. If a chunk job dies after sending 300 of 500 emails, the retry re-sends those 300. A campaign_sends table with a unique (campaign_id, subscriber_id) index, checked before send, is the only reliable fix. This is the single highest-value piece of code you'll write around this package.
No bounce or complaint handling inbound. The package sends; it almost certainly doesn't receive. Hard bounces will keep retrying against a dead address and complaints will keep hitting people who marked you as spam. Whatever provider you point at, wire its webhook to a suppression list and check it before sending. This is out of the package's boundary but squarely in your blast radius.
Deliverability is entirely outside the package. SPF, DKIM, DMARC alignment, domain warm-up, and list hygiene are all yours. Before the first external send, authenticate the sending domain and send to an internal seed list (Gmail, Outlook, Yahoo) to verify inbox placement. A working staging send proves the code, not the deliverability.
Views: override, don't edit in place. Republish views into resources/views/vendor/newsletter/ and restyle there. Keep the package's own URLs and signed-link generation intact — hand-editing the vendor Blade to change a link breaks the signature and produces confusing 403s.
Extension points to probe in the source. Whether the subscriber model is config-swappable (can you point it at your User?), whether the Mailable is resolvable from the container, and whether there's a before-send hook. Any of those, if present, saves you a fork later. If none exist, keep your integration behind a thin application-level service class so replacing the package is a single-file change rather than a grep across the codebase.
Assign an owner. Zero stars, single-maintainer namespace, no SLA. The realistic support model is "one of our engineers reads vendor/nuewire/newsletter/src/ when it breaks." That's fine for a bounded package — but name the person before it becomes load-bearing for lifecycle email, not after.
How can I help you explore Laravel packages today?