Building a bulk SMS platform requires careful architecture to handle large recipient lists, carrier rate limits, and delivery tracking. This guide walks through creating a production-ready bulk SMS system with Laravel.
Start with migrations for your core entities.
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('contacts', function (Blueprint $table) {
$table->id();
$table->string('phone_number');
$table->string('first_name')->nullable();
$table->string('last_name')->nullable();
$table->string('email')->nullable();
$table->json('metadata')->nullable();
$table->foreignId('group_id')->nullable()->constrained();
$table->timestamps();
});
}
};
---
### Groups Table
```php
Schema::create('groups', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('description')->nullable();
$table->foreignId('user_id')->constrained();
$table->timestamps();
});
---
### Campaigns Table
```php
Schema::create('campaigns', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('message_body');
$table->string('status')->default('draft'); // draft, scheduled, sending, completed, failed
$table->integer('total_recipients')->default(0);
$table->integer('sent_count')->default(0);
$table->integer('failed_count')->default(0);
$table->timestamp('scheduled_at')->nullable();
$table->timestamp('sent_at')->nullable();
$table->foreignId('user_id')->constrained();
$table->timestamps();
});
---
### Campaign Contact Pivot
```php
Schema::create('campaign_contact', function (Blueprint $table) {
$table->id();
$table->foreignId('campaign_id')->constrained()->cascadeOnDelete();
$table->foreignId('contact_id')->constrained()->cascadeOnDelete();
$table->string('status')->default('pending'); // pending, sent, failed
$table->string('message_sid')->nullable();
$table->string('error_message')->nullable();
$table->timestamp('delivered_at')->nullable();
$table->timestamps();
});
---
## Importing Contacts via CSV
Create an import command or controller method:
```php
<?php
namespace App\Imports;
use App\Models\Contact;
use App\Models\Group;
use Illuminate\Support\Collection;
use Maatwebsite\Excel\Concerns\ToCollection;
use Maatwebsite\Excel\Concerns\WithHeadingRow;
class ContactsImport implements ToCollection, WithHeadingRow
{
public function __construct(public Group $group) {}
public function collection(Collection $rows): void
{
$chunks = $rows->chunk(500);
foreach ($chunks as $chunk) {
$contacts = $chunk->map(fn ($row) => [
'phone_number' => $row['phone'],
'first_name' => $row['first_name'] ?? null,
'last_name' => $row['last_name'] ?? null,
'email' => $row['email'] ?? null,
'metadata' => json_encode($row->except(['phone', 'first_name', 'last_name', 'email'])->toArray()),
'group_id' => $this->group->id,
'created_at' => now(),
'updated_at' => now(),
])->toArray();
Contact::insert($contacts);
}
}
}
---
Process imports via a queued job for large files:
```php
<?php
namespace App\Jobs;
use App\Imports\ContactsImport;
use App\Models\Group;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Maatwebsite\Excel\Facades\Excel;
class ImportContactsJob implements ShouldQueue
{
use Dispatchable, Queueable;
public function __construct(
public string $filePath,
public Group $group
) {}
public function handle(): void
{
Excel::import(
new ContactsImport($this->group),
$this->filePath
);
}
}
---
## Creating SMS Campaigns
Build a campaign service:
```php
<?php
namespace App\Services;
use App\Models\Campaign;
class CampaignService
{
public function create(array $data, array $groupIds): Campaign
{
$campaign = Campaign::create($data);
$contactIds = \App\Models\Contact::whereIn('group_id', $groupIds)
->pluck('id');
$campaign->contacts()->attach($contactIds);
$campaign->update(['total_recipients' => $contactIds->count()]);
return $campaign;
}
public function launch(Campaign $campaign): void
{
$campaign->update(['status' => 'sending']);
$campaign->contacts()
->wherePivot('status', 'pending')
->chunk(100, function ($contacts) use ($campaign) {
SendCampaignBatchJob::dispatch($campaign, $contacts);
});
}
}
---
## Chunking Large Recipient Lists
When sending to thousands of contacts, chunking prevents memory exhaustion:
```php
<?php
namespace App\Jobs;
use App\Models\Campaign;
use App\Services\TwilioService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Collection;
class SendCampaignBatchJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable;
public $timeout = 300;
public function __construct(
public Campaign $campaign,
public Collection $contacts
) {}
public function handle(TwilioService $twilio): void
{
foreach ($this->contacts as $contact) {
try {
$result = $twilio->sendSms(
$contact->phone_number,
$this->campaign->message_body
);
$this->campaign->contacts()
->updateExistingPivot($contact->id, [
'status' => 'sent',
'message_sid' => $result['sid'],
]);
$this->campaign->increment('sent_count');
} catch (\Exception $e) {
$this->campaign->contacts()
->updateExistingPivot($contact->id, [
'status' => 'failed',
'error_message' => $e->getMessage(),
]);
$this->campaign->increment('failed_count');
}
}
}
}
---
## Rate Limiting to Avoid Carrier Blocks
Carriers impose strict rate limits. Implement a rate limiter:
```php
<?php
namespace App\Services;
use Illuminate\Cache\RateLimiter;
class SmsRateLimiter
{
public function __construct(
protected RateLimiter $limiter
) {}
public function allow(string $phoneNumber): bool
{
return $this->limiter->tooManyAttempts(
"sms:{$phoneNumber}",
10, // max 10 messages
60 // per 60 seconds
) === false;
}
public function consume(string $phoneNumber): void
{
$this->limiter->hit("sms:{$phoneNumber}", 60);
}
}
---
Update your sending job to use the rate limiter:
```php
use Illuminate\Support\Facades\RateLimiter;
$executed = RateLimiter::attempt(
"sms-send",
$perSecond = 20,
function () use ($twilio, $contact, $campaign) {
$result = $twilio->sendSms(
$contact->phone_number,
$campaign->message_body
);
// update pivot...
},
$decaySeconds = 1
);
if (!$executed) {
// Re-queue with delay
SendCampaignBatchJob::dispatch(
$this->campaign,
$this->contacts
)->delay(now()->addSeconds(2));
}
---
## Delivery Tracking and Status Callbacks
Configure a status callback URL when sending through Twilio:
```php
public function sendWithTracking(string $to, string $message, int $campaignContactId): array
{
$this->client->messages->create($to, [
'from' => $this->fromNumber,
'body' => $message,
'statusCallback' => route('webhooks.twilio.status'),
]);
}
---
Handle the callback:
```php
<?php
namespace App\Http\Controllers;
use App\Models\Campaign;
use App\Models\Contact;
use Illuminate\Http\Request;
class CampaignStatusController extends Controller
{
public function handle(Request $request): \Illuminate\Http\Response
{
$validated = $request->validate([
'MessageSid' => 'required|string',
'MessageStatus' => 'required|string',
]);
$pivot = \DB::table('campaign_contact')
->where('message_sid', $validated['MessageSid'])
->first();
if ($pivot) {
$status = match ($validated['MessageStatus']) {
'delivered' => 'delivered',
'failed', 'undelivered' => 'failed',
default => $pivot->status,
};
\DB::table('campaign_contact')
->where('id', $pivot->id)
->update([
'status' => $status,
'delivered_at' => $status === 'delivered' ? now() : null,
]);
if ($status === 'delivered') {
Campaign::where('id', $pivot->campaign_id)
->increment('sent_count');
} else {
Campaign::where('id', $pivot->campaign_id)
->increment('failed_count');
}
}
return response()->json(['status' => 'ok']);
}
}
---
## Campaign Analytics
Provide campaign-level statistics:
```php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Campaign extends Model
{
public function analytics(): array
{
$totals = $this->contacts()
->selectRaw("
COUNT(*) as total,
SUM(CASE WHEN campaign_contact.status = 'sent' THEN 1 ELSE 0 END) as sent,
SUM(CASE WHEN campaign_contact.status = 'delivered' THEN 1 ELSE 0 END) as delivered,
SUM(CASE WHEN campaign_contact.status = 'failed' THEN 1 ELSE 0 END) as failed,
ROUND(AVG(CASE WHEN campaign_contact.delivered_at IS NOT NULL
THEN TIMESTAMPDIFF(SECOND, campaign_contact.created_at, campaign_contact.delivered_at)
ELSE NULL END), 2) as avg_delivery_seconds
")
->first()
->toArray();
$totals['delivery_rate'] = $totals['total'] > 0
? round(($totals['delivered'] / $totals['total']) * 100, 2)
: 0;
return $totals;
}
}
---
Display these metrics in a dashboard view or expose them via API:
```php
Route::get('/campaigns/{campaign}/analytics', [CampaignAnalyticsController::class, 'show']);
---
## Related Articles
- [Twilio SMS Integration with Laravel (Complete Guide 2026)](/blog/laravel-twilio-sms-integration)
- [SMS Gateway Integration in Laravel (Multi-Provider Guide)](/blog/laravel-sms-gateway-integration)
- [Laravel Custom SMS Channel for Notifications](/blog/laravel-custom-sms-channel)