Laravel Task Scheduler Transactional Email with Rovela (2026): Schedule:: + Idempotency-Key per Window
Wire Laravel Task Scheduler (Schedule::job / call / command) to Rovela POST /emails with server-only sending_access and a window-scoped Idempotency-Key for digests and ops reports.
Laravel Task Scheduler owns the schedule — Rovela delivers the mail
Laravel's task scheduler lets you define cron-like schedules in PHP — historically app/Console/Kernel.php, and in modern Laravel often routes/console.php / bootstrap/app.php with Schedule:: facades. On the server you add a single system crontab entry: * * * * * php artisan schedule:run. Laravel then decides which Schedule::job / Schedule::call / Schedule::command entries are due each minute. That fits digests whose body depends on live Eloquent state at fire time rather than content frozen earlier with Rovela scheduled_at.
This post is the scheduler → Rovela path — distinct from one-shot OTP / magic-link Laravel mailers.
Rovela queues, DKIM-signs, and delivers when your job method calls POST https://api.rovela.dev/emails (Send Email API, Quick Start, Authentication). Laravel Task Scheduler owns the cadence and “is due?” logic. Your job method owns aggregation, secrets for sending_access, and a stable Idempotency-Key per schedule window. Rovela only delivers.
This guide wires Laravel Task Scheduler → job method → Rovela that:
- Declares
Schedule::job(new SendDailyDigestEmail)->dailyAt("09:00")->timezone("UTC")->name("daily-digest-email")->withoutOverlapping() - Keeps
ROVELA_API_KEY(sending_access),MAIL_FROM, and recipients in server-only config — never in browsers, SPA bundles, or a public Git tree - Aggregates a digest / report for the current schedule window inside the job method
- Sends via Rovela with a window-scoped
Idempotency-Key - Survives retries, restarts, and multi-worker fleets without duplicate mail
Rovela does not create Laravel Task Scheduler schedules, workers, or storage. Laravel Task Scheduler owns schedule → enqueue/fire → execute. Your job method owns query → map fields → POST /emails. Rovela owns delivery.
Distinct from:
- Quartz Scheduler Transactional Email — Java Quartz
- node-cron Transactional Email — node-cron
- NestJS Schedule Cron Email — NestJS @Cron
- APScheduler Cron Email — APScheduler
- Spring @Scheduled Email — Spring @Scheduled
- Resque-scheduler Email — Resque-scheduler
- RQ Scheduler Email — RQ Scheduler
- delayed_job Recurring Email — delayed_job
- Solid Queue Recurring Email — Solid Queue
- Hangfire Recurring Jobs Email — Hangfire
- Sidekiq-Cron Transactional Email — Sidekiq-Cron
- Celery Beat Transactional Email — Celery Beat
- Agenda.js Scheduled Jobs Email — agenda
- BullMQ Repeatable Email — BullMQ
- Temporal Schedule Email — Temporal
- Trigger.dev Cron Email — Trigger.dev
- Inngest Cron Transactional Email — Inngest
- Kubernetes CronJob Email — K8s CronJob
- Vercel Cron Digest Email — Vercel cron
- Scheduled Email Sending — scheduled_at
Also distinct from Laravel OTP Email API (one-shot verification) — this post is recurring Task Scheduler only.
What Laravel Task Scheduler schedules vs what Rovela sends
| Concern | Owner |
|---|---|
| Schedule definition, timezone, mutex / withoutOverlapping | Laravel Schedule:: |
One OS crontab schedule:run every minute | Server crontab / container |
Queue workers (if using Schedule::job) | queue:work / Horizon |
Server secrets for ROVELA_API_KEY (sending_access) | Process / worker env / secret store |
| Query DB / APIs for the window | Your job method |
| Render HTML + text | Your job method |
POST /emails, DKIM, queue, bounces | Rovela |
Idempotency-Key = laravel-schedule-daily-digest:{yyyy-mm-dd} | Your job method |
A 200 from Rovela means accepted and queued, not “already in the inbox” (Quick Start).
Laravel Task Scheduler constraints that shape the email job
-
One machine (or leader) should run
schedule:run. Multiple cron hosts without overlap protection can double-dispatch. -
Use
withoutOverlapping()/onOneServer()(Redis/cache mutex) for mail digests. -
Prefer
Schedule::job+ queue for long aggregations soschedule:runstays fast. -
Timezone via
->timezone(...)— do not rely on php.ini alone for mail windows. -
Secrets in
.env/ vault only — never commitROVELA_API_KEY. -
Keep
sending_accessserver-only. StoreROVELA_API_KEYin environment / vault — never in client apps or public Git (Authentication). -
Prefer HTTPS
POST /emails. Outbound SMTP from a worker is a poor fit (Email API vs SMTP).
When to use Laravel Task Scheduler + POST /emails (not scheduled_at)
Use Laravel Task Scheduler → live send when:
- The HTML must include fresh metrics at fire time
- You already run Laravel Task Scheduler and want schedules next to the same workers
- You need cron-like digests without adopting a separate SaaS scheduler
- One (or a few) transactional messages go to ops / founder / customer addresses looked up at run time
Use Rovela scheduled_at when the body is already correct and you only need “deliver later” without a running scheduler (Scheduled Email Sending). Prefer Sidekiq-Cron / Solid Queue when the app is Rails; prefer Nest @Cron on NestJS.
Prerequisites
- A verified sending domain in Rovela. Unverified
fromdomains are refused with400(Quick Start). - An API key shaped like
re_…. Prefersending_accessfor the worker (Authentication). - Laravel app with scheduler configured and a single OS crontab calling
php artisan schedule:run. - Config:
ROVELA_API_KEY,MAIL_FROM,DIGEST_TO. - Optional queue worker if using
ShouldQueuejobs.
Register the schedule
OS crontab (once per environment):
* * * * * cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1
routes/console.php (Laravel 11+ style):
<?php
use Illuminate\Support\Facades\Schedule;
use App\Jobs\SendDailyDigestEmail;
Schedule::job(new SendDailyDigestEmail)
->dailyAt('09:00')
->timezone('UTC')
->name('daily-digest-email')
->withoutOverlapping()
->onOneServer();
Schedule::job(new SendOpsPulseEmail)
->everyFifteenMinutes()
->timezone('UTC')
->name('ops-pulse-email')
->withoutOverlapping();
Job method that calls Rovela
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class SendDailyDigestEmail implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function handle(): void
{
$day = now('UTC')->toDateString();
$idempotencyKey = "laravel-schedule-daily-digest:{$day}";
$stats = [/* replace with Eloquent aggregates */ 'signups' => 12, 'paid' => 3, 'incidents' => 1];
$to = array_values(array_filter(array_map('trim', explode(',', env('DIGEST_TO', '')))));
$subject = "Acme daily digest — {$day}";
$text = "Signups: {$stats['signups']}\nPaid: {$stats['paid']}\nIncidents: {$stats['incidents']}";
$html = "<h1>Daily digest — {$day}</h1><ul>"
."<li>Signups: <b>{$stats['signups']}</b></li>"
."<li>Paid: <b>{$stats['paid']}</b></li>"
."<li>Open incidents: <b>{$stats['incidents']}</b></li></ul>";
Log::info('laravel digest', ['day' => $day, 'idempotencyKey' => $idempotencyKey]); // never log API key
$response = Http::withToken(env('ROVELA_API_KEY'))
->withHeaders(['Idempotency-Key' => $idempotencyKey])
->timeout(30)
->post('https://api.rovela.dev/emails', [
'from' => env('MAIL_FROM'),
'to' => $to,
'subject' => $subject,
'html' => $html,
'text' => $text,
'tags' => [
'kind' => 'laravel-schedule-daily-digest',
'day' => $day,
'platform' => 'laravel-scheduler',
],
]);
if (!$response->successful()) {
throw new \RuntimeException('Rovela '.$response->status().': '.$response->body());
}
Log::info('rovela accepted', ['emailId' => $response->json('id'), 'day' => $day]);
}
}
Window-scoped Idempotency-Key
Rovela’s Idempotency-Key (up to 256 characters) makes a retry of the same send within 24 hours return the original response instead of mailing again (Send Email API). That matters because:
- Retries re-enter the job after 5xx / timeouts
- Crash after Rovela
200but before the job is marked succeeded can redeliver - Queue lag can shift wall-clock — key off the business schedule window, not millisecond now
- Multi-tenant jobs need tenant ids in the key
Recommended keys:
| Key | Use when |
|---|---|
laravel-schedule-daily-digest:{yyyy-mm-dd} | One ops digest per UTC day |
laravel-schedule-ops-pulse:{yyyy-mm-dd}T{HH}-{MM} | /15 or hourly crontab (bucket to the window) |
laravel-schedule-tokyo-ops:{yyyy-mm-dd} | Local-TZ morning digest |
laravel-schedule-weekly-report:{yyyy}-W{ww} | One founder report per ISO week |
laravel-schedule-tenant-digest:{tenantId}:{yyyy-mm-dd} | Per-tenant job args |
If you mail many personalized digests in one job, prefer per-recipient keys (or POST /emails/batch for up to 100 non-template messages — batch rejects template / topic_id by design).
Attach secrets and verify the schedule
- Set
ROVELA_API_KEY(sending_access),MAIL_FROM,DIGEST_TOon the process that runs the scheduler/worker. - Confirm the schedule entry is registered with the expected cron and timezone.
- Confirm workers are online.
- Wait for the next tick, or trigger once in non-prod while keeping the window key stable.
- Check logs for
idempotencyKey+ RovelaemailId. Optionally correlate later with Rovela webhooks.
Prefer sending_access on production senders — never full_access. Never log API keys. Rotate Rovela by create-then-revoke.
Failure modes and how to handle them
| Symptom | Likely cause | What to do |
|---|---|---|
| Cron never fires | Scheduler/worker down | Keep the scheduling process always on |
| Double digests | Multiple schedulers / replicas | Single leader or distributed lock / persistent store |
| Wrong wall time | Assumed UTC / wrong timezone | Set timezone explicitly |
| Duplicate digests after retries | Unstable key / keyed by ms now | Use window-scoped keys; Idempotency-Key collapses duplicates within 24h |
Rovela 401 | Bad / missing ROVELA_API_KEY | Fix server env; confirm sending_access |
Rovela 400 naming a domain | MAIL_FROM domain not verified | Verify the domain under Rovela Domains |
Rovela 429 | Rate limit | Raise/retry; keep the same Idempotency-Key |
| Secrets in Git | Key committed | Keep only in env / vault |
Production checklist
- Verified Rovela sending domain;
MAIL_FROMmatches it ROVELA_API_KEYissending_access, in worker env / vault only- Scheduler + workers always running as documented for Laravel Task Scheduler
- Documented cron + timezone
- Rovela send uses a window-scoped
Idempotency-Key - Logs show successful
emailIdwithout printing secrets - Alerts on consecutive job failures / scheduler down
- Optional: Rovela webhooks for
email.bounced/email.complainedon digest recipients
FAQ
Can the scheduler call Rovela without a job method? No usefully — the scheduler only wakes your code. Your method must aggregate live data and keep sending_access local. Rovela does not host your scheduler or workers.
Should I use Rovela scheduled_at from the job? Usually no. If the scheduler already fires at send time with a live body, call POST /emails immediately. Use scheduled_at when you accept the message now and want Rovela to deliver later without another wake (Scheduled Email Sending).
What key scope does sending_access cover? Enough to queue mail via POST /emails / batch. Reading messages, rescheduling, and cancel need full_access or a dashboard session (Send Email API, Authentication). Keep the worker on sending_access.
Does a 200 mean the founder already got the digest? No — it means Rovela accepted and queued the message. Use GET /emails/:id, events, or webhooks for delivery state (Quick Start).
Why both scheduler retries and Rovela Idempotency-Key? The scheduler retries the job when your code fails. Rovela’s key collapses duplicate sends if the HTTP call succeeds twice in the same window (or times out after Rovela accepted). You want both layers.
Ship the recurring job; let Rovela deliver
Register the Laravel Task Scheduler schedule with an explicit cron and timezone, put live aggregation and POST /emails in the job method, scope Idempotency-Key to the schedule window (and tenant), and keep sending_access server-only. Laravel Task Scheduler wakes on time; Rovela delivers the transactional mail.