Skip to content

Client portal ​

The half of the app a car owner signs in to: their vehicles, the garages they deal with, the people who share those vehicles, and the invoices behind the work. It is the Client context of the multi-tenancy model — routes/webClient.php, Pages/Client/*, ClientLayout — and it reads shop-owned records without ever being a shop tenant.

The tenant unit here is the household: a Client row with one or more User rows attached through the client_user pivot. A household deals with one or more garages (client_shop), and each of its cars belongs to the household, not to a person.

Surfaces ​

Every route sits under the client prefix and the client. name prefix, behind auth and EnsureUserTypeClient (routes/webClient.php:23). There is no {shop} segment and no MakeCurrentShop — the portal never has a current shop.

RouteNameHandler
GET client/dashboardclient.dashboardClient\DashboardController@show
GET client/car/{car}client.car.showClient\CarController@show
GET client/garagesclient.garages.indexClient\GarageController@index
GET client/garages/{garage}client.garages.showClient\GarageController@show
GET client/householdclient.householdClient\HouseholdController
POST client/household/membersclient.household.members.storeClient\InviteHouseholdMember
GET client/work-orders/{workOrder}client.workOrders.showClient\WorkOrderController@show
POST client/appointmentsclient.appointments.storeClient\RequestAppointment
GET client/profileclient.profile.editClient\ProfileController

Some surfaces also depend on the shop's plan. With no current shop there is no route gate to lean on, so each one checks the relevant shop itself and aborts 403 with ShopModules::refusal(): Client\WorkOrderController@show and the invoice PDF need client_portal on the work order's shop, and RequestAppointment needs client_portal and appointments on the requested shop. See Plans & modules.

{garage} type-hints Shop and resolves through the ordinary implicit binding, so it is keyed by slug (Shop::getRouteKeyName() returns slug). Everything the portal renders about that shop goes through GarageData, never ShopData's tenant view of it.

Client::current() here, Shop::current() there ​

Both models use HasCurrentModel, but they are resolved by different machinery and only one of them exists on a portal request.

Shop::current() is resolved from the {shop} route segment by MakeCurrentShop. No portal route carries that segment, so Shop::current() is null for the whole request. Anything reaching for it — Car::forCurrentShop(), HasShop::resolveRouteBindingQuery(), ShopSearchable — is unusable from here by construction.

Client::current() is resolved from users.current_id instead. The MakeCurrentAccount listener runs on Laravel's Authenticated event; when current_id is empty it fills it with the user's first household, then calls User::makeCurrentAccount(), which binds $user->currentClient — a plain belongsTo(Client::class, 'current_id'). Client::onMakingCurrent() writes the id back onto the user, so the selection persists across requests.

Two consequences follow, and the second one is the security section below:

  • Scope client-side queries through the household, not through a shop: Client::current()->cars(), $client->shops(), $client->workOrders().
  • current_id is a stored preference, not a proof of membership. Nothing revalidates it on read.

Security ​

Every surface that resolves Client::current() must guard it ​

App\Actions\RemoveUserClient detaches a user from a household with $client->users()->detach($user) and leaves users.current_id pointing at the household they just left. MakeCurrentAccount only fills current_id when it is empty, and User::makeCurrentAccount() loads currentClient straight off that foreign key, so on the next request Client::current() still returns a household the user is no longer in.

Route-bound surfaces are safe by accident — CarPolicy, WorkOrderPolicy and ShopPolicy all resolve access off client_user rather than off current_id. The surfaces with no route-bound model are the exposed ones, and each of them carries the guard explicitly:

php
$client = Client::current();

abort_if($client === null, 403);

$this->authorize('view', $client);
WhereSource
GarageController::index()app/Http/Controllers/Client/GarageController.php:24
GarageController::show()app/Http/Controllers/Client/GarageController.php:48
HouseholdController::__invoke()app/Http/Controllers/Client/HouseholdController.php:20
DashboardController::vehicles()app/Http/Controllers/Client/DashboardController.php:32

ClientPolicy::view() is what makes the pair meaningful: for a client user it asks $client->users()->where('id', $user->id)->exists() — the pivot, not current_id. The abort_if is not redundant with it, because authorize() on null would be a type error rather than a 403.

DashboardController::vehicles() is the one that is easy to miss: the guard lives in the deferred prop closure, not in show(). The initial visit renders nothing but a placeholder, and the household is only read on Inertia's follow-up partial request. A guard in show() would pass the first visit and leave the second one unprotected.

InviteHouseholdMember::authorize() and RequestAppointment::authorize() express the same rule in the Action form — Client::current() non-null and $request->user()->can('update', $client).

ClientPortalTest::a_user_removed_from_a_household_can_no_longer_read_it pins all four: it reads client.household successfully, detaches the pivot, asserts current_id is still the old household, then asserts 403 on the garages index, on the household, and on the dashboard's partial cars request.

InvoiceLineData exists so the garage's cost never ships ​

WorkOrderLineData nests a whole ProductData on $product (app/Data/WorkOrderLineData.php:45), and ProductData carries cost (the purchase cost), stock_quantity, minimum_stock_level, reorder_quantity, supplier_id and a supplier. The line itself adds the garage's purchasing: part_order_status, purchase_order_id and purchase_order_number. Those are the garage's numbers. WorkOrderController::show() eager-loads lines.related with each product's supplier, and lines.purchaseOrderLine.purchaseOrder, so serialising a WorkOrderLineData from the portal would populate every one of them.

App\Data\InvoiceLineData is the portal's own line DTO and has exactly eight fields — id, name, description, reference, unit_price, quantity, total, children. Only the reference survives from the related record — the product's part_number or the service's sku; name and description fall back to the product's when the line does not override them.

ClientPortalTest::work_order_show_never_exposes_the_garage_purchase_cost asserts the exact key list of the serialised line, so adding a field to the DTO fails the test rather than shipping quietly.

Never serialise a shop-side DTO from a Client\* controller. WorkOrderData and CarData are shared, and both are used here with an explicit ->include(...) allowlist; line-level and product-level data is not. :::

The invite form is not an account-existence oracle ​

InviteHouseholdMember::rules() deliberately has no Rule::unique('users', 'email'). Rejecting an address that already has an account would answer "is this email registered with Vroum?" for anyone who can reach the form. An existing account is attached instead.

It mirrors app/Actions/ShopOnboarding/InviteShopUser.php step for step:

  1. User::firstOrNew(['email' => $email]) inside a transaction; $created is ! $user->exists.
  2. An existing account that is not a client account is refused with a ValidationException on email whose message names no account type — :email cannot be invited. Use a different address. A garage employee attached to a household would be a member the household can see but who can never sign in to it, because EnsureUserTypeClient logs them out of every portal route.
  3. A brand new account is created with Str::password() — unusable by design — and type = UserType::CLIENT.
  4. $client->users()->syncWithoutDetaching([$user->id => ['role' => $role]]).
  5. Only a new account gets Password::sendResetLink(), and only in DB::afterCommit(). A rolled-back invitation leaves no link in anyone's inbox, and re-posting the form for an existing member is not a way to spray reset mails at them.

role is constrained to member|admin (Rule::in) and lands in client_user.role. The household is never read from the request: asController() binds it to Client::current(), so an invite can only ever land in the caller's own household — pinned by InviteHouseholdMemberTest::an_invite_never_reaches_a_household_the_caller_is_not_in.

ShopBelongsToClient is the cross-tenant validation boundary ​

app/Rules/ShopBelongsToClient.php takes a nullable client id and fails when clients.id → shops has no row for the submitted shop_id. A null client id fails closed (Unable to validate shop ownership.).

It guards the two places where the portal writes a shop id into shop-owned data:

FieldWhere
shop_id on an appointment requestRequestAppointment::rules()
cars.shop_id, the preferred garageCar\UpdateCar::rules()

The companion constraint on the same form is Rule::exists('cars', 'id')->where('client_id', $clientId), which is what stops a request naming another household's car.

Garages ​

GarageController@index lists $client->shops() ordered by name, and @show renders one. Both build a GarageData.

App\Data\GarageData is a shop as the portal sees it, deliberately distinct from ShopData (which is the tenant record, with its users, bays, services and work orders hanging off it). It carries the contact block (address, city, state, zip, email, phone) plus this household's own history with that shop: visits_count, last_visit, and the cars relevant to it. GarageData::fromShop() is the only constructor in use.

visits_count and last_visit come from one grouped query over work_orders filtered on client_id and the shop ids — count(*) and max(invoice_date). A household with no invoiced visit gets 0 and null.

The cars list differs between the two screens, and the difference is intentional:

ScreenpreferredOnlyCars shown
IndextrueOnly cars whose shop_id is this shop
ShowfalseThose, plus every car with a work order at this shop

@show additionally attaches hours: GetShopSchedule::run($garage) filtered to ->filter->enabled, so a closed day is absent rather than rendered as closed.

The preferred garage (cars.shop_id) ​

database/migrations/2026_09_17_141502_add_shop_id_to_cars_table.php adds a nullable shop_id to cars, after client_id, constrained() with nullOnDelete().

It is a preference, not a tenancy key. A car's tenancy still runs through its client: Car::forCurrentShop() (app/Models/Car.php:247) filters on client → shops, and Car::toSearchableArray() indexes shop_id as the array of the client's shop ids, not this column. A car whose preferred garage is deleted keeps working; the column simply goes null.

It is written from Modals/Client/PreferredGarage.vue through PUT car.update (Car\UpdateCar), validated with ShopBelongsToClient, and read by GarageController::carsFor() for the index listing above.

Requesting an appointment ​

App\Actions\Client\RequestAppointment is the portal's only write into shop-owned operational data. The client asks for a half-day, not a slot; the garage then places it on its own calendar.

The period maps to a wall-clock hour through one constant:

php
private const PERIOD_HOUR = [
    'morning' => 8,
    'afternoon' => 13,
    'any' => 8,
];

handle() creates a work order on $shop->workOrders() with:

FieldValue
statusWorkOrderStatus::APPOINTMENT
appointment_statusAppointmentStatus::SCHEDULED
appointment_atpreferred_date at the period's hour
client_id / car_idtaken from the car, not from the request
contact_user_id$car->owner_id
customer_notesreason, or "{reason} — {notes}" when notes are given
taxes$shop->taxSnapshot()

Before handle(), asController() loads the requested shop and aborts 403 unless it has client_portal and then appointments — both checked with that shop passed explicitly, since the portal has no current shop.

There are no work order lines. Nothing is booked into a bay, no appointment_start_at is written, and none of the bay scheduling machinery runs — a RequestAppointment work order is a request sitting in the garage's appointment list. The garage picks it up on the shop side: Pages/Shop/WorkOrder/Partials/Appointments.vue puts appointment_status through shop.workOrders.update, moving it to CONFIRMED (or CANCELED, NO_SHOW), and books the actual lines from the calendar.

taxes is snapshotted at request time on purpose — it is the same snapshot every work order takes, so a rate change between the request and the invoice does not rewrite the customer's total.

The form is Modals/Client/RequestAppointment.vue, opened from both Pages/Client/Car/Show.vue and Pages/Client/Garage/Show.vue.

The household ​

HouseholdController renders Client/Household with the household itself (ClientData::from($client)->include('users')) and, as a deferred prop, its cars with their owner.

Members are client_user rows carrying a nullable role string (2026_07_01_170906_add_role_to_client_user_table.php). The invite form writes member or admin; nothing else in the portal narrows on the value today.

The invoice ​

WorkOrderController@show authorizes view on the route-bound work order, then aborts 403 unless the work order's shop has the client_portal module, then loads shop, car and the line tree — top-level lines (parent_work_order_line_id null) ordered by order, each with its related and its children.

The response is WorkOrderData::from($workOrder)->include('shop', 'car') plus InvoiceLineData::collect($workOrder->lines). Children are nested inside their parent line's children, so the page renders the same grouping the shop sees without any of the shop's numbers.

The PDF (pdf.workOrders, DownloadInvoiceController) applies the same client_portal check to client users; shop users are not gated there.

Technical surface ​

KindValueSource
Routesthe client. grouproutes/webClient.php
MiddlewareEnsureUserTypeClient — redirects to login after logging the user outapp/Http/Middleware/EnsureUserTypeClient.php
ControllersGarage, Household, WorkOrder, Car, Dashboardapp/Http/Controllers/Client/
ActionsInviteHouseholdMember, RequestAppointmentapp/Actions/Client/
DTOsGarageData, InvoiceLineDataapp/Data/
RuleShopBelongsToClientapp/Rules/ShopBelongsToClient.php
Plan checksclient_portal (work order show, invoice PDF, appointment request) and appointments (appointment request), via ShopModules::refusal($module, $shop) — 403Plans & modules
PoliciesClientPolicy, ShopPolicy, CarPolicy, WorkOrderPolicyapp/Policies/
PagesPages/Client/{Dashboard,Household,Car/Show,Garage/Index,Garage/Show,WorkOrder/Show}resources/js/Pages/Client/
LayoutClientLayout — auto-selected for Pages/Client/*resources/js/Layouts/ClientLayout.vue
Tablesclients, client_user (role), client_shop, cars.shop_id

Gotchas ​

  • Shop::current() is null on every portal request. There is no {shop} segment and no MakeCurrentShop. Calling Car::forCurrentShop() from here throws on Shop::current()->id; HasShop::resolveRouteBindingQuery() matches nothing (1 = 0), which surfaces as a 404 on a model that exists.
  • Route::bind('workOrder', …) falls back to an unscoped findOrFail when the request has no {shop} (app/Providers/RouteServiceProvider.php:83), which is always the case here. The binding resolves any work order in the database; WorkOrderPolicy::view() is the only thing between the client and someone else's invoice. Do not add a portal route that skips the authorize() call.
  • CarController::show() calls $car->client->makeCurrent() after authorizing the car. Opening a car from another of your households therefore switches the current household — and writes the new current_id through Client::onMakingCurrent(). That is deliberate (the policies are keyed on the pivot, so the car was already yours), but it means the household shown in ClientLayout can change as a side effect of a car visit.
  • ClientPolicy splits on user type, and the shop branch uses current_id. For a shop user, view()/update() ask whether the client belongs to $user->current_id; for a client user they ask the client_user pivot. Only the client branch is used by the portal.
  • The dashboard's cars prop is deferred. A test that only issues the full visit asserts nothing about its contents — see ClientPortalTest::getDeferred(), which does the full visit first (Inertia answers 409 unless the version header matches one it has already handed out) and then the partial one.
  • cars.shop_id is not a tenancy column. See above. Filtering a client-side query on it would silently hide every car whose owner never picked a garage.

Tests ​

FileCovers
tests/Feature/Client/ClientPortalTest.phpevery portal surface, the removed-member guard, the invoice-cost leak, and the three cross-household refusals
tests/Unit/Actions/Client/InviteHouseholdMemberTest.phpattach vs create, the reset link, the shop-account refusal, the role allowlist, and the current-household binding
tests/Unit/Actions/Client/RequestAppointmentTest.phpthe half-day mapping, the created work order's status pair, and the two validation refusals
  • Multi-tenancy — the two contexts and how each resolves its tenant
  • Bay scheduling — what the garage does with an appointment request
  • Security — the authorization rules this page applies
  • CASL consent — consent is per client and per shop, recorded on client_shop