Skip to content

Multi-Tenancy Model

Vroum Solutions supports two user contexts within a single shared database:

  • Shop users — repair shop / garage staff (the tenant is the Shop model)
  • 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 HasCurrentModel trait on Shop and Client.
  • 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 (AppLayout vs ClientLayout).

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

FilePurpose
webShop.phpShop operational interfaces (inventory, work orders, scheduling)
webClient.phpClient-facing portal (service history, appointments)
auth.phpAuthentication flows (Fortify-backed login, registration, password reset)
web.phpShared routes, the api. JSON endpoints, the ai. endpoints, and the {shop} prefixed group
webhook.phpInbound webhooks (CSRF-exempt)

Current Shop

php
$shop = Shop::current();      // resolved per request; also works inside queued jobs

Rules:

  • Never pass a Shop into a queued job — resolve it with Shop::current() inside the job instead.
  • EnsureUserTypeShop redirects (302) rather than returning 403; assert accordingly in tests.
  • Laravel Actions run authorize() before asController(), 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-permission attaches roles & direct permissions on top.
  • Model broadcasting is deliberately per-instance (models.{class}.{id}); a class-wide channel would authorize via viewAny(), 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-level Error page → GuestLayout
  • Pages/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.