ibrows/newsletter-bundle
ibrows/newsletter-bundle is a Symfony bundle that adds newsletter management to PHP applications, helping you organize subscribers and send email campaigns. It offers a starting point for integrating newsletter workflows into existing projects.
Before anything else: ibrows/newsletter-bundle is a Symfony bundle, not a Laravel package. There is no ServiceProvider, no extra.laravel block, no publishable config, no facade, and no Blade views. Running composer require ibrows/newsletter-bundle inside a Laravel app will pull in Symfony container/bundle contracts you cannot boot. The realistic "getting started" is vendoring it read-only as a specification.
Minimal steps that actually work:
git clone into a scratch directory (or composer require --prefer-source in a throwaway Symfony sandbox) so you can read the source without contaminating your app's composer.json.composer.json first — PHP version floor, Symfony version, and whether it requires sonata-project/*, doctrine/orm, or swiftmailer/swiftmailer. If it's SwiftMailer-era, note it and move on; you aren't installing it anyway.pending -> confirmed -> unsubscribed, plus expiry and re-subscribe paths. This is the only durable asset in the repo.First use case to target: double opt-in. Subscribe → persist pending subscriber with a token → send confirmation mail → confirm endpoint flips to confirmed → unsubscribe endpoint records opt-out. Port that loop natively before touching lists or admin UI.
A five-minute Laravel skeleton of the destination:
Schema::create('subscribers', function (Blueprint $table) {
$table->id();
$table->string('email')->unique(); // duplicate suppression at the DB layer
$table->string('status')->default('pending'); // pending|confirmed|unsubscribed
$table->timestamp('confirmed_at')->nullable();
$table->timestamp('unsubscribed_at')->nullable();
$table->timestamps();
});
Schema::create('subscription_tokens', function (Blueprint $table) {
$table->id();
$table->foreignId('subscriber_id')->constrained()->cascadeOnDelete();
$table->string('token', 64)->index(); // indexed, hashed, single-use
$table->string('purpose'); // confirm|unsubscribe
$table->timestamp('expires_at');
$table->timestamp('consumed_at')->nullable();
});
If that took you ten minutes, you have already replaced 80% of the package's value.
Pattern 1 — Spec mining, not installing. Grep the bundle for its confirmation-token generation and expiry logic and lift the rules, not the code. Typical behaviours worth capturing in tests: tokens expire (24–72h), tokens are single-use, confirming an already-confirmed subscriber is idempotent, subscribing twice while pending resends rather than duplicating.
Pattern 2 — Actions over fat models. Keep the ported state machine framework-agnostic so it's testable and swappable. One action per transition:
final class ConfirmSubscription
{
public function __construct(private SubscriptionTokens $tokens) {}
public function handle(string $rawToken): Subscriber
{
$token = $this->tokens->findValid($rawToken); // hashed lookup, not expired, not consumed
abort_if($token === null, 404);
return DB::transaction(function () use ($token) {
$subscriber = $token->subscriber()->lockForUpdate()->first();
$subscriber->update(['status' => 'confirmed', 'confirmed_at' => now()]);
$token->update(['consumed_at' => now()]);
event(new SubscriberConfirmed($subscriber));
return $subscriber;
});
}
}
Pattern 3 — Queued confirmation mail, synchronously-triggered. Public signup should return fast. Dispatch SendConfirmationEmail on the queue; assert dispatch in the subscribe test. The bundle almost certainly sends inline (Symfony mailer call in a controller) — do not port that.
SubscribeToNewsletter::dispatch($email);
// inside the job:
Mail::to($email)->queue(new ConfirmSubscriptionMail($signedUrl));
Pattern 4 — Signed unsubscribe URLs, no login. Unsubscribe must work from an email client with zero friction, and must be a GET-safe, signed URL (Laravel URL::temporarySignedRoute). Pair it with the List-Unsubscribe header so mail clients surface it:
$message->withSymfonyMessage(function ($m) use ($url) {
$m->getHeaders()->addTextHeader('List-Unsubscribe', "<{$url}>");
$m->getHeaders()->addTextHeader('List-Unsubscribe-Post', 'List-Unsubscribe=One-Click');
});
Pattern 5 — ESP for sending, app for consent. The bundle's mail transport is its weakest and most framework-bound piece. Keep Laravel as the source of truth for consent (confirmed_at, unsubscribed_at, token audit trail) and push the address into whatever provider you choose (Resend, Postmark, Mailchimp) via a queued listener on SubscriberConfirmed. You get deliverability, bounces, and compliance tooling for free, and the Laravel side stays small.
Pattern 6 — Admin surface from scratch, not ported. The bundle's admin is Twig + likely Sonata. Rebuild as a Filament resource in an afternoon: table with status badge, email search, a bulk "resend confirmation" action, and an export action for the GDPR erasure workflow. Do not attempt to port templates.
Pattern 7 — Lists as a pivot, not a hierarchy. If you need multiple lists, list_id on a list_subscriptions pivot (subscriber_id, list_id, status, confirmed_at) beats a single status column once a user is on two lists. The bundle's model is likely simpler; don't inherit its limitation.
composer.json you get zero behaviour change plus an unresolvable Symfony dependency graph. Treat any composer require attempt as a spike in a disposable branch.tinyint, no soft deletes). Redesign for Laravel conventions rather than preserving the schema unless live subscriber data must migrate.unique(email) and wrap transitions in lockForUpdate(). Test with Concurrency or parallel PHPUnit processes if you can.confirmed as "email is deliverable" rather than "human consented" — and document that choice, because it matters for GDPR audit.consumed_at. Store hash('sha256', $raw), index the hash, and null out on use. Otherwise a leaked DB snapshot is an account-takeover vector for every pending subscriber.pending rows are a GDPR grey area. Decide retention up front: unconfirmed signups older than 30 days should be purged by a scheduled command. Also ensure erasure requests delete queue payloads (failed_jobs, job_batches) containing the address — the package certainly doesn't do this.LICENSE in the repo root before any code crosses into your codebase.How can I help you explore Laravel packages today?