Skip to main content

Documentation

No results found.
Features

Automotive

Dealer vehicle inventory for WebProCMS: a searchable lot with filterable inventory pages, favorites, price-drop and new-arrival alerts, trade-in leads, and multi-location support. Modeled on the Real Estate module (app/Features/Automotive/,...

Dealer vehicle inventory for WebProCMS: a searchable lot with filterable inventory pages, favorites, price-drop and new-arrival alerts, trade-in leads, and multi-location support. Modeled on the Real Estate module (app/Features/Automotive/, tables prefixed auto_, feature key automotive).

Rollout status: the module is being built in batches. Batch 1 (schema, models, intake engine — feeds, CSV import, demo inventory, search compiler), Batch 2 (the full dashboard: vehicle browser + manual entry, locations, feeds + CSV import UI, leads, settings), Batch 3 (the public /inventory search + detail pages, lead capture, autocomplete, sitemap), Batch 4 (member accounts, favorites, price-drop + new-arrival alerts), and Batch 5 (page-builder rows, data presets, the sell-your-car page) are in. The Motorline theme lands in the final batch; sections below describe what exists.

Enabling

Dashboard → Settings → Features → Automotive (in the Verticals group). The feature is off by default. Disabling hides everything and 404s its routes; data is preserved.

Getting inventory in

Four intake paths, all normalized through one vocabulary (NormalizedVehicle), so a vehicle behaves identically no matter how it arrived:

  • By hand — vehicles with no feed (feed_id null) are dealer-owned records, created in the dashboard or by CSV import. They are never touched by feed syncs.
  • CSV import — CsvVehicleImporter parses an uploaded inventory file (delimiter auto-detected), auto-maps common DMS column spellings (Stock #, Ext Color, Miles, Selling Price, Photo URLs…) with a manual override map, supports dry-run previews, and writes manual vehicles row by row through Eloquent. Re-importing matches existing manual vehicles by VIN (then vehicle key) and updates only the columns the file maps — a sparse export never blanks data it doesn't carry. Capped at 5,000 rows.
  • Inventory feed (URL) — the DMS-export seam. A feed points at a CSV or JSON URL; every sync pulls the current snapshot. The URL is admin-supplied, so the request goes through OutboundHttp::guarded() (SSRF pinning, no redirects) and lands via CappedDownload (10 MB cap). The file's sha256 doubles as the sync cursor — an unchanged export is a no-op. Optional credentials (bearer token, or basic auth) are stored encrypted and never round-trip to the browser.
  • Demo inventory — php artisan automotive:seed-demo (the dashboard's "Seed demo listings" start calls this) creates a demo feed and imports 50 bundled sample vehicles across two demo locations. Idempotent; --fresh re-imports after a wipe.

Syncing

automotive:sync-feeds runs every 15 minutes via LazyCron (and per-feed from the dashboard). Runs are slice-bounded (2,500 records) with a resumable cursor. Feeds are full snapshots: after a complete pull, vehicles the export no longer mentions are settled per the feed's remove behavior — mark sold (default; keeps the page live for old links and alerts) or delete. Truncated or empty pulls never settle absences. Out-of-range numeric values are clamped, never allowed to abort a batch. Sync errors land on the feed (last_error) and its run history; unreadable credentials (rotated APP_KEY) surface a clear re-enter-credentials error instead of hammering the export host.

Manual curation survives re-syncs: is_featured, is_hidden, the slug, and arrived_at are insert-only.

Price-drop tracking

Every price change flows through one choke point (Vehicle::priceChangeState()): the outgoing price is recorded as prior_price, the moment as price_changed_at, and an entry is appended to price_history (capped at 20). Dashboard edits and CSV re-imports hit it via the model's updating hook; feed syncs diff each chunk against the stored prices before upserting. A cut shows as a "price drop" for automotive.price_drop_days (Setting, default 14) — the badge, search filter, and (Batch 4) price alerts all read the same window.

Vehicle browser

Dashboard → Automotive → Vehicles (manager+). A filterable table of the whole inventory — search (make/model/trim/VIN/stock/year), status (available / pending / sold / featured / hidden), condition, make, location, and source (a specific feed, or "entered by hand"). Each row shows the lead photo, title, condition, stock number, source, status badge, price with a green price-drop badge, mileage, days in stock, and location. The row menu edits, opens the public page, toggles featured/hidden, marks sold (stamps sold_at) or available again, and deletes with confirmation. Checkboxes drive bulk mark-sold and bulk delete.

An empty inventory shows the three starts panel: Add a vehicle (manual entry), Seed demo listings (runs automotive:seed-demo in-process), and Import listings (deep-links to the feeds page with the CSV modal open).

Adding & editing vehicles

Full manual-entry form (create + edit share one form): condition/status, year, make, model (make and model suggest from live inventory), trim, body type, colors, specs (mileage, transmission, drivetrain, fuel, engine, MPG, doors, seats), pricing (price + MSRP), description, a features checklist (common staples plus free-add), a photo gallery picked from the media library (multi-select, reorder, first photo = card/hero), VIN, stock number, location, video URL, and featured/hidden switches.

A duplicate VIN soft-warns ("another vehicle already has this VIN") but never blocks the save. Manual price edits route through the price-change recorder, so a dashboard price cut shows the drop badge exactly like a feed cut. Editing a feed-synced vehicle shows a banner warning that the next sync may overwrite feed-supplied fields.

Locations

Dealer lots are first-class (auto_locations): name, address, phone, email, hours JSON, photo, active flag. Every vehicle belongs to a location; a feed row naming a lot ("Main Street Showroom") auto-creates it on first sync for the dealer to flesh out. City/state search filters resolve through the vehicle's location.

Dashboard → Automotive → Locations manages them: create/edit modal (address, phone, email, per-weekday opening-hours windows, a media-library photo, sort order, active toggle), activate/deactivate, and delete. Deleting a lot that still has vehicles requires picking where they go first (another lot, or explicitly "no location"). Enabling the feature auto-creates a default location named after the site, so single-lot dealers never have to think about the concept.

Feeds page & CSV import

Dashboard → Automotive → Feeds (manager+). Feed CRUD (name, platform, URL, CSV/JSON format, remove behavior, active), Test Connection (downloads the export, counts rows, lists unmapped columns), Sync Now (runs in-process, deferred past the click), per-feed status badge with the last error, and a per-feed sync log (timestamps rendered in the site timezone). Credentials are write-only: blank inputs keep the saved secret, and credentials saved under a since-rotated APP_KEY surface a re-enter banner instead of a 500. Feed URLs are structurally validated at intake (no private hosts or IP literals); the sync-time request still runs the full SSRF guard. Deleting a feed deletes its vehicles (mirror, not archive) after confirmation.

Import CSV opens the upload modal: upload an export (12 MB cap), headers are auto-mapped through the DMS alias table with a manual per-column mapper (map or ignore), an automatic dry-run previews the first rows plus per-row problems and would-be counts, then Import writes manual vehicles and reports created/updated/skipped. The used mapping is remembered (automotive.csv_field_map) so the next export from the same system lands pre-mapped. Re-imports match by VIN and update instead of duplicating.

Leads page

Dashboard → Automotive → Leads (manager+). Every captured lead — vehicle inquiries, test-drive requests, trade-ins, general questions — with a type chip, the vehicle link, contact info, origin country (from the capture IP), and received time. The detail modal shows the message plus the typed payload (a trade-in's vehicle details), links to the CRM contact when the CRM feature is on, and deletes with confirmation (the CRM contact is kept).

Settings

Dashboard → Automotive → Settings (admin): dealer name override, lead notification emails (comma-separated; blank notifies every admin), alert-email sender (from name/email, reply-to), the price-drop badge window (days), inventory-page defaults (default sort, vehicles per page, cards vs list view), the detail-page disclaimer, and — while sample data is installed — Remove Demo Data, which deletes the demo feed, its vehicles, and the demo locations while leaving real inventory untouched.

The dashboard home also gets a Vehicles widget card (available count, new leads this week, current price drops) and the sidebar gains an Automotive group (Vehicles, Locations, Leads, Feeds, Settings).

VehicleSearch::query() is the one filter compiler every surface shares. Filters: keyword (each token matched across make/model/trim/body/color/VIN/stock, numeric tokens as year), condition (new/used/certified), make, model, body type, exterior color, transmission, drivetrain, fuel type, year/price ranges, max mileage, location/city/state, featured, price-drop, and arrival cursor. Sorts: newest arrivals, price both ways, lowest mileage, newest year (nulls always last). facetCounts() powers the sidebar counts Carvana-style — each facet's counts are computed with every other active filter applied but its own dimension removed. availableMakes()/availableModels() drive the dependent make → model dropdowns from live inventory.

Public inventory search (/inventory)

Uncached, per-visitor Livewire (like the Real Estate /properties page) — filters, load-more, and the view toggle all live in the URL, so every search is shareable and deep-linkable (/inventory?condition=used&body[0]=Truck&maxPrice=30000). The page:

  • Top bar — keyword search with grouped autocomplete (makes, models, body types, direct vehicle matches by title/stock/VIN via GET /auto/autocomplete, throttled and cached per term), All/New/Used/Certified condition tabs, and the sort select (newest arrivals, price both ways, lowest mileage, newest year).
  • Filter sidebar (Carvana-style; a slide-over drawer behind a Filters button on mobile) — collapsible facet groups with live counts from VehicleSearch::facetCounts(): make checkboxes, a dependent model select (appears once a make is checked; resets when the make changes), body-type and color checkboxes, price/year ranges, max mileage, transmission/drivetrain/fuel selects, a location select (only when the dealer runs more than one active lot), and a "price drops only" toggle.
  • Active-filter chips — one removable chip per applied value, plus Clear all.
  • Results — a card grid or list rows, toggled by the visitor (the default view, sort, and page size come from Settings). Cards show the lead photo, condition badge, price with the green drop badge, title, mileage, and lot; list rows add the spec line (transmission · drivetrain · fuel). Load-more pagination; hidden and sold vehicles never appear.

Free-text searches are recorded to the analytics Search dashboard (source automotive) once per new term.

Detail pages (/inventory/{slug})

Response-cached like product pages. Gallery with lightbox (the hero is the LCP element and gets an eager Glide srcset for media-library photos), title + price with MSRP strikethrough and the price-drop badge, condition and sale-pending badges, description, a specs grid (mileage, transmission, drivetrain, fuel, MPG, colors, engine, doors, seats, VIN, stock #), the features checklist, the dealer-lot card (address, phone, opening hours, a Get Directions link), the detail-page disclaimer Setting, share links, and a similar-vehicles rail (same body type or make in a ±25% price band, padded from the rest of the lot).

Sold vehicles never 404 — the URL stays live for old links and alert emails, with a "no longer available" banner, the inquiry form swapped for a browse-inventory CTA, and the similar rail underneath. Hidden vehicles and unknown slugs 404.

Each page emits schema.org Vehicle + Offer JSON-LD, canonical/OpenGraph meta (OG image = first photo), and available vehicles are listed in the sitemap alongside the search page (sold/pending/hidden are not).

Lead capture

AutomotiveLeads::capture($type, $attrs) is the single capture path (mirrors RealEstateLeads): persists to auto_leads with the capture IP/country, records the analytics lead goal, pushes a CRM contact + timeline interaction with the typed source automotive:{type} (re-firing source automations on recapture), honors the marketing opt-in checkbox (subscribes + adds to the "Automotive Leads" CRM group; shown only when the Marketing feature is on), and emails the dealer through the shared CampaignMailer — the configured notification addresses, else every admin, else nobody (never a noreply fallback). Types: vehicle_inquiry, test_drive (the detail form's intent select), trade_in (the sell-your-car widget), general.

The public form carries a honeypot and a per-IP rate limit (5 per 10 minutes); CRM, analytics, and email failures are reported but never break the visitor flow.

Visitor accounts & alerts

Favorites and alerts ride the shared member account system (automotive is in MemberAccounts::FEATURES, so an automotive-only install gets public member login/registration without the paid Memberships product — same soft dependency as Real Estate: plain member_id, no FK).

  • Saved vehicles — a heart on every search card/list row (the search page is uncached, so the toggle is inline) and on the detail page (a lazy Livewire island, so the response-cached HTML stays visitor-agnostic). Guests who tap it are sent to member login with the current search/vehicle stashed as the intended URL. Managed at /members/saved-vehicles (grid with drop/sold badges and un-save).
  • Price-drop alerts — a "Watch price" button beside the detail-page heart subscribes the member to that vehicle (auto_price_alerts, baselined at the current price; re-subscribing re-baselines). The automotive:send-price-alerts cron (LazyCron, 15 min) emails when the price falls below last_notified_price ?? price_at_subscribe and then advances it, so each further cut notifies exactly once. Vehicles that sell, hide, or disappear deactivate their alerts quietly. The island also shows the member's own "↓ $X since you started watching" delta.
  • New-arrival alerts (saved searches) — "Save search" on the /inventory toolbar (and the empty-state "Alert me when one comes in" CTA) stores the current filter set in VehicleSearch language with a name + frequency (instant/daily/weekly). The automotive:send-vehicle-alerts cron (LazyCron, 5 min — RE's alert loop) emails up to 10 vehicles that arrived after the search's cursor; last_notified_at advances only when an email actually goes out, so a quiet stretch never skips matches. Both alert emails send through CampaignMailer using the Settings sender/reply-to and link back to the vehicle/search plus the manage page.
  • /members/vehicle-alerts — saved searches (criteria summary, frequency select, pause/resume, delete with confirm) and price watches (subscribed vs current price with the drop delta, remove).

The front-end account nav gains Saved Vehicles + Vehicle Alerts pills, and the member dashboard gets a "Your vehicle search" card with both counts. The account pages live under the module's public/account/ folder, which the page editor's feature-page discovery never walks — they're logged-in app pages, not row-editable.

Design library rows

An Automotive category appears in the page builder's row picker whenever the feature is enabled (the whole category — and every row in it, via @requiresFeature automotive — disappears when it's off):

  • Hero with Inventory Search — full-bleed photo hero with the live autocomplete search bar (makes, models, body types, stock/VIN matches) and All/New/Used/Certified quick tabs deep-linking /inventory.
  • Hero with Quick Filters — condition, make, a dependent model select (populated from live inventory, no round-trip), body type, and max price, submitting straight to a filtered inventory search.
  • Featured Vehicles / Latest Arrivals / Price Drops — live collection card grids over the Vehicles preset (featured-flagged, newest arrivals, and recent price cuts with the old price struck through and a "Save $X" badge).
  • Browse by Body Type — editable quick-link tiles (SUVs, trucks, sedans…), each pre-filtering the search.
  • Browse by Make — tiles for every make on the lot with live counts (Makes preset).
  • Dealer Locations — lot cards with photo, address, today's hours, phone, a directions link, and that lot's inventory (Locations preset).
  • Sell Your Car Lead — the two-column trade-in lead magnet embedding the trade-in widget (below).
  • Payment Calculator — a pure client-side Alpine loan estimator (price, down payment, APR, term → monthly figure) with a browse-inventory CTA and disclaimer.

Three data presets back the collection rows and are available to any custom row: Vehicles (status/condition/featured/price-drop/make/body-type filters; newest, price, mileage, and year orders; tokens for title, price, MSRP, prior price, drop amount, mileage, specs, photo, URL, lot), Dealership Locations (address, phone, today's hours, photo, directions URL, per-lot inventory URL + count), and Vehicle Makes (distinct makes with live counts and pre-filtered search URLs).

Sell Your Car page (/sell-your-car)

Enabling the feature materializes an editable Sell Your Car page from the active theme's feature-pages/automotive/ source (falling back to the default theme's): a page-title band, the trade-in lead row, a "How Selling Your Car Works" three-step section, and a dealership FAQ accordion. The page is a normal page-builder page — edit rows, swap sections, unpublish — and the route (feature:automotive, response-cached) is injected alongside it. Re-enabling never overwrites edits; feature-pages:resync automotive refreshes an untouched copy from the theme source.

The embedded trade-in widget (automotive::widget.trade-in-form) is a lazy Livewire island, so it stays interactive on the cached page. It's multi-step-lite: the vehicle first (year, make, model, trim, mileage, condition, ZIP), then contact info — captured through AutomotiveLeads::capture('trade_in') with the vehicle details in the lead payload and the marketing opt-in when that feature is on. No automated valuation is quoted: the payload carries estimate: null as the seam for a future instant-offer provider, and the dealer replies with a real appraisal. Honeypot + the shared per-IP rate limit apply.

The Motorline theme

The Motorline theme (resources/themes/automotive/) is the dealer starter site built on this feature — pick it at install (or switch to it later) and the Automotive feature comes on automatically (required_features: ["automotive"]), a default location is created, and the Sell Your Car page materializes from the theme's own styled version.

  • Pages. Home (inventory-search hero over a dark showroom photo → featured vehicles → browse-by-body-type → latest arrivals → trade-in lead band → dealer locations → testimonials → shared CTA), About (dealership story + "Why Buy From Us"), Contact (form + location cards), plus privacy / terms / 404. Home, About, and Contact compile from the automotive/motorline-* page bundles (Dashboard → Design Library → Pages), so "Use this page" works on any install.
  • Variants. Apex (default) — dark performance look: Fjalla One headings, near-black charcoal with an electric red accent, transparent header over the hero. Showroom — bright dealership look: DM Sans headings, steel blue with an azure accent, classic solid light header. Both ship the solid full-width header the /inventory search page pins, and switching variants never touches content.
  • Starter content. Dealer-voice copy (hero, testimonials, about story, why-buy grid, contact intro, CTA band, header "Browse Inventory" buttons) seeded via overrides.json, plus 8 bundled photos (heroes, showroom, service bay, keys handoff, lot aerial, advisor, test drive) registered in the media library under defaults/automotive/. Navigation ships Home · Inventory · Sell Your Car · About · Contact. Inventory itself comes from the feature — seed the demo fleet from the vehicle browser's empty state to see the data-driven rows filled.

Storage

Table Holds
auto_vehicles inventory (specs, price + drop state, photos, status, curation flags)
auto_locations dealer lots
auto_feeds feed configs (URL, format, field map, encrypted credentials, cursor)
auto_feed_sync_runs per-sync history (counts, status, error)
auto_leads vehicle inquiries, test-drive requests, trade-in leads
auto_saved_vehicles member favorites (one row per member+vehicle)
auto_saved_searches saved searches (criteria json, frequency, alert cursor)
auto_price_alerts price watches (subscribe baseline, last notified price)

Feed vehicles cascade with their feed (mirror, not archive); manual vehicles persist. Member rows cascade with their vehicle; member_id is a plain indexed column.