Skip to content

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 ​

  1. 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.
  2. Templates. An InspectionTemplate selects categories from that library, in the order the technician walks them. SaveInspectionTemplate saves the title and sync()s the pivot with order = position in the posted category_ids, inside one transaction.
  3. 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.
  4. Filling it in. GET …/work-orders/{workOrder}/lines/{workOrderLine}/inspection opens the Shop/CarInspection modal. SaveInspection creates the inspection on the first save, stamping work_order_line_id and the template the line sells at that moment, then updateOrCreates one result per point.
  5. Printing it. RenderInspectionPdf prints one inspection on its own; RenderWorkOrderPdf appends every sold inspection to the invoice, each on its own page. Both render the same inspections.partials.report from the array BuildInspectionReport returns. 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 / columnMeaning
inspection_templatesshop_id, title, order. Soft-deletes. order is maintained by HasIncrementingColumn and renumbered on delete
inspection_category_inspection_templatethe pivot, model InspectionCategoryTemplate: order within the template, unique on (inspection_template_id, inspection_category_id)
products.inspection_template_idnullable; nullOnDelete
inspections.work_order_line_idthe line that sold it — one live inspection per line
inspections.inspection_template_idthe 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 ​

KindValueSource
Pageshop.settings.inspections.index — templates and the category libraryroutes/webShop.php:497, SettingsController::inspections()
Pageshop.settings.inspections.templates.create / .editroutes/webShop.php:498-499, SettingsController::inspectionTemplate()
RoutePOST /inspection-templates, PUT /inspection-templates/{inspectionTemplate}SaveInspectionTemplate
RoutePUT /inspection-templates/reorder (ids)ReorderInspectionTemplates
RouteDELETE /inspection-templates/{inspectionTemplate}DeleteInspectionTemplate
RoutePUT /inspection-templates/{inspectionTemplate}/products (product_id)LinkProductToInspectionTemplate
Modalshop.workOrders.inspection.show — lines/{workOrderLine}/inspectionWorkOrderInspectionController
Routeshop.workOrders.inspection.store / .destroySaveInspection, DeleteInspection
Routeshop.workOrders.inspection.pdf — lines/{workOrderLine}/inspection/pdfDownloadInspectionController → RenderInspectionPdf
Shared propinspectionTemplates, requested by the product create and show pagesSharedPropsServiceProvider::getInspectionTemplates()
ValidationInspectionItemBelongsToTemplate on results.*.inspection_item_idapp/Rules/InspectionItemBelongsToTemplate.php
SeederSeedInspectionTemplate, run from onboarding and InspectionTemplateSeederapp/Actions/Seeders/SeedInspectionTemplate.php
Gatemodule:inspections on every route aboveroutes/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.* and shop.inspection-categories.*. The work order routes also inherit module:work_orders from their group. A shop without the module gets a 403 — see Plans & modules.
  • Tenant boundary. InspectionTemplate uses HasShop, so {inspectionTemplate} only binds within the current shop. Ids arriving in a request body — category_ids.*, ids.*, product_id — go through BelongsToShop; CreateProduct and UpdateProduct validate inspection_template_id with Rule::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 through update().
  • 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 through WorkOrder::lines() (child lines included) and a line of another work order is a 404 before any policy runs — see Multi-tenancy › Route model binding. ScopedChildBindingTest covers 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 urgent and needs_attention results, 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. InspectionResult registers a non-queued 160×160 thumb conversion 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 line skips() would drop (a cancelled line) and orders the rest by line order.

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. WorkOrderInspectionController opens $workOrderLine->inspection?->template before falling back to the line's current template, and SaveInspection::rules() validates against inspection->inspection_template_id the same way. Moving a product to another template only affects inspections not yet started.
  • Deleting a template is refused while a product sells it (DeleteInspectionTemplate throws a validation error on template); silently deleting it would turn those products back into plain lines. Inspections already filled against it stay readable and editable — InspectionItemBelongsToTemplate includes trashed templates for that reason.
  • InspectionItemBelongsToTemplate resolves the allowed items once and reuses them for every results.* entry: the validator runs the same rule instance for each point of the sheet.
  • RenderWorkOrderPdf eager-loads everything the reports read (inspections.workOrderLine, template.categories.items, results.media, inspector) so BuildInspectionReport's loadMissing finds it already loaded instead of querying per inspection. WorkOrder::inspections() and WorkOrderLine::inspection() are both chaperone()d, so an inspection reached through either already holds its parent.
  • A work order line cascades its inspection on delete (WorkOrderLine::$cascadeDeletes).
  • Documents — the invoice PDF the reports are appended to.
  • Queues & realtime — an Inspection also broadcasts on work_orders.{id}, an InspectionResult on inspections.{id}.

Files ​

ConcernFiles
Modelsapp/Models/{InspectionTemplate,InspectionCategoryTemplate,Inspection,InspectionResult,WorkOrderLine,Product}.php
Template actionsapp/Actions/Inspection/{SaveInspectionTemplate,ReorderInspectionTemplates,DeleteInspectionTemplate,LinkProductToInspectionTemplate,GetInspectionCategories}.php
Inspection actionsapp/Actions/Inspection/{SaveInspection,GetInspection,DeleteInspection}.php, app/Rules/InspectionItemBelongsToTemplate.php
Printingapp/Actions/Inspection/BuildInspectionReport.php, app/Actions/Printing/RenderInspectionPdf.php, resources/views/inspections/**
Seedingapp/Actions/Seeders/SeedInspectionTemplate.php, app/Actions/ShopOnboarding/Steps/SaveShopInspectionCategories.php
Teststests/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__/*