Subscribers #

Manage email contacts with validation, import/export, and metadata tracking.

Overview #

Subscribers represent email contacts:

  • Email Validation: Syntax, disposable, and MX record checking
  • Status Management: Active, unsubscribed, unconfirmed, bounced
  • Metadata: Custom JSON data for segmentation
  • List Management: Many-to-many list relationships
  • Import/Export: CSV bulk operations

Database Structure #

Schema::create('campaign_subscribers', function($table) {
    $table->increments('id');
    $table->integer('organization_id')->unsigned();
    $table->integer('status_id')->unsigned();
    $table->string('email')->unique();
    $table->string('first_name')->nullable();
    $table->string('last_name')->nullable();
    $table->json('meta_data')->nullable();
    $table->timestamp('confirmed_at')->nullable();
    $table->timestamp('unsubscribed_at')->nullable();
    $table->timestamps();
    $table->softDeletes();
});

Model Relationships #

class Subscriber extends Model
{
    public $belongsTo = [
        'organization' => Organization::class,
        'status' => SubscriberStatus::class
    ];

    public $belongsToMany = [
        'subscriberLists' => [
            SubscriberList::class,
            'table' => 'campaign_subscribers_subscriber_lists'
        ]
    ];
}

Creating Subscribers #

Basic Subscriber #

use Albrightlabs\Campaign\Models\Subscriber;

$subscriber = Subscriber::create([
    'organization_id' => $org->id,
    'status_id' => SubscriberStatus::STATUS_ACTIVE,
    'email' => 'user@example.com',
    'first_name' => 'John',
    'last_name' => 'Doe'
]);

// Subscriber is automatically added to the "Everyone" list
// No need to manually attach them to it

Automatic List Assignment #

Every subscriber is automatically added to the "Everyone" list when created:

  • Works for all creation methods (manual, import, API, frontend signup)
  • Happens automatically in Subscriber::afterCreate()
  • Ensures every subscriber can be targeted via the "Everyone" list
  • Cannot be removed from this list manually
// After creating a subscriber
$subscriber = Subscriber::create(['email' => 'user@example.com', ...]);

// They're already in the "Everyone" list
$everyoneList = SubscriberList::getEveryoneList($subscriber->organization_id);
$isInList = $everyoneList->subscribers()->where('id', $subscriber->id)->exists(); // true

With Lists #

$subscriber = Subscriber::create([
    'organization_id' => $org->id,
    'status_id' => SubscriberStatus::STATUS_ACTIVE,
    'email' => 'user@example.com'
]);

$subscriber->subscriberLists()->attach([1, 2, 3]);

With Metadata #

$subscriber->meta_data = [
    'source' => 'website',
    'plan' => 'premium',
    'interests' => ['tech', 'marketing']
];
$subscriber->save();

With Phone Number #

$subscriber = Subscriber::create([
    'organization_id' => $org->id,
    'email' => 'user@example.com',
    'first_name' => 'John',
    'last_name' => 'Doe',
    'phone' => '555-123-4567'
]);

📱 UI Feature: Phone numbers are automatically formatted with US phone masking (555-123-4567) in the backend form fields.

Managing Subscriber Lists #

Via Backend UI #

When viewing a subscriber's preview page, you can manage their list memberships:

  • Active Subscribers: Full editing capabilities - add or remove from lists
  • Archived Subscribers: Read-only view - cannot modify list memberships

This ensures archived subscriber data remains unchanged while allowing active management of current subscribers.

Programmatically #

// Add to lists
$subscriber->subscriberLists()->attach([1, 2, 3]);

// Remove from lists
$subscriber->subscriberLists()->detach([2]);

// Sync lists (replaces all)
$subscriber->subscriberLists()->sync([1, 3, 4]);

💡 Tip: Click on list names in the subscriber preview to quickly navigate to that list's details.

Email Validation #

Validation Process #

use Albrightlabs\Campaign\Classes\EmailValidator;

$validator = new EmailValidator();

// Validate email
$result = $validator->validate('user@example.com');

if ($result['is_valid']) {
    // Create subscriber
} else {
    // Show error: $result['reason']
}

Validation Levels #

// 1. Syntax only (fast)
$validator->validateSyntax($email);

// 2. Disposable check
$validator->isDisposable($email);

// 3. MX record verification
$validator->hasMxRecords($email);

// 4. Full validation
$result = $validator->validate($email);

Disposable Email Detection #

use Albrightlabs\Campaign\Classes\DisposableEmailChecker;

$checker = new DisposableEmailChecker();

if ($checker->isDisposable('temp@mailinator.com')) {
    throw new ValidationException(['email' => 'Disposable emails not allowed']);
}

Status Management #

Status Constants #

const STATUS_ACTIVE = 1;        // Can receive emails
const STATUS_UNSUBSCRIBED = 2;  // Opted out
const STATUS_UNCONFIRMED = 3;   // Email not verified
const STATUS_BOUNCED = 4;       // Hard bounce

Changing Status #

// Unsubscribe
$subscriber->status_id = SubscriberStatus::STATUS_UNSUBSCRIBED;
$subscriber->unsubscribed_at = now();
$subscriber->save();

// Resubscribe
$subscriber->status_id = SubscriberStatus::STATUS_ACTIVE;
$subscriber->unsubscribed_at = null;
$subscriber->save();

// Mark as bounced
$subscriber->status_id = SubscriberStatus::STATUS_BOUNCED;
$subscriber->save();

Import/Export #

CSV Import #

use Albrightlabs\Campaign\Models\SubscriberImport;

$import = new SubscriberImport;
$import->organization_id = $org->id;
$import->list_ids = [1, 2]; // Assign to lists
$import->import($csvFilePath);

CSV Export #

use Albrightlabs\Campaign\Models\SubscriberExport;

$export = new SubscriberExport;
$export->list_id = 1;
$export->status = 'active';
$csvPath = $export->export();

CSV Format #

email,first_name,last_name,meta_data
john@example.com,John,Doe,"{""source"":""website""}"
jane@example.com,Jane,Smith,

Querying Subscribers #

By Status #

// Active subscribers
$active = Subscriber::whereHas('status', function($q) {
    $q->where('code', 'active');
})->get();

// Unsubscribed
$unsubscribed = Subscriber::whereHas('status', function($q) {
    $q->where('code', 'unsubscribed');
})->get();

By List #

$subscribers = Subscriber::whereHas('subscriberLists', function($q) use ($listId) {
    $q->where('subscriber_list_id', $listId);
})->get();
$results = Subscriber::where(function($q) use ($search) {
    $q->where('email', 'LIKE', "%{$search}%")
      ->orWhere('first_name', 'LIKE', "%{$search}%")
      ->orWhere('last_name', 'LIKE', "%{$search}%");
})->get();

Metadata Management #

Setting Metadata #

$subscriber->meta_data = [
    'plan' => 'premium',
    'signup_source' => 'landing_page',
    'interests' => ['tech', 'business']
];
$subscriber->save();

Querying by Metadata #

// Find subscribers with specific metadata
$premium = Subscriber::whereJsonContains('meta_data->plan', 'premium')->get();

Updating Metadata #

$data = $subscriber->meta_data ?? [];
$data['last_activity'] = now()->toString();
$subscriber->meta_data = $data;
$subscriber->save();

Double Opt-In #

Confirmation Flow #

// 1. Create unconfirmed subscriber
$subscriber = Subscriber::create([
    'email' => $email,
    'status_id' => SubscriberStatus::STATUS_UNCONFIRMED,
    'organization_id' => $org->id
]);

// 2. Send confirmation email
$token = str_random(32);
// Store token, send email with confirmation link

// 3. On confirmation
$subscriber->status_id = SubscriberStatus::STATUS_ACTIVE;
$subscriber->confirmed_at = now();
$subscriber->save();

Validation Rules #

public $rules = [
    'organization_id' => 'required|exists:organizations,id',
    'email' => 'required|email|unique:campaign_subscribers,email',
    'first_name' => 'nullable|max:100',
    'last_name' => 'nullable|max:100',
    'meta_data' => 'nullable|json'
];

Best Practices #

Data Quality #

  • Validate emails before import
  • Block disposable email providers
  • Implement double opt-in
  • Clean bounced emails regularly
  • Remove inactive subscribers

List Hygiene #

  • Remove hard bounces immediately
  • Handle unsubscribes within 10 days
  • Segment by engagement level
  • Re-engagement campaigns for inactive
  • Archive old unsubscribes

Performance #

  • Index email column
  • Cache list counts
  • Batch import operations
  • Use queues for large imports
  • Soft delete for audit trail

Compliance #

  • Store consent timestamps
  • Provide easy unsubscribe
  • Honor unsubscribe immediately
  • Include physical address
  • GDPR data export/delete

Troubleshooting #

Duplicate Email Error #

// Check if exists
$exists = Subscriber::where('email', $email)
    ->where('organization_id', $org->id)
    ->withTrashed()
    ->first();

if ($exists && $exists->trashed()) {
    // Restore soft-deleted
    $exists->restore();
} elseif ($exists) {
    // Update existing
}

Import Failures #

// Validate CSV structure
// Check email format
// Verify list IDs exist
// Monitor error logs

Status Not Updating #

// Check status ID exists
$status = SubscriberStatus::find($statusId);

// Verify not locked by campaign
// Check database constraints

← Lists | Next: Email Validation →