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
/inventorysearch + 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_idnull) are dealer-owned records, created in the dashboard or by CSV import. They are never touched by feed syncs. - CSV import —
CsvVehicleImporterparses 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 viaCappedDownload(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;--freshre-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).
Search
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). Theautomotive:send-price-alertscron (LazyCron, 15 min) emails when the price falls belowlast_notified_price ?? price_at_subscribeand 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
/inventorytoolbar (and the empty-state "Alert me when one comes in" CTA) stores the current filter set inVehicleSearchlanguage with a name + frequency (instant/daily/weekly). Theautomotive:send-vehicle-alertscron (LazyCron, 5 min — RE's alert loop) emails up to 10 vehicles that arrived after the search's cursor;last_notified_atadvances 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
/inventorysearch 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 underdefaults/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.