beyondcode/laravel-mailbox
Laravel Mailbox lets your Laravel app catch and process incoming email. Define mailboxes with fluent rules, handle messages via webhook drivers like Mailgun and Postmark, store attachments, and reply, turning inbound email into application logic.
Laravel Mailbox turns an inbound-email webhook from a provider into a familiar Laravel handler: providers (Mailgun, Postmark, SendGrid) POST a parsed message to a route the package registers, the package normalizes it into a BeyondCode\Mailbox\InboundEmail, and you match it against mailboxes you define in a service provider.
Minimal path to first email:
Install and publish config
composer require beyondcode/laravel-mailbox
php artisan vendor:publish --provider="BeyondCode\Mailbox\MailboxServiceProvider"
This gives you config/mailbox.php. Set the driver in .env:
MAILBOX_DRIVER=mailgun # or postmark, sendgrid, log
Version note: 6.0.0 adds Laravel 13 compatibility and bumps the major from 5.0.2. Based on the reported changelog, it is a supported-versions release (framework/PHP constraints), not an API rewrite — the driver layer, InboundEmail, and the Mailbox facade are unchanged. Still, treat a major bump as "verify, not assume": run your existing handler tests on the log driver before deploying the constraint change, and check composer why-not if you're on an older Laravel, since 6.x will drop support for whatever 5.x covered.
Point the provider at your app. In the Mailgun/Postmark/SendGrid dashboard, configure the inbound webhook to the package route (see config/mailbox.php for the path, typically /laravel-mailbox/{driver}). Use a publicly reachable URL — valet share, ngrok, or a staging deploy.
Define your first mailbox in app/Providers/MailboxServiceProvider.php (or any provider's boot()):
use BeyondCode\Mailbox\Facades\Mailbox;
use BeyondCode\Mailbox\InboundEmail;
public function boot(): void
{
Mailbox::from('[email protected]', function (InboundEmail $email) {
logger()->info('Inbound mail', [
'from' => $email->from(),
'subject' => $email->subject(),
]);
});
}
Send a test email to that address and check storage/logs/laravel.log. If nothing arrives, first verify the provider dashboard shows a successful delivery attempt, then check the app's route/middleware behavior before suspecting your handler.
The first real use case is almost always the same: replace a shared inbox with a handler that creates a record (ticket, lead, reply) from the message body. Start with the log driver locally, switch to the real driver once webhook plumbing is verified.
Move from closures to invokable classes immediately. Closures are fine for a spike, but real handlers accumulate parsing, attachment storage, and dispatch logic. The package accepts a class name alongside a closure:
Mailbox::from('[email protected]', SupportMailbox::class);
class SupportMailbox
{
public function __invoke(InboundEmail $email): void
{
$ticket = Ticket::create([
'from' => $email->from(),
'subject' => $email->subject(),
'body' => $email->text() ?: strip_tags($email->html()),
]);
foreach ($email->attachments() as $attachment) {
$ticket->addMediaFromString($attachment->getContents())
->usingFileName($attachment->getName())
->toMediaCollection('email-attachments');
}
}
}
Route by recipient, not by subject. Mailbox definitions are address-based, which is the strongest routing primitive the package gives you:
Mailbox::from('[email protected]', BillingMailbox::class);
Mailbox::from('[email protected]', SupportMailbox::class);
// Fallback for anything else on the domain
Mailbox::catchAll(UnmatchedMailbox::class);
Use catchAll deliberately — it will silently absorb typos and provider test emails, so route it to a low-priority queue or a "needs triage" table rather than a real handler.
Keep handlers thin and dispatch work. Provider webhooks have short timeouts and will retry on failure. Do the minimum inside the handler (identify, persist, dispatch) and let a job do the slow part:
public function __invoke(InboundEmail $email): void
{
$record = InboundMessage::create([
'message_id' => $email->messageId(),
'from' => $email->from(),
'subject' => $email->subject(),
'payload' => $email->toArray(),
]);
ProcessInboundMessage::dispatch($record);
}
Persisting the whole payload up front is the single highest-leverage habit here: when parsing is wrong or you need to backfill a new handler, you can replay from your own table instead of asking the provider to redeliver.
Replies and forwards are first-class. The two operations that make this package worth using over a generic webhook:
$email->reply(new TicketCreatedReply($ticket)); // threaded reply
$email->forward('[email protected]', new ForwardedMail($email));
Reply subjects/threading are handled for you (Re: prefixing, In-Reply-To), which is fiddly to get right by hand. Use forward() for archival and escalation flows instead of building a mailer manually.
Normalize across providers so you can switch. Mailgun and SendGrid ship multipart/form-data with attachment URLs; Postmark ships JSON with inline content. If you write your handler against InboundEmail only — never against request() — you can develop on log locally and flip drivers in production without code changes. Treat $email->html() as untrusted input: sanitize before rendering it into an internal ticket view.
Multi-tenant / multi-domain routing. Combine catch-all with the recipient header to resolve a tenant:
Mailbox::catchAll(function (InboundEmail $email) {
$tenant = Tenant::byInboundDomain(Str::after($email->to(), '@'));
abort_unless($tenant, 404);
ProcessInboundMessage::dispatch($tenant, $email->toArray());
});
Test by handler, not by webhook. Instantiate the handler class directly with an InboundEmail you construct (inspect its constructor in vendor/beyondcode/laravel-mailbox/src/InboundEmail.php — drivers build it and you can build it the same way), or post a provider-shaped payload to the registered route to test the full path. Assert on the database and dispatched jobs, not on the mailer. Keep a handful of these tests green before your next major-version bump — they're the cheap way to confirm a compatibility release didn't move anything you rely on.
CSRF and middleware will bite you first. Webhook POSTs have no session token. The package registers its routes with a configurable middleware group (config/mailbox.php), typically the api group. If you change it to web — or your app applies VerifyCsrfToken globally — every delivery 419s. Keep the package route on the api group, and make sure any auth middleware you add is a signature/token check, not a session guard.
Providers retry, so handlers must be idempotent. Mailgun and Postmark will redeliver on non-2xx or timeout. Deduplicate on $email->messageId() with a unique index before creating records, or you will get duplicate tickets from a single email. Returning a 500 from your handler is a request to be retried, not an error report.
Attachment access differs per provider. Mailgun posts attachment URLs that require your API key to download, so the mailgun.key value in config is functional, not decorative — a missing/incorrect key yields empty attachments while the handler still "succeeds." Postmark inlines attachments. Always check $attachment->getSize() and enforce your own max size before writing to disk.
from() is a header, not an identity. It is trivially spoofable. Never authorize actions (e.g., "close this ticket") solely on the From address. If you need verified senders, use provider-level authentication or a signed reply address.
Email bodies are hostile input. HTML from html() can contain tracking pixels, remote images, and scripts. Strip or proxy images and sanitize with something like mews/purifier before showing it to staff in a dashboard.
Large emails and timeouts. Providers cap message size (often 25MB). Downloading several attachments can exceed the webhook timeout; if you see retries on attachment-heavy mail, move download + storage into the queued job and store only metadata in the handler.
Local development. Use MAILBOX_DRIVER=log and trigger the route with a captured provider payload (save a real webhook body from staging and curl it locally). This avoids needing a public tunnel just to iterate on parsing logic.
Config caching. After changing config/mailbox.php or the driver, run php artisan config:clear in dev and config:cache in deploy. A stale cached driver is a classic "why is it still writing to the log" bug.
Upgrading to 6.0.0. The changelog for 6.0.0 is two PRs wide: Laravel 13 compatibility plus a version bump landed as 5.1.0. That's a good sign for the package's health and a small blast radius for you — but the major number exists to signal a supported-version break, so read the composer.json constraints in the tag before you bump. Practical sequence: update on a branch, composer update beyondcode/laravel-mailbox, run php artisan vendor:publish --tag=... --force only if config keys actually changed (diff first, don't blow away your driver settings), config:clear, run your handler test suite, then replay a captured webhook against the log driver on the new version. If you're mid-project on Laravel 10/11 and 6.x drops it, staying pinned on 5.0.2 is fine — the API you use is the same.
Maintenance reality check. The package has historically been a small, single-maintainer, readable codebase; the 6.0.0 release shows it's still being picked up and kept compatible with new framework versions (Laravel 13), with community PRs merged rather than the repo being abandoned. That is "stable and maintained at low velocity" — expect compatibility releases, not new features. Budget an hour to read src/ (drivers, InboundEmail, the controller); it is short, and knowing exactly how payloads are normalized saves far more time than debugging through the facade. If you need a provider it does not support, the driver layer is the extension point; otherwise a custom controller that constructs InboundEmail is a viable escape hatch — and it's also your insurance policy if maintenance stalls later.
Wrap it in your own abstraction early. Route handlers through your own InboundMessage model/job. If the package stalls or you outgrow it, swapping the webhook edge while keeping your storage and job pipeline intact is a day of work instead of a rewrite. This is exactly what makes a 5.x → 6.x bump a non-event: nothing your app depends on lives inside the package.
How can I help you explore Laravel packages today?