Skip to content

Plans & modules ​

What a shop is allowed to use is decided per shop, not per user. A shop sits on a plan (full, essential), the plan turns modules on and gives some of them a limit, and a per-shop override can replace the plan's setting for any one module. Permissions and policies still decide who in the shop may do something; modules decide whether the shop has the feature at all.

A new shop has no plan

shops.plan_id is nullable, and neither sign-up (CreateShop) nor anything else assigns a plan. A shop with no plan has every module off except the ones on by default (request_appointments): no work orders, no calendar, no SMS. Put it on a plan by hand with sail artisan shop:plan {slug} {plan} (see Operating it).

Data model ​

database/migrations/2026_09_26_000000_create_plans_tables.php creates three tables and a column:

TableColumns that carry meaning
planskey (unique: full, essential), name
plan_modulesplan_id (cascade on delete), module, enabled, limit (nullable, unsigned); unique on (plan_id, module)
shop_modulesshop_id (cascade on delete), module, enabled, limit; unique on (shop_id, module) — the overrides
shops.plan_idnullable, indexed, nullOnDelete()

plan_id is in Shop's #[Guarded] list, so it cannot be mass assigned: change it with $shop->plan()->associate($plan)->save(). The relations are Shop::plan() and Shop::moduleOverrides() (ShopModule rows). Both PlanModule and ShopModule cast module to App\Entities\Module.

The seeded plans ​

Plans are reference data. The migration runs Database\Seeders\PlanSeeder itself at the end of up(), so every environment — including the test database, migrated once per process — has them. DatabaseSeeder calls it again first.

PlanSeeder is idempotent: it upserts plans on key and plan_modules on (plan_id, module), then deletes every plan_modules row of a plan whose module is no longer listed. Removing a module from a plan's definition therefore turns it off for every shop on that plan the next time the seeder runs. It writes through the query builder (no model events, no activity rows) and clears every shop's cache by hand at the end.

PlanSeeder runs inside a migration

It may only write columns that exist when 2026_09_26_000000 runs. A column added to plans or plan_modules by a later migration and written by the seeder breaks migrate:fresh at this migration.

A plan definition maps a module to true (enabled, unlimited) or an int (enabled, that limit). A module left out falls back to its Module::defaultValue() — off, except request_appointments.

Modulefullessential
usersunlimited3
baysunlimited3
storage_spacesunlimitedoff
phone_numbersunlimited1
smsunlimited500 / month
emailsunlimited1000 / month
aiunlimited100 / month
inspections, accounting, appointments, work_ordersonon
tire_storage, inventory, automations, scheduling, client_portalonoff
request_appointmentsonon (default)

Plan::FULL_KEY ('full') is the constant the factory and the demo seeder use.

Modules ​

App\Entities\Module is a backed enum with a kind in its metadata ($module->getMeta('kind')):

KindModulesWhat the limit meansUsage
countusers, bays, storage_spaces, phone_numbershow many records the shop may havecounted live from the shop's rows
monthlysms, emails, aihow many may be spent per calendar monthcounted live since the start of the month
toggletire_storage, inventory, accounting, automations, inspections, scheduling, client_portal, appointments, request_appointments, work_ordersno limit — on or offalways 0

How usage is counted (ShopModules::usage()):

ModuleCounted from
users$shop->users()
bays$shop->bays() (soft-deleted bays are not counted)
storage_spaces$shop->storageSpaces()
phone_numberssender_identities on the SMS channel whose status is not Released, plus number_requests still PENDING
sms / emailsoutbound messages whose communication belongs to the shop and has type SMS / EMAIL
ai$shop->aiUsages() rows

The monthly window starts on the first of the month in the shop's timezone (now($shop->resolvedTimezone())->startOfMonth()->utc()). The utc() is load-bearing: without it the builder formats local midnight as if it were UTC.

scheduling is the staff schedule (shop.schedule.*: shifts, requests, manager overrides). Booking work into bays is appointments — see Bay scheduling.

Resolution ​

App\Services\ShopModules, always called through the facade App\Facades\ShopModules:

MethodReturns
resolvedModules(?Shop)every module as ['enabled' => bool, 'limit' => ?int, 'source' => string]
has(Module, ?Shop)enabled
limit(Module, ?Shop)limit, null meaning unlimited
usage(Module, ?Shop)live usage, see above
withinLimit(Module, ?Shop)enabled and (unlimited or usage < limit)
refusal(Module, ?Shop)null when allowed, otherwise the translated reason
cacheKey(int) / forget(int)the per-shop cache key, and clearing it

For each module, an override row wins; otherwise the plan's row; otherwise the module is off. source records which applied: override, plan:<key>, or none. A null limit on the winning row means unlimited.

Which shop ​

Every method takes an optional Shop and falls back to Shop::current(). With no shop at all, every module is off, nothing is cached and usage is 0.

Pass the shop explicitly whenever the code can run outside the shop's own HTTP request, or on behalf of a shop that is not the current one:

  • queued and scheduled work — ActionSender uses the automation subject's shop, SmsChannel uses $notification->shop();
  • the client portal, where Shop::current() is always null — $workOrder->shop, or the shop_id of the request;
  • anything with a work order in hand — SendWorkOrderApprovalRequest and SendAppointmentConfirmation use $workOrder->shop.

Relying on the fallback there reads either nothing (every module off) or a different shop's plan.

Caching ​

resolvedModules() is cached for 5 hours under shop_modules.{id}. Usage is never cached, so two creates in one request see each other. Every path that changes a shop's resolution clears the key:

ChangeCleared by
an override saved or deletedShopModule dispatches ShopModuleChanged → ClearCacheOnShopModuleChanged (that shop)
a plan module saved or deletedPlanModule dispatches PlanModuleChanged → ClearCacheOnPlanModuleChanged (every shop on that plan)
a shop's plan_id changedClearCacheOnShopSaved, when plan_id is dirty
a plan deletedPlan::booted() deleting hook — reads the plan's shops before the database cascades plan_modules and nulls shops.plan_id without model events
PlanSeeder runclears every shop's key at the end, since it writes without events

A write that bypasses these — a raw DB::table('shop_modules') update, a mass Plan::query()->delete() — leaves the cache stale for up to five hours. Clear it with ShopModules::forget($shopId).

Refusal messages ​

refusal() is the one place the messages are built, and they are translated (lang/fr.json):

  • module off → Your plan does not include this feature.
  • count limit reached → You have reached your plan's limit of :limit.
  • monthly quota used up → You have reached your plan's monthly limit of :limit.

Callers pass this string through (abort(403, …), $fail(…), ValidationException::withMessages(…)) rather than writing their own.

Enforcement ​

Route gate ​

App\Http\Middleware\EnsureShopHasModule, aliased module in bootstrap/app.php, is applied as ->middleware('module:<value>'). It calls ShopModules::refusal() for the current shop and aborts 403 with the message — it does not redirect the way EnsureUserTypeShop does. Because refusal() checks the limit as well, a monthly module (sms, emails, ai) is also refused once its quota is used up. An unknown value is a ValueError from Module::from(), not a pass.

On shop.* routes the gate runs after the group's MakeCurrentShop, so it reads the shop in the URL. On api.* routes there is no {shop} segment; the current shop is the user's stored one (MakeCurrentAccount).

ModuleGated routesFile
accountingshop.report.*, shop.settings.chart-of-accounts.*, shop.settings.periods.*routes/webShop.php
work_ordersshop.workOrders.*, shop.workBoard.*, shop.clients.workOrders.store, shop.clients.cars.workOrders.store, shop.clients.work-orders-unpaidroutes/webShop.php
appointmentsshop.calendar.*, shop.workOrders.product.appointment, shop.workOrders.product.appointment.remainingroutes/webShop.php
request_appointmentsshop.appointmentRequests.*routes/webShop.php
inventoryshop.purchaseOrders.*, shop.suppliers.*, shop.workOrders.product.purchase-orderroutes/webShop.php
tire_storageshop.storage-spaces.*, shop.workOrders.storage-ticket.update, shop.workOrders.storage-ticket.assignroutes/webShop.php
inspectionsshop.workOrders.inspection.*, shop.settings.inspections.*, shop.inspection-templates.*, shop.inspection-categories.*routes/webShop.php
automationsshop.settings.automations.*routes/webShop.php
schedulingshop.schedule.*routes/webShop.php
aiapi.ai.*, shop.workOrders.analyze (both after throttle:ai)routes/web.php, routes/webShop.php
smsapi.send-smsroutes/web.php
emailsapi.send-emailroutes/web.php

A service advisor's dashboard (shop.dashboard.home) renders the work board, so DashboardController refuses it with a 403 when work_orders is off; the manager and technician dashboards stay open.

The routes nested in shop.workOrders.* carry both gates: opening an inspection needs work_orders and inspections. client_portal, and the count modules, are not route gates — they are enforced in code, below.

Count limits on create ​

App\Rules\WithinModuleLimit is a validation rule that fails with refusal() for the current shop. It is implicit, so it runs even when the field it is attached to is empty — the limit is on creating the record, not on the value.

ModuleWhere
baysSaveBay::rules(), on name, only when there is no {bay} route parameter (create, not update)
storage_spacesStorageSpaceController@store, on name
phone_numbersRequestPhoneNumber::rules(), on phone_number, and again in handle() under a per-shop lock
usersInviteShopUser::handle(), on invitations, only when the user is new or not yet a member of the shop

RequestPhoneNumber re-checks because each request buys a number: handle() takes Cache::lock("module-limit:{$shop->id}:phone_numbers", 10)->block(5, …) and calls refusal() again inside it, throwing a ValidationException on phone_number.

Sends ​

Code that spends a monthly quota outside a gated route checks withinLimit() or refusal() with an explicit shop:

WhereModuleBehaviour when refused
SmsChannel::send()smssilent no-op; staff SMS alerts share the shop's quota, the screen and email channels still deliver — see Notifications
SendAppointmentConfirmation::handle()smsreturns without sending
SendWorkOrderApprovalRequest::handle()sms or emails, by channelValidationException on channel
ActionSender::send()automations, then sms / emailsrun marked skipped — see below

ActionSender checks in this order, each a markSkipped() with a reason:

  1. no_recipient, no_shop (unchanged);
  2. module_disabled — the subject's shop has no automations;
  3. no_casl_consent — see CASL consent;
  4. plan_limit_reached — a SEND_SMS / SEND_EMAIL action whose quota is used up. SEND_NOTIFICATION has no quota here.

Client portal ​

The portal has no current shop, so each surface checks the work order's (or the requested) shop directly and aborts 403 with the refusal:

WhereRequires
Client\WorkOrderController@showclient_portal on the work order's shop, after authorize('view')
DownloadInvoiceController — client users onlyclient_portal on the work order's shop
Client\RequestAppointment::asController()client_portal, then appointments, then request_appointments, on the requested shop_id

Shop users downloading the same invoice PDF are not gated. See Client portal.

Frontend ​

HandleInertiaRequests::features() shares auth.features as { [module]: { enabled, limit } }. Neither usage nor source is sent. It is an empty object unless there is a current shop and the user is a shop user and $user->hasAccessToShop($shop): public {shop} routes (estimate approval, CASL consent) make the URL's shop current for any signed-in user, so being current is not proof of membership.

The type is AuthProps.features in resources/js/types/Props.ts. Read it with the auth plugin:

ts
import { Module } from '@/types/generated.ts';
import { useAuth } from '@/Plugins/auth.ts';

const { hasModule } = useAuth();

hasModule(Module.INVENTORY.value); // false when the key is missing

Hiding a link is presentation only; the route gate is what refuses. The shop dashboard, for example, still computes its deferred kpis, invoicesDue and purchase order props server-side — see Dashboard KPIs.

Operating it ​

Three artisan commands, all keyed on the shop's slug:

bash
# Put a shop on a plan (unknown key → lists the available ones)
sail artisan shop:plan garage-dupont full

# Override one module
sail artisan shop:module garage-dupont sms --limit=2000
sail artisan shop:module garage-dupont inventory --enable
sail artisan shop:module garage-dupont ai --disable
sail artisan shop:module garage-dupont sms --reset

# Resolved table: Module | Source | Enabled | Limit | Usage
sail artisan shop:modules garage-dupont

shop:module rules (SetShopModule::optionsError()):

  • An override is a complete row. --enable without --limit writes limit = null, i.e. unlimited — not "keep the plan's limit". --limit=N writes an enabled row with that limit.
  • --limit must be a whole number ≥ 0, is rejected on a toggle module, and cannot be combined with --disable. --limit=0 is allowed and refuses the first use.
  • --enable and --disable are mutually exclusive; --reset stands alone and deletes the override so the plan applies again.
  • An unknown module lists the valid values.

Local data: migrate --seed creates its shops through ShopFactory (on full), and DemoSeeder puts its shop on full. A shop created through the onboarding flow has no plan.

Testing ​

  • ShopFactory sets plan_id to the full plan, so factory shops have every module unlimited. Plans exist in every test because the migration seeds them.
  • To test a gated or limited path, write an override ($shop->moduleOverrides()->create(['module' => Module::SMS, 'enabled' => true, 'limit' => 0])) or move the shop to another plan.
  • EnsureShopHasModuleTest::gatedRoutes() is the data provider of every_gated_route_carries_its_module, which pins which route carries which gate.

Adding a route or a feature ​

From .ai/rules/routes.md: every new shop route group — and every api.* route that spends a quota — either gets ->middleware('module:<value>') or is deliberately shared, and goes into EnsureShopHasModuleTest::gatedRoutes(). A create capped by a count module uses WithinModuleLimit; a send checks ShopModules::withinLimit() with an explicit shop; messages come only from ShopModules::refusal().

Adding a module means a case in Module with its kind in withMeta(), a usage() arm (both matches are exhaustive), and an entry in each plan of PlanSeeder::plans() that should include it — full picks up every case through Module::values().

Known limits ​

  • Limits are check-then-write. WithinModuleLimit, InviteShopUser and the send checks read usage and then write, with no lock. Parallel requests can each pass and overshoot a count limit or a monthly quota by a few. Phone numbers are the exception: they cost money, so RequestPhoneNumber re-checks under a per-shop lock.
  • A quota-skipped automation run is final. markSkipped('plan_limit_reached') sets status = skipped, and only pending runs are ever picked up again. Raising the limit or reaching the next month does not resend it; the same holds for module_disabled.
  • Lowering a count limit removes nothing. A shop already above its new limit keeps its records; it only cannot create more.

Files ​

KindPath
Enumapp/Entities/Module.php
Service / facadeapp/Services/ShopModules.php, app/Facades/ShopModules.php
Modelsapp/Models/Plan.php, PlanModule.php, ShopModule.php
Middlewareapp/Http/Middleware/EnsureShopHasModule.php
Ruleapp/Rules/WithinModuleLimit.php
Listenersapp/Listeners/Cache/ClearCacheOnShopModuleChanged.php, ClearCacheOnPlanModuleChanged.php, ClearCacheOnShopSaved.php
Commandsapp/Console/Commands/AssignShopPlan.php, SetShopModule.php, ListShopModules.php
Seeder / migrationdatabase/seeders/PlanSeeder.php, database/migrations/2026_09_26_000000_create_plans_tables.php
Teststests/Feature/Services/ShopModulesTest.php, tests/Feature/Middleware/EnsureShopHasModuleTest.php, tests/Feature/Console/ShopModuleCommandsTest.php, tests/Feature/Shop/ShopModuleTest.php, tests/Feature/Middleware/SharedAuthDataTest.php