elfsundae/laravel-apps
elfsundae/laravel-apps lets a single Laravel installation host multiple self-contained applications, each with its own routes, controllers, views, and config. It simplifies modular monoliths and multi-app projects while sharing framework and vendor code.
Install and publish
composer require elfsundae/laravel-apps
php artisan vendor:publish --provider="Elfsundae\LaravelApps\AppsServiceProvider"
If the service provider isn't auto-discovered, register it in config/app.php (or bootstrap/providers.php on Laravel 11+). The package ships a config file (commonly config/apps.php) — this is the first file to read. It defines the list of applications, where each one's routes/config/providers live, and how the current app is resolved.
Where to look first
First use case: split admin from public site
The canonical scenario is one codebase serving two surfaces with different routes, providers, and configs:
app/ # shared code (models, services, traits)
apps/
web/
routes/web.php
Providers/WebServiceProvider.php
config/
admin/
routes/web.php
Providers/AdminServiceProvider.php
config/
Register both in config/apps.php, point each at its routes and providers, then resolve by domain or path prefix (admin.example.com vs www.example.com). Once you can php artisan route:list and see both app route sets, you're wired up correctly.
Domain/path-based app resolution
The dominant pattern is resolving the app during bootstrap from the request host or path prefix. Keep the resolution logic declarative in config so it's cacheable and greppable:
'apps' => [
'web' => ['domain' => 'www.example.com', 'routes' => 'apps/web/routes'],
'admin' => ['domain' => 'admin.example.com','routes' => 'apps/admin/routes'],
],
Avoid closures in the config if you plan to config:cache — that's a common footgun in this family of packages.
Per-app service providers
Treat each app's provider as its composition root: bind app-specific interfaces, register app-specific event listeners, and load app-specific config. Shared providers stay in config/app.php; app providers get loaded by the package and should not assume other apps' providers ran.
Config layering
Merge order typically is: package defaults → config/apps.php → app-specific config/*.php. If you need to override a single key (e.g., session.cookie per app), do it in the app's config directory rather than AppServiceProvider::boot() — it keeps the override visible next to the app it belongs to.
Shared vs. app-scoped code
HomeControllers) belongs app-scoped; use namespaces per app.Routes and middleware
Group all app routes under the app's middleware stack, and prefer route-name prefixes (admin.) to avoid collisions when both apps register a dashboard route. Use separate auth guards per app — sharing a session cookie across admin and public surfaces is the most common source of "logged in as wrong user" bugs.
Migrations and seeders
Run app-scoped migrations with --path=apps/{slug}/database/migrations. Keep shared tables in the root database/migrations and namespace app tables with a prefix so a single database can host both apps without ambiguity.
Testing
Add a test bootstrap helper that spins up each app explicitly (set the host header or the resolution key) so feature tests don't accidentally exercise the wrong app. One Pest/PHPUnit dataset per app keeps the matrix small.
Config caching kills dynamic app resolution. If app definitions contain closures or read from env() at runtime, php artisan config:cache will break them. Resolve the app in a bootstrapper/middleware, keep the config file pure arrays.
Route caching and multiple apps. route:cache in Laravel expects a single route set. If the package registers all apps' routes up front, caching works but the cache is app-agnostic; if routes are registered conditionally by resolved app, route:cache will freeze whichever app you cached from. Verify behavior on your version before deploying to production, and prefer clearing/rebuilding route cache in your deploy script.
Service provider ordering. App providers run after the framework's; don't rely on config() being final inside register(). Defer anything config-dependent to boot().
Autoloading collisions. Two apps with App\Http\Controllers\... namespaces will collide. Give each app a distinct PSR-4 prefix in composer.json (e.g., Apps\Admin\) and composer dump-autoload after adding a new app — the most common "class not found" after scaffolding a new app.
Artisan commands are global unless scoped. make:controller won't know which app you mean. Either pass full paths, or override the generator stubs per app directory. Confirm which make:* commands the package patches before assuming app-awareness.
Debugging the resolved app. Log the resolved app key early (a one-liner in a middleware or a dd() in the bootstrapper) whenever routes or configs look wrong. Nine times out of ten the resolution rule matched a host you didn't expect — local dev on localhost:8000 often falls through to a default.
Sessions, cookies, and cache prefixes. Each app should have distinct session.cookie, cache.prefix, and queue names. Two apps sharing a cookie name will fight over sessions; two apps sharing a cache prefix will serve each other's cached fragments.
Extension points. The package's service provider and bootstrapper are the intended place to hook custom resolution logic (e.g., subdomain → tenant mapping). If you need behavior the config doesn't express, extend the package's resolver rather than forking — check the README's "extending" section for the exact class name on your installed version.
Version pinning. At 84 stars and a 0.95 score, the package is small and slow-moving, and Laravel's bootstrap (especially 11+) shifted service-provider registration. Pin a version in composer.json and re-test the bootstrap path on every Laravel minor upgrade.
How can I help you explore Laravel packages today?