Laravel queues for email: retries, duplicates and rate limits
Updated
Putting email on a queue is the right default in Laravel: the web request returns at once, and a slow API call cannot hold a user waiting. But queues bring retries, and retries are how users receive the same email three times.
The two failure modes
Not all failures are alike:
| Failure | Was the email sent? | Safe to retry? | |---|---|---| | HTTP 429, rate limited | No, rejected before sending | Yes, after a delay | | HTTP 4xx validation or auth error | No | No, it will fail again | | HTTP 5xx | Unknown | Use judgement | | Timeout or connection error | Unknown | Only with a duplicate guard |
Laravel's defaults ignore this: a job that throws is simply retried up to $tries. For email you want to decide per failure.
A job that decides
With the Inboxili package, the SDK already retries HTTP 429 itself and the package's SendInboxiliEmail job runs once. If you write your own job, make the same choices explicit:
<?php
namespace App\Jobs;
use App\Models\Invoice;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Inboxili\Client;
use Inboxili\ConnectionException;
use Inboxili\InboxiliException;
class SendInvoiceEmail implements ShouldQueue
{
use InteractsWithQueue, Queueable;
public int $tries = 1; // we decide about retries ourselves
public function __construct(public int $invoiceId) {}
public function handle(Client $inboxili): void
{
$invoice = Invoice::findOrFail($this->invoiceId);
// Duplicate guard: claim the send atomically. If another run already claimed it, stop.
$claimed = Invoice::whereKey($invoice->id)->whereNull('email_claimed_at')->update(['email_claimed_at' => now()]);
if (! $claimed) {
return;
}
try {
$result = $inboxili->emails->send([
'to' => $invoice->customer->email,
'from_email' => 'billing@yourdomain.com',
'subject' => 'Invoice {{number}}',
'html_body' => '<p>Invoice {{number}} is paid. <a href="{{url}}">Download</a></p>',
'template_data' => ['number' => $invoice->number, 'url' => $invoice->pdf_url],
]);
$invoice->update(['email_message_id' => $result->messageId, 'emailed_at' => now()]);
} catch (InboxiliException $e) {
// The API rejected it, so nothing was sent: release the claim so a human or a later job can retry.
Invoice::whereKey($invoice->id)->update(['email_claimed_at' => null]);
$this->fail($e);
} catch (ConnectionException $e) {
// Outcome unknown. Keep the claim so it is never auto-retried, and flag it for review.
$invoice->update(['email_needs_review' => true]);
$this->fail($e);
}
}
}
The email_claimed_at update is a single atomic statement, so two workers cannot both win it. That is the entire duplicate defence.
Rate limits
The Inboxili API allows 120 requests per minute per key. A burst of jobs can exceed it. Two options:
Job middleware. Define a limiter and attach it to the job:
<?php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
// app/Providers/AppServiceProvider.php, in boot()
RateLimiter::for('inboxili', fn () => Limit::perMinute(100));
<?php
use Illuminate\Queue\Middleware\RateLimited;
class SendInvoiceEmail implements ShouldQueue
{
// ...the properties and handle() shown above...
public function middleware(): array
{
return [new RateLimited('inboxili')];
}
}
When the limit is hit, Laravel releases the job back to the queue instead of failing it. Set the limit a little below 120 to leave headroom for other callers.
A dedicated queue. Send emails on their own queue (onQueue('emails')) with a single worker, so concurrency is bounded.
Unique jobs
ShouldBeUnique prevents the same job being queued twice while one is pending. It helps against double-clicks and duplicate webhooks, but it is not a substitute for the database claim above, because it releases its lock when the job finishes.
Testing
<?php
use App\Jobs\SendInvoiceEmail;
use Illuminate\Support\Facades\Queue;
it('queues one email job per paid invoice', function () {
Queue::fake();
// ... mark an invoice paid ...
Queue::assertPushed(SendInvoiceEmail::class, 1);
});
Test the job body with a fake client bound in the container, as shown on the Laravel integration page.
Checklist
- One queue for email, one worker, limiter just under the key limit.
$tries = 1or explicit handling, never blind retries after a timeout.- A database claim before sending.
- A flag for "outcome unknown" so a person reviews it.
- A
bouncedwebhook handler that stops future sends to that address.
Frequently asked questions
- Should I retry a failed email job?
- Retry only when you know the email was not sent, such as a rate-limit response. After a timeout you cannot tell, and a retry may send a duplicate.
- How do I stay under an API rate limit?
- Put sends on a dedicated queue and use rate-limiting job middleware, or run a worker that sleeps between jobs.
Send from Laravel with Inboxili
Create a workspace, verify a domain, and make your first API call.