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 Multidomain Laravel Package

gecche/laravel-multidomain

Run multiple HTTP domains from a single Laravel installation. Each domain gets its own env file, storage path, and database, letting different customers share code but keep separate configuration and data.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Install with a version matched to your Laravel major. From Laravel 10 up, the package version mirrors the framework: composer require gecche/laravel-multidomain:^12.0 on Laravel 12.

2. Swap the container class at the very top of bootstrap/app.php. This is the one step people get wrong. The package must know the domain before the environment file is loaded, so it replaces Illuminate\Foundation\Application with its own subclass:

// Laravel 11/12 style
use Gecche\Multidomain\Foundation\Application;

$domainParams = [
    'domain_detection_function_web' => fn() => $_SERVER['HTTP_HOST'] ?? null,
];

return Application::configure(
    basePath: dirname(__DIR__),
    environmentPath: null,
    domainParams: $domainParams,
)->withRouting(...)->create();

Nothing else may run before this line — no require, no env() call.

3. Replace the queue provider in config/app.php so queue:work --domain= is honoured:

'providers' => \Illuminate\Support\ServiceProvider::defaultProviders()->merge([
    // Package Service Providers...
])->replace([
    \Illuminate\Queue\QueueServiceProvider::class => \Gecche\Multidomain\Queue\QueueServiceProvider::class,
])->merge([
    // Added Service Providers (Do not remove this line)...
])->toArray(),

4. php artisan vendor:publish to get the config file, then confirm the published filename (config/domains.php — the README also writes config/domain.php; trust whatever the publish actually created).

5. First use case — add two tenants:

php artisan domain:add site1.com
php artisan domain:add site2.com
php artisan domain:list

That single command produces .env.site1.com, storage/site1_com/ (full storage skeleton), and an entry under the domains key of the config file. The .env.site1.com is a blank-ish file you then populate with the tenant's DB, cache, mail and APP_URL settings. domain:list is your verification step and should be the first thing you run whenever routing or storage looks wrong.

Where to look first, in order: bootstrap/app.php → the published domain config → .env.site1.com → config/domains/ (per-domain config overrides).

Implementation Patterns

Per-domain env is the primary isolation lever. Everything you'd normally put in .env gets a tenant copy: DB_DATABASE, CACHE_PREFIX, REDIS_DB, MAIL_*, QUEUE_DEFAULT, APP_URL. Because these are separate files, you can hand a single tenant's secrets to a single tenant's ops team.

Per-domain config overlays for anything env can't express. Create config/domains/site1_com.php (sanitized name: dots → underscores) returning a nested array that is merged recursively over config/*.php:

return [
    'app' => ['name' => 'Site 1 Application'],
    'features' => ['billing' => true],
];

This is the cleanest place for feature flags and per-tenant third-party credentials, and it's the pattern to prefer over scattering env() calls with domain checks.

Read the domain from the container, not from request(). app()->domain() works identically in HTTP and in every --domain= artisan invocation; app()->domainList() returns the associative array from the config:

$domains = app()->domainList();
foreach ($domains as $domain => $info) {
    // $info['env'], $info['storage_path']
}

Scope tenant data with a global scope keyed on app()->domain(), or with a middleware that binds a Tenant model into the container. Don't rely on route files per domain — keep one codebase and one route set, and group routes by host only where the URLs genuinely differ.

CLI is first-class but opt-in. Every artisan command accepts --domain=; without it you get the base .env and base storage/. So the day-to-day command surface is:

php artisan migrate --domain=site1.com
php artisan db:seed --domain=site1.com
php artisan queue:work --domain=site1.com --queue=default1
php artisan list --domain=site1.com

Queues need deliberate channel separation. The provider replacement lets queue:work boot under a domain's env. But if tenants share a jobs table, give each a distinct queue name (QUEUE_DEFAULT=default1 in .env.site1.com) and wire 'queue' => env('QUEUE_DEFAULT', 'default') in config/queue.php, then run one worker per domain/queue pair. Under Horizon, additionally swap the Horizon service provider base class:

// app/Providers/HorizonServiceProvider.php
use Gecche\Multidomain\Horizon\HorizonApplicationServiceProvider;

Caching config is per-domain and must be scripted. php artisan config:cache --domain=site2.com writes config-site2_com.php — a separate artifact per tenant. Your deploy pipeline therefore needs a loop, not a single call:

php artisan config:cache
for d in $(php artisan domain:list --format=... ); do
    php artisan config:cache --domain="$d"
done

Scheduler: fan out over domainList(). Register one scheduled entry per tenant, each with --domain=, rather than a single global job that guesses the tenant.

storage:link has to be done by hand. Laravel hardcodes the link name storage, so create per-tenant links and point filesystems.php at them via an env suffix:

ln -s storage/site1_com/app/public public/storage-site1_com
// .env.site1.com
APP_PUBLIC_STORAGE=-site1_com

// config/filesystems.php
'url' => env('APP_URL').'/storage'.env('APP_PUBLIC_STORAGE', ''),

Bulk env edits across tenants use domain:update_env, which accepts a JSON object and appends it to one or all env files:

php artisan domain:update_env --domain_values='{"TOM_DRIVER":"TOMMY"}'
php artisan domain:update_env site2.com --domain_values='{"FEATURE_X":"1"}'

Omitting the domain argument hits every .env, including the base one — convenient for a global rollout, dangerous if you only meant one tenant.

Gotchas and Tips

$_SERVER['SERVER_NAME'] is the default detection source and is frequently absent (php-fpm behind nginx, containers, some proxies). When it's missing the package silently falls back to the base .env and base storage — you get a working app pointed at the wrong tenant. Pass domain_detection_function_web in domainParams and switch to HTTP_HOST. If you do, normalise inside the closure: strip the port, decide whether www. is a separate tenant, lowercase the host.

Silent-fallback is the dominant class of bug in this package. No exception is thrown when a domain has no env file. When something looks wrong, immediately dd(app()->domain()) and cross-check app()->domainList(). If the domain is null or unexpected, the problem is detection, not configuration.

config:cache without --domain caches the base config only. On production, a missing per-domain cache file means that tenant boots the base .env values — a data-leak-adjacent failure mode. Add a health check that asserts app()->domain() inside a per-tenant request, and make per-domain caching part of the deploy script rather than a manual step.

Name sanitization is mechanical and asymmetric. site1.com → folder site1_com, config file site1_com.php, cached config config-site1_com.php. Don't assume str_replace('.', '_') covers everything — hyphens and ports survive as-is, so site1.com:8080 becomes an odd folder name. Normalise before calling domain:add.

README inconsistencies to verify locally: domain.add vs domain:add, and config/domains.php vs config/domain.php. The commands are namespaced (domain:add). The config filename is whatever the publish tag creates — check config/ after installing rather than trusting the prose.

domain:add creates files, not databases. You still have to provision the MySQL/Postgres database and user outside Laravel, then php artisan migrate --domain=site1.com. Nothing in the package automates tenant provisioning; wrap both steps in your own tenant:provision command.

domain:remove deletes the env file and config entry but leaves storage unless you pass force. After force, the manual symlinks you created for storage:link and the config-<domain>.php cache file are orphaned — clean both in your teardown, or you'll eventually serve a stale cached config to a re-registered domain.

domain:update_env appends. Repeated runs accumulate duplicate keys in the same file. Prune periodically, or generate env files from a template instead of incrementally patching them.

Never assume --domain is set in CLI. Cron entries, supervisor configs and CI scripts each need it explicitly, or they silently operate on the base environment. This is the single most consequential operational detail: a queue:work started without --domain processes tenant jobs under the wrong database.

Container swap is invasive by design. Anything type-hinting Illuminate\Foundation\Application still resolves because the subclass extends it, but packages that construct their own new Application or inspect the concrete class will misbehave. Test third-party packages that boot early (Telescope, Debugbar, Octane) after the swap — and note Octane/long-lived workers need extra care since the domain is bound at boot.

Testing: HTTP tests don't populate SERVER_NAME naturally. Use $this->withServerVariables(['SERVER_NAME' => 'site1.com']), or set the domain on the container directly in setUp(). Write at least one test that asserts app()->domain() and one that asserts storage_path() resolves per tenant, so a detection regression fails loudly instead of quietly serving tenant A's data to tenant B.

Extension points: the domainParams array passed to the Application constructor is the supported hook (domain_detection_function_web is the documented key), and subclassing Gecche\Multidomain\Foundation\Application is where you'd override domain resolution or storage-path derivation if you need a non-standard tenant mapping.

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.
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package