Message Scheduling #

Automate email campaigns with one-time scheduled sends and recurring messages.

Overview #

The MessageSchedule system provides:

  • Scheduled Sends: Launch campaigns at specific dates/times
  • Recurring Campaigns: Daily, weekly, monthly, or yearly automation
  • Timezone Support: Send at correct local times
  • Maximum Runs: Limit recurring campaign sends
  • Smart Calculations: Automatic next-run-time computation

Send Timing Options #

Immediate Sending #

Messages enter the queue immediately upon launch:

// Message with immediate send
$message->is_delayed = false;
$message->launch_at = null;
$message->save();

Scheduled Sending #

Launch at a specific date and time:

$message->is_delayed = true;
$message->launch_at = '2024-02-14 10:00:00';
$message->launch_timezone = 'America/New_York';
$message->save();

Recurring Schedules #

Database Structure #

Recurring messages use the campaign_message_schedules table:

Schema::create('campaign_message_schedules', function($table) {
    $table->increments('id');
    $table->integer('message_id')->unsigned();
    $table->integer('organization_id')->unsigned();
    $table->enum('frequency', ['daily', 'weekly', 'monthly', 'yearly']);
    $table->string('send_time', 5); // HH:MM format
    $table->string('timezone', 50);
    $table->integer('day_of_week')->nullable(); // 0=Sunday, 6=Saturday
    $table->integer('day_of_month')->nullable(); // 1-31
    $table->boolean('skip_weekends')->default(false);
    $table->integer('max_runs')->nullable();
    $table->integer('run_count')->default(0);
    $table->timestamp('last_run_at')->nullable();
    $table->timestamp('next_run_at')->nullable();
    $table->boolean('is_active')->default(true);
    $table->text('notes')->nullable();
    $table->timestamps();
});

Creating Schedules #

use Albrightlabs\Campaign\Models\MessageSchedule;

$schedule = MessageSchedule::create([
    'message_id' => $message->id,
    'organization_id' => $org->id,
    'frequency' => 'weekly',
    'send_time' => '09:00',
    'timezone' => 'America/New_York',
    'day_of_week' => 1, // Monday
    'max_runs' => 52,
    'is_active' => true
]);

Frequency Options #

Daily #

$schedule->frequency = 'daily';
$schedule->send_time = '09:00';
$schedule->skip_weekends = true; // Optional

Weekly #

$schedule->frequency = 'weekly';
$schedule->day_of_week = 1; // Monday
$schedule->send_time = '09:00';

Monthly #

$schedule->frequency = 'monthly';
$schedule->day_of_month = 1; // First of month
$schedule->send_time = '09:00';

Yearly #

$schedule->frequency = 'yearly';
$schedule->day_of_month = 15;
$schedule->send_time = '09:00';

Model Relationships #

// Message has one schedule
class Message extends Model
{
    public $hasOne = [
        'schedule' => MessageSchedule::class
    ];
}

// Schedule belongs to message and organization
class MessageSchedule extends Model
{
    public $belongsTo = [
        'message' => Message::class,
        'organization' => Organization::class
    ];
}

Schedule Calculations #

Next Run Time #

The calculateNextRunTime() method computes when a schedule should run next:

public function calculateNextRunTime($from = null)
{
    $from = $from ?? Carbon::now($this->timezone);
    list($hour, $minute) = explode(':', $this->send_time);

    switch ($this->frequency) {
        case 'daily':
            $next = $from->copy()->setTime($hour, $minute, 0);
            if ($next->lte($from)) {
                $next->addDay();
            }

            // Skip weekends if enabled
            if ($this->skip_weekends) {
                while ($next->isWeekend()) {
                    $next->addDay();
                }
            }
            return $next;

        case 'weekly':
            $targetDay = $this->day_of_week ?? 1;
            $next = $from->copy()->setTime($hour, $minute, 0);
            $next->next($targetDay);
            return $next;

        case 'monthly':
            $dayOfMonth = $this->day_of_month ?? 1;
            $next = $from->copy()->setTime($hour, $minute, 0);
            $next->day(min($dayOfMonth, $next->daysInMonth));

            if ($next->lte($from)) {
                $next->addMonth();
                $next->day(min($dayOfMonth, $next->daysInMonth));
            }
            return $next;

        case 'yearly':
            $dayOfMonth = $this->day_of_month ?? 1;
            $next = $from->copy()->setTime($hour, $minute, 0);
            $next->day(min($dayOfMonth, $next->daysInMonth));

            if ($next->lte($from)) {
                $next->addYear();
                $next->day(min($dayOfMonth, $next->daysInMonth));
            }
            return $next;
    }
}

Automatic Updates #

After each run, the schedule updates automatically:

$schedule->run_count++;
$schedule->last_run_at = now();
$schedule->next_run_at = $schedule->calculateNextRunTime();

// Check if max runs reached
if ($schedule->max_runs && $schedule->run_count >= $schedule->max_runs) {
    $schedule->is_active = false;
}

$schedule->save();

Schedule Management #

Pausing Schedules #

$schedule->is_active = false;
$schedule->save();

Resuming Schedules #

$schedule->is_active = true;
$schedule->next_run_at = $schedule->calculateNextRunTime();
$schedule->save();

Checking Schedule Status #

if ($schedule->is_active && $schedule->next_run_at <= now()) {
    // Ready to send
    CampaignManager::instance()->launchCampaign($message);
}

Getting Schedule Description #

public function getDescription()
{
    switch ($this->frequency) {
        case 'daily':
            $desc = 'Send daily at ' . $this->send_time;
            if ($this->skip_weekends) {
                $desc .= ' (weekdays only)';
            }
            return $desc;

        case 'weekly':
            $days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday',
                     'Thursday', 'Friday', 'Saturday'];
            return 'Send weekly on ' . $days[$this->day_of_week]
                   . ' at ' . $this->send_time;

        case 'monthly':
            return 'Send monthly on day ' . $this->day_of_month
                   . ' at ' . $this->send_time;

        case 'yearly':
            return 'Send yearly on day ' . $this->day_of_month
                   . ' at ' . $this->send_time;
    }
}

Timezone Handling #

Available Timezones #

public static function getTimezoneOptions()
{
    $timezones = DateTimeZone::listIdentifiers();
    $grouped = [];

    foreach ($timezones as $tz) {
        try {
            $timezone = new DateTimeZone($tz);
            $offset = $timezone->getOffset(new DateTime());
            $hours = floor($offset / 3600);
            $minutes = abs(floor(($offset % 3600) / 60));

            $offsetString = sprintf('UTC%s%02d:%02d',
                $hours >= 0 ? '+' : '', $hours, $minutes);

            // Group by region
            $parts = explode('/', $tz);
            $region = $parts[0];

            if (!isset($grouped[$region])) {
                $grouped[$region] = [];
            }

            $city = str_replace('_', ' ', end($parts));
            $grouped[$region]["($offsetString) $city"] = $tz;

        } catch (Exception $e) {
            continue;
        }
    }

    return $grouped;
}

DST Handling #

Carbon automatically handles Daylight Saving Time transitions:

// Schedule always uses specified timezone
$next = Carbon::now($schedule->timezone);

// DST transitions are automatic
// 2:00 AM EST → 3:00 AM EDT happens seamlessly

Console Commands #

Process Schedules #

# Run scheduled campaigns (triggered by cron)
php artisan campaign:run

List Active Schedules #

// Get all active schedules ready to run
$schedules = MessageSchedule::where('is_active', true)
    ->where('next_run_at', '<=', now())
    ->whereHas('message', function($q) {
        $q->where('status', 'scheduled');
    })
    ->get();

Validation Rules #

public $rules = [
    'message_id' => 'required|exists:campaign_messages,id',
    'organization_id' => 'required|exists:organizations,id',
    'frequency' => 'required|in:daily,weekly,monthly,yearly',
    'send_time' => 'nullable|regex:/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/',
    'timezone' => 'required|string',
    'day_of_week' => 'nullable|integer|between:0,6',
    'day_of_month' => 'nullable|integer|between:1,31'
];

Best Practices #

Schedule Configuration #

  • Always set max_runs for finite campaigns
  • Use appropriate timezones for audience
  • Test with immediate sends first
  • Monitor first 3 scheduled sends

Performance #

  • Limit daily schedules to essential campaigns
  • Stagger multiple schedules across different times
  • Use queue workers for processing
  • Monitor cron job execution

Maintenance #

// Archive completed schedules
MessageSchedule::where('is_active', false)
    ->where('run_count', '>=', 'max_runs')
    ->where('updated_at', '<', now()->subYear())
    ->delete();

Troubleshooting #

Schedule Not Running #

Check cron job:

crontab -l | grep artisan
php artisan schedule:list

Verify next run time:

$schedule = MessageSchedule::find($id);
echo "Next run: " . $schedule->next_run_at;
echo "Is active: " . ($schedule->is_active ? 'Yes' : 'No');
echo "Runs: {$schedule->run_count}/{$schedule->max_runs}";

Wrong Send Time #

Verify timezone configuration:

echo "Server timezone: " . config('app.timezone');
echo "Schedule timezone: " . $schedule->timezone;
echo "Current time (schedule TZ): " . Carbon::now($schedule->timezone);

← Configuration | Next: Sender Profiles →