Appearance
Multi-Tenancy Model
Vroum Solutions supports two user contexts within a single shared database:
- Shop users — repair shop / garage staff (the tenant is the
Shopmodel) - Client users — vehicle owners / customers
A user's type column (App\Entities\UserType) decides which context they are in.
Strategy
Rather than database-per-tenant or separate schemas, tenancy is enforced by:
- User context methods:
User::isShop(),User::isClient(). - A "current" tenant resolved per request via the
HasCurrentModeltrait onShopandClient. - Middleware:
MakeCurrentShop(resolves the{shop}route parameter),EnsureUserTypeShop,EnsureUserTypeClient. - Permissions & roles via
spatie/laravel-permission. - Route partitioning (
routes/webShop.php,routes/webClient.php). - UI layout differentiation (
AppLayoutvsClientLayout).
Membership is many-to-many on both sides: ShopUser links users to shops, ClientShop links clients to shops. The user's active tenant is stored on the user (currentShop() / currentClient()).
Route Segmentation
| File | Purpose |
|---|---|
webShop.php | Shop operational interfaces (inventory, work orders, scheduling) |
webClient.php | Client-facing portal (service history, appointments) |
auth.php | Authentication flows (Fortify-backed login, registration, password reset) |
web.php | Shared routes, the api. JSON endpoints, the ai. endpoints, and the {shop} prefixed group |
webhook.php | Inbound webhooks (CSRF-exempt) |
Current Shop
php
$shop = Shop::current(); // resolved per request; also works inside queued jobsRules:
- Never pass a
Shopinto a queued job — resolve it withShop::current()inside the job instead. EnsureUserTypeShopredirects (302) rather than returning 403; assert accordingly in tests.- Laravel Actions run
authorize()beforeasController(), so a policy failure surfaces as 403.
Authorization Layer
- Policies decide granular access (e.g., a Client may view only their own cars).
spatie/laravel-permissionattaches roles & direct permissions on top.- Model broadcasting is deliberately per-instance (
models.{class}.{id}); a class-wide channel would authorize viaviewAny(), which is not shop-scoped, and would leak newly created records across tenants.
Data Scoping Patterns
Most tenant-owned models use the App\Traits\HasShop trait. Prefer scoping at the query level over branching in controllers:
php
// Inside an Action or Controller
$cars = Car::query()
->where('shop_id', Shop::current()->id)
->get();For client-facing queries, scope through the client relationship instead of the shop:
php
$cars = Client::current()->cars()->get();Search indexes are scoped the same way — App\Traits\ShopSearchable requires shop_id in toSearchableArray().
Frontend Layout Resolution
Automatic layout selection (resources/js/app.ts):
Pages/Auth/*and the top-levelErrorpage →GuestLayoutPages/Client/*→ClientLayout- everything else (
Pages/Shop/*,Pages/Profile/*,Pages/Public/*, …) →AppLayout
Override per page:
ts
import CustomLayout from '@/Layouts/CustomLayout.vue';
defineOptions({ layout: CustomLayout });
// or: defineOptions({ noLayout: true });Caching & Performance
- Permission checks are cached by Spatie; clear the cache when updating roles.
- Avoid cross-tenant data leakage by confirming constraints at the model & query level, not just in the UI.
Testing Considerations
tests/Unit/Actions/ActionTestCase.php builds fully wired contexts:
php
['shop' => $shop, 'user' => $user, 'client' => $client] = $this->createShopUser();
['client' => $client, 'user' => $user] = $this->createClientUser();For unit tests that need a tenant without an HTTP request:
php
Shop::fakeCurrent($shop);Tests\TestCase::tearDown() calls Shop::stopFakingCurrent() and Client::stopFakingCurrent(), so no manual cleanup is needed.
Feature tests should assert data isolation between contexts.
See queues-realtime.md for asynchronous processing across contexts.