Most apps grow a "send this to everyone" button sooner or later: a product announcement, a policy update, a back-in-stock alert. A loop in a controller is fine for ten recipients. For ten thousand it holds the request open, one bad address can stop the whole run, and once it starts you can't see how far it got or stop it.
That batching, progress and cancel plumbing gets rebuilt in project after project, so we packaged it once.
What Campaigns for Laravel does
Campaigns for Laravel sends one message to many recipients through Laravel queues and job batches. Every campaign is a job batch with one queued delivery job per recipient, so the request that starts it returns straight away and a queue worker does the sending.
- Address recipients by email address, by a contact record, or by any model that owns contacts (it builds on our contacts package). An address added twice still gets one message.
- Send now, or prepare now and start later.
- Follow progress while the batch runs: status, sent, failed and still to go. A failed delivery is never counted as sent.
- Cancel a running campaign and the remaining messages don't go out.
- One failing recipient doesn't stop the rest. A campaign only ends as failed when not one message got through.
- A campaign that is already sending can't be started a second time, not even by two requests at the same moment.
Install
composer require roundly-consulting/campaigns-for-laravel
php artisan vendor:publish --tag="campaigns-migrations" --tag="options-migrations" --tag="contacts-migrations"
php artisan migrate
Because every campaign is a job batch, the app needs Laravel's job_batches table (php artisan make:queue-batches-table if it has none) and a running queue worker.
Send a campaign
Create, address and send a campaign through the facade, then follow its progress:
use RoundlyConsulting\Campaigns\Facades\Campaigns;
$campaign = Campaigns::create('Buy today and spend less', '<p>Hello dear customer!</p>')
->from('no-reply@eshop.tld', 'Best E-Shop Ever')
->to(['john@doe.tld', 'jane@doe.tld']) // emails, Contact records or HasContacts owners
->dispatch(); // one queued delivery job per recipient
$progress = Campaigns::campaign($campaign->uuid)->progress();
$progress->percentage(); // share delivered so far: 50.0
$progress->failed; // deliveries that failed
$progress->remaining(); // recipients still to go
Campaigns::cancel($campaign->uuid); // stop the rest
If you'd rather not use a facade, inject CampaignManager for the same API, or call the action classes directly.
Prepare now, start later
prepare() stores the campaign and its recipients and opens the batch without sending anything. Start it when you're ready, from a scheduled command, after an approval, or from a second button:
use RoundlyConsulting\Campaigns\Facades\Campaigns;
$campaign = Campaigns::create('Newsletter', '<h1>Hi</h1>')
->from('news@example.com', 'News')
->to(['ann@example.com', 'bob@example.com'])
->prepare();
Campaigns::start($campaign->uuid);
Campaigns::campaign($campaign)->progress()->percentage();
Campaigns::campaign($campaign)->recipients(limit: 50);
Campaigns::cancel($campaign);
recipients() pages through the recipients in the order they were added, each with whether its message went out or failed, and the error if it failed.
Email, notifications or your own job
Out of the box each recipient gets an email from the campaign's sender. The delivery step is a swappable job: set process-recipient-job to the shipped SendCampaignNotification, point CAMPAIGNS_NOTIFICATION at a subclass of CampaignNotification, and the same campaign goes out over a Laravel notification channel such as SMS or push. Any other channel fits behind the ProcessesCampaignRecipient contract.
By default campaigns live in memory for the process that sends them, so you don't need campaign tables to get started. To follow progress across requests, for a dashboard, a delayed send or an audit trail, switch to the database store:
CAMPAIGNS_STORE='RoundlyConsulting\Campaigns\Stores\DatabaseCampaignStore'
CAMPAIGNS_NOTIFICATION='App\Notifications\ProductLaunchNotification'
Change send defaults without a deploy
The default sender, sending queue and notification channel are stored options (from our options package), seeded from config. A stored value wins, and forgetting it falls back to config again:
use RoundlyConsulting\Campaigns\Options\DefaultChannel;
use RoundlyConsulting\Campaigns\Options\DefaultFromAddress;
use RoundlyConsulting\Options\Facades\Options;
Options::set(DefaultFromAddress::class, 'news@example.com');
Options::set(DefaultChannel::class, 'vonage'); // SendCampaignNotification routes here
Options::get('campaigns.from-address'); // by registry key
Options::forget(DefaultFromAddress::class); // back to config('campaigns.from-address')
Events, commands and tests
Lifecycle events such as CampaignStarted, CampaignCompleted, CampaignCancelled, RecipientProcessed and RecipientFailed let your app log, notify or update a dashboard as a campaign moves. php artisan campaigns:list prints every campaign with its progress, and php artisan campaigns:cancel {uuid} stops one from the terminal.
In tests, Campaigns::fake() records every write and sends nothing: no batch, no queued job, no event. Assertions like assertDispatched(), assertCancelled() and assertRecipientFailed() check what your code would have sent.
Requirements
PHP ^8.4, Laravel 12 or 13, MIT license. Runtime dependencies are Laravel itself and a few of our own packages (contacts, options, enums and our package toolkit), nothing else.
Links
- Docs: https://roundly-consulting.com/open-source/docs/campaigns-for-laravel
- GitHub: https://github.com/roundly-consulting/campaigns-for-laravel
- Registry of all our packages, written for coding agents: https://github.com/roundly-consulting/roundly-for-laravel
Campaigns for Laravel is part of Roundly Consulting's open-source set of Laravel packages, and we'd like to hear how you use it.













