Skip to content

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.

Quickstart#

Before you start

You need an API key (sk_live_…, or sk_test_… to try it without delivering anything) and a sending domain you have verified in PostStack. See Authentication and Domains.

  1. Generate a Mailable

    bash
    php artisan make:mail WelcomeMail --markdown=mail.welcome   # no Composer package needed
  2. Configure the SMTP mailer in .env

    php
    # .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_SCHEME
  3. Write the Mailable and send it

    php
    <?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 worker
  4. Handle errors

    php
    <?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);
    }

Good to know

  • 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:clear after editing .env if you cache config in production
  • An sk_test_... key accepts and logs mail without delivering it — use it on staging

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
<?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
<?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
<?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.

php
# .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_PASSWORD is not a valid PostStack API key, or the key lacks sending permission. If you cache config, run php artisan config:clear so the new value is read.

Rejected From address

Laravel’s default MAIL_FROM_ADDRESS=hello@example.com is refused. Use an address on a domain you have verified in PostStack — and check any from: 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=database or redis, a worker must be running (php artisan queue:work, Supervisor or Horizon). With sync, queued mail sends inline during the request.

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.

Ready to send from Laravel?

Create a free account and an API key — no card needed.

Get started free

Next steps

Was this page helpful?