synergitech/laravel-postal
Laravel Postal is a lightweight Laravel integration for the Postal mail server API. Send transactional email and manage messages, domains, and webhooks through an expressive client, with simple configuration for reliable mail delivery in any Laravel application.
Postal is a self-hosted mail server; this package's job is to make Laravel's Mail facade speak its HTTP API instead of SMTP. Day-to-day, that means you get Laravel Mailables, queues, and events for free, while Postal handles delivery, bounce tracking, and the message log UI.
Minimal path:
composer require synergitech/laravel-postal — on Laravel 13 you need v5.0.4+ (see Gotchas).php artisan vendor:publish and pick the Postal provider's tag — check php artisan vendor:publish --list rather than guessing the tag name).POSTAL_HOST — the full base URL of your Postal install, including scheme (e.g. https://postal.example.com).POSTAL_KEY — the per-server API credential from Postal's UI (Your Server → Credentials → API), not an org/user key.config/mail.php:
'postal' => [
'transport' => 'postal',
],
and set MAIL_MAILER=postal. Credentials live in config/postal.php, so the mailer entry stays thin.// php artisan tinker
Mail::raw('hello from postal', fn ($m) => $m->to('[email protected]')->subject('smoke test'));
Where to look first when it misbehaves, in order: Postal's own message log (did Postal receive it at all?) → storage/logs/laravel.log (did the transport throw?) → your config/postal.php (host/key correct?) → DNS verification for the sending domain.
The canonical first real use case is transactional mail: password resets, receipts, notifications. If you only need "send mail from Laravel," a Mailable + this transport is the whole integration — don't reach for the API client layer until you need bounce/message introspection.
Treat it as a transport, not an SDK. The 90% path is plain Laravel mail. Write normal Mailables, use Mail::to()->send(), and let the package do the rest. Anything you'd do with SMTP in Laravel transfers unchanged.
Per-mailer credentials for multi-domain setups. Because credentials sit in the package config while config/mail.php just names the transport, the cleanest pattern for "marketing from one domain, receipts from another" is multiple mailers pointing at different config keys, then selecting at call time:
Mail::mailer('postal_billing')->to($user)->send(new Receipt($order));
Keep the credential blocks in config/postal.php keyed by mailer name so one config:cache covers everything.
Queue everything. Mailables implementing ShouldQueue work transparently — the transport is resolved inside the worker. The one discipline required: workers must see identical config to web nodes (same .env, same config:cache state), otherwise you get mystifying 401s that only appear in production.
Use Mail::fake() in tests, and don't try to unit-test the transport. Assert the Mailable was sent and its content — that's your contract. If you want real integration coverage, stand up Postal via its Docker image in CI and send one message through a dedicated test mailer; verify it appears in the log rather than checking inboxes.
Reach for the API layer only for Postal-specific concerns. Things SMTP can't tell you — message status, bounce detail, per-message info — are why the package exposes an API client alongside the transport. Wrap those calls in small services or jobs with explicit timeouts and a bounded retry count; they're network calls to infrastructure you own, so failures should degrade (log and move on) rather than break a user-facing request.
Tag messages from the start. Add a tag/header identifying the mail type (e.g. receipt, password-reset) on the Mailable. It costs one line and turns Postal's dashboard into something you can actually filter during an incident, when "which of our six mail types is bouncing?" is the question you're asking.
Handle bounces yourself. The package sends; it does not receive webhooks. Plan a small controller for Postal's webhook events if delivery feedback matters to your product — otherwise hard bounces silently accumulate and you keep mailing dead addresses.
^5.0.4; on older Laravel, ^5.0 is fine and you gain nothing by upgrading. Read it as "unblocked," not "new features."https:// or a stray trailing slash are the two most common config typos and both surface as opaque connection/path errors rather than validation messages. Normalize it once and eyeball it.MAIL_FROM_ADDRESS — a from-address on an unverified domain gets rejected or rewritten.config:cache after every env change. This bites people who edit .env on a cached deployment and conclude the package is broken. php artisan config:clear is your first debugging step, always.$tries > 1 will resend if the worker dies after Postal accepted the message but before the job was marked done. Keep tries low, and prefer marking failure over aggressive retry for transactional mail.MessageSending/MessageSent events plus Http::globalRequestMiddleware() (or Guzzle middleware if the package pins its own client) will show you the exact request and response body — far faster than reading transport source.How can I help you explore Laravel packages today?