SMS marketing delivers engagement rates of 98% open rate and 45% response rate — dwarfing email. This guide walks you through building a compliant, effective SMS marketing system in Laravel with subscriber management, campaign creation, A/B testing, and analytics.
Start with a subscribers table and a double opt-in flow to ensure compliance:
<?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('sms_subscribers', function (Blueprint $table) {
$table->id();
$table->string('phone', 20)->unique();
$table->string('name')->nullable();
$table->string('email')->nullable();
$table->json('tags')->nullable();
$table->json('custom_fields')->nullable();
$table->string('source', 64)->nullable(); // web_signup, checkout, api, import
$table->timestamp('subscribed_at')->nullable();
$table->timestamp('unsubscribed_at')->nullable();
$table->string('unsubscribe_reason')->nullable();
$table->timestamp('verified_at')->nullable();
$table->string('verification_token', 64)->nullable();
$table->timestamps();
});
Schema::create('sms_campaigns', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('message');
$table->json('target_segments')->nullable();
$table->string('status', 32)->default('draft'); // draft, scheduled, sending, sent, cancelled
$table->timestamp('scheduled_at')->nullable();
$table->timestamp('sent_at')->nullable();
$table->unsignedInteger('total_recipients')->default(0);
$table->unsignedInteger('delivered_count')->default(0);
$table->unsignedInteger('failed_count')->default(0);
$table->unsignedInteger('click_count')->default(0);
$table->unsignedInteger('conversion_count')->default(0);
$table->timestamps();
});
}
};
---
Double opt-in flow in a controller:
```php
<?php
namespace App\Http\Controllers\Marketing;
use App\Models\SmsSubscriber;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class SubscriptionController extends Controller
{
public function subscribe(Request $request)
{
$data = $request->validate([
'phone' => 'required|phone:mobile',
'name' => 'nullable|string|max:255',
'email' => 'nullable|email',
'tags' => 'nullable|array',
]);
$subscriber = SmsSubscriber::firstOrNew(
['phone' => $data['phone']],
$data
);
if ($subscriber->exists && $subscriber->verified_at) {
return response()->json(['message' => 'Already subscribed.']);
}
$subscriber->verification_token = Str::random(32);
$subscriber->save();
// Send verification SMS
$subscriber->sendVerificationSms();
return response()->json([
'message' => 'Verification code sent. Please confirm to complete subscription.',
]);
}
public function verify(Request $request)
{
$data = $request->validate([
'phone' => 'required|phone:mobile',
'token' => 'required|string|size:32',
]);
$subscriber = SmsSubscriber::where('phone', $data['phone'])
->where('verification_token', $data['token'])
->whereNull('verified_at')
->firstOrFail();
$subscriber->update([
'verified_at' => now(),
'subscribed_at' => now(),
'verification_token' => null,
]);
return response()->json(['message' => 'Subscription confirmed!']);
}
}
---
## Compliance (TCPA, GDPR, CAN-SPAM)
Compliance is the most critical aspect of SMS marketing. Violations can result in fines of $500-$1,500 per message.
```php
<?php
namespace App\Services;
use App\Models\SmsSubscriber;
class ComplianceService
{
public function canSend(string $phone): bool
{
$subscriber = SmsSubscriber::where('phone', $phone)->first();
if (!$subscriber || !$subscriber->verified_at) {
return false; // No opt-in
}
if ($subscriber->unsubscribed_at) {
return false; // Previously unsubscribed
}
return true;
}
public function enforceConsent(SmsSubscriber $subscriber): void
{
// Record consent with timestamp
$subscriber->consentRecords()->create([
'ip' => request()->ip(),
'user_agent' => request()->userAgent(),
'consent_type' => 'marketing',
'consent_text' => 'I agree to receive marketing messages via SMS. Msg & data rates may apply.',
'consented_at' => now(),
]);
}
public function includeOptOut(string $message): string
{
// Append opt-out instructions
if (!str_contains($message, 'STOP')) {
$message .= "\n\nReply STOP to unsubscribe.";
}
return $message;
}
public function isWithinQuietHours(string $timezone = 'UTC'): bool
{
$localTime = now()->setTimezone($timezone);
return $localTime->hour < 8 || $localTime->hour >= 21;
}
}
---
## Creating Marketing Campaigns
Build a campaign manager with segmentation:
```php
<?php
namespace App\Http\Controllers\Marketing;
use App\Models\SmsCampaign;
use App\Models\SmsSubscriber;
use Illuminate\Http\Request;
class CampaignController extends Controller
{
public function store(Request $request)
{
$data = $request->validate([
'name' => 'required|string|max:255',
'message' => 'required|string|max:1600',
'target_segments' => 'nullable|array',
'target_segments.*' => 'string',
'scheduled_at' => 'nullable|date|after:now',
]);
$campaign = SmsCampaign::create($data);
// Calculate recipient count
$query = $this->buildSegmentQuery($data['target_segments'] ?? []);
$campaign->update([
'total_recipients' => $query->count(),
]);
return response()->json($campaign, 201);
}
protected function buildSegmentQuery(array $segments)
{
$query = SmsSubscriber::whereNotNull('verified_at')
->whereNull('unsubscribed_at');
foreach ($segments as $segment) {
match (true) {
str_starts_with($segment, 'tag:') => $query->whereJsonContains('tags', substr($segment, 4)),
str_starts_with($segment, 'source:') => $query->where('source', substr($segment, 7)),
str_starts_with($segment, 'since:') => $query->where('created_at', '>=', substr($segment, 6)),
default => null,
};
}
return $query;
}
public function send(SmsCampaign $campaign)
{
abort_if($campaign->status !== 'draft', 422, 'Campaign already sent or sending.');
$campaign->update(['status' => 'sending']);
SendCampaignJob::dispatch($campaign);
return response()->json(['message' => 'Campaign sending started.']);
}
}
---
## Segmentation and Targeting
Segment subscribers for more relevant campaigns:
```php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SmsSubscriber extends Model
{
public function scopeSegment($query, string $segment)
{
return match ($segment) {
'active_30d' => $query->whereHas('orders', fn($q) => $q->where('created_at', '>=', now()->subDays(30))),
'lapsed_90d' => $query->whereHas('orders', fn($q) => $q->where('created_at', '<', now()->subDays(90))),
'high_value' => $query->whereHas('orders', fn($q) => $q->havingRaw('SUM(total) > 500')),
'new_subscriber' => $query->where('created_at', '>=', now()->subDays(7)),
'birthday_month' => $query->whereMonth('custom_fields->birthday', now()->month),
default => $query,
};
}
public function scopeActive($query)
{
return $query->whereNotNull('verified_at')
->whereNull('unsubscribed_at');
}
}
---
## A/B Testing SMS Messages
Test message variants to optimize engagement:
```php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SmsCampaign extends Model
{
public function variants()
{
return $this->hasMany(CampaignVariant::class);
}
public function runAbTest(string $messageA, string $messageB, int $sampleSize = 100)
{
$subscribers = SmsSubscriber::active()
->inRandomOrder()
->limit($sampleSize * 2)
->get();
$groupA = $subscribers->take($sampleSize);
$groupB = $subscribers->skip($sampleSize);
$variantA = $this->variants()->create([
'message' => $messageA,
'sample_size' => $groupA->count(),
]);
$variantB = $this->variants()->create([
'message' => $messageB,
'sample_size' => $groupB->count(),
]);
// Send variants
$groupA->each(fn($s) => $s->sendSms($messageA, ['campaign_variant' => $variantA->id]));
$groupB->each(fn($s) => $s->sendSms($messageB, ['campaign_variant' => $variantB->id]));
return [
'variant_a' => $variantA,
'variant_b' => $variantB,
];
}
}
---
## Analytics and Reporting
Track every campaign with detailed analytics:
```php
<?php
namespace App\Console\Commands;
use App\Models\SmsCampaign;
use Illuminate\Console\Command;
class CampaignAnalytics extends Command
{
protected $signature = 'campaign:analytics {campaign}';
protected $description = 'Show analytics for a specific campaign';
public function handle()
{
$campaign = SmsCampaign::findOrFail($this->argument('campaign'));
$this->info("Campaign: {$campaign->name}");
$this->newLine();
$this->table(
['Metric', 'Value', 'Rate'],
[
['Recipients', $campaign->total_recipients, '100%'],
['Delivered', $campaign->delivered_count,
$campaign->total_recipients > 0
? round(($campaign->delivered_count / $campaign->total_recipients) * 100, 1) . '%'
: 'N/A'],
['Failed', $campaign->failed_count,
$campaign->total_recipients > 0
? round(($campaign->failed_count / $campaign->total_recipients) * 100, 1) . '%'
: 'N/A'],
['Clicked', $campaign->click_count,
$campaign->delivered_count > 0
? round(($campaign->click_count / $campaign->delivered_count) * 100, 1) . '%'
: 'N/A'],
['Conversions', $campaign->conversion_count,
$campaign->click_count > 0
? round(($campaign->conversion_count / $campaign->click_count) * 100, 1) . '%'
: 'N/A'],
]
);
$this->newLine();
$totalRevenue = $campaign->conversion_count * $campaign->average_order_value;
$this->info("Estimated Revenue: \$" . number_format($totalRevenue, 2));
$this->info("ROI: " . $this->calculateROI($campaign, $totalRevenue));
}
}
---
## Unsubscribe Handling
Make unsubscribing frictionless — it's required by law and improves sender reputation:
```php
<?php
namespace App\Http\Controllers\Sms;
use App\Models\SmsSubscriber;
use Illuminate\Http\Request;
class UnsubscribeController extends Controller
{
public function handleReply(Request $request)
{
$from = $request->input('From');
$body = strtoupper(trim($request->input('Body')));
if (!in_array($body, ['STOP', 'UNSUBSCRIBE', 'CANCEL', 'END', 'QUIT'])) {
return response('OK');
}
$subscriber = SmsSubscriber::where('phone', $from)->first();
if ($subscriber && !$subscriber->unsubscribed_at) {
$subscriber->update([
'unsubscribed_at' => now(),
'unsubscribe_reason' => 'reply_keyword',
]);
}
// Send final confirmation (required by TCPA)
$this->sendUnsubscribeConfirmation($from);
return response('OK');
}
public function unsubscribeLink(Request $request)
{
$token = $request->query('token');
$subscriber = SmsSubscriber::where('verification_token', $token)->firstOrFail();
$subscriber->update([
'unsubscribed_at' => now(),
'unsubscribe_reason' => 'link',
]);
return view('marketing.unsubscribed');
}
}
---
## Best Practices
1. **Always warm up new sender IDs** — start with small volumes and gradually increase
2. **Respect frequency caps** — no more than 4-5 marketing messages per month per subscriber
3. **Track consent records** — store IP, timestamp, and exact consent text for every opt-in
4. **Monitor bounce rates** — suppress numbers that consistently fail
5. **Test on multiple carriers** — rendering differs across carriers
6. **Use short URLs** — track clicks and stay within character limits
## Testing the Marketing System
```php
<?php
namespace Tests\Feature\Marketing;
use App\Models\SmsCampaign;
use App\Models\SmsSubscriber;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class MarketingCampaignTest extends TestCase
{
public function test_campaign_sends_to_segment()
{
Queue::fake();
$subscriber = SmsSubscriber::factory()->create([
'verified_at' => now(),
'tags' => ['vip'],
]);
$campaign = SmsCampaign::factory()->create([
'target_segments' => ['tag:vip'],
'status' => 'draft',
]);
$this->postJson("/api/campaigns/{$campaign->id}/send")
->assertOk();
Queue::assertPushed(SendCampaignJob::class);
}
public function test_unsubscribes_via_keyword()
{
$subscriber = SmsSubscriber::factory()->create([
'phone' => '+1234567890',
'verified_at' => now(),
]);
$this->postJson('/sms/reply', [
'From' => '+1234567890',
'Body' => 'STOP',
]);
$this->assertNotNull($subscriber->fresh()->unsubscribed_at);
}
}
---
## Conclusion
SMS marketing integration in Laravel is straightforward with the right architecture. Focus on compliance first, then build segmentation and analytics to drive campaign performance.
Ready to launch your SMS marketing? Get started with our [Laravel SMS API](/services/laravel-sms-api) or explore the [enterprise SMS solution](/services/enterprise-sms-solution-laravel) for high-volume campaigns.
For a complete notification infrastructure, see our [customer notification software](/services/laravel-customer-notification-software).