Email Campaign Targeting
Difficulty: Basic | Time: 35 minutes | Integration: Collections, Jobs, Events, Commands
Build a flexible email targeting system that segments users based on complex criteria using composable specifications with background job processing.
The Problem: Hardcoded Email Segments
Your email marketing system has grown organically with hardcoded user segments, making it impossible to create flexible, reusable targeting criteria.
🔍 Examining the Existing Code
Here's the problematic email service with rigid targeting logic:
<?php
namespace App\Services;
use App\Models\User;
use App\Models\EmailCampaign;
use App\Jobs\SendEmailJob;
use Carbon\Carbon;
class EmailCampaignService
{
public function sendToActiveUsers(EmailCampaign $campaign): int
{
// Hardcoded "active user" definition
$users = User::where('email_verified_at', '!=', null)
->where('last_login_at', '>=', now()->subDays(30))
->where('is_suspended', false)
->where('unsubscribed_at', null)
->get();
foreach ($users as $user) {
SendEmailJob::dispatch($campaign, $user);
}
return $users->count();
}
public function sendToPremiumUsers(EmailCampaign $campaign): int
{
// Another hardcoded segment - duplicate logic!
$users = User::where('email_verified_at', '!=', null)
->where('subscription_tier', 'premium')
->where('subscription_expires_at', '>', now())
->where('is_suspended', false)
->where('unsubscribed_at', null)
->get();
foreach ($users as $user) {
SendEmailJob::dispatch($campaign, $user);
}
return $users->count();
}
public function sendToEngagedUsers(EmailCampaign $campaign): int
{
// Even more duplicate logic with slight variations
$engagedUsers = collect();
$recentlyActive = User::where('last_login_at', '>=', now()->subDays(7))
->where('email_verified_at', '!=', null)
->where('is_suspended', false)
->where('unsubscribed_at', null)
->get();
foreach ($recentlyActive as $user) {
// Check if user has engaged with emails in last 30 days
$emailEngagement = $user->emailClicks()
->where('created_at', '>=', now()->subDays(30))
->count();
if ($emailEngagement >= 3) {
$engagedUsers->push($user);
}
}
foreach ($engagedUsers as $user) {
SendEmailJob::dispatch($campaign, $user);
}
return $engagedUsers->count();
}
public function sendToInactiveUsers(EmailCampaign $campaign): int
{
// Winback campaign logic - more hardcoded rules
$users = User::where('email_verified_at', '!=', null)
->where('last_login_at', '<', now()->subDays(60))
->where('last_login_at', '>=', now()->subDays(180)) // Not completely dormant
->where('is_suspended', false)
->where('unsubscribed_at', null)
->get();
// Filter out users who've received winback emails recently
$filteredUsers = $users->filter(function ($user) {
return $user->emailCampaignHistory()
->where('campaign_type', 'winback')
->where('sent_at', '>=', now()->subDays(30))
->count() === 0;
});
foreach ($filteredUsers as $user) {
SendEmailJob::dispatch($campaign, $user);
}
return $filteredUsers->count();
}
public function getTargetableUserCount(string $segment): int
{
// Duplicate query logic again! 😱
return match($segment) {
'active' => User::where('email_verified_at', '!=', null)
->where('last_login_at', '>=', now()->subDays(30))
->where('is_suspended', false)
->where('unsubscribed_at', null)
->count(),
'premium' => User::where('email_verified_at', '!=', null)
->where('subscription_tier', 'premium')
->where('subscription_expires_at', '>', now())
->where('is_suspended', false)
->where('unsubscribed_at', null)
->count(),
// ... more duplicated logic
default => 0
};
}
}
🎯 Business Logic Analysis
Let's identify the distinct targeting criteria:
- Email Verified: User has confirmed their email address
- Account Status: Not suspended and not unsubscribed
- Activity Level: Recently active, engaged, or inactive users
- Subscription Tier: Free, premium, enterprise users
- Email Engagement: Users who click/open emails frequently
- Campaign History: Haven't received specific campaign types recently
- Geographic Targeting: Users in specific regions/timezones
Step 1: Generate Base Specifications
Using the Artisan command to create our targeting specifications:
# Core targeting specifications
php artisan make:specification Email/EmailVerifiedSpecification --model=User
php artisan make:specification Email/AccountEligibilitySpecification --model=User
php artisan make:specification Email/ActivityLevelSpecification --model=User
php artisan make:specification Email/SubscriptionTierSpecification --model=User
php artisan make:specification Email/EmailEngagementSpecification --model=User
php artisan make:specification Email/CampaignHistorySpecification --model=User
php artisan make:specification Email/GeographicTargetingSpecification --model=User
# Composite specifications for common segments
php artisan make:specification Email/ActiveUserSegmentSpecification --composite
php artisan make:specification Email/PremiumUserSegmentSpecification --composite
php artisan make:specification Email/WinbackSegmentSpecification --composite
Step 2: Implement Core Specifications
EmailVerifiedSpecification
<?php
namespace App\Specifications\Email;
use App\Models\User;
use DangerWayne\LaravelSpecifications\AbstractSpecification;
use Illuminate\Database\Eloquent\Builder;
class EmailVerifiedSpecification extends AbstractSpecification
{
public function isSatisfiedBy(mixed $candidate): bool
{
if (!$candidate instanceof User) {
return false;
}
return !is_null($candidate->email_verified_at);
}
public function toQuery(Builder $query): Builder
{
return $query->whereNotNull('email_verified_at');
}
}
AccountEligibilitySpecification
<?php
namespace App\Specifications\Email;
use App\Models\User;
use DangerWayne\LaravelSpecifications\AbstractSpecification;
use Illuminate\Database\Eloquent\Builder;
class AccountEligibilitySpecification extends AbstractSpecification
{
public function isSatisfiedBy(mixed $candidate): bool
{
if (!$candidate instanceof User) {
return false;
}
return !$candidate->is_suspended
&& is_null($candidate->unsubscribed_at);
}
public function toQuery(Builder $query): Builder
{
return $query->where('is_suspended', false)
->whereNull('unsubscribed_at');
}
}
ActivityLevelSpecification
<?php
namespace App\Specifications\Email;
use App\Models\User;
use DangerWayne\LaravelSpecifications\AbstractSpecification;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
class ActivityLevelSpecification extends AbstractSpecification
{
public const ACTIVE = 'active';
public const INACTIVE = 'inactive';
public const DORMANT = 'dormant';
public function __construct(
private readonly string $level,
private readonly ?Carbon $referenceDate = null
) {}
public function isSatisfiedBy(mixed $candidate): bool
{
if (!$candidate instanceof User) {
return false;
}
$referenceDate = $this->referenceDate ?? now();
return match($this->level) {
self::ACTIVE => $this->isActive($candidate, $referenceDate),
self::INACTIVE => $this->isInactive($candidate, $referenceDate),
self::DORMANT => $this->isDormant($candidate, $referenceDate),
default => false
};
}
public function toQuery(Builder $query): Builder
{
$referenceDate = $this->referenceDate ?? now();
return match($this->level) {
self::ACTIVE => $query->where('last_login_at', '>=', $referenceDate->subDays(30)),
self::INACTIVE => $query->where('last_login_at', '<', $referenceDate->subDays(30))
->where('last_login_at', '>=', $referenceDate->subDays(180)),
self::DORMANT => $query->where('last_login_at', '<', $referenceDate->subDays(180)),
default => $query->whereRaw('1 = 0')
};
}
private function isActive(User $user, Carbon $date): bool
{
return $user->last_login_at && $user->last_login_at >= $date->subDays(30);
}
private function isInactive(User $user, Carbon $date): bool
{
if (!$user->last_login_at) {
return false;
}
return $user->last_login_at < $date->subDays(30)
&& $user->last_login_at >= $date->subDays(180);
}
private function isDormant(User $user, Carbon $date): bool
{
return !$user->last_login_at || $user->last_login_at < $date->subDays(180);
}
}
SubscriptionTierSpecification
<?php
namespace App\Specifications\Email;
use App\Models\User;
use DangerWayne\LaravelSpecifications\AbstractSpecification;
use Illuminate\Database\Eloquent\Builder;
class SubscriptionTierSpecification extends AbstractSpecification
{
public function __construct(
private readonly string|array $requiredTiers,
private readonly bool $requireActive = true
) {}
public function isSatisfiedBy(mixed $candidate): bool
{
if (!$candidate instanceof User) {
return false;
}
$tiers = is_array($this->requiredTiers)
? $this->requiredTiers
: [$this->requiredTiers];
$hasCorrectTier = in_array($candidate->subscription_tier, $tiers);
if (!$hasCorrectTier) {
return false;
}
if ($this->requireActive) {
return $candidate->subscription_expires_at &&
$candidate->subscription_expires_at > now();
}
return true;
}
public function toQuery(Builder $query): Builder
{
$tiers = is_array($this->requiredTiers)
? $this->requiredTiers
: [$this->requiredTiers];
$query = $query->whereIn('subscription_tier', $tiers);
if ($this->requireActive) {
$query->where('subscription_expires_at', '>', now());
}
return $query;
}
}
EmailEngagementSpecification
<?php
namespace App\Specifications\Email;
use App\Models\User;
use DangerWayne\LaravelSpecifications\AbstractSpecification;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
class EmailEngagementSpecification extends AbstractSpecification
{
public function __construct(
private readonly int $minimumEngagements = 3,
private readonly int $periodDays = 30,
private readonly ?Carbon $referenceDate = null
) {}
public function isSatisfiedBy(mixed $candidate): bool
{
if (!$candidate instanceof User) {
return false;
}
$referenceDate = $this->referenceDate ?? now();
$cutoffDate = $referenceDate->subDays($this->periodDays);
$engagementCount = $candidate->emailClicks()
->where('created_at', '>=', $cutoffDate)
->count();
return $engagementCount >= $this->minimumEngagements;
}
public function toQuery(Builder $query): Builder
{
$referenceDate = $this->referenceDate ?? now();
$cutoffDate = $referenceDate->subDays($this->periodDays);
return $query->whereHas('emailClicks', function ($q) use ($cutoffDate) {
$q->where('created_at', '>=', $cutoffDate);
}, '>=', $this->minimumEngagements);
}
public function getEngagementLevel(User $user): string
{
$referenceDate = $this->referenceDate ?? now();
$cutoffDate = $referenceDate->subDays($this->periodDays);
$count = $user->emailClicks()
->where('created_at', '>=', $cutoffDate)
->count();
return match(true) {
$count >= 10 => 'highly_engaged',
$count >= 5 => 'engaged',
$count >= 1 => 'somewhat_engaged',
default => 'not_engaged'
};
}
}
CampaignHistorySpecification
<?php
namespace App\Specifications\Email;
use App\Models\User;
use DangerWayne\LaravelSpecifications\AbstractSpecification;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
class CampaignHistorySpecification extends AbstractSpecification
{
public function __construct(
private readonly string $campaignType,
private readonly int $daysSinceLastCampaign,
private readonly ?Carbon $referenceDate = null
) {}
public function isSatisfiedBy(mixed $candidate): bool
{
if (!$candidate instanceof User) {
return false;
}
$referenceDate = $this->referenceDate ?? now();
$cutoffDate = $referenceDate->subDays($this->daysSinceLastCampaign);
$recentCampaigns = $candidate->emailCampaignHistory()
->where('campaign_type', $this->campaignType)
->where('sent_at', '>=', $cutoffDate)
->count();
return $recentCampaigns === 0;
}
public function toQuery(Builder $query): Builder
{
$referenceDate = $this->referenceDate ?? now();
$cutoffDate = $referenceDate->subDays($this->daysSinceLastCampaign);
return $query->whereDoesntHave('emailCampaignHistory', function ($q) use ($cutoffDate) {
$q->where('campaign_type', $this->campaignType)
->where('sent_at', '>=', $cutoffDate);
});
}
public function getDaysSinceLastCampaign(User $user): ?int
{
$lastCampaign = $user->emailCampaignHistory()
->where('campaign_type', $this->campaignType)
->latest('sent_at')
->first();
return $lastCampaign
? $lastCampaign->sent_at->diffInDays(now())
: null;
}
}
Step 3: Create Composite Specifications
Build common segments from our base specifications:
<?php
namespace App\Specifications\Email;
use DangerWayne\LaravelSpecifications\AbstractSpecification;
class ActiveUserSegmentSpecification extends AbstractSpecification
{
private $compositeSpec;
public function __construct()
{
$this->compositeSpec = (new EmailVerifiedSpecification())
->and(new AccountEligibilitySpecification())
->and(new ActivityLevelSpecification(ActivityLevelSpecification::ACTIVE));
}
public function isSatisfiedBy(mixed $candidate): bool
{
return $this->compositeSpec->isSatisfiedBy($candidate);
}
public function toQuery($query)
{
return $this->compositeSpec->toQuery($query);
}
}
<?php
namespace App\Specifications\Email;
use DangerWayne\LaravelSpecifications\AbstractSpecification;
class PremiumUserSegmentSpecification extends AbstractSpecification
{
private $compositeSpec;
public function __construct()
{
$this->compositeSpec = (new EmailVerifiedSpecification())
->and(new AccountEligibilitySpecification())
->and(new SubscriptionTierSpecification('premium'));
}
public function isSatisfiedBy(mixed $candidate): bool
{
return $this->compositeSpec->isSatisfiedBy($candidate);
}
public function toQuery($query)
{
return $this->compositeSpec->toQuery($query);
}
}
<?php
namespace App\Specifications\Email;
use DangerWayne\LaravelSpecifications\AbstractSpecification;
class WinbackSegmentSpecification extends AbstractSpecification
{
private $compositeSpec;
public function __construct()
{
$this->compositeSpec = (new EmailVerifiedSpecification())
->and(new AccountEligibilitySpecification())
->and(new ActivityLevelSpecification(ActivityLevelSpecification::INACTIVE))
->and(new CampaignHistorySpecification('winback', 30));
}
public function isSatisfiedBy(mixed $candidate): bool
{
return $this->compositeSpec->isSatisfiedBy($candidate);
}
public function toQuery($query)
{
return $this->compositeSpec->toQuery($query);
}
}
Step 4: Refactored Email Service
Now let's create a clean, flexible email service:
<?php
namespace App\Services;
use App\Models\User;
use App\Models\EmailCampaign;
use App\Jobs\SendEmailJob;
use DangerWayne\LaravelSpecifications\SpecificationInterface;
use Illuminate\Database\Eloquent\Collection;
class EmailTargetingService
{
public function sendToSegment(
EmailCampaign $campaign,
SpecificationInterface $targetingSpec,
int $batchSize = 100
): int {
$query = User::query();
$targetedQuery = $targetingSpec->toQuery($query);
$totalSent = 0;
$targetedQuery->chunk($batchSize, function (Collection $users) use ($campaign, &$totalSent) {
foreach ($users as $user) {
SendEmailJob::dispatch($campaign, $user);
$totalSent++;
}
});
return $totalSent;
}
public function getTargetableCount(SpecificationInterface $targetingSpec): int
{
$query = User::query();
return $targetingSpec->toQuery($query)->count();
}
public function previewTargetedUsers(
SpecificationInterface $targetingSpec,
int $limit = 10
): Collection {
$query = User::query();
return $targetingSpec->toQuery($query)->limit($limit)->get();
}
public function analyzeSegment(SpecificationInterface $targetingSpec): array
{
$query = User::query();
$targetedUsers = $targetingSpec->toQuery($query);
return [
'total_count' => $targetedUsers->count(),
'subscription_breakdown' => $targetedUsers
->selectRaw('subscription_tier, COUNT(*) as count')
->groupBy('subscription_tier')
->pluck('count', 'subscription_tier')
->toArray(),
'activity_stats' => [
'avg_days_since_login' => $targetedUsers
->whereNotNull('last_login_at')
->avg(\DB::raw('DATEDIFF(NOW(), last_login_at)')),
'verified_percentage' => ($targetedUsers->whereNotNull('email_verified_at')->count() /
max($targetedUsers->count(), 1)) * 100,
],
];
}
}
Step 5: Dynamic Segment Builder
Create a flexible segment builder for marketers:
<?php
namespace App\Services;
use App\Specifications\Email\EmailVerifiedSpecification;
use App\Specifications\Email\AccountEligibilitySpecification;
use App\Specifications\Email\ActivityLevelSpecification;
use App\Specifications\Email\SubscriptionTierSpecification;
use App\Specifications\Email\EmailEngagementSpecification;
use App\Specifications\Email\CampaignHistorySpecification;
use DangerWayne\LaravelSpecifications\SpecificationInterface;
class EmailSegmentBuilder
{
private array $specifications = [];
public function emailVerified(): self
{
$this->specifications[] = new EmailVerifiedSpecification();
return $this;
}
public function accountEligible(): self
{
$this->specifications[] = new AccountEligibilitySpecification();
return $this;
}
public function activityLevel(string $level): self
{
$this->specifications[] = new ActivityLevelSpecification($level);
return $this;
}
public function subscriptionTier(string|array $tiers): self
{
$this->specifications[] = new SubscriptionTierSpecification($tiers);
return $this;
}
public function engaged(int $minimumEngagements = 3, int $periodDays = 30): self
{
$this->specifications[] = new EmailEngagementSpecification($minimumEngagements, $periodDays);
return $this;
}
public function excludeRecentCampaign(string $campaignType, int $daysSince): self
{
$this->specifications[] = new CampaignHistorySpecification($campaignType, $daysSince);
return $this;
}
public function build(): SpecificationInterface
{
if (empty($this->specifications)) {
throw new \InvalidArgumentException('At least one specification must be added');
}
$composite = array_shift($this->specifications);
foreach ($this->specifications as $spec) {
$composite = $composite->and($spec);
}
return $composite;
}
public function reset(): self
{
$this->specifications = [];
return $this;
}
}
Step 6: Controller Integration
Create a clean controller for campaign management:
<?php
namespace App\Http\Controllers;
use App\Models\EmailCampaign;
use App\Services\EmailTargetingService;
use App\Services\EmailSegmentBuilder;
use App\Specifications\Email\ActiveUserSegmentSpecification;
use App\Specifications\Email\PremiumUserSegmentSpecification;
use App\Specifications\Email\WinbackSegmentSpecification;
use Illuminate\Http\Request;
class EmailCampaignController extends Controller
{
public function __construct(
private readonly EmailTargetingService $targetingService,
private readonly EmailSegmentBuilder $segmentBuilder
) {}
public function sendToActiveUsers(EmailCampaign $campaign)
{
$segment = new ActiveUserSegmentSpecification();
$sentCount = $this->targetingService->sendToSegment($campaign, $segment);
return response()->json([
'message' => "Campaign sent to {$sentCount} active users",
'sent_count' => $sentCount
]);
}
public function sendToPremiumUsers(EmailCampaign $campaign)
{
$segment = new PremiumUserSegmentSpecification();
$sentCount = $this->targetingService->sendToSegment($campaign, $segment);
return response()->json([
'message' => "Campaign sent to {$sentCount} premium users",
'sent_count' => $sentCount
]);
}
public function sendCustomSegment(Request $request, EmailCampaign $campaign)
{
$builder = $this->segmentBuilder->emailVerified()->accountEligible();
if ($request->has('activity_level')) {
$builder->activityLevel($request->activity_level);
}
if ($request->has('subscription_tiers')) {
$builder->subscriptionTier($request->subscription_tiers);
}
if ($request->boolean('engaged_only')) {
$builder->engaged(3, 30);
}
if ($request->has('exclude_campaign')) {
$builder->excludeRecentCampaign($request->exclude_campaign, 30);
}
$segment = $builder->build();
$sentCount = $this->targetingService->sendToSegment($campaign, $segment);
return response()->json([
'message' => "Custom campaign sent to {$sentCount} users",
'sent_count' => $sentCount
]);
}
public function previewSegment(Request $request)
{
$builder = $this->segmentBuilder->emailVerified()->accountEligible();
// Apply same filters as sendCustomSegment
if ($request->has('activity_level')) {
$builder->activityLevel($request->activity_level);
}
if ($request->has('subscription_tiers')) {
$builder->subscriptionTier($request->subscription_tiers);
}
$segment = $builder->build();
return response()->json([
'targetable_count' => $this->targetingService->getTargetableCount($segment),
'preview_users' => $this->targetingService->previewTargetedUsers($segment, 5),
'segment_analysis' => $this->targetingService->analyzeSegment($segment)
]);
}
}
Step 7: Artisan Command Integration
Create an Artisan command for running campaigns:
<?php
namespace App\Console\Commands;
use App\Models\EmailCampaign;
use App\Services\EmailTargetingService;
use App\Services\EmailSegmentBuilder;
use Illuminate\Console\Command;
class SendEmailCampaignCommand extends Command
{
protected $signature = 'email:send-campaign
{campaign : The campaign ID}
{--segment=active : The segment to target}
{--tier=* : Subscription tiers to include}
{--engaged : Only include engaged users}
{--dry-run : Preview without sending}';
protected $description = 'Send email campaign to specified user segment';
public function __construct(
private readonly EmailTargetingService $targetingService,
private readonly EmailSegmentBuilder $segmentBuilder
) {
parent::__construct();
}
public function handle(): int
{
$campaign = EmailCampaign::findOrFail($this->argument('campaign'));
$segment = $this->buildSegment();
if ($this->option('dry-run')) {
return $this->previewCampaign($campaign, $segment);
}
return $this->executeCampaign($campaign, $segment);
}
private function buildSegment()
{
$builder = $this->segmentBuilder->emailVerified()->accountEligible();
$segmentType = $this->option('segment');
if ($segmentType === 'active') {
$builder->activityLevel('active');
} elseif ($segmentType === 'inactive') {
$builder->activityLevel('inactive');
}
if ($tiers = $this->option('tier')) {
$builder->subscriptionTier($tiers);
}
if ($this->option('engaged')) {
$builder->engaged();
}
return $builder->build();
}
private function previewCampaign(EmailCampaign $campaign, $segment): int
{
$count = $this->targetingService->getTargetableCount($segment);
$preview = $this->targetingService->previewTargetedUsers($segment, 3);
$this->info("Campaign: {$campaign->subject}");
$this->info("Targetable users: {$count}");
$this->line('');
$this->info('Preview users:');
foreach ($preview as $user) {
$this->line("- {$user->name} ({$user->email})");
}
return self::SUCCESS;
}
private function executeCampaign(EmailCampaign $campaign, $segment): int
{
$this->info("Sending campaign: {$campaign->subject}");
$sentCount = $this->targetingService->sendToSegment($campaign, $segment);
$this->info("Campaign sent to {$sentCount} users");
return self::SUCCESS;
}
}
Step 8: Testing Setup
Comprehensive Specification Tests
<?php
namespace Tests\Unit\Specifications\Email;
use App\Models\User;
use App\Specifications\Email\ActivityLevelSpecification;
use App\Specifications\Email\EmailEngagementSpecification;
use App\Specifications\Email\SubscriptionTierSpecification;
use App\Services\EmailSegmentBuilder;
use Tests\TestCase;
use Carbon\Carbon;
class EmailTargetingSpecificationTest extends TestCase
{
/** @test */
public function activity_level_specification_identifies_active_users()
{
$activeUser = User::factory()->create([
'last_login_at' => now()->subDays(15)
]);
$inactiveUser = User::factory()->create([
'last_login_at' => now()->subDays(45)
]);
$spec = new ActivityLevelSpecification(ActivityLevelSpecification::ACTIVE);
$this->assertTrue($spec->isSatisfiedBy($activeUser));
$this->assertFalse($spec->isSatisfiedBy($inactiveUser));
}
/** @test */
public function subscription_tier_specification_validates_tiers_and_expiry()
{
$premiumUser = User::factory()->create([
'subscription_tier' => 'premium',
'subscription_expires_at' => now()->addMonths(6)
]);
$expiredUser = User::factory()->create([
'subscription_tier' => 'premium',
'subscription_expires_at' => now()->subDay()
]);
$spec = new SubscriptionTierSpecification('premium');
$this->assertTrue($spec->isSatisfiedBy($premiumUser));
$this->assertFalse($spec->isSatisfiedBy($expiredUser));
}
/** @test */
public function segment_builder_creates_composite_specifications()
{
$builder = new EmailSegmentBuilder();
$segment = $builder->emailVerified()
->accountEligible()
->activityLevel(ActivityLevelSpecification::ACTIVE)
->subscriptionTier('premium')
->build();
$qualifiedUser = User::factory()->create([
'email_verified_at' => now(),
'is_suspended' => false,
'unsubscribed_at' => null,
'last_login_at' => now()->subDays(10),
'subscription_tier' => 'premium',
'subscription_expires_at' => now()->addMonths(3)
]);
$this->assertTrue($segment->isSatisfiedBy($qualifiedUser));
}
/** @test */
public function email_engagement_specification_tracks_click_activity()
{
$engagedUser = User::factory()->create();
// Create email click records
$engagedUser->emailClicks()->createMany([
['created_at' => now()->subDays(5)],
['created_at' => now()->subDays(10)],
['created_at' => now()->subDays(20)],
['created_at' => now()->subDays(25)],
]);
$spec = new EmailEngagementSpecification(3, 30);
$this->assertTrue($spec->isSatisfiedBy($engagedUser));
$this->assertEquals('engaged', $spec->getEngagementLevel($engagedUser));
}
}
Pest Tests
<?php
use App\Models\User;
use App\Specifications\Email\ActivityLevelSpecification;
use App\Specifications\Email\EmailVerifiedSpecification;
use App\Specifications\Email\AccountEligibilitySpecification;
use App\Services\EmailSegmentBuilder;
it('identifies email verified users correctly', function () {
$verifiedUser = User::factory()->create(['email_verified_at' => now()]);
$unverifiedUser = User::factory()->create(['email_verified_at' => null]);
$spec = new EmailVerifiedSpecification();
expect($spec->isSatisfiedBy($verifiedUser))->toBeTrue();
expect($spec->isSatisfiedBy($unverifiedUser))->toBeFalse();
});
it('validates account eligibility for campaigns', function () {
$eligibleUser = User::factory()->create([
'is_suspended' => false,
'unsubscribed_at' => null
]);
$suspendedUser = User::factory()->create(['is_suspended' => true]);
$unsubscribedUser = User::factory()->create(['unsubscribed_at' => now()]);
$spec = new AccountEligibilitySpecification();
expect($spec->isSatisfiedBy($eligibleUser))->toBeTrue();
expect($spec->isSatisfiedBy($suspendedUser))->toBeFalse();
expect($spec->isSatisfiedBy($unsubscribedUser))->toBeFalse();
});
it('builds complex segments using fluent builder', function () {
$builder = new EmailSegmentBuilder();
$segment = $builder
->emailVerified()
->accountEligible()
->activityLevel(ActivityLevelSpecification::ACTIVE)
->engaged(5, 30)
->build();
expect($segment)->toBeInstanceOf(SpecificationInterface::class);
});
it('categorizes users by activity level correctly', function () {
$activeUser = User::factory()->create(['last_login_at' => now()->subDays(5)]);
$inactiveUser = User::factory()->create(['last_login_at' => now()->subDays(90)]);
$dormantUser = User::factory()->create(['last_login_at' => now()->subDays(200)]);
$activeSpec = new ActivityLevelSpecification(ActivityLevelSpecification::ACTIVE);
$inactiveSpec = new ActivityLevelSpecification(ActivityLevelSpecification::INACTIVE);
$dormantSpec = new ActivityLevelSpecification(ActivityLevelSpecification::DORMANT);
expect($activeSpec->isSatisfiedBy($activeUser))->toBeTrue();
expect($inactiveSpec->isSatisfiedBy($inactiveUser))->toBeTrue();
expect($dormantSpec->isSatisfiedBy($dormantUser))->toBeTrue();
});
Key Learnings
This refactoring demonstrates:
✅ Flexible Targeting: Dynamic segments built from reusable specifications
✅ Collection Processing: Efficient batch processing with Laravel collections
✅ Background Jobs: Email delivery moved to background processing
✅ Artisan Commands: CLI interface for campaign management
✅ Fluent Builder: Intuitive segment building with method chaining
✅ Performance Optimization: Database queries optimized with specifications
✅ Comprehensive Testing: Full coverage of targeting logic
Next Steps
With your email targeting system refactored:
- Add geographic targeting with timezone and location specifications
- Implement A/B testing using specification-based user splitting
- Create email templates that adapt based on user segment
- Add analytics dashboard showing segment performance metrics
Ready for more complexity? Explore intermediate patterns with E-commerce Discount Engine.