Building a Bulk SMS Platform with Laravel (Step-by-Step)

Building a Bulk SMS Platform with Laravel (Step-by-Step)
Laravel SMS Team
Laravel SMS Team
June 21, 2026

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.

Database Schema

Start with migrations for your core entities.

Contacts Table

<?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)