Send email with Laravel
Point Laravel’s SMTP mailer at PostStack and keep using Mailables, queues and notifications — or call the REST API with the Http client.
Laravel sends email through Symfony Mailer, so PostStack needs no package: set the SMTP mailer in .env to smtp.poststack.dev with your API key as the password, and every Mailable, notification, password reset and queued mail goes through PostStack. Your mail code does not change. When you need the em_… email id back — to match delivery and bounce webhooks to a row in your database — call the REST API with Laravel’s Http client instead. There is no PHP SDK; the API is one JSON POST, and both paths are below.
1. Generate a Mailable
php artisan make:mail WelcomeMail --markdown=mail.welcome # no Composer package needed2. Configure the SMTP mailer in .env
# .env (Laravel 11 and 12)
MAIL_MAILER=smtp
MAIL_HOST=smtp.poststack.dev
MAIL_PORT=587
MAIL_USERNAME=poststack # any non-empty value
MAIL_PASSWORD=sk_live_... # your PostStack API key
MAIL_SCHEME=smtp # STARTTLS on 587; use smtps with MAIL_PORT=465 for implicit TLS
MAIL_FROM_ADDRESS="hello@yourdomain.com" # must be on a verified domain
MAIL_FROM_NAME="${APP_NAME}"
# Laravel 10 and earlier: MAIL_ENCRYPTION=tls instead of MAIL_SCHEME3. Write the Mailable and send it
<?php
// app/Mail/WelcomeMail.php
namespace App\Mail;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class WelcomeMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(public User $user) {}
public function envelope(): Envelope
{
return new Envelope(subject: 'Welcome to Acme');
}
public function content(): Content
{
return new Content(markdown: 'mail.welcome'); // resources/views/mail/welcome.blade.php
}
}
// anywhere — e.g. a controller after registration
use App\Mail\WelcomeMail;
use Illuminate\Support\Facades\Mail;
Mail::to($user)->send(new WelcomeMail($user)); // synchronous
Mail::to($user)->queue(new WelcomeMail($user)); // via the queue worker4. Handle errors
Laravel idioms for error handling, retries, and structured logging when sending through PostStack.
<?php
// app/Mail/WelcomeMail.php — a queued Mailable that retries transient failures
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class WelcomeMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public $tries = 5;
public $backoff = [30, 60, 120, 300]; // seconds between attempts
// envelope() and content() as above
}
// Synchronous sends: catch the transport error yourself
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
try {
Mail::to($user)->send(new WelcomeMail($user));
} catch (TransportExceptionInterface $e) {
// 535: MAIL_PASSWORD is not a valid PostStack API key
// 550: permanent rejection, e.g. the From domain is not verified
// 451 / 450: rate limited or temporary — safe to retry later
Log::error('PostStack rejected the email', ['error' => $e->getMessage()]);
report($e);
}Attachments, HTML views and custom headers
Return attachments from attachments() and PostStack headers from headers(). Limits: 10 attachments per message, 10 MB per file and 25 MB in total. X-PostStack-Tags and X-PostStack-Idempotency-Key are read by the relay; other X- headers pass through.
<?php
namespace App\Mail;
use App\Models\Order;
use Illuminate\Mail\Attachment;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Mail\Mailables\Headers;
class OrderReceipt extends Mailable
{
public function __construct(public Order $order) {}
public function envelope(): Envelope
{
return new Envelope(
subject: "Receipt for order #{$this->order->number}",
replyTo: [new Address('billing@yourdomain.com', 'Acme Billing')],
);
}
public function content(): Content
{
return new Content(
view: 'mail.receipt', // HTML Blade view
text: 'mail.receipt-text', // plain-text alternative
);
}
public function attachments(): array
{
return [
Attachment::fromStorage("invoices/{$this->order->number}.pdf")
->as("invoice-{$this->order->number}.pdf")
->withMime('application/pdf'),
];
}
public function headers(): Headers
{
return new Headers(text: [
'X-PostStack-Tags' => 'receipt,transactional',
'X-PostStack-Idempotency-Key' => "receipt-{$this->order->id}",
]);
}
}Notifications
Mail notifications use the same mailer, so they go through PostStack with no extra setup.
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class InvoicePaid extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(public string $amount) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('Payment received')
->greeting("Hi {$notifiable->name},")
->line("We received your payment of {$this->amount}.")
->action('View invoice', url('/billing'));
}
}
// $user->notify(new InvoicePaid('€15.00'));Send through the REST API with the Http client
The API returns 202 with { "id": "em_…" }. Send an Idempotency-Key so a retried request is never delivered twice, and keep the key in config/services.php rather than calling env() at runtime.
<?php
// config/services.php
'poststack' => [
'key' => env('POSTSTACK_API_KEY'),
],
// app/Services/PostStack.php
namespace App\Services;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
class PostStack
{
public function send(array $payload, ?string $idempotencyKey = null): string
{
return Http::withToken(config('services.poststack.key'))
->withHeaders(['Idempotency-Key' => $idempotencyKey ?? (string) Str::uuid()])
->acceptJson()
->timeout(10)
// retry network errors, 429 and 5xx — never 4xx validation errors
->retry(3, 1000, fn ($e) => $e instanceof ConnectionException
|| ($e instanceof RequestException
&& ($e->response->status() === 429 || $e->response->serverError())))
->post('https://api.poststack.dev/emails', $payload)
->throw()
->json('id');
}
}
// usage
$id = app(\App\Services\PostStack::class)->send([
'from' => 'Acme <hello@yourdomain.com>',
'to' => ['user@example.com'], // always an array
'subject' => 'Hello from Laravel!',
'html' => view('mail.welcome-html', ['user' => $user])->render(),
'text' => 'Welcome to Acme!',
'tags' => ['welcome'],
]);Local development and tests
Keep real delivery out of development and CI. The log mailer writes messages to storage/logs, and Mail::fake() records them in tests.
# .env (local) and .env.testing
MAIL_MAILER=log
// tests/Feature/RegistrationTest.php
use App\Mail\WelcomeMail;
use Illuminate\Support\Facades\Mail;
public function test_welcome_email_is_queued(): void
{
Mail::fake();
$this->post('/register', [/* ... */]);
Mail::assertQueued(WelcomeMail::class, fn ($mail) => $mail->hasTo('ada@example.com'));
}Framework integrations
Queued mail
Implement ShouldQueue on the Mailable (or call Mail::to(...)->queue(...)) so the SMTP round-trip happens in a worker. Use a real queue driver (database, Redis) in production and set $tries and $backoff so transient failures retry.
Password resets and verification
Laravel’s built-in ResetPassword and VerifyEmail notifications use the default mailer, so they send through PostStack as soon as .env is set. Breeze, Jetstream and Fortify need no changes.
Multiple mailers
Keep PostStack as the default and add others in config/mail.php if you need them — Mail::mailer("poststack")->to(...)->send(...) picks one explicitly. A failover mailer can fall back to log in development.
Horizon and failed jobs
Queued mail that exhausts its retries lands in failed_jobs. Horizon shows throughput and failures; php artisan queue:retry all resends after you fix the cause.
Common pitfalls
Expected response code "250" but got "535"
MAIL_PASSWORDis not a valid PostStack API key, or the key lacks sending permission. If you cache config, runphp artisan config:clearso the new value is read.Rejected From address
Laravel’s default
MAIL_FROM_ADDRESS=hello@example.comis refused. Use an address on a domain you have verified in PostStack — and check anyfrom:set on individual Mailables.Connection timeouts on port 587
Some hosts block outbound SMTP. Try port 465 with
MAIL_SCHEME=smtps; if both time out, use the REST API over HTTPS (443) instead.Queued mail never sends
With
QUEUE_CONNECTION=databaseorredis, a worker must be running (php artisan queue:work, Supervisor or Horizon). Withsync, queued mail sends inline during the request.
Notes
- SMTP host
smtp.poststack.dev: port 587 with STARTTLS (MAIL_SCHEME=smtp) or implicit TLS, or port 465 with implicit TLS (MAIL_SCHEME=smtps) - The password is a PostStack API key; the username can be any non-empty value
- Run
php artisan config:clearafter editing.envif you cache config in production - An
sk_test_...key accepts and logs mail without delivering it — use it on staging
FAQ
How do I send email in Laravel?
Set MAIL_MAILER=smtp, MAIL_HOST=smtp.poststack.dev, MAIL_PORT=587, MAIL_USERNAME=poststack and MAIL_PASSWORD to your API key in .env, create a Mailable with php artisan make:mail, and call Mail::to($user)->send(new WelcomeMail($user)).
Do I need a Composer package?
No. Laravel’s SMTP mailer (Symfony Mailer) is built in, and the REST example uses Laravel’s own Http client.
Which SMTP port and encryption should I use?
Port 587 with MAIL_SCHEME=smtp (STARTTLS) is the default. Port 465 with MAIL_SCHEME=smtps (implicit TLS) also works. On Laravel 10 and earlier use MAIL_ENCRYPTION=tls on 587 or ssl on 465.
How do I queue emails in Laravel?
Implement ShouldQueue on the Mailable or call Mail::to($user)->queue(...), then run a queue worker. The worker opens the SMTP connection to PostStack and retries according to $tries and $backoff.
How do I get the email id for webhooks?
SMTP does not return it. Send through the REST API with the Http client — the response is { "id": "em_…" } — and store the id to match delivery, bounce and open events.
Related guides
Ready to send emails with Laravel?
Create a free account and get your API key in under a minute.