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.
Minimal path from zero to a tenant-branded email (about 10 minutes):
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.
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],
];
}
}
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());
}
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.
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.
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.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.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.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.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.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.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.How can I help you explore Laravel packages today?