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();
Search #
$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