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, and EnsureShopHasModule (module:<value>), which refuses a route the shop's plan does not include.
  • Permissions & roles via spatie/laravel-permission.
  • Route partitioning (routes/webShop.php, routes/webClient.php).
  • UI layout differentiation (AppLayout vs ClientLayout).

Each shop also carries a plan (shops.plan_id) and per-shop module overrides (shop_modules), which decide which features it has at all — see Plans & modules.

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()). ClientShop also carries the per-shop accounting_settings and the per-shop CASL consent (see CASL consent).

Route Segmentation ​

FilePurpose
webShop.phpShop operational interfaces (inventory, work orders, scheduling)
webClient.phpThe client portal: vehicles, garages, household members, invoices, appointment requests
auth.phpAuthentication flows (Fortify-backed login, registration, password reset)
web.phpShared routes, the api. JSON endpoints, the ai. endpoints, and the {shop} prefixed group

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.
  • EnsureShopHasModule (module:<value>) returns 403, not a redirect, when the current shop's plan lacks the module or its monthly quota is used up. See Plans & modules.
  • 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.
  • ShopPolicy splits read from write. view() passes for shop staff and for a client who is a customer of that shop — the portal reads a garage's details through it. update() additionally requires $user->isShop(), so a client never writes to the shop record.
  • ShopPolicy::clientBelongsToShop() is keyed on $user->id, through the client_user pivot, not on $user->current_id. A client who belongs to several households therefore reaches any of their garages without first switching context — the same way CarPolicy and WorkOrderPolicy resolve access. The "own cars" rule above is about the cars → client chain and does not cover this.
  • 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().

Route model binding ​

Every model parameter is bound explicitly, so Laravel's implicit binder — and the child scoping it implements for ->scopeBindings() — never runs for them. Three layers take its place:

LayerWhereWhat it does
Model binderModelServiceProvider::bindModel()One Route::bind() per model in app/Models, keyed on the camel-cased short name. On a route with ->scopeBindings() whose previous parameter is a model other than Shop, it resolves the child through $parent->resolveChildRouteBinding(); otherwise through the model's own resolveRouteBinding(). Nothing found is a 404.
{shop} bindersRouteServiceProvider::boot()workOrder, purchaseOrder, product, service, supplier and client are rebound through the {shop} segment ($shop->workOrders()->findOrFail()), and fall back to an unscoped findOrFail() on a route without {shop}. Registered after ModelServiceProvider, so they win for those names.
Tenant filterHasShop::resolveRouteBindingQuery()Restricts the lookup to Shop::current() plus rows with a null shop_id (catalog rows), and matches nothing when there is no current shop. It sits on the query so that the child lookup, which builds its query through the parent's relation, applies it too.

The shop. group in routes/webShop.php declares ->scopeBindings(), so /{shop}/work-orders/{workOrder}/{workOrderLine} only resolves a line of that work order. Children directly under {shop} are not scoped through Shop: Shop has no relation for several of them (dailyClosing, period, shiftPunch, …), and the tenant filter above already covers them.

The child lookup calls the relation named after the plural of the parameter ({shiftPunch} → User::shiftPunches()). A missing relation is a BadMethodCallException, i.e. a 500, so a parent whose relation is named differently maps it:

ParentParameterResolved through
WorkOrderworkOrderLinelines() (childRouteBindingRelationshipName())
PurchaseOrderpurchaseOrderLinelines()
SuppliersupplierContactcontacts()
InspectionCategoryinspectionItemitems()
MakemakeModelmodels()
ServiceproductServiceProductService rows of the service (resolveChildRouteBinding())

A route whose last parameter is not a child of the one before it opts out with ->withoutScopedBindings(). That turns scoping off for every parameter on the route, so the action checks the parent link itself — storage-ticket.assign (AssignStorageTicket) and product.variant (ChooseWorkOrderLineVariant) both abort(404) in asController(). tests/Feature/Shop/ScopedChildBindingTest.php covers each nested route.

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.