Appearance
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:
| Table | Columns that carry meaning |
|---|---|
plans | key (unique: full, essential), name |
plan_modules | plan_id (cascade on delete), module, enabled, limit (nullable, unsigned); unique on (plan_id, module) |
shop_modules | shop_id (cascade on delete), module, enabled, limit; unique on (shop_id, module) — the overrides |
shops.plan_id | nullable, 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.
| Module | full | essential |
|---|---|---|
users | unlimited | 3 |
bays | unlimited | 3 |
storage_spaces | unlimited | off |
phone_numbers | unlimited | 1 |
sms | unlimited | 500 / month |
emails | unlimited | 1000 / month |
ai | unlimited | 100 / month |
inspections, accounting, appointments, work_orders | on | on |
tire_storage, inventory, automations, scheduling, client_portal | on | off |
request_appointments | on | on (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')):
| Kind | Modules | What the limit means | Usage |
|---|---|---|---|
count | users, bays, storage_spaces, phone_numbers | how many records the shop may have | counted live from the shop's rows |
monthly | sms, emails, ai | how many may be spent per calendar month | counted live since the start of the month |
toggle | tire_storage, inventory, accounting, automations, inspections, scheduling, client_portal, appointments, request_appointments, work_orders | no limit — on or off | always 0 |
How usage is counted (ShopModules::usage()):
| Module | Counted from |
|---|---|
users | $shop->users() |
bays | $shop->bays() (soft-deleted bays are not counted) |
storage_spaces | $shop->storageSpaces() |
phone_numbers | sender_identities on the SMS channel whose status is not Released, plus number_requests still PENDING |
sms / emails | outbound 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:
| Method | Returns |
|---|---|
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 —
ActionSenderuses the automation subject's shop,SmsChanneluses$notification->shop(); - the client portal, where
Shop::current()is always null —$workOrder->shop, or theshop_idof the request; - anything with a work order in hand —
SendWorkOrderApprovalRequestandSendAppointmentConfirmationuse$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:
| Change | Cleared by |
|---|---|
| an override saved or deleted | ShopModule dispatches ShopModuleChanged → ClearCacheOnShopModuleChanged (that shop) |
| a plan module saved or deleted | PlanModule dispatches PlanModuleChanged → ClearCacheOnPlanModuleChanged (every shop on that plan) |
a shop's plan_id changed | ClearCacheOnShopSaved, when plan_id is dirty |
| a plan deleted | Plan::booted() deleting hook — reads the plan's shops before the database cascades plan_modules and nulls shops.plan_id without model events |
PlanSeeder run | clears 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).
| Module | Gated routes | File |
|---|---|---|
accounting | shop.report.*, shop.settings.chart-of-accounts.*, shop.settings.periods.* | routes/webShop.php |
work_orders | shop.workOrders.*, shop.workBoard.*, shop.clients.workOrders.store, shop.clients.cars.workOrders.store, shop.clients.work-orders-unpaid | routes/webShop.php |
appointments | shop.calendar.*, shop.workOrders.product.appointment, shop.workOrders.product.appointment.remaining | routes/webShop.php |
request_appointments | shop.appointmentRequests.* | routes/webShop.php |
inventory | shop.purchaseOrders.*, shop.suppliers.*, shop.workOrders.product.purchase-order | routes/webShop.php |
tire_storage | shop.storage-spaces.*, shop.workOrders.storage-ticket.update, shop.workOrders.storage-ticket.assign | routes/webShop.php |
inspections | shop.workOrders.inspection.*, shop.settings.inspections.*, shop.inspection-templates.*, shop.inspection-categories.* | routes/webShop.php |
automations | shop.settings.automations.* | routes/webShop.php |
scheduling | shop.schedule.* | routes/webShop.php |
ai | api.ai.*, shop.workOrders.analyze (both after throttle:ai) | routes/web.php, routes/webShop.php |
sms | api.send-sms | routes/web.php |
emails | api.send-email | routes/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.
| Module | Where |
|---|---|
bays | SaveBay::rules(), on name, only when there is no {bay} route parameter (create, not update) |
storage_spaces | StorageSpaceController@store, on name |
phone_numbers | RequestPhoneNumber::rules(), on phone_number, and again in handle() under a per-shop lock |
users | InviteShopUser::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:
| Where | Module | Behaviour when refused |
|---|---|---|
SmsChannel::send() | sms | silent no-op; staff SMS alerts share the shop's quota, the screen and email channels still deliver — see Notifications |
SendAppointmentConfirmation::handle() | sms | returns without sending |
SendWorkOrderApprovalRequest::handle() | sms or emails, by channel | ValidationException on channel |
ActionSender::send() | automations, then sms / emails | run marked skipped — see below |
ActionSender checks in this order, each a markSkipped() with a reason:
no_recipient,no_shop(unchanged);module_disabled— the subject's shop has noautomations;no_casl_consent— see CASL consent;plan_limit_reached— aSEND_SMS/SEND_EMAILaction whose quota is used up.SEND_NOTIFICATIONhas 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:
| Where | Requires |
|---|---|
Client\WorkOrderController@show | client_portal on the work order's shop, after authorize('view') |
DownloadInvoiceController — client users only | client_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 missingHiding 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-dupontshop:module rules (SetShopModule::optionsError()):
- An override is a complete row.
--enablewithout--limitwriteslimit = null, i.e. unlimited — not "keep the plan's limit".--limit=Nwrites an enabled row with that limit. --limitmust be a whole number ≥ 0, is rejected on a toggle module, and cannot be combined with--disable.--limit=0is allowed and refuses the first use.--enableand--disableare mutually exclusive;--resetstands 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
ShopFactorysetsplan_idto thefullplan, 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 ofevery_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,InviteShopUserand 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, soRequestPhoneNumberre-checks under a per-shop lock. - A quota-skipped automation run is final.
markSkipped('plan_limit_reached')setsstatus = skipped, and onlypendingruns are ever picked up again. Raising the limit or reaching the next month does not resend it; the same holds formodule_disabled. - Lowering a count limit removes nothing. A shop already above its new limit keeps its records; it only cannot create more.
Files
| Kind | Path |
|---|---|
| Enum | app/Entities/Module.php |
| Service / facade | app/Services/ShopModules.php, app/Facades/ShopModules.php |
| Models | app/Models/Plan.php, PlanModule.php, ShopModule.php |
| Middleware | app/Http/Middleware/EnsureShopHasModule.php |
| Rule | app/Rules/WithinModuleLimit.php |
| Listeners | app/Listeners/Cache/ClearCacheOnShopModuleChanged.php, ClearCacheOnPlanModuleChanged.php, ClearCacheOnShopSaved.php |
| Commands | app/Console/Commands/AssignShopPlan.php, SetShopModule.php, ListShopModules.php |
| Seeder / migration | database/seeders/PlanSeeder.php, database/migrations/2026_09_26_000000_create_plans_tables.php |
| Tests | tests/Feature/Services/ShopModulesTest.php, tests/Feature/Middleware/EnsureShopHasModuleTest.php, tests/Feature/Console/ShopModuleCommandsTest.php, tests/Feature/Shop/ShopModuleTest.php, tests/Feature/Middleware/SharedAuthDataTest.php |
Related
- Multi-tenancy — the shop context the gate reads
- Security — where a 403 can come from
- Client portal — the
client_portalchecks - CASL consent — the automation skip order