Checkout waited on Mail::send() while the HTTP API took several seconds, and the request timed out. Queue the mailable instead. Implement ShouldQueue, run php artisan queue:work, retry with backoff, and call afterCommit() so a rolled-back order never reaches Arawa Mail. The official path is the sharasolns/sdp-email package with MAIL_MAILER=sdp — the same HTTP transport used for synchronous send, not mailbox SMTP.
Why synchronous Mail::send blocks checkout
Laravel’s default send() talks to the mailer inside the web request. Arawa Mail’s Laravel package is an HTTP client (default timeout 10 seconds via SDP_EMAIL_TIMEOUT). If the API is slow or the network blips, the shopper waits. Queue the work: accept the order, commit the database row, then let a worker call the API.
Basic sending, Markdown mail, and Mail::raw live in the published Send email in Laravel and Laravel Mail::raw guides. This article only covers workers, ShouldQueue, retries, and afterCommit.
Documented transport (do not invent SMTP)
From the Laravel quickstart:
composer require sharasolns/sdp-email
MAIL_MAILER=sdp
SDP_EMAIL_KEY=your_api_key
# optional
SDP_EMAIL_ENDPOINT=https://app.arawamail.com
SDP_EMAIL_TIMEOUT=10
The package is auto-discovered. You do not edit config/mail.php. Sending is rejected when the From domain is unregistered, outbound sending is inactive, the key lacks the sending scope, or a domain-restricted key does not allow the sender domain. Mailbox “Connect a Device” SMTP is for human clients, not this application path.
ShouldQueue enqueues — it does not send by itself
ShouldQueue tells Laravel to put the mailable on the queue even if you call send(). Nothing leaves the app until a worker runs. If the worker is down overnight, customers get nothing until morning.
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class OrderShipped extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public int $tries = 5;
public array $backoff = [30, 120, 300];
public function __construct(public Order $order)
{
$this->afterCommit();
$this->onQueue('emails');
}
public function envelope(): Envelope
{
return new Envelope(subject: 'Your order shipped');
}
public function content(): Content
{
return new Content(markdown: 'mail.orders.shipped');
}
}
Dispatch after the order exists:
Mail::to($order->customer)->queue(new OrderShipped($order));
// or, because ShouldQueue is on the class:
Mail::to($order->customer)->send(new OrderShipped($order));
Then keep a worker alive:
php artisan queue:work --queue=emails
afterCommit: why a rolled-back order still got a receipt
If you dispatch mail inside DB::transaction() before commit, the worker can run while the transaction is still open — or after it rolls back. The API may already have accepted the message. afterCommit() on the mailable (or after_commit => true on the queue connection) waits until the transaction commits. No commit, no job.
That stops the “ghost receipt” case. It does not replace an idempotency key. The package does not implement Idempotency-Key. Store send intent in your app as described in prevent duplicate transactional emails, then let retries reuse the same intent so a worker crash does not double-send.
Retries, backoff, and failed jobs
$tries and $backoff cover transient HTTP failures (timeouts, 429, 5xx from the API). Permanent rejects — unregistered domain, sending inactive, key scope — will not heal by retrying. Fix configuration, then release the job.
Do not wire Laravel failed-job handlers to Arawa Mail outgoing webhooks. Those webhooks notify you about inbound mailbox mail, not queue outcome or inbox placement. After a successful API accept you can Retrieve Email by id; last_event is not proof of inbox placement.
Do not put OTP on the receipt queue
A shared Redis list behind 10,000 nightly receipts will expire verification codes in the backlog. Put OTP and password-reset mailables on a dedicated high-priority queue and run a second worker for it. Deliverability and copy for those messages belong in OTP email deliverability and password-reset deliverability — the queue split is the only operational rule here.
Laravel Notifications over the mail channel are a separate article (planned). Testing fakes and assertions are also separate. Use this piece only for the worker contract.
How we analyzed this
Snippets target Laravel 12.x mail and queue primitives (stable through 11.x–12.x; 13.x does not change the afterCommit idea). Env keys, package name, timeout default, and rejection reasons are taken from the current Arawa Mail Laravel quickstart. No SMTP host is documented for MAIL_MAILER=sdp, and no package-level idempotency header is claimed.
FAQ
Why did checkout time out when Mail::send hit the API?
The HTTP call ran inside the request. Queue the mailable and let a worker wait on the 10-second client timeout.
Does ShouldQueue send the email?
No. It only enqueues. Without queue:work, nothing is sent.
What happens if the worker is not running?
Jobs sit in Redis (or your driver) until a worker starts. Urgent mail needs its own queue and a process supervisor.
Why did a rolled-back order still get a receipt?
The job was dispatched before commit. Use afterCommit().
How should retries interact with idempotency?
Retries must reuse your application send-intent record. The Laravel package does not add Idempotency-Key for you.