Appearance
Inspections
A shop sells a vehicle inspection the way it sells anything else: as a product on a work order. The product points at an inspection template — an ordered selection of categories from the shop's shared library of points — and the work order line that sells it carries one inspection, filled in by the technician and printed for the customer on its own or behind the invoice.
The one rule
An inspection is sold, never given. Only a work order line whose related is a Product with an inspection_template_id can start one. WorkOrderLine::inspectionTemplate() is the single place that answers "does this line sell an inspection", and InspectionPolicy::create() gates the first save on it. There is no "add an inspection" button on a work order that bypasses the product.
How it works
- The library. Each shop owns inspection categories (
inspection_categories), each with ordered points (inspection_items). Categories and points are edited in the library, not in a template, so a point renamed once reads the same in every template that uses its category. - Templates. An
InspectionTemplateselects categories from that library, in the order the technician walks them.SaveInspectionTemplatesaves the title andsync()s the pivot withorder= position in the postedcategory_ids, inside one transaction. - Selling it. A product carries the template in
products.inspection_template_id, set from the product form (CreateProduct/UpdateProduct) or from the template's settings page (LinkProductToInspectionTemplate). Adding that product to a work order is what makes the line carry an inspection. - Filling it in.
GET …/work-orders/{workOrder}/lines/{workOrderLine}/inspectionopens theShop/CarInspectionmodal.SaveInspectioncreates the inspection on the first save, stampingwork_order_line_idand the template the line sells at that moment, thenupdateOrCreates one result per point. - Printing it.
RenderInspectionPdfprints one inspection on its own;RenderWorkOrderPdfappends every sold inspection to the invoice, each on its own page. Both render the sameinspections.partials.reportfrom the arrayBuildInspectionReportreturns. See Printing.
On the work order page, useWorkOrderInspections lists one entry per product line whose product sells a template — started or not — walking service children too, since a product can sit under a service as part of a kit.
Data model
Tables from database/migrations/2026_09_23_150000_create_inspection_templates_table.php:
| Table / column | Meaning |
|---|---|
inspection_templates | shop_id, title, order. Soft-deletes. order is maintained by HasIncrementingColumn and renumbered on delete |
inspection_category_inspection_template | the pivot, model InspectionCategoryTemplate: order within the template, unique on (inspection_template_id, inspection_category_id) |
products.inspection_template_id | nullable; nullOnDelete |
inspections.work_order_line_id | the line that sold it — one live inspection per line |
inspections.inspection_template_id | the template the inspection was filled against, frozen at creation |
inspections.work_order_id is only indexed, no longer unique: a work order can sell several inspections, one per line.
The uniqueness on work_order_line_id is a partial index, WHERE deleted_at IS NULL, written as raw SQL because the schema builder cannot express it. Inspection soft-deletes, so a plain unique index would make a line impossible to inspect again once its inspection was deleted.
Inspection::template() is declared withTrashed(): an inspection keeps reading the template it was filled against even after that template is deleted.
Technical surface
| Kind | Value | Source |
|---|---|---|
| Page | shop.settings.inspections.index — templates and the category library | routes/webShop.php:497, SettingsController::inspections() |
| Page | shop.settings.inspections.templates.create / .edit | routes/webShop.php:498-499, SettingsController::inspectionTemplate() |
| Route | POST /inspection-templates, PUT /inspection-templates/{inspectionTemplate} | SaveInspectionTemplate |
| Route | PUT /inspection-templates/reorder (ids) | ReorderInspectionTemplates |
| Route | DELETE /inspection-templates/{inspectionTemplate} | DeleteInspectionTemplate |
| Route | PUT /inspection-templates/{inspectionTemplate}/products (product_id) | LinkProductToInspectionTemplate |
| Modal | shop.workOrders.inspection.show — lines/{workOrderLine}/inspection | WorkOrderInspectionController |
| Route | shop.workOrders.inspection.store / .destroy | SaveInspection, DeleteInspection |
| Route | shop.workOrders.inspection.pdf — lines/{workOrderLine}/inspection/pdf | DownloadInspectionController → RenderInspectionPdf |
| Shared prop | inspectionTemplates, requested by the product create and show pages | SharedPropsServiceProvider::getInspectionTemplates() |
| Validation | InspectionItemBelongsToTemplate on results.*.inspection_item_id | app/Rules/InspectionItemBelongsToTemplate.php |
| Seeder | SeedInspectionTemplate, run from onboarding and InspectionTemplateSeeder | app/Actions/Seeders/SeedInspectionTemplate.php |
| Gate | module:inspections on every route above | routes/webShop.php, Plans & modules |
The frontend lives in resources/js/Pages/Shop/Settings/Inspection/ (settings), resources/js/Modals/Shop/CarInspection.vue (the sheet), resources/js/Elements/Inspection/ and the hooks useInspectionLibrary and useWorkOrderInspections. useInspectionLibrary resolves a template's category ids against the library, which is why the settings pages ship the library alongside the templates.
Permissions
- Plan. Every inspection route sits behind
module:inspections:shop.workOrders.inspection.*,shop.settings.inspections.*,shop.inspection-templates.*andshop.inspection-categories.*. The work order routes also inheritmodule:work_ordersfrom their group. A shop without the module gets a 403 — see Plans & modules. - Tenant boundary.
InspectionTemplateusesHasShop, so{inspectionTemplate}only binds within the current shop. Ids arriving in a request body —category_ids.*,ids.*,product_id— go throughBelongsToShop;CreateProductandUpdateProductvalidateinspection_template_idwithRule::exists(...)->where('shop_id', …)->whereNull('deleted_at'). - Starting an inspection requires
InspectionPolicy::create(): a shop user with access to the shop, on a line that sells a template. Later saves go throughupdate(). - Viewing and downloading use
InspectionPolicy::view()— shop users of the work order's shop, or client users of its client. - The line must belong to the work order in the URL. The routes sit in the
shop.group's->scopeBindings(), so{workOrderLine}resolves throughWorkOrder::lines()(child lines included) and a line of another work order is a 404 before any policy runs — see Multi-tenancy › Route model binding.ScopedChildBindingTestcovers the four inspection routes. - The template actions only check
isShop(); there is no finer role.
Printing
BuildInspectionReport::run($inspection) returns everything the report renders — counts per status, the context block (client, vehicle, plate, odometer, inspector, date), the flagged points and the full checklist — so the Blade partials stay presentation only.
- Flagged points are the
urgentandneeds_attentionresults, worst first (FLAGGED_ORDER), each with its note and photos. - A point with no result prints as not checked (
InspectionItemStatus::NONE). Results are not pre-created for every point. - Photos are inlined as
data:URIs, like the shop logo in Documents: Chrome may render on a remote instance that cannot reach the app's storage.InspectionResultregisters a non-queued 160×160thumbconversion for this; a photo without the conversion falls back to the original, and one missing from disk is left out. - Language.
RenderInspectionPdf::handle()sets the locale from the work order's contact, the same rule as the invoice — see Documents › Language. - Page height.
RenderInspectionPdf::printableHeight()gives the partial the letter page's printable height as$minHeight, so the legend sits at the foot of the page. It subtracts an extra 0.05 in so rounding never pushes the footer onto a blank extra page. The invoice passes its own margins plus the footer band (inspectionPageHeight). - Behind the invoice,
RenderWorkOrderPdf::inspections()skips an inspection whose lineskips()would drop (a cancelled line) and orders the rest by lineorder.
Onboarding and seeding
SeedInspectionTemplate gives a new shop a 56-point, 8-category library, a Complete inspection template walking all of it, and an Inspection product selling that template. Each part is skipped when it already exists, checked withTrashed, so a shop that deleted the seeded rows does not get them back.
The product is only created when the shop has both chart accounts 4300 (revenue) and 5050 (COGS): its Inspections category requires both, and a half-built row would abort the onboarding seed. A shop missing either keeps the template and simply has no product selling it.
SaveShopInspectionCategories, the onboarding inspections step, re-syncs the shop's first template to the categories kept in that step, so the sheet built during onboarding is the one the seeded product sells.
Gotchas
- An inspection keeps its template.
WorkOrderInspectionControlleropens$workOrderLine->inspection?->templatebefore falling back to the line's current template, andSaveInspection::rules()validates againstinspection->inspection_template_idthe same way. Moving a product to another template only affects inspections not yet started. - Deleting a template is refused while a product sells it (
DeleteInspectionTemplatethrows a validation error ontemplate); silently deleting it would turn those products back into plain lines. Inspections already filled against it stay readable and editable —InspectionItemBelongsToTemplateincludes trashed templates for that reason. InspectionItemBelongsToTemplateresolves the allowed items once and reuses them for everyresults.*entry: the validator runs the same rule instance for each point of the sheet.RenderWorkOrderPdfeager-loads everything the reports read (inspections.workOrderLine,template.categories.items,results.media,inspector) soBuildInspectionReport'sloadMissingfinds it already loaded instead of querying per inspection.WorkOrder::inspections()andWorkOrderLine::inspection()are bothchaperone()d, so an inspection reached through either already holds its parent.- A work order line cascades its inspection on delete (
WorkOrderLine::$cascadeDeletes).
Related
- Documents — the invoice PDF the reports are appended to.
- Queues & realtime — an
Inspectionalso broadcasts onwork_orders.{id}, anInspectionResultoninspections.{id}.
Files
| Concern | Files |
|---|---|
| Models | app/Models/{InspectionTemplate,InspectionCategoryTemplate,Inspection,InspectionResult,WorkOrderLine,Product}.php |
| Template actions | app/Actions/Inspection/{SaveInspectionTemplate,ReorderInspectionTemplates,DeleteInspectionTemplate,LinkProductToInspectionTemplate,GetInspectionCategories}.php |
| Inspection actions | app/Actions/Inspection/{SaveInspection,GetInspection,DeleteInspection}.php, app/Rules/InspectionItemBelongsToTemplate.php |
| Printing | app/Actions/Inspection/BuildInspectionReport.php, app/Actions/Printing/RenderInspectionPdf.php, resources/views/inspections/** |
| Seeding | app/Actions/Seeders/SeedInspectionTemplate.php, app/Actions/ShopOnboarding/Steps/SaveShopInspectionCategories.php |
| Tests | tests/Unit/Actions/Inspection/*, tests/Feature/WorkOrder/InspectionReportTest.php, tests/Feature/Shop/InspectionControllerTest.php, tests/Unit/Rules/InspectionItemBelongsToTemplateTest.php, resources/js/Pages/Shop/Settings/Inspection/__tests__/* |