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_runsfor 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);