Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Scoped Mail Config Laravel Package

lacodix/laravel-scoped-mail-config

Send Laravel mail with dynamic, per-model mail configuration. Implement HasMailConfig on tenants, teams, users, or any model to scope SMTP and sender settings—ideal for multi-tenancy and admin-managed mail configs.

View on GitHub
Deep Wiki
Context7

Getting Started

Minimal path from zero to a tenant-branded email (about 10 minutes):

  1. Install and let the package auto-register its service provider:

    composer require lacodix/laravel-scoped-mail-config
    

    No config/scoped-mail.php publish step is documented — the surface area is deliberately tiny: one concern, one facade, one resolver hook.

  2. Put the HasMailConfig concern on the model that owns the mailbox credentials (tenant, team, user, or a plain class — the README explicitly allows "any model or class").

    use Lacodix\LaravelScopedMailConfig\Concerns\HasMailConfig;
    
    class Tenant extends Model implements HasMailConfig
    {
        public function getMailConfig($name): array
        {
            return [
                'transport'    => 'smtp',
                'host'         => $this->mail_host,
                'port'         => (int) $this->mail_port,
                'encryption'   => $this->mail_encryption,
                'username'     => $this->mail_username,
                'password'     => $this->mail_password, // cast: encrypted
                'timeout'      => 60,
                'local_domain' => null,
                'from'         => ['address' => $this->mail_from, 'name' => $this->name],
            ];
        }
    }
    
  3. Point the package at the right scope in AppServiceProvider::register(). Skip this only if you want the default (current authenticated user):

    use Lacodix\LaravelScopedMailConfig\Facades\ScopedMail;
    
    public function register(): void
    {
        ScopedMail::resolveScopeUsing(fn () => Tenant::getCurrentTenant());
    }
    
  4. Swap Mail for ScopedMail at the call sites that must use the tenant's transport. It extends the standard facade, so to(), cc(), send(), queue(), raw() all behave as you already know:

    ScopedMail::to($user->email)->send(new InvoiceReady($invoice));
    

Where to look first: the docs site (lacodix.de/docs/laravel-scoped-mail-config) rather than the repo — the README is a landing page, and the docs carry the "special use case" notes added in v1.2.0. Then read the package source: it's small enough to read in full, which matters because the docs don't describe fallback behaviour or what $name actually contains.

First real use case: a SaaS tenant onboarding flow where each tenant's transactional mail must come from their own domain with their own SMTP relay (so deliverability, SPF/DKIM alignment, and bounce handling belong to the tenant, not to you). Leave your platform-wide config/mail.php untouched and route only tenant-facing mailables through ScopedMail.

Implementation Patterns

1. Multi-tenant with spatie/laravel-multitenancy. The package is designed for this pairing. Don't resolve from auth(); resolve from the tenant container:

ScopedMail::resolveScopeUsing(fn () => app(Tenant::class));

Because the closure is evaluated per send, it picks up whatever tenant the middleware has bound — no rebinding, no per-request singleton gymnastics.

2. Layer the resolution chain rather than branching inside one closure. Real apps need "tenant SMTP → team SMTP → platform default". Keep the closure dumb and let getMailConfig() return [] (or a merged config('mail.mailers.smtp')) when the scope has nothing configured, so unconfigured tenants silently degrade to your global config instead of throwing:

ScopedMail::resolveScopeUsing(
    fn () => auth()->user()?->team ?? Tenant::getCurrentTenant()
);

3. Admin-editable configuration. The strongest day-to-day win: an admin settings screen writes mail_host/mail_username/mail_password onto the tenant, and getMailConfig() returns them. Cast the password column 'encrypted', mark it $hidden, and add a "Send test email" action that calls ScopedMail::raw('Test', fn ($m) => $m->to($admin->email)) so an admin can validate credentials without a deploy.

4. Use $name for per-mailer selection. getMailConfig($name) receives the mailer name, which lets one scope serve multiple transports — e.g. return an SMTP array for 'smtp' and a Postmark/SES array for 'postmark'. Verify against the source what is passed when you don't call mailer(), then use ScopedMail::mailer('postmark')->to(...) where you need the alternate branch.

5. Queue everything, and carry the scope into the job. ScopedMail::queue() works, but the resolver closure runs on the worker where auth() is empty and the tenant middleware never ran. The reliable pattern is to bake the scope identifier into the mailable and rehydrate it in the job:

class InvoiceReady extends Mailable implements ShouldQueue
{
    public function __construct(public Invoice $invoice, public int $tenantId) {}

    public function handle(): void
    {
        ScopedMail::resolveScopeUsing(fn () => Tenant::findOrFail($this->tenantId));
    }
}

If the package doesn't expose that hook cleanly from within a mailable, resolve the tenant in a JobProcessing listener keyed by the queued payload, or keep a queue-aware resolver closure that reads a tenant id from the job context. Either way: test the queued path explicitly, because it's where scoped config most often silently falls back to global.

6. Test with the same facade you ship with. ScopedMail::fake() pairs with ScopedMail::assertSent(...). Asserting against Mail::fake() after sending through ScopedMail (or vice versa) is the classic false-green. Add a separate unit test on getMailConfig() itself asserting the array shape and that no secret leaks into toArray().

7. Adapter-wrap it. One star and zero dependents mean you should not let ScopedMail:: calls sprawl across 40 files. Introduce your own TenantMailer service that internally calls the facade; swapping the package out later (or forking it under MIT) becomes one file instead of a codebase-wide find-and-replace.

8. Roll out behind a config flag. if (config('features.tenant_mail')) choose ScopedMail, else Mail. Lets you ship the integration dark, enable per-tenant, and fall back instantly in an incident without a revert.

Gotchas and Tips

  • The README contradicts itself. It says "add our interface" but imports Lacodix\LaravelScopedMailConfig\Concerns\HasMailConfig — a Concerns namespace usually means a trait, yet the example writes implements HasMailConfig. Check the actual source before wiring: if it's a trait, use use HasMailConfig; and implements; if it's an interface only, your getMailConfig() signature must match exactly or you'll get a fatal error at class load.
  • encryption is a Laravel-version trap. The README's config array mirrors Laravel 8-era mailer options. Laravel 9+ moved toward scheme (smtp / smtps) for Symfony transports, and local_domain/timeout are frequently ignored depending on the transport. Don't copy the README array verbatim into Laravel 11/12 — start from your own working config/mail.php mailer block and mutate the credentials.
  • The default resolver is auth()->user() — and that silently fails in the places that matter. Console commands, scheduled jobs, queued workers, and webhooks have no authenticated user. The resolution then returns null and you fall back to global config, which means a tenant's invoice email goes out from your domain and nobody notices until a customer complains. Always set an explicit resolveScopeUsing() in production; treat the default as a local-dev convenience.
  • Always return a from block. Without it you inherit config('mail.from') and tenants send mail as your platform — bad for deliverability and confusing for recipients. Enforce it in a test.
  • Credential hygiene. The returned array flows into transport construction and can surface in exception traces, dd() output, and log context. Keep passwords encrypted-cast and $hidden; log the resolved config with the password redacted when debugging; never Log::info($tenant->getMailConfig('smtp')) raw.
  • config:cache isn't the danger — the DB is. Static config caching is harmless because the array is built at runtime. The real cost is a query per send. Cache the resolved config on the tenant model for the request (once() / a memoised accessor), and index whatever column your resolver reads.
  • Notifications bypass the facade. Notifiable mail notifications route through the container-bound mailer, not through ScopedMail::. If a notification must use tenant mail, build the mailable and send it through ScopedMail explicitly rather than relying on routeNotificationForMail.
  • Per-tenant rate limits are a real operational concern. Every tenant has a different provider with different throughput caps. Queue + a per-tenant rate limiter; a shared fast queue will happily blast one tenant's SES account into a throttle.
  • Deliverability failures aren't bugs. Tenant mail landing in spam is usually missing SPF/DKIM/DMARC on their domain. Surface this in onboarding docs and in the admin test-email result, or you'll spend sprint time debugging your own code.
  • Debugging loop: write a one-off Artisan command that resolves the scope, prints the config with the password masked, and sends a raw() message to a fixed address. That single command answers "did the resolver return the right model?" and "are the credentials valid?" faster than any log dive.
  • Vendor risk is the honest headline. 1 star, 0 dependents, single maintainer, last release 2026-06-09, ~15/100 internal score. It's MIT and tiny, so the mitigation is cheap: keep the adapter wrapper, read the source once, and pin the version. If maintenance stops, forking two files is a weekend, not a rewrite.
  • Extension point that's easy to miss: because HasMailConfig accepts "any model or class", you can back the config with a secrets manager (Vault, AWS Secrets Manager) via a small non-Eloquent class and pass that to resolveScopeUsing(). That keeps raw SMTP passwords out of your database entirely — worth doing if you're SOC2-adjacent.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
mehedimi/laravel-amp-mail
happones/laravel-vue-i18n-generator
jwerd/inertia-table
siberfx/flysystem-wasabi
hollyit/token-replace
ippanelcom/laravel-sdk
adachsoft/changelog-linter
adachsoft/collection
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel