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, andEnsureShopHasModule(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 (
AppLayoutvsClientLayout).
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
| File | Purpose |
|---|---|
webShop.php | Shop operational interfaces (inventory, work orders, scheduling) |
webClient.php | The client portal: vehicles, garages, household members, invoices, appointment requests |
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 |
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.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()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.ShopPolicysplits 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 theclient_userpivot, not on$user->current_id. A client who belongs to several households therefore reaches any of their garages without first switching context — the same wayCarPolicyandWorkOrderPolicyresolve access. The "own cars" rule above is about thecars→clientchain and does not cover this.- 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().
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:
| Layer | Where | What it does |
|---|---|---|
| Model binder | ModelServiceProvider::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} binders | RouteServiceProvider::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 filter | HasShop::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:
| Parent | Parameter | Resolved through |
|---|---|---|
WorkOrder | workOrderLine | lines() (childRouteBindingRelationshipName()) |
PurchaseOrder | purchaseOrderLine | lines() |
Supplier | supplierContact | contacts() |
InspectionCategory | inspectionItem | items() |
Make | makeModel | models() |
Service | productService | ProductService 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-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.