Easily Manage Internal Newsletter Subscribers in Laravel — with campaigns, mail sending, and tracking
mydnic/laravel-subscribers is a Laravel package for easily manage internal newsletter subscribers in laravel — with campaigns, mail sending, and tracking.
It currently has 26 GitHub stars and 5.041 downloads on Packagist (latest version v2.0.4).
Install it with composer require mydnic/laravel-subscribers.
Discover more Laravel packages by mydnic
or browse all Laravel packages to compare alternatives.
Last updated
A lightweight newsletter subscriber management package for Laravel. Handle subscriptions, send campaigns, track opens and clicks — all without a third-party service.
Heads-up: This package is designed for small to medium audiences (think side-projects, indie apps, internal tools). It sends mail through whatever driver is configured in your
config/mail.phpand has no bounce handling, no complaint webhooks, and no deliverability tooling. If you're sending to tens of thousands of subscribers or need professional deliverability guarantees, use a dedicated email service provider instead. Sendboo is a great option with full campaign management, AI features, and solid deliverability.
User model with the subscribers table via a trait or Artisan commandInstall via Composer:
composer require mydnic/kanpen
The service provider is auto-discovered. Next, publish and run the migrations:
php artisan vendor:publish --tag="kanpen-migrations"
php artisan migrate
This creates three tables: subscribers, campaigns, and campaign_deliveries.
Publish the config file:
php artisan vendor:publish --tag="kanpen-config"
This creates config/kanpen.php:
return [
// Enable email verification (double opt-in)
'verify' => env('KANPEN_VERIFY', false),
// Named route to redirect to after web form submission
'redirect_url' => 'home',
// Verification email content
'mail' => [
'verify' => [
'expiration' => 60, // minutes
'subject' => 'Verify Email Address',
'greeting' => 'Hello!',
'content' => ['Please click the button below to verify your email address.'],
'action' => 'Verify Email Address',
'footer' => ['If you did not sign up for our newsletter, no further action is required.'],
],
],
// Campaign sending
'campaigns' => [
'enabled' => true,
'middleware' => ['api'], // middleware for campaign management routes
'from' => [
'name' => env('MAIL_FROM_NAME', 'Newsletter'),
'email' => env('MAIL_FROM_ADDRESS', '[email protected]'),
],
'queue' => env('KANPEN_QUEUE', 'default'),
'schedule' => true, // auto-register the dispatch command on the scheduler
],
// Open and click tracking
'tracking' => [
'enabled' => true,
'open' => true,
'click' => true,
'allowed_domains' => [], // empty = allow all; ['example.com'] = allowlist
],
];
Add a form anywhere in your Blade views:
<form action="{{ route('kanpen.store') }}" method="POST">
@csrf
<input type="email" name="email" placeholder="Your email address" required>
<button type="submit">Subscribe</button>
</form>
@if (session('subscribed'))
<div class="alert alert-success">
{{ session('subscribed') }}
</div>
@endif
On success the user is redirected to the route defined in redirect_url with a subscribed session flash message.
A JSON endpoint is also available:
POST /kanpen-api/subscriber
Content-Type: application/json
{ "email": "[email protected]" }
Response 201 Created:
{ "created": true }
Duplicate emails return a 422 Unprocessable Entity with a validation error.
Add the HasNewsletterSubscription trait to any Eloquent model that has an email attribute:
use Mydnic\Kanpen\Traits\HasNewsletterSubscription;
class User extends Authenticatable
{
use HasNewsletterSubscription;
}
Then call the trait methods:
$user->subscribe(); // adds the user's email to subscribers
$user->unsubscribe(); // soft-deletes the subscriber record
$user->isSubscribed(); // returns bool
If verify is enabled in config, subscribe() automatically sends the verification email.
Set KANPEN_VERIFY=true in your .env (or set 'verify' => true in config) to enable double opt-in. When enabled:
subscribe() or web form submission.email_verified_at is not null) receive campaigns.You can customise every line of the verification email in the mail.verify config key.
The verification route is GET /kanpen/verify/{id}/{hash} — this is handled automatically.
Every subscriber gets a unique random unsubscribe_token generated automatically on creation. Use it to build a safe unsubscribe link — the subscriber's email address is never exposed in the URL:
<a href="{{ $subscriber->getUnsubscribeUrl() }}">Unsubscribe</a>
This generates a URL like /kanpen/unsubscribe/Xk9mP... (64-char opaque token). The subscriber record is soft-deleted, and the user sees the unsubscribe confirmation page (which you can publish and customise — see Publishing Assets).
The token is also injected automatically into all campaign emails via the default base.blade.php layout, so you don't need to add it manually to campaigns.
Note: For subscribers created before this version (without a token),
getUnsubscribeUrl()generates and persists a token on the fly. The backfill migration handles bulk assignment for existing rows.
Use the Campaign model directly:
use Mydnic\Kanpen\Models\Campaign;
$campaign = Campaign::create([
'name' => 'March Newsletter',
'subject' => 'What\'s new this month',
'from_name' => 'Acme Newsletter', // optional, falls back to config
'from_email' => '[email protected]', // optional, falls back to config
'reply_to' => '[email protected]', // optional
'content_html' => '<h1>Hello!</h1><p>Here is what\'s new...</p>',
]);
Campaigns are created in draft status and are not sent until you explicitly trigger a send.
Inject or resolve the SendCampaignAction and call execute():
use Mydnic\Kanpen\Actions\SendCampaignAction;
use Mydnic\Kanpen\Models\Campaign;
$campaign = Campaign::find(1);
app(SendCampaignAction::class)->execute($campaign);
This dispatches a queued job that:
sendingCampaignDelivery record with a unique tracking token per subscribersent once all jobs are dispatchedMake sure you have a queue worker running:
php artisan queue:work
Set scheduled_at on a campaign to send it at a specific time in the future:
use Mydnic\Kanpen\Models\Campaign;
$campaign = Campaign::create([
'name' => 'March Newsletter',
'subject' => 'What\'s new this month',
'content_html' => '<p>...</p>',
'scheduled_at' => now()->addDays(3), // send in 3 days
]);
The campaign stays in draft status and is sent automatically when its scheduled_at time passes.
The package registers the kanpen:dispatch-scheduled Artisan command on your application scheduler and runs it every minute:
kanpen:dispatch-scheduled
This command queries for all draft campaigns whose scheduled_at is in the past and calls SendCampaignAction::execute() on each of them. Internally this dispatches SendCampaignJob to your configured queue — the same path as an immediate send.
Important: Your scheduler must be running. Add this to your server's cron if you haven't already:
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1
If you prefer to schedule the command yourself (e.g. less frequently, or with a specific environment condition), set schedule to false in config and add the command manually in your Console/Kernel.php (Laravel 10) or bootstrap/app.php (Laravel 11+):
// config/kanpen.php
'campaigns' => [
'schedule' => false,
],
// bootstrap/app.php (Laravel 11+)
->withSchedule(function (Schedule $schedule) {
$schedule->command('kanpen:dispatch-scheduled')->everyFiveMinutes();
})
By default campaigns are rendered using the package's built-in email layout. You can point a campaign to any Blade view in your application:
$campaign = Campaign::create([
'name' => 'Special Announcement',
'subject' => 'Big news!',
'view' => 'emails.special-announcement', // your own Blade view
]);
Your view receives these variables:
| Variable | Type | Description |
|---------------|----------------|--------------------------------------|
| $campaign | Campaign | The campaign model |
| $send | CampaignDelivery | The per-subscriber send record |
| $subscriber | Subscriber | The subscriber receiving this email |
Example view:
{{-- resources/views/emails/special-announcement.blade.php --}}
<!DOCTYPE html>
<html>
<body>
<h1>{{ $campaign->subject }}</h1>
<p>Hi {{ $subscriber->email }},</p>
<p>We have big news for you!</p>
<a href="{{ $subscriber->getUnsubscribeUrl() }}">Unsubscribe</a>
</body>
</html>
Tracking (open pixel and link rewriting) is applied automatically to the rendered HTML regardless of which view is used.
A full REST API for managing campaigns is available under /kanpen-api/campaigns. The middleware protecting these routes defaults to ['api'] and is configurable via campaigns.middleware in config.
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | /kanpen-api/campaigns | List all campaigns (paginated) |
| POST | /kanpen-api/campaigns | Create a new campaign |
| GET | /kanpen-api/campaigns/{id} | Get campaign details + stats |
| PUT | /kanpen-api/campaigns/{id} | Update a draft campaign |
| DELETE | /kanpen-api/campaigns/{id} | Soft-delete a campaign |
| POST | /kanpen-api/campaigns/{id}/send | Dispatch the send job |
| POST | /kanpen-api/campaigns/{id}/test | Send a test copy to one address |
Create a campaign:
curl -X POST /kanpen-api/campaigns \
-H "Content-Type: application/json" \
-d '{
"name": "April Newsletter",
"subject": "Hello from April",
"content_html": "<p>This month...</p>"
}'
Get campaign stats:
{
"campaign": { "id": 1, "name": "April Newsletter", "status": "sent" },
"stats": {
"sent": 1200,
"opened": 340,
"clicked": 85,
"open_rate": 28.33,
"click_rate": 7.08
}
}
Send a test email:
curl -X POST /kanpen-api/campaigns/1/test \
-H "Content-Type: application/json" \
-d '{ "email": "[email protected]" }'
The email is sent immediately (not queued) as an exact copy of what subscribers would receive — same subject, same content, same tracking links (though the token won't exist in the database so opens/clicks won't be recorded). Does not create any CampaignDelivery records or alter the campaign's status or counts. Works for campaigns in any status — useful for previewing already-sent campaigns too.
To add authentication to campaign routes, update the middleware in config:
'campaigns' => [
'middleware' => ['api', 'auth:sanctum'],
],
When a campaign is sent, the package automatically processes every email's HTML before delivery:
Open tracking — a 1×1 transparent GIF pixel is injected just before </body>:
<img src="https://yourapp.com/kanpen/tracking/open/{token}" width="1" height="1" style="display:none;" />
When a mail client loads the pixel, opened_at is set and open_count is incremented on the CampaignDelivery record.
Click tracking — every <a href="..."> in the email is rewritten to go through a redirect proxy:
/kanpen/tracking/click/{token}?url=<base64-encoded-original-url>
When a subscriber clicks, the original URL is decoded, clicked_at is set, and the click is appended to click_log before the redirect.
Both tracking routes are public and do not require authentication. mailto: and #anchor links are left untouched.
You can disable tracking globally or individually:
// config/kanpen.php
'tracking' => [
'enabled' => false, // disables all tracking
'open' => false, // disables open pixel only
'click' => false, // disables click rewriting only
],
To prevent the click proxy from redirecting to arbitrary domains, set an allowlist:
'tracking' => [
'allowed_domains' => ['mysite.com', 'blog.mysite.com'],
],
Clicks to domains not on the list return a 403 response.
Listen to tracking events in your EventServiceProvider:
use Mydnic\Kanpen\Events\EmailOpened;
use Mydnic\Kanpen\Events\EmailLinkClicked;
protected $listen = [
EmailOpened::class => [
UpdateAnalyticsDashboardListener::class,
],
EmailLinkClicked::class => [
LogClickListener::class,
],
];
Both events carry the CampaignDelivery model (which has the campaign, subscriber, and all tracking timestamps).
You can automatically keep your application's users in sync with the subscribers table.
Add the HasNewsletterSubscription trait to your User model and implement the two required methods.
use Mydnic\Kanpen\Traits\HasNewsletterSubscription;
class User extends Authenticatable
{
use HasNewsletterSubscription;
/**
* Define when this user should be a subscriber.
* Any logic works — check a column, a role, a plan, a combination.
*/
public function shouldBeSubscribed(): bool
{
return $this->subscribed_to_newsletter;
}
/**
* Called automatically when the subscriber is removed from outside your app
* (e.g. the user clicks the unsubscribe link in an email).
* Use this to keep your own model in sync so the user isn't re-subscribed next time they save.
*/
public function onUnsubscribed(): void
{
$this->updateQuietly(['subscribed_to_newsletter' => false]);
}
}
How it works:
shouldBeSubscribed() returns true → the email is added to the subscribers table.email_verified_at set → it is copied to the subscriber record.shouldBeSubscribed() returns false → the subscriber record is soft-deleted.onUnsubscribed() is called on your model so your own data stays in sync.Important: Use
updateQuietly()insideonUnsubscribed()to avoid triggering thesavedevent and causing a sync loop.
Manual sync trigger:
You can also call syncSubscriberRecord() directly:
$user->syncSubscriberRecord();
To sync your existing users in bulk (e.g. after adding the trait to an app that already has users), use the kanpen:sync command:
php artisan kanpen:sync "App\Models\User"
This calls syncSubscriberRecord() on every record, which in turn calls your shouldBeSubscribed() implementation. Records that return true are subscribed (or restored if previously unsubscribed), records that return false are soft-deleted from the subscribers table.
All events live in the Mydnic\Kanpen\Events namespace.
| Event | Fired When | Properties |
|-------|-----------|------------|
| SubscriberCreated | A new subscriber is saved | $subscriber |
| SubscriberDeleted | A subscriber is deleted | $subscriber |
| SubscriberVerified | A subscriber verifies their email | $subscriber |
| CampaignDeliverying | A campaign's send job starts | $campaign |
| CampaignSent | All subscriber jobs are dispatched | $campaign |
| EmailOpened | A tracking pixel is loaded | $send |
| EmailLinkClicked | A tracked link is clicked | $send, $url |
Publish and customise the email layout and unsubscribe page:
php artisan vendor:publish --tag="kanpen-views"
Files are copied to resources/views/vendor/kanpen/:
resources/views/vendor/kanpen/
├── mail/
│ └── campaign.blade.php ← email template (full-doc passthrough or default HTML wrapper)
└── subscriber/
└── deleted.blade.php ← unsubscribe confirmation page
Laravel's view resolution picks up your published files automatically — no config change needed.
A first-party Filament plugin is available at mydnic/kanpen-filament-plugin.
It adds a full admin UI on top of Kanpen:
composer require mydnic/kanpen-filament-plugin
See the plugin README for full installation and usage instructions.
v2 is a breaking release. The following changes require attention:
PHP and Laravel requirements
Model namespace
The Subscriber model has moved from Mydnic\Subscribers\Subscriber to Mydnic\Kanpen\Models\Subscriber.
The old class still exists as a deprecated alias, so existing code continues to work, but you should update your imports:
// Before
use Mydnic\Subscribers\Subscriber;
// After
use Mydnic\Kanpen\Models\Subscriber;
Migrations
Publish and run the new migrations to create the campaigns and campaign_deliveries tables:
php artisan vendor:publish --tag="kanpen-migrations"
php artisan migrate
Events
The three subscriber events (SubscriberCreated, SubscriberDeleted, SubscriberVerified) no longer implement ShouldBroadcast. If you were broadcasting these events, re-implement broadcasting in your own listeners.
The MIT License (MIT). See LICENSE for details.