Skip to content

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

  1. Email Verified: User has confirmed their email address
  2. Account Status: Not suspended and not unsubscribed
  3. Activity Level: Recently active, engaged, or inactive users
  4. Subscription Tier: Free, premium, enterprise users
  5. Email Engagement: Users who click/open emails frequently
  6. Campaign History: Haven't received specific campaign types recently
  7. Geographic Targeting: Users in specific regions/timezones

Step 1: Generate Base Specifications

Using the Artisan command to create our targeting specifications:

bash
# 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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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.

View Intermediate Examples →

Released under the MIT License.