Dealer vehicle inventory for WebProCMS, for one dealership or a multi-store dealer group: a
searchable lot with filterable inventory pages, favorites, price-drop and new-arrival alerts,
trade-in leads, and stores with their own pages and lead routing.
Modeled on the Real Estate module (app/Features/Automotive/, tables prefixed auto_,
feature key automotive). The Motorline theme is the dealer starter site built on it (see
The Motorline theme).
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
Six 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 (plain,
zipped or gzipped); every sync pulls the current snapshot. The URL is admin-supplied, so
the request goes through
OutboundHttp::guarded()(SSRF pinning, no redirects) with the feed's byte cap on the wire, and lands viaCappedDownload. The cap is per feed (max_bytes: default 50 MB, ceiling 200 MB). The normalised file's sha256 is the sync cursor — a scheduled sync of an unchanged export is a no-op (a dealer's Sync Now reads it again — see Syncing). Optional credentials (bearer token, or basic auth) are stored encrypted and never round-trip to the browser. - Drop folder (platform
drop) — the provider PUSHES the nightly export to this site: an SFTP account on the install's own server uploads intostorage/app/private/automotive-drops/{feed id}/, and each sync takes the newest complete file. The industry default for HomeNet / vAuto / DMS exports, and no outbound connection at all. Setting up the account is a server task — see Drop-folder feeds. - FTP / FTPS / SFTP pull (platforms
ftp,ftps,sftp) — this site logs in to the provider's server and downloads a fixed file, or the newest export in a directory (a path ending in/, or one the server reports as a directory). SFTP runs on phpseclib (no ssh2 extension needed), FTP and FTPS (explicit TLS) on ext-ftp; plain FTP gets an "unencrypted" warning on the form. See Pulling from an FTP or SFTP server. - 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.
Recognised export columns. Beyond the basics, the header guesser maps the columns
dealer-group exports carry: Certified / IsCertified / CPO (a truthy value turns a
used row into Certified Pre-Owned), DealerId / DealerCode / StoreId / RooftopId
(the store code — see Locations), DateInStock / InStockDate (the in-stock date,
never in the future), Age / DaysInStock, InvoicePrice (dealer cost — stored, never
shown or serialized), Cylinders, WindowStickerURL / Monroney, Owners /
CarfaxOneOwner, CarfaxNoAccident, TitleStatus (Clean → clean title; Salvage /
Rebuilt → not), CarfaxPersonalUse, Highlights (options), and VideoURL /
Video Link / YouTube variants. Condition also reads N / U / C and Spanish
(nuevo, usado, certificado); status reads In Transit / Incoming / On Order
as the in_transit status (ordered or on the truck, not on the lot yet).
Dealer-format presets. The feed form's Export from picks a named field map —
HomeNet IOL, vAuto, Dealer.com, DealerSocket Inventory+, Authenticom (DealerVault)
(DealerFormatPresets, stored as auto_feeds.field_preset). A preset is an OVERRIDE on top
of the header guesser, never a replacement: it re-points only the columns that format names
unusually (HomeNet's bare Type is New/Used; Dealer.com advertises its Internet Price), so a
preset whose columns are absent changes nothing and every other header maps as it would
without one. Headers match however the export spells them (Entry Date = EntryDate =
entry_date); a preset column that claims a field takes it from the header the guesser would
have used. An explicit per-feed field_map still wins over both (Feed::fieldMapFor()).
Nothing a feed sends may abort an import. Every string is clamped to its column
(NormalizedVehicle::COLUMN_LIMITS, checked against the migrations by
NormalizedVehicleColumnLimitsTest) — under MySQL strict mode one 39-character fuel
description in a 32-character column used to throw and stop the import part-way. A
URL longer than its column is dropped rather than truncated into a broken link. On a
CSV import, a row the database still refuses becomes an error line and the rows after
it keep importing. CSV parsing uses RFC 4180 quoting with no escape character, so a
field ending in a backslash no longer swallows the rest of the line.
Vehicle vocabulary
A dealer group's exports spell the same fact many ways ("CHEVROLET" / "Chevrolet" /
"Chevy", "4x4" / "4WD" / "Four Wheel Drive", "Crew Cab Pickup (Short Box)" / "Pickup",
hundreds of OEM paint names). VehicleVocabulary
(app/Features/Automotive/Support/VehicleVocabulary.php) is the one place that reads
them, and every write path goes through it: NormalizedVehicle::fromArray() for feeds
and CSV, and the Vehicle model's saving hook for everything Eloquent (dashboard
saves, CSV re-imports, factories).
- Closed dimensions store the canonical English value (the same in the URL and the database in every language), or null when unreadable: body type (SUV, Truck, Sedan, Coupe, Convertible, Hatchback, Wagon, Minivan, Van — a blank body is inferred from the model, and a known minivan wins over "Passenger Van"), drivetrain (FWD / RWD / AWD / 4WD; 4MATIC, quattro, xDrive, SH-AWD → AWD; "4x2" / "2WD" → RWD on trucks, vans and body-on-frame SUVs, else FWD; read off the trim when the field is blank), transmission (Automatic / Manual / CVT), fuel (Gasoline / Diesel / Hybrid / Plug-In Hybrid / Electric / Flex Fuel; a mild hybrid is Gasoline; inferred from the engine when blank).
- Open dimensions are cleaned and kept: make (OEM casing + aliases; an unknown make is re-cased, never dropped), model and trim (re-cased only when the whole record comes from an all-caps export — an all-caps trim alone is often the OEM's styling), the OEM paint names (kept for display; a bare paint code like "41" is dropped), and the raw engine string.
- Derived facet columns:
exterior_color_family/interior_color_family(~13 families + Other),cab_type+bed_length(trucks only, from the body / trim / model),cylinders+engine_family(I4, V6, V8, I6, H4, Electric…, so "V-8 cyl", "V8 engine" and "6.2L EcoTec3 V8 (420 hp)" are one filter value),seatsfrom a body like "SUV (7 seats)", andfeature_tags— ~40 curated shopper features (VehicleVocabulary::FEATURE_TAGS, grouped tech / safety / comfort / exterior / towing, with a "most popular" shortlist) read from the option list, plus third-row seating from 7+ seats or a "3-row" body, leather from the interior description, and off-road from trims like Z71 / AT4 / Rubicon. The feed's own body wording is kept inbody_style, and every derivation reads it, so re-normalizing never loses the cab. - Template tokens never reach shoppers:
<<< … >>>,{{ }},[object Object],PLEASE_CALL-style tokens and stray TBD are scrubbed; a value that is only a placeholder ("TBD", "N/A", "null") is empty; a description drops the sentence a removed token leaves dangling; a non-numeric or zero price is "Call for price". - Labels and Spanish: every display label comes from
VehicleVocabulary::labels($dimension). In Spanish (localees) that isVehicleVocabulary::SPANISH_LABELS, the es-MX term for each canonical value (Café, Quemacocos, Pickup…), read directly — never throughlang/es.json, whose keys are app-wide ("Pickup" is store pickup there). Every other language gets the literal__()call beside it, so the lang corpus sees each one.VehicleVocabulary::SYNONYMSmatchTerm()map shopper words in both languages onto canonical values ("camioneta" → Truck and SUV, "seminuevo" → used, "blanco" → White, "4x4" → 4WD) for search. A row count is the third-row seating tag: "third row", "3rd row", "3 row(s)", "three row(s)", "… row seating", "tercera fila", "tres filas", "3 filas", "3ra fila", "3a fila" — so "3 row suv" never searches for the keyword "3" (which a Mazda3, a Model 3 or a 3 Series would match).
- Backfill:
php artisan automotive:normalize-vehicles(--dry-runto preview) re-normalizes every stored vehicle without bumpingupdated_at. It ran once from the migration that added these columns and is safe to re-run. - Dashboard: the vehicle form stays free text, but a body type, transmission, drivetrain or fuel the vocabulary can't read is refused with a hint instead of being stored empty.
Syncing
Every feed pass is a task on the task runner — type
automotive-feed-sync, one live pass per feed (idempotency key automotive-feed-sync:{feed}),
so the cron tick and a dealer's Sync Now join the same pass instead of racing two.
automotive:sync-feeds (Lazy Cron, every 5 minutes) only launches: it starts a pass for
each active feed whose next_sync_at has passed, at most three passes in flight at once (a
group with a feed per rooftop has them all due the same minute; the rest launch on later
ticks), then checks for stale feeds. automotive:sync-feeds --feed=N launches one active feed
whatever its schedule and the three-pass cap (a pass already running is left to finish). FeedSynchronizer::sync() runs a whole pass in-process through the
same task (the demo seed and the test suite use it).
A pass, in three phases (FeedSyncTask):
- Stage — open the run, have the adapter land the snapshot, normalise it to
storage/app/private/automotive-feeds/{feed}/{sha256}.{csv|ndjson}(FeedStaging). A scheduled pass stops here, as a no-op, at a file identical to the last completed pass's — when it was also read under the same field map, preset, remove behaviour and rooftops (names, slugs, dealer codes;auto_feeds.sync_fingerprint, taken at sync time). A mapping fixed on the feed form or a store added under Locations therefore reaches an export the provider has not changed: the next pass reads it again (and such a re-read is not new inventory — it leaves the staleness clock alone). Sync Now always reads the current file (the task payload'sreread): a URL / FTP / FTPS / SFTP feed downloads it again for that press, a drop feed re-reads its newest complete file, so a dealer can re-apply the feed after a dashboard edit or a mapping fix. It does not reset the staleness clock either — only a NEW file (a different hash) counts as an import, so a feed that stopped sending still raises the 36-hour alert after any number of presses — and the truncation guard judges a forced read like any other pass. The cron's passes,automotive:sync-feeds --feed=NandFeedSynchronizer::sync()never force a read. Not pumpable (one blocking download); its claim may go 30 minutes without a heartbeat, so an FTP / SFTP session stops itself after 20 (RemoteFeedConnector::SESSION_SECONDS— a server dripping bytes never trips the per-read timeout) and fails as a transient error, and a stage whose claim another driver took meanwhile writes nothing (no no-op finish, no state). - Upsert — up to 1,000 rows per step from a stored byte offset, in bulk chunks of 200,
each row stamped
last_seen_pass = {pass id}; a heartbeat per chunk, and the cursor written together with every counter. A chunk's counts are decided before it writes and parked in the state aspendingfirst, so a step killed after its write and replayed reports what the first write did — the replay would otherwise see prices already changed and count nothing. - Settle — absent = this feed's vehicles not stamped with this pass, settled per the feed's remove behavior — mark sold (default; keeps the page live for old links and alerts for the sold-page retention window, 60 days by default — see Detail pages) or delete. Because "seen" is a database stamp, a pass of any size, spread over any number of steps and processes, settles correctly; the old in-request loop only settled when one slice covered the whole file, so a group export never marked a sold car sold.
The staged file is deleted when the pass finishes or is cancelled; a failed pass keeps it so Retry resumes from the cursor (the next pass's stage step clears it). If the staged file vanishes mid-pass, the pass re-stages under a new pass id.
The staging contract (for every adapter): FeedAdapter::stage($feed, $destination, $maxBytes) lands the provider's file byte-for-byte at $destination — at most $maxBytes,
stopping the moment a transfer passes it — and returns 'csv' / 'json', or null for
"nothing to take right now" (an unchanged no-op). It throws FeedFileRefused for a failure a
retry can't fix (misconfiguration, a blocked host; the pass fails at once) and anything else
for a transient one (retried on the runner's backoff ladder). A feed URL answering a redirect
or a 4xx — a wrong address (404 / 410), a refused login (401 / 403) — is refused at once with
a message saying which; a 408, a 429 and every 5xx are retried. FeedStaging then does the
rest identically for every adapter: re-checks the landed size, unpacks a ZIP (exactly one
inventory member; dot-files and __MACOSX/ ignored) or gzip while counting the bytes
actually written against the same cap (a ZIP bomb's size header is never trusted), rewrites
JSON as NDJSON (JSON is capped at 10 MB — it is decoded whole, which costs 5–12x the file in
memory, so a bigger one is refused and asked for as CSV, which is streamed),
hashes, and renames into place.
The truncation guard. A provider that sends a truncated or empty file would otherwise mark
the lot sold overnight. When a pass lists fewer than automotive.feed_settle_min_percent
(default 60, Settings → Feed Safety; 0 turns the percentage check off — an empty file is still held) of the vehicles the last settled pass
listed (auto_feeds.last_pass_rows), the settle is held: nothing is marked sold, the run
carries a settle_refused warning, every admin is emailed — once per episode: the short files
that follow a held pass are not re-announced, and a settle step re-run after a kill never sends
twice — and the baseline is left alone so
the next file is measured against the last good one. Below 10 vehicles only an empty file is
held (a small lot can legitimately sell a big share in a day). The first pass after the
upgrade measures against the feed's unsold inventory. When the drop is real (a store closed),
Accept new inventory size on the feed takes the held count as the baseline and re-runs the
file, which then settles.
Schedule. Per feed: every 15 minutes (the default, and every existing feed's cadence),
hourly, or daily at a wall-clock time in the site timezone (daily@HH:MM). The pass sets
next_sync_at from its outcome: an interval schedule waits its interval; a daily schedule that
imported a new file waits for tomorrow's slot, while one that found the same file (the export
is late) or failed checks again within the hour until the new file lands.
Staleness alert. A feed that brings no NEW snapshot for 36 hours (last_imported_at)
emails every admin once per episode (stale_alerted_at, cleared by the next import). Measured
on new content on purpose: a provider that stops exporting leaves the old file in place, every
pass finds it unchanged and "succeeds", and the site would serve stale stock under a green
status — a Sync Now that reads the same old file again does not count either. Demo and
inactive feeds never alert.
Runs. Each pass records fetched / saved / price changes / sold / removed counts and
warnings — store names that matched no rooftop (with how many vehicles came in
unassigned), duplicate VINs, unreadable rows skipped, and a held settle — shown in the feed's
sync log. Out-of-range numeric values are clamped, never allowed to abort a batch. One
listing per VIN: when a pass delivers the same VIN twice (a group-level row naming no store
plus the rooftop's own row), the first row wins unless a later one resolves to a store the
first didn't, and the losing row's key is removed rather than kept as a second, storeless
listing — inside a chunk in memory, across chunks and steps by the VIN plus the pass stamp of
the row already written. (A VIN listed by two different feeds is not deduplicated — each
feed is its own mirror.) Sync errors land on the feed (last_error) and its run history;
unreadable credentials (rotated APP_KEY) fail the pass with a clear re-enter-credentials error
instead of hammering the export host. A pass that changed what a visitor sees clears the
response cache (through InventoryVersion::forgetCachedPages()), so prices and sold banners
reach the cached detail pages, and launches the photo mirror and (when ad feeds are on) the
ad-feed rebuild; new or changed rows also launch the description translator (when translation
is on). "Changed" is measured, not assumed from rows written: a row counts when
it is new or differs from the stored vehicle in a column a visitor sees (changed per chunk,
decided before the write so a replayed chunk reports it once) — the provider's own timestamps
(feed_modified_at), the raw record and the pass stamp don't count — plus anything removed or
settled. So a timestamped export that restamps every row of an unchanged inventory rewrites the
rows without emptying the page cache or the search-count caches every 15 minutes. Comparing
against the stored vehicle (not the last file) means a car the dashboard marked sold that the
feed still lists counts as a change.
Manual curation survives re-syncs: is_featured, is_hidden, the slug, and arrived_at
are insert-only.
Drop-folder feeds (the SFTP-account runbook)
A drop feed reads storage/app/private/automotive-drops/{feed id}/ (shown on the feed's
edit form; created when the feed is saved). A half-uploaded file is never read (FM9):
- the newest file is skipped until its mtime is 2 minutes old — an upload in progress keeps touching it — and a newer file still arriving blocks the older complete one it supersedes;
- its size, mtime and inode must be unchanged after the copy and the copy must hold exactly that many bytes, so a writer that resumes mid-copy costs a retry, not half an inventory;
- only
.csv.tsv.txt.json.zip.gzcount (so.filepart,.part,.tmpuploads in flight are ignored), dot-files are ignored, and symlinks are ignored — the SFTP user can create one pointing at.env, and the file is re-checked after it is opened so a swap between the check and the open is caught.
"Nothing to take yet" ends the pass as an unchanged no-op; the schedule re-checks (within the hour on a daily schedule). After a successful take, superseded exports older than 3 days are deleted so a nightly upload can't fill the disk. The folder is not backed up, and deleting the feed empties it (the bind mount and the SFTP account stay — remove them on the server).
Creating the SFTP account is an ops task on the install's server, never code. On a plain Linux host (adapt it for a control panel such as RunCloud — the principle is the same):
- Create a system user for the provider with no shell:
useradd -M -s /usr/sbin/nologin feed-acmeand give it a password or the provider's public key. - OpenSSH requires a chroot directory to be root-owned and not writable by the user,
which a path inside the site never is. So chroot to a root-owned directory with one
writable child, and bind-mount that child onto the feed's drop folder:
mkdir -p /srv/sftp/feed-acme/upload && chown root:root /srv/sftp/feed-acme && chown feed-acme:{web group} /srv/sftp/feed-acme/upload && chmod 2775 /srv/sftp/feed-acme/upload, thenmount --bind /srv/sftp/feed-acme/upload {site}/storage/app/private/automotive-drops/{id}(and the matching/etc/fstabline so it survives a reboot). - In
sshd_config:Match User feed-acme→ChrootDirectory /srv/sftp/feed-acme,ForceCommand internal-sftp,AllowTcpForwarding no,X11Forwarding no; reload sshd. - The web user must be able to read and delete in the folder (the setgid group above does
it). Send the provider the host, port, username and "upload into
upload/". - Save the feed, wait for the first upload, then Test Connection — it names the folder when nothing has arrived yet, and says when the newest file is still arriving.
Pulling from an FTP or SFTP server
The form takes the server, port (default 22 for SFTP, 21 for FTP/FTPS), the file or folder on the server, and write-only credentials — username, password and, for SFTP, an optional private key (OpenSSH or PEM; a passphrase goes in the Password field).
The server can never be a private address (FM10). The host is admin-supplied, so the
adapter does OutboundHttp::guarded()'s job itself, because FTP can't ride it: the form
refuses a structurally private host at intake (no DNS on the request: private / reserved IP
literals, localhost, single-label names, .local / .internal / .test); at connect time
the name is resolved and every address must be public; the session opens to the address
that was checked — an IP literal, never the hostname (RemoteFeedConnector refuses one), so a
TTL-0 record can't rebind between check and connect; and FTP's passive-mode data channel is
held to that same address (FTP_USEPASVADDRESS off — a hostile server's PASV reply can name
any host, and ext-ftp would otherwise follow it). Rows saved before the check are checked
again at sync time. Control characters in the path or login are refused (they would be extra
FTP commands).
Half-written files get the drop folder's rules (FM9): a file younger than 2 minutes, or
whose size or mtime moved during the download, is not taken. The download is byte-capped on
the wire (FM11): a SIZE over the cap is refused before the transfer, and the running count
stops it the moment it passes the cap whatever SIZE claimed (FTP checks between non-blocking
chunks; SFTP asks phpseclib for one byte past the cap and counts what arrives). A whole
session — listing, probing, download — is held to 20 minutes, inside the stage step's
stale window.
A folder path takes the newest export in the WHOLE listing, whatever order the server lists it in. MLSD gives every file's date in one transfer. A server without MLSD costs two round trips per name (SIZE, MDTM), so only export-looking names are probed, and a folder holding more than 2,000 of them is refused with a message to point the feed at the file or clear out old exports — never cut short in listing order, which would pick the newest of whichever names came first.
SFTP host keys: trust on first use. The first sync that logs in records the server's
host-key fingerprint (SHA256:…, OpenSSH's format) on the feed; every later connection
presenting a different key is refused before the password is sent. Test Connection shows
the fingerprint (compare it with the one the provider publishes) but never records it — it
runs on unsaved form values. Trust a new host key on the form forgets it, and pointing the
feed at a different server, port or protocol drops it automatically.
Test Connection uses the SAVED credentials. A secret still typed in the form is one the admin hasn't saved, and reading a write-only input back is what the credential rule forbids — so the form says "Save the feed first" instead. Saving merges: only the fields typed this time replace their stored values (retyping the password never wipes the username), each field has its own Remove stored value, and the inputs are blanked the moment they are stored.
Vehicle photos (the local mirror)
Feed photos are CDN URLs, and hot-linking them means full-size images on every search card
(slow LCP on the money page) and broken images when the CDN expires a URL. The
photo mirror (VehiclePhotoCache + the automotive-photo-cache task, modelled on Real
Estate's ListingPhotoCache) keeps a local copy of the lead photo + the next three (Settings
→ Vehicle Photos, automotive.photo_cache_count, default 4, 0–12; 0 = hot-link everything) of
every vehicle on the site — available, in transit or pending, not hidden. Copies land on the
public disk at auto-photos/{vehicle id}/{feed index}-{md5(url) prefix}.{ext}, resized to at
most 1600 px wide, fetched only through SafeImageFetcher (guarded, image/* only, byte-capped,
must decode; a decompression bomb is refused). The rest of the gallery keeps hot-linking.
A pass runs after every feed pass that changed inventory (launched from the end of the
settle), when the photo count changes, and daily (automotive:cache-photos, the backstop for
hand edits and refused photos): census (purge, then count shared photos — the vehicles and
models carrying each — both stop at the step's deadline and resume, so a dashboard poll that pumps
this phase never runs past its slice: the purge's cursor is the directories still left, the
count's a vehicle id saved WITH its running counts and the models seen per photo in one scratch
file, storage/app/private/automotive-photo-census/{task}.json,
written whole by rename and deleted when the census completes) → fetch (walk
the in-scope vehicles by id; a vehicle whose record already matches costs a few stats) →
warm (pre-generate the Glide card + hero variants, budgeted). One pass at a time across
every feed; a feed that imports mid-pass sends it round again.
Never fetched twice, never kept after the sale (FM13). Filenames are content-addressed by
URL, so an unchanged photo is never re-downloaded and a changed one is (the replaced file and
its Glide variants are swept; once a vehicle's variants are gone its empty variant directory,
img/v/auto-photos/{id}/, is removed too — only when empty, so a variant written meanwhile is
never deleted). A vehicle that sells, hides, loses its photos or leaves the feed
has its directory purged on the next pass (reconcile() — the sync's bulk updates and deletes
fire no model events, so the pass checks the directories against the scope); deleting one
vehicle from the dashboard purges at once (a bulk delete waits for the next pass, like the
sync's). A vehicle whose photos are refused three passes running is parked for a
day — the attempt is counted before the download, so a CDN that hangs long enough to kill the
step still gets parked. A mirrored file that has gone missing (the mirror is not backed up)
falls back to the feed URL and is re-fetched next pass. The mirror's writes never touch
updated_at.
The shared-slide rule (FM12). Dealer galleries carry store-wide branding slides — often as photo #1 of every used car on a lot, which would make every card in a row show the same slide. A photo three or more vehicles of more than one make + model carry is SHARED: never mirrored, and moved to the END of the gallery so the car's own photos lead (a vehicle whose only photo is shared still shows it). A branding slide rides on cars of every model on the lot; a photo only identical units carry — the manufacturer's stock CGI on every new unit of one model — is a photo of that car, so it keeps its place (it can lead) and is mirrored like any other. Make and model compare case- and spacing-blind; trim and year don't count; a car missing its make or model counts toward the three but can't vouch for a second model. Judged two ways under that one rule: by URL before any download (query string ignored — a dealer-site CDN serving one slide to every car), and by content hash after download (a photo provider that re-hosts the slide under a per-vehicle URL; the pass that sees the hash reach three vehicles across models marks it shared everywhere and deletes the copies). The rule looks at each car's first 12 photos, or the mirrored count plus eight when that is more (slides sit at fixed positions like #1 and #5).
For views (Vehicle): galleryPhotos() is the gallery in display order, each entry
{url, cached, path, width, height, index, shared} — cached true for a mirrored copy (local
URL and recorded dimensions), index the photo's position in the feed's list, shared a
demoted store slide. Every read checks the disk for each mirrored photo, so it is memoised on
the model instance, keyed on the stored photos + photos_local columns: changing either
recomputes it, and re-reading the row (refresh(), a fresh query — setRawAttributes())
drops it, so a mirrored file that has gone missing is noticed on the next read of the row. A
card, the vehicle page, schema, ad feeds and chat tools holding the same instance share it.
photoUrls() / firstPhotoUrl() follow that order. photoImage($position, $widths, $srcWidth) returns
{url, srcset, avif_srcset, width, height, cached} for a display position: a mirrored photo
gets a Glide WebP srcset (+ AVIF when the server can encode it) capped at its recorded width,
with the original as the high-DPR entry; a media-library pick on a hand-entered vehicle gets a
Glide srcset too; a hot-linked feed photo gets its plain URL. Card srcsets
should ask for VehiclePhotoCache::CARD_WIDTHS (400 / 600 / 824) and the detail hero for
DETAIL_WIDTHS (824 / 1100 / 1536) — those are the sizes the pass warms.
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, the search filter and the Price drops row read the same window.
Price alerts don't: they fire on any cut below the watcher's last-notified price (see
Visitor accounts & alerts). The vehicle page charts price_history (see Detail pages).
Vehicle browser
Dashboard → Automotive → Vehicles (manager+). A filterable table of the whole
inventory — search (make/model/trim/VIN/stock/year), status (available / in transit /
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) — for a dealer group, each one is a
rooftop: one store, the record inventory, search and lead routing key on. Each carries:
- Identity — name, kind (dealership, collision center, service center, car wash, powersports, RV, marine; it decides the schema.org type the store publishes as), the brands it sells (canonical makes — "chevy" and "CHEVROLET" are saved as Chevrolet, so a brand filter never splits a store), its own website, a finance application link, a Carfax partner code, and a per-store disclaimer (state pricing and fee rules differ).
- Where and when — address, latitude/longitude, a market (the metro the search's store filter groups it under, free text — blank groups it by its city; not the state, since a metro can cross a state line), a timezone (IANA id; blank = the site's), the main phone, email and weekly hours.
- Departments — sales, service, parts and collision, each with its own phone, public email, lead inbox (comma separated), CRM (ADF) lead address, online scheduling link and hours. Sales with no hours of its own uses the store's main hours; the other departments never borrow them (their hours differing is the point).
dealer_codes— the feed / DMS store identifiers (and any alternate spellings a feed uses) that map an inventory row to this rooftop.content_item_id— its public page (below).
A feed or CSV row is matched to a rooftop by its dealer code, then by its store name against
those codes, the slug, and the name (case and punctuation ignored). The intake never creates
a location: a store reference that matches nothing imports the vehicle unassigned and is
reported (a CSV error row; a warning on the sync run), because a typo or a renamed store used
to mint a junk lot that then showed in the public store filter. The bundled demo feed is the
one exception — its two sample lots are created flagged is_demo. City/state search filters
resolve through the vehicle's location.
Store hours are wall-clock by design. "09:00" means nine in the morning at the store; it is
never converted. Only "now" is converted — into the store's own timezone — before it is compared
with the hours, so a Florida store on a Central-time site shows the right "open now" and flips
"today" at its own midnight (Location::localNow(), hoursToday(), isOpenNow(); a store with
no hours on file is unknown, never "closed"). A window is open from its start up to (not
including) its end, except one ending 23:59 (Location::UNTIL_MIDNIGHT), which runs through that
last minute to midnight — the hours editor's time input can't hold 24:00. To keep a store open
until midnight, end its window at 11:59 PM; for hours past midnight add the rest as the next
day's 12:00 AM window. A window that doesn't end after it starts is refused on save.
Dashboard → Automotive → Locations manages them: a create/edit modal with three tabs —
Store (name, kind, brands, address, market, coordinates, timezone, main phone/email, hours, photo,
sort order, active), Departments (one department at a time), and Links & Feed (website,
finance link, dealer codes, Carfax code, disclaimer, the linked location page and Create
Page). A validation error opens the tab it is on. A dealer code another store already uses is
refused (compared the way the intake compares), since two stores sharing one would split a
store's cars on the next sync. On save the store is placed on the map through the core
Geocoder (its durable cache, remembered misses and backoff) when it has no coordinates or its
address changed; typed coordinates win, and the pin left over from an old address is dropped
rather than kept when the new one can't be placed — the store's ZIP centroid stands in instead
when it has a known ZIP. geo_source records where a pin came from (manual, geocoder,
zip) and geocoded_at when it was last tried. Stores that predate coordinates (or were saved
while Nominatim was down) are placed by automotive:geocode-stores (Lazy Cron, every 15
minutes, three stores a tick — Nominatim allows one request a second): address first, else the
ZIP centroid; a ZIP-only pin is retried for a precise one at most once a day and kept when that
still fails. A store with neither is simply left out of distance results.
A store edit reaches guests at once. A save that changes anything a page prints — name,
phones, hours, brands, the store the call/text wording names — clears the response cache (its
cars' pages, the landing pages, the store rows), so a phone fix never waits days for the cache to
expire. A save that changes only Location::OFF_PAGE_COLUMNS — the pin (latitude, longitude,
geo_source, geocoded_at) and the timestamps — does not: the geocode sweep saves stores every
tick, and a pin shows only in the vehicle page's JSON-LD geo and in distances, which can wait
for the cache's own expiry. Activate/deactivate and delete are in each row's menu (beside
Edit and Create Page / Edit Page): 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.
Store pages
A rooftop's public page is an ordinary item of the core Locations content type, linked by
content_item_id (one page per store) — never a second location model inside the module, so the
page keeps everything core gives a location: its generated detail page, translations, Local
Rankings / Business Listing, LocalBusiness JSON-LD, and staff / review / FAQ relations.
Create Page (RooftopPage::create()) makes it from what is saved for the store — creating
the Locations type through the same scaffold path as Content Types → Add from template when the
install has none — and a store can instead be linked to an existing location item (never one
that belongs to another store). Create Page publishes the page when the store is active (a
draft otherwise); from then on its status is the page's own — switching the store off or on
moves only its inventory link.
Two kinds of data live on that page:
- Copied once, then the page's own: name, address (with the coordinates), phone (the Sales department's line, else the store's main phone), hours (two windows a day — a third folds into the second), the service scheduling link (as the page's Schedule button) and the store photo (featured image). Edit them on the page; they are translated there.
- Owned by the store, rewritten by every store save that changes their source
(
RooftopPage::SYNCED_KEYS, fromLocation's saved hook, whoever saved it, when a column inRooftopPage::SYNC_SOURCESchanged — the page link, brands, website, departments,is_active,slug):brands,inventory_url,website_url,service_phone,parts_phone— the fields the Location Detail - Store Overview and Locations Directory page designs read.inventory_urlis the store's landing page (/inventory/store/{slug}) while the store is active and has a slug, else the search filtered to it — so deactivating, reactivating or re-slugging a store moves the page's link with it. The first Create Page (or the first link to an existing page) appends those fields (plussummary, the page's own blurb) to the Locations type, because the content editor drops any value its type does not declare. Deleting the store blanks them, so the page's "Shop this store's inventory" button can't lead to an empty search.
The page's JSON-LD publishes as what the store is — AutoDealer, AutoBodyShop, AutoWash,
MotorcycleDealer, AutoRepair — instead of the type's generic LocalBusiness. Core exposes
this as StructuredData::narrowItemTypesUsing(): a feature may narrow one item's type, only
within the LocalBusiness branch (every subtype shares the parent's properties, so nothing is
lost and nothing outside the branch can be smuggled in). Those four types (plus the existing
Automotive Business / Auto Repair) are also selectable for a content type and for the site.
Feeds page & CSV import
Dashboard → Automotive → Feeds (manager+). Feed CRUD (name, platform — URL, drop folder, SFTP, FTPS, FTP or demo — URL or server / port / path, CSV/JSON format, Export from preset, remove behavior, sync schedule, maximum file size, active; a drop feed shows its folder path, an SFTP feed its trusted host key), Test Connection (stages the export exactly as a sync would — archives included — in a throwaway directory, counts rows, lists unmapped columns), Sync Now (launches the pass on the task runner — one that reads the current file even when it is unchanged, see Syncing — or, while a pass for the feed is already running (the cron's or an earlier press), joins that one as it is), a progress bar per pass in flight (a pass that ends while the page is open keeps its bar with the outcome, and the table re-reads at once — status, count, Held), a per-feed schedule line with the next sync time, a status badge (Syncing / Error / Held — the truncation guard held the last settle, with an Accept new inventory size action — / Stale / Ready), 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 localhost, single-label, dev-TLD or private / reserved IP hosts — no DNS lookup); the sync-time request still runs the full SSRF guard. Deleting a feed deletes its vehicles (mirror, not archive) after confirmation, and every export its provider uploaded to its drop folder (a bind-mounted folder is emptied; the mount and the SFTP account are the server's to remove).
Import CSV opens the upload modal: upload an export (12 MB cap) — with more than one store,
pick one for every row or leave it to the file's location column (Assign to location) —, 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, pre-qualification requests, general questions — filed under its
store (location_id), with a type chip, the shopper's language badge (when it isn't the
team's), the vehicle link, contact info, origin country (from the capture IP), received time and a
Forwarded status (Sent / Sending / Failed / Interrupted / CRM not configured — the worst thing
true of the lead's deliveries). A type filter (?typeFilter=) narrows it to one kind of lead.
With more than one store the list gains a Store column and a store filter (?store=); a
single-lot install never sees either.
The detail drawer (?lead={id} opens it — the dealer email links straight there) shows the
message through the core Customer Messages viewer: in the team's language with Show original
when it is translated, else the shopper's words with Translate (Retranslate, failures and
stale translations as everywhere else — see Message Translation). The
translation is kept on the lead, so the CRM timeline shows the same one. Below it: the call/text
consent record (yes/no, when, and the exact wording shown), the typed payload (a trade-in's
vehicle, a pre-qualify budget), and Forwarded to — each delivery with its channel, recipient,
status, time and error, and a Resend button (allowed to send again, even a sent row; who
pressed it and when is kept; while a send is in flight the open drawer re-reads every 5 seconds until it settles). A "not configured" row explains where to add a CRM address; Resend
on it sends to whatever is configured now. Links to the CRM contact when the CRM feature is on;
deletes with confirmation (the CRM contact is kept).
Guest alerts page
Dashboard → Automotive → Guest alerts (dashboard.automotive.guest-alerts, manager+ — the
Leads page's gate, since both hold shoppers' addresses; logic in Support/GuestSignups). It
lists every email-only alert a shopper made without an account, whichever form made it — the
zero-results Notify me or the results toolbar's Save search (see Visitor accounts &
alerts), newest first, 25 a page: the address; the search by its saved name ("Every vehicle"
when it has none), linking to the search, with its frequency; the language; the status (Not
confirmed / Active / Stopped); and when it was signed up and last notified ("Never"),
on the site clock. Members' own saved searches stay in their accounts and are never listed,
exported or touched here. Signups also go by themselves: one never confirmed 7 days after its
link was sent, a stopped one a year after it stopped (see Visitor accounts & alerts).
- Search by email — part of the address (
?q=), capitals and spaces ignored. - Export CSV — the list with its search applied: Email, Search, Search link, Filters (the
stored criteria as JSON), Language (its code), Status, Frequency, Signed up / Confirmed / Last
notified (ISO-8601 on the site clock, with its offset). RFC 4180 — a quote is escaped only by
doubling it, so a backslash before a quote reads back as typed. Every cell goes through the core
CsvCell::harden()— an address, a search name and a filter value were all typed into a public form. - Delete one signup (the row's menu, behind a confirmation).
- Delete by email (the header button, or a row's menu, which fills in its address) — for a
privacy request: every guest alert for the address, whatever its status. The address is
compared after lower-casing (any alphabet's capitals) and removing every space — tabs and
no-break spaces included — on both sides, in PHP over the guest rows
(
GuestSignups::idsForEmail()), so no spelling a stored row holds escapes an erasure; the modal shows how many match, and its button stays disabled at none. The dialog counts the address's signups only while it is open. Deleting a stopped alert also deletes the record that the address said no, so the site's form could send that address a confirmation again — the modal says so.
A delete holds for an alert run already under way: the sender re-reads each search just before it mails it (see Visitor accounts & alerts).
Spanish
The inventory works end to end in every site language; Spanish is the one the vocabulary carries its own terms for.
Links stay in the visitor's language. Every public automotive link is built through
AutomotiveLinks (to(), path(), route()) or the helpers that use it —
Vehicle::publicUrl(), SearchUrl::url(), InventoryLanding::url() / urlForCriteria(),
Location::inventoryUrl() — never a bare route(). They read the language rendering
(cms.current_language, which the /{lang}/ route, Livewire round-trips and MailLocale set)
or one passed in, add the prefix, and spell the path in that language's words. A shopper on
/es/inventario stays on /es/ through cards, landing hubs, breadcrumbs, the vehicle page,
compare, the account pages, the autocomplete and the chat. A stored link — a menu item, a
CTA, a store page's inventory_url — is re-spelled on a translated render too: the module
registers InventoryLanding::localizePath() with core LocalizedPaths, which LinkLocalizer
consults, so /inventory/used/toyota typed into a Spanish page links to
/es/inventario/usados/toyota, and vehicle pages (/inventory/{slug}) are prefixed (as
/es/inventario/{slug}) although they are parameterized. Staff-facing links (lead emails, the
ADF copy, the webhook) and the ad feeds stay in the default language.
Off means untouched. The provider registers that translator on every install, so it answers
null for everything while Automotive is off (the path is tested first, so a link outside
/inventory never reads the setting) — the rule for any LocalizedPaths translator. An install
with the feature off is byte-identical to one without the hook: hreflang alternates, canonicals,
sitemap twins, the language switcher and LinkLocalizer leave its own CMS pages at
/inventory/... with their own words, never re-spelled or prefixed toward a 404. The route
resolver (below) answers null while Automotive is off too, so /es/inventario is whatever the
install has there.
One URL per page per language. Spanish path words: inventario for the inventory itself,
nuevos, usados, certificados, store agencia; seminuevo(s), usado, nuevo,
certificado and the old store word tienda are accepted aliases. A page reached by any word
other than its language's canonical one 301s to it in one hop, query kept
(/es/inventory/usados → /es/inventario/usados, /es/inventario/used →
/es/inventario/usados, /es/inventory/tienda/{store} → /es/inventario/agencia/{store},
/inventory/usados → /inventory/used), so no landing page is served twice. Search, compare
and vehicle pages reached by the old /es/inventory 301 the same way (RedirectToInventoryWord,
before the response cache). The hreflang alternates, the canonical and the sitemap's language
twins all go through Hreflang::urlFor(), which asks the same translator — each language's
entry is its own spelling, never a redirect. The language switcher links to the same spellings.
The inventory word is the route's, not a second route. No route is registered at
inventario: the /{lang}/ catch-all asks LocalizedPaths::routePath() (the module's resolver,
InventoryLanding::routePath()) which route serves /es/inventario/…, and matches
inventory/… — only that first word changes; agencia and the condition words are route
parameters the landing routes already accept. A second set of routes would take /inventario
from an install's own page on every install, Automotive on or off. The word changes under a
language prefix only: a Spanish-default site keeps unprefixed /inventory/usados and
/inventory/agencia/{store} — its condition and store words are Spanish; only the inventory word
stays inventory. An install's own route at /inventario (a page, a content type, a
redirect line) wins — read from the route table once a request, since a cached route table builds
a Route object per route on every read — so it answers at /inventario and /es/inventario and
keeps its canonical and hreflang, and the Spanish inventory stays at /es/inventory/…, with no
redirect either way, until that route is gone. Watch for it on a dealer rebuild whose old site's
redirect map has /inventario. The page-title breadcrumb walks the route spelling, so the
Spanish trail keeps its Inventory crumb.
Labels and numbers. Vocabulary values (body, drivetrain, transmission, fuel, colour families,
cab, bed, engine, features, condition) are labelled by VehicleVocabulary::labels() — the
Spanish table in Spanish (see Vehicle vocabulary). The vehicle page's spec list labels them
too; in another language the body line is the labelled body type and cab (the feed's own body
wording is English). OEM paint names stay as the feed has them. Money and mileage print through
VehicleFormat (money(), offerMoney(), miles(), number()) with fixed US separators in
every language — $32,995, 32,995 mi — never the es-ES 32.995 US$ a locale-aware formatter
would print. Page titles and meta descriptions are set in rendering() so they are in the
visitor's language.
Descriptions. A vehicle's description is translated once with AI and kept on the vehicle
(auto_vehicles.translations, the HasTranslations bag), stamped with a fingerprint of the
English it came from. The automotive-translate-vehicles task (TranslateVehicleTask) walks
the vehicles a shopper can open (available, in transit, pending; not hidden) and translates any
description with no current translation in a site language — new cars, and cars whose English
changed since. It runs after every feed pass that brought new or changed vehicles, after a
dashboard edit or import changes a description (one launch per request), hourly from
automotive:translate-vehicles, and at once when the switch is turned on (saving a special
launches one too — see Specials & offers). Rules:
- Never stale as current. An English edit (dashboard or feed) makes the Spanish stale: the page shows the new English (the site's stale-translation render policy) until the next pass re-translates it. The write is conditional on the English still being the text that was translated, so an edit landing mid-call is never stamped as its source.
- Switch. Settings → Vehicle Descriptions in Other Languages. Unset, it follows the site's "translate new content automatically" setting (off by default). Off, every language shows the English description, nothing is spent, and the translation tooling sees no vehicles. The same switch and the same daily cap also cover live specials' disclaimers, titles and short descriptions, which each pass does first — unless the site's own "translate new content automatically" is on, when the core translator owns them (see Specials & offers).
- Daily cap (
automotive.translate_daily_cap, default 300, ≤ 5,000): one per description (or special-offer title, short description or disclaimer) per language, counted in a Setting on the site clock and claimed before each call; the rest wait for the next day. Three unanswered calls in a row (an error, a timeout) end a pass — the provider is down; a refused answer says nothing about the provider and does not count. - A failure is remembered, not re-bought. Each failed description is recorded per vehicle and
language against a fingerprint of the English it tried (
automotive.translate_failures, the 1,000 most recent) asfailed(the provider answered and the answer was refused) orunavailable(no answer), and passes skip it until its retry wait is over (VehicleTranslation::heldBack()): 24 hours after a refusal, 1 hour after no answer, doubling with each further failure of the same English, at most 7 days. Changed English is new text and is tried at once; a success forgets the failure. A special's fields share the memory under their own keys —special:{id}:{lang}for the disclaimer,special:{id}:{lang}:titleandspecial:{id}:{lang}:excerptfor the others — which no vehicle's{id}:{lang}can be; one field's refusal never holds back another (VehicleTranslation::heldBackFor()/rememberFailureFor()/forgetFailureFor()). - Never translated: an empty description, one over 5,000 characters, one holding template syntax (checked on both sides before anything is stored).
- Only this module buys them.
Vehicleis inTranslationSync::TRANSLATED_MODELS, sotranslations:statusreports vehicle descriptions (a stale one counts as stale until this module's pass reaches it) andtranslations:import/confirmreach them. It is also inTranslationSync::MODULE_TRANSLATED_MODELS, sotranslations:filland the hourlytranslations:syncrepair never translate one — neither knows this module's daily cap or its length limit, and one night's fill would otherwise buy every new car on the lot.
The description renders with a lang attribute naming the language it is actually in.
Alerts. Saved searches and price watches store the language they were started in
(auto_saved_searches.language, auto_price_alerts.language). Alert emails are composed in
MailLocale::stored() of that language — wording, labels and every link (vehicles, the search,
Manage alerts) in it. A guest's confirmation link opens the confirm page in that language. The
signed stop link stays unprefixed (a mail client's one-click unsubscribe posts to exactly that
URL), and the stop page answers in the alert's language, linking back to its inventory.
Settings
Dashboard → Automotive → Settings (admin): dealer name override, the group's lead
notification emails (comma-separated; leads with no store, an inactive store, or a store with
neither a department lead inbox nor a main email land here — blank notifies every admin), the group's CRM lead address (ADF/XML)
(automotive.adf_email, comma-separated; used for stores with no CRM address of their own —
blank leaves those leads "CRM not configured"), alert-email sender
(from name/email, reply-to), the price-drop badge window (days), inventory-page defaults
(default sort — Recommended unless changed —, vehicles per page, cards vs list view), the detail-page disclaimer, Feed
Safety (the truncation guard's percentage, default 60; how many days sold vehicle pages are
kept, default 60), Vehicle Photos (how many photos per vehicle the local mirror keeps,
default 4 — a change starts a photo pass), Payment Estimates (show estimates on/off, default
term, down payment and credit tier, the APR table by condition × tier, and the estimate
disclaimer — see Payment estimates under Search; a blank APR is refused, 0% is allowed),
Plain-Language Search (the AI help mode — Off / Smart (default) / Always — and the most
AI searches per day, default 300, 0 = no AI; with the last 30 days' counts: searches, read with
AI, changed by the shopper afterwards, AI searches today — see Plain-language search),
Vehicle Descriptions in Other Languages (the translate switch and the most translations per
day, default 300 — both also covering live specials' titles, short descriptions and
disclaimers, as the note under the switch says — see Spanish), 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.
A save that changes anything a cached guest page prints — the dealer name, either disclaimer, the price-drop window, sold-page retention, the search page's defaults, or any payment assumption (estimates on/off, term, down payment, credit tier, the APR table) — clears the response cache, so the cards and vehicle pages never quote an old APR beside a budget filter already using the new one. Flipping the description-translation switch clears it too (the other languages' vehicle pages start or stop showing translations), and turning it on starts the first pass at once. A save that changes none of these leaves the cache alone.
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, Guest alerts, Feeds, Specials — once the type exists —, and for an Admin Ad feeds and Settings).
Search
VehicleSearch is the one search brain every surface shares — the /inventory page, the
autocomplete, saved-search alerts, the design-library presets, the chat tools — so a filter
behaves the same everywhere. Its filter language (every key optional; list keys take one value
or a list; unknown keys are ignored):
status—available(default: on the lot),shoppable(on the lot or in transit — what the public search shows;Vehicle::scopeShoppable()/SHOPPABLE_STATUSES),sold,all. Alerts, the sitemap, the dashboard widget and the presets stay onavailable/scopeAvailable(): they promise a car is there today.- Keyword
q— every word must match. A word or phrase (up to three words) that names a vocabulary value, in English or Spanish, becomes that filter: "troca" → Truck, "camioneta" → Truck or SUV, "seminuevo" → used, "blanco" → the White family, "quemacocos" → the sunroof tag, "camara de reversa" → backup camera, "3 row" / "tres filas" → third-row seating, "4x4" → 4WD. It still matches as text too, so "Hybrid" finds a model named Hybrid. Filler words ("auto", "carro", "de", "con", "for sale") are left out. The synonym tables areVehicleVocabulary's (one set, shared with the plain-language search);interpretKeywords()reports what was matched, what was searched as text and what was left out. - Lists (OR within, AND across) —
condition(new · used · certified, so certified pre-owned is its own option),make,model,trim,body_type,exterior_color_family,interior_color_family,transmission,drivetrain,fuel_type,cab_type,bed_length,engine_family,cylinders,doors,seats(5 · 6 · 7 · 8+),location_id,market,availability(available · in_transit). Values are canonicalised on the way in, sobody=truck,color=blancoand an old link's OEM paint name (color=Summit White) all work. A trim is one value however the feeds spell it: hyphens, spaces, capitals and other punctuation fold away (VehicleVocabulary::trimKey()— the vocabulary's fold with the spaces taken out, the same squashed spelling the plain-language parser reads trims by), so "TRD Off-Road", "TRD Off Road" and "trd offroad" are one trim, as are "Big Horn/Lone Star" and "BIGHORN/LONESTAR". The whole spelling must match — "SR" is not "SR5", and a "+" stays ("SXT+" is not "SXT").normalize()writes a trim in the spelling the site's cars use most (VehicleSearch::trimSpelling(): the most cars on the site, hidden ones aside, whatever their status, ties by name — one spelling site-wide, not per model), and the filter matches every stored spelling (a plainwhereIn, expanded from the cached model tree — no extra query), so an old link or saved search carrying another spelling still finds all of them. The rawexterior_colorkey still works for saved searches that stored one. - Make → model → trim is a tree. Ticking Toyota and Honda and then Tacoma shows Toyota Tacomas and every Honda: a model narrows only its own make, a trim only its own model.
- Body → cab is a tree too. A cab type narrows only the body types that have one
(
VehicleVocabulary::CAB_BODY_TYPES— trucks): SUV + Regular Cab is every SUV plus the regular-cab trucks, Truck + Crew Cab is the crew-cab trucks, and a cab with no body type ticked is those trucks — never "SUVs with a regular cab", which would leave every SUV out of a search that names a cab.bed_lengthis still ANDed. Removing the last truck body type takes the cab and bed filters with it (VehicleSearch::dropsLastTruck(),TRUCK_FILTERS), the way a make takes its models — on the chip's ×, a relax suggestion, the panel checkbox andVehicleSearch::without()alike; left behind, a cab would still list its trucks and removing Truck would change nothing. Only that transition counts: a cab with no body type ticked, or beside an SUV alone, is a search of its own and stays (FM87). - Broad body styles —
VehicleVocabulary::BODY_GROUPSis the ONE definition of Cars (Sedan, Coupe, Convertible, Hatchback, Wagon), SUVs (SUV), Trucks (Truck) and Vans (Minivan, Van); labels vialabels('body_group')(Spanish: Autos, SUV, Pickups, Vans y minivans). The step-by-step search hero, its count endpoint and the/inventoryhand-off all expand a group from it, as plainbodyvalues. features— tag keys, AND: a car must have every ticked feature (read from theauto_vehicle_featuresindex).- Toggles —
one_owner,accident_free,clean_title,personal_use,price_drop,new_arrival(on the site ≤ 7 days),has_offer(a live lease / APR / cash special is on the car — see Specials & offers),featured. - Ranges —
min_year/max_year,min_price/max_price,min_mileage/max_mileage,min_mpg(highway). - Distance —
zip(or a shopper's ownlat+lng) +radiusin miles. The breakpoints are 30 / 60 / 120 / 240 (blank = any distance) —VehicleSearch::RADIUS_PRESETS, the ONE list the/inventorypicker, the facets and the step-by-step search hero offer. A saved search, alert or shared link holding an older radius (25, 100…) still works: the picker adds that value to its choices. See Distance below. - Payment —
max_monthlyplus the shopper'sdown,term,credittier: turned into a maximum price per condition (new, certified and used carry different APRs) by the payment estimator, so the indexedpricecolumn does the work. See Payment estimates. city,state(the store's),arrived_after(the alerts' cursor),sort.
Sorts (SORTS; sortOptions() gives the labels, with "Nearest" only when there is a ZIP):
Recommended (the public search's default), newest arrivals, price ↑↓, monthly payment ↑,
lowest mileage, newest year, nearest. Recommended is a documented score, not price:
score = 3 × has photos + 2 × price dropped (inside the badge window)
+ 2 × arrived ≤ 7 days (else 1 × ≤ 30 days) − 4 × in transit
ties going to the newest arrival (RECOMMENDED_WEIGHTS). "Has photos" is the feed's photo
count — a car whose only photo is a store's shared slide still counts as having one. Payment
sorts by the same per-condition factor the estimate uses; Nearest orders by the store's distance
(cars with no placeable store last) and reads as Recommended when there is no origin; on
/inventory it is the starting sort of any search that has one (see Location is opt-in).
Facets (VehicleSearch::facets(), built by VehicleFacets) — every facet with its options,
in one UNION ALL query whatever the inventory or the number of facets:
- Disjunctive counts: each facet's counts apply every other filter but not its own, so
ticking Toyota leaves Honda's count on the make list. The make list ignores make, model and
trim; the model list follows the ticked makes (each option names its
parentmake); the trim list appears only under ticked models — one option per trim however many spellings the feeds use, labelled with the site's most common one and counting the cars of all of them. Body type and cab count along their tree: with a cab ticked the body counts see every body type (an SUV counted whole, the trucks by the cab), and with a body type ticked the cab counts always see the trucks — each option says what ticking it would add. Features and the toggles count within their own selections (they are AND). - Nothing vanishes: an option is listed when the inventory carries it at all, or when it is selected — with a count of 0 when the current filters exclude it, so a ticked option whose count falls to zero can still be unticked and the list doesn't reshuffle.
- The store facet lists stores that carry inventory, labelled with their name,
grouped by market (the store's market, else its city) and, when the search has a ZIP, itsdistance.marketas a filter picks every store in that market. - Also returned: the payment facet (selected terms, the term and credit-tier choices, and the printed assumptions per condition), the distance facet (origin + radius + presets), and the year / price / mileage / MPG spans for range pickers.
- Shape per facet:
key(the filter key),label,type(multi · all · toggles · range · payment · distance),group(the panel section) andoptions— each{value, label, count, selected}, plusparent,group,distanceorpopularwhere they apply. facetCounts()keeps the original flatfacet => (value => count)shape for existing callers.
Zero results — relaxSuggestions() counts the search with one filter removed at a time (a
make takes its models and trims with it, the last truck its cab and bed; an AND features list
relaxes one tag at a time) and returns the ones that would find cars, biggest first:
{filter, value, count, gain} — the page renders each as the chip it already shows ("Remove
Up to $15,000 → 23 vehicles"). At most 12 variants are counted: every whole filter first, in
RELAXABLE order, then one per ticked feature, so a long feature list never crowds out the filter
that emptied the search; a caller's $only (the step-by-step hero's own choices) narrows the
candidates before that cap, never after. The variants are counted in one UNION ALL query and
the suggestions cached as one entry (automotive:relax:{inventory version}:{filters fingerprint}:{hash of $only}, ten minutes) — never a query and a cache write per variant, since a
zero-result search is what a shopper, or a scraper cycling values, lands on most.
Speed and freshness — count(), the facet counts, the inventory's own option lists and
the store directory are cached for ten minutes under the filters plus
automotive.inventory_version (InventoryVersion), a random token in Settings that every
inventory write replaces: a vehicle or store saved or deleted, a feed deleted (its cars cascade),
each feed-sync chunk and settle, the dashboard's bulk mark-sold / delete, a CSV import (once, at
the end), the vocabulary backfill, a special saved, deleted or expired, and a Settings save. A
cached count can therefore never
outlive the stock it counted. Labels and "selected" flags are added after the cache, so one
cached count serves every language. A cold search page is a fixed handful of queries (results,
count, one facet union, the inventory's options, the store directory) regardless of inventory
size; a warm one adds none for facets. The two lists every filter branch reads — the make/model
tree and the store directory — are also memoised per application by inventory version
(InventoryVersion::memo(); the tree query also counts each make / model / trim — still one
query — which is what picks a trim's spelling), so a search with a model and a distance
unserializes each once
rather than once per facet branch: one slot per key (a long-running process holds one copy, not
one per version seen), held on the application rather than in a static (each test or Octane
request starts empty), and trusted for at most 10 seconds before the cache is asked again. A word
given for a closed filter (body, colour, fuel…) is checked against a per-process index of folded
names (VehicleSearch::namesValue()) — one fold and a lookup, not a pass over the whole
vocabulary per word.
availableMakes() / availableModels() / availableMakeModelMap() still drive the hero
quick-filter row from available stock.
Distance
A shopper's ZIP becomes a point without any network call — distance search never geocodes
per request (the core Geocoder's Nominatim budget is shared by every install on the server).
The point comes from a bundled file, Database/Data/us-zip-centroids.bin: the US Census
Bureau's ZIP Code Tabulation Area (ZCTA) Gazetteer, 2026 vintage (ZCTAs of the 2020 Census,
public domain —
https://www2.census.gov/geo/docs/maps-data/data/gazetteer/2026_Gazetteer/2026_Gaz_zcta_national.zip),
33,791 ZIP internal points in a 440 KB sorted fixed-width file read by binary search
(Geo\CentroidIndex, Geo\ZipCentroids) — no table, no migration seed, nothing held in memory.
A PO-box or single-building ZIP (no ZCTA of its own) is answered by the nearest ZCTA in its
3-digit sectional centre and flagged approximate; anything else is unknown. Rebuild the file
from a newer Gazetteer with php artisan automotive:build-geo-index zip <unzipped .txt> —
CentroidIndex::write() writes the whole file beside the live one and renames it into place, so a
reader (which takes no lock) sees the old file or the new one, never a half-written one that would
answer a real ZIP as unknown. The
format takes any key scheme: the place-name index (Geo\PlaceCentroids, below) is a second file in it.
Distances are great-circle miles from the point to each store (a group has dozens of stores,
never thousands of cars to measure), and a radius keeps the stores inside it. A store's pin is its
coordinates, else its ZIP's centroid. An unknown ZIP never crashes or empties the page: the
radius is ignored, every store shows, and origin() reports unknown so the page can say the
ZIP wasn't found. A store with neither coordinates nor a known ZIP is left out of radius results
and sorts last for Nearest. The browser's location is opt-in only (the page sends lat/lng
when the shopper asks) — never a prompt on load.
Payment estimates
PaymentEstimator is the one payment calculation: the "Up to $400/mo" filter, a card's
"Est. $412/mo", the payment sort and the vehicle page all call it, so the filter can never let
through a car whose printed estimate is over the line. It amortises the price less the down
payment at the APR for the shopper's credit tier and the car's condition, rounds up to the
dollar, and leaves out taxes and fees (the disclaimer says so). maxPrice() is its exact
inverse (walked to the boundary through the same rounding). Defaults — 72 months, $2,000 down,
"Good" credit, and a deliberately conservative APR table (new 5.9 / 7.4 / 10.9 / 14.9 %,
certified 6.9 / 8.9 / 13.9 / 18.9 %, used 7.4 / 9.9 / 14.9 / 19.9 % for excellent / good / fair /
rebuilding) — are Settings. A blank, non-numeric or out-of-range stored value falls back to the
default, never to zero (a 0-month term would divide by zero; a blank APR would print "0% APR"),
and a car the down payment covers gets no estimate rather than "$0/mo". 0% APR is allowed and
divides straight across.
API: terms(array $input), apr($condition, $tier), monthly($price, $condition, $input),
maxPrice($monthly, $condition, $input), quote(...) / forVehicle($vehicle, $input) (monthly,
term, APR, down, tier, the full label — "Est. $425/mo · 72 mo · 9.9% APR · $2,000 down" — and
the assumptions line on its own), assumptions($condition, $input), enabled(),
disclaimer(), tierLabels(), aprTable() (the table in force, stored values over defaults),
factorFor($condition, $input) (the monthly factor the payment sort orders by). $input uses the filter keys down, term, credit.
Public inventory search (/inventory)
/inventory itself is uncached, per-visitor Livewire (like the Real Estate /properties page);
its landing paths are guest-cached (see Landing pages below). Results are on the lot
or in transit (status = shoppable, in-transit ranked lower); hidden, pending and sold vehicles
never appear. The total is VehicleSearch::count() and every facet comes from one
VehicleSearch::facets() call (both cached), so the page costs the same handful of queries
whatever the inventory size or the number of facets.
The URL is the state. Every filter is a #[Url] property, named by
Support/SearchUrl — the one map from a VehicleSearch filter key to its query parameter
(body_type → body, exterior_color_family → color, location_id → location,
max_monthly → maxMonthly, toggles as oneOwner=1, lists as make[0]=Toyota…). The page's
own links, the login bounce and the saved-search alert emails (SavedSearch::searchUrl()) all
build URLs through SearchUrl::url(), so an alert link rebuilds exactly the search that was
saved — a test round-trips every key. A single value for any list key is accepted
(?condition=new, ?location=5, ?model=Tacoma — the shape older links, the presets and the
design-library rows send), and list values from a URL are cleaned (scalars only, trimmed,
de-duplicated, at most 60). view, sort, budget and page ride along.
A list filter written with empty brackets — ?make[]=Toyota, what a plain GET form sends (the
step-by-step search's no-script form among them) — is 301'd once, before the page renders, to the
indexed spelling the site's own links use (?make[0]=Toyota), on /inventory, every landing
path and their Spanish forms. Livewire's script would otherwise write a junk make. key into the
address bar beside its own, so a copied link carried it (and its canonical, as make_); the same
301 drops that key from a link copied before. An indexed filter, a single value (?make=Toyota)
and a bracketed key the page does not own are left alone.
Load more = page. ?page=3 renders the first three pages of results server-side, so the
Back button from a vehicle page reloads the same cars and the browser restores the scroll. The
Load more control is a real <a rel="next" href="…page=N+1"> (crawlers, middle-click) that
Livewire handles in place. A page past the end shows the last page; nonsense shows page 1.
Rendering stops growing at 20 pages (MAX_PAGES): past that the page says "Showing N of M
vehicles. Narrow your search to see the rest." instead of offering more, so neither a shopper nor a crawler walking
rel=next can make one request render the whole lot. Any filter or sort change starts again at
page 1.
The filter panel (partials/search/filter-panel.blade.php; the desktop sidebar and the
phone sheets are the same markup), in the tiers of the filter-UX research:
- Tier 1, open: Condition (multi-select pills: New / Used / Certified Pre-Owned) · Location (ZIP + distance breakpoints 30/60/120/240 or any — plus an older saved radius, kept as a choice — an opt-in Use my location button, and the stores grouped by market with counts and, once there is an origin, distances — shown when there is more than one store with stock) · Make → model → trim (each ticked make lists its models underneath, each ticked model its trims; type-to-find when there are more than 8 makes; unticking a make drops its models, unticking a model its trims) · Budget, a Price | Monthly payment toggle (price: min/max + Up to $15k–$40k presets; payment: max per month + presets, down, term, credit tier, and the estimator's assumptions printed on the facet with the disclaimer — one budget at a time, switching clears the other; payment is hidden when estimates are turned off) · Body style as icon tiles · Year (from/to) · Mileage (Up to 15k–100k presets + min/max).
- Tier 2, open on desktop, folded on a phone: Features (the popular tags as one-tap pills, plus "All features" grouped Technology / Safety / Comfort / Exterior / Towing with type-to-find) · Powertrain (drivetrain, fuel) · Truck bed & cab (only when Body = Truck or a cab/bed is ticked) · Vehicle history toggles · Deals (price drop, new arrival, has a special offer — listed only while some car carries a live offer, or while ticked — in stock / in transit) · Exterior + interior colour swatches · Seats.
- Tier 3, under "More filters": transmission, engine, cylinders, doors, minimum highway MPG.
Zero counts never vanish. An option the other filters leave at 0 stays listed, greyed. A ticked one stays ticked and enabled, so it can always be unticked; an unticked one is disabled (ticking it could only empty the page). This holds for every widget — checkbox rows, pills, body tiles, toggles, swatches, stores — and for a model or trim linked without its make or model, which is listed on its own. A whole facet is hidden only when it doesn't apply (no data for it at all, or the truck section without a truck).
Applied filters show as plain-language chips above the results — "Up to $450/mo · 72 mo",
"Within 50 mi of 62701", "$20,000 – $30,000", "2018 or newer", "Up to 50,000 mi", a store's
name — each with ×, plus Clear all. A range or the distance is one chip. Both ends of a price,
mileage or monthly budget are inclusive (a car priced exactly $20,000 is in a $20,000 minimum),
so a lone end reads "$20,000 and up", "30,000 mi and up", "Up to $30,000" — never "Over" or
"Under". VehicleFormat::priceRange() / mileageRange() / monthlyBudget() hold that wording
for the chips, the relax suggestions, the panel's payment presets, the step-by-step search's
suggestions, a saved search's name and a member's alert list; the panel's price and mileage
presets say the same in short ("Up to $15k", "Up to 15k"), and the payment chip adds its term
("Up to $450/mo · 72 mo"). (The design-library hero rows' own budget choices and the landing pages' price-tier links
still read "Under …" — an open owner decision.) An unknown ZIP shows "ZIP … not found" and a notice that every
store is shown (the engine ignores the radius). A trim reached in another spelling (an old
link, the folded form, the shopper's own words) is rewritten to the site's spelling on arrival,
so the URL, the ticked option, the chip and the count say the same thing.
Update model. Desktop applies each change as it is made. On a phone, a sticky bar carries
Filters (n) and Sort plus the live total and the chips (scrolling sideways), and a
row of quick chips (Condition · Price · Make & model · Body style · Near me · More filters)
opens a bottom sheet holding just that facet; Filters opens the full-height sheet. Changes in
a sheet apply to the search behind it at once (one state, the URL always current); the
sheet's sticky Show N vehicles button closes it, and at 0 it becomes the best relax
suggestion ("No matches — remove “Up to $15,000”? Shows 23 vehicles", from
VehicleSearch::relaxSuggestions()), else "clear all filters". The phone's Back button
closes a sheet: opening one pushes one history entry and every way of closing it pops that
entry again; because Livewire writes each change onto the entry on top, the entry underneath
is then given the search's latest URL (rendered on the page root as data-auto-search-url),
so Back from a vehicle page never reloads the search as it was before the sheet opened
(resources/js/automotive-search.js, registered in public.js as autoSearchPage).
Location is opt-in. The browser is asked for a position only when the shopper presses Use
my location (and the button shows only where the API exists on a secure page); the page then
calls useLocation(lat, lng), which rounds to two decimals (about a kilometre — enough for
"within 25 miles", and a shared link doesn't carry anyone's front door) and clears the ZIP. A
typed ZIP clears the location. Setting an origin turns the default sort into Nearest;
losing it turns Nearest back into the default; a sort the shopper picked is left alone. A
search that arrives with an origin and names no sort — the step-by-step search's hand-off, a
saved-search alert or shared link with zip (or lat + lng), a landing path with a ZIP in
its query — starts at Nearest too. A sort the URL names wins (even the default one), and a ZIP
no dataset knows is no origin, so it keeps the default. The page's own links (rel=next, the
Back-restore URL, a landing redirect) leave out the sort the URL would arrive at anyway — no
sort=nearest beside a ZIP — but keep a default sort the shopper named beside one, so a reload
doesn't turn it Nearest.
Top bar — the search box, read in the shopper's own words (Plain-language search,
below: Enter sends the words through searchWords(), a stock number or VIN is kept as a
keyword), with grouped autocomplete (makes, models, body types, direct vehicle matches by
title/stock/VIN via GET /auto/autocomplete, throttled and cached per term, over the same
shoppable scope as the results, so an in-transit car is suggested too; picking a suggestion
still sets the keyword or opens the car) and the sort select (VehicleSearch::sortOptions() —
Recommended by default, Nearest only with an origin).
Results — a card grid or list rows, toggled by the visitor (the default view, sort, and page size come from Settings). Save search (alerts) and the Saved link sit above the results; for a shopper who isn't signed in, Save search opens an email-only box (see Visitor accounts & alerts).
Plain-language search (/inventory?ask=…)
A shopper can search in their own words — "used Tacoma in Springfield under 400 a month",
"troca 4x4 con tercera fila menos de 35 mil", "AWD SUV under $30k with CarPlay" — and always
sees the result as the ordinary, editable chips, plus the words it could not use ("Not used
from your search: …", data-search-unused) and any dimension it understood but would not filter
on (data-search-unfiltered, see Sparse dimensions below), never a black box and never a
silently dropped word. ?ask= on /inventory is the entry point, so any static form can
submit to it with a plain GET (<form action="/inventory"><input name="ask">); the page's own
search box calls searchWords() with the same reading. The page reads ask once on mount, writes the filters
into its normal #[Url] properties and clears ask — the URL then holds the filters, so a
reload or Back keeps the shopper's edits instead of reading the words again. The words replace
the parts of the search they name (and the keyword); whatever else was set stays. A price,
mileage or year range is one part with two ends: words that name one end keep the other end only
while the two still make a range — "under 20k" typed after "from 30k" replaces the $30,000 floor
(a $30,000 – $20,000 search would show no cars), while "under 30k" after "from 20k" narrows to
$20,000 – $30,000. A price or mileage range must be wider than one value; one model year is a
range of its own. A place's
name rides in near (a display-only filter key: "Within 30 mi of Springfield, IL"). A store's
name is that store: "Acme Toyota Springfield", "camry at Acme Toyota Springfield" and "trocas en
Acme Ford Springfield" tick that store (location), with no distance and no Nearest sort, and
two names tick both. The words' where replaces the old one — a typed store clears the ticked
stores and ticked markets and an earlier typed place's distance (the shopper's own ZIP or
location stays, without its radius), and a typed town clears the ticked stores and markets —
since either would otherwise AND the search down to nothing.
The parser (Support/NaturalSearchParser, deterministic, free — no AI, no network, only the
cached catalog of the site's makes/models/trims and stores, NaturalSearchCatalog; about a
millisecond a parse whatever the inventory's size — its phrase lists are folded once per process
and models looked up in a per-catalog spelling index, since the header search runs it on every
keystroke) reads, each pass seeing only what the earlier ones left:
- set phrases — history toggles ("one owner", "sin accidentes"), sort words ("cheapest", "pocas millas"), credit ("bad credit", "mal crédito"), and need phrases ("good on gas", "que rinda la gasolina") that are kept for the AI rather than misread ("gas" ≠ Gasoline);
- numbers with their unit: "menos de 400 al mes" / "under 400 a month" / "$450/mo" is a
monthly budget (
max_monthly), never a price; "35 mil" is $35,000, never 35; "enganche de 2 mil" / "$2,000 down" is the down payment (FM30); "under 50k miles", "2019 or newer", "del 2019 en adelante", "2018-2021", "72 meses", "7 pasajeros", "30+ mpg", a ZIP. A bare number with nothing marking it as money ("silverado 1500") is never money — except a round five-digit one ("suv 25000" is up to $25,000, never a ZIP); after a cue word an amount under 100 is thousands ("under 30" = $30,000) and 100–999 is a monthly payment. A lone "$" joins the amount after it ("tacoma $ 18500" is a price, never ZIP 18500); a year with a currency word after it is money ("1999 dollars", "2000 dolares"), while "2015 grand cherokee" stays a model year. "from 20k to 25k", "de 20 a 25 mil" and "desde 20 mil hasta 25 mil" are price ranges and use their opening word (so a fully read search costs no Smart-mode AI call). A lone "from 20k" / "desde 20 mil" is where the prices start — a minimum (FM107), as are "starting at / starting from 20k", "a partir de 20 mil", "20k and up", "25k or more", "20 mil o más" and "25 mil en adelante"; "from 20k under 30k" keeps both ends. It takes money ("from 20" is not) and only a price — "from 2019" is a year, "from 300 a month" a payment — and Spanish "de" is not "from" ("camioneta de 20 mil" is a $20,000 truck). Mileage reads the same way (FM122): "from 20k miles", "desde 20 mil millas", "mileage from 30k", "kilometraje desde 20 mil", "20k miles and up", "50k miles or more", "50,000 or more miles", "50,000+ miles", "50 mil millas o más" and "40 mil millas en adelante" are the lowest mileage (min_mileage); "under 50k miles", "menos de 50 mil millas" and a bare "50k miles" stay a ceiling (max_mileage), and "desde 20 mil millas hasta 60 mil millas" keeps both ends. "between 2018 and 2021" / "entre 2018 y 2021" / "from 2018 to 2021" are model years, never a $2,018–$2,021 price; - stores and places — a store's whole name anywhere is that store only (
location_id; the longest name wins, so "Acme Toyota Springfield" is never "Acme Toyota" plus the town; a hyphen in a name may be typed as a hyphen or a space; several names are several stores; only active stores). Only "near / close to / around / cerca de / alrededor de" before it, or a radius ("within 20 miles of …"), keeps a distance from the store, nearest first — the shopper asked for proximity; "by" / "por" are not near-words (they read as "sold by") — "by" before a store's full name goes with it ("trucks by Acme Toyota Springfield", "by the Acme …"), as does a store word right after it ("… store", "dealership", "lot", "agencia"). A one-word store name counts only after a word that introduces a place ("at / in / en", or "by / por"), or typed loose when it is also a store's town or market: alone, one word is as likely a brand or a town ("Kia" stays the make). A store needs no map pin to be named: one with no coordinates is still that store (location_id) — after "at / in / en", as a one-word name, as the AI's place and as the chat tool'slocation— and "near" it or a radius round it falls back to the store itself (there is no point to measure from). Whatever follows "in / near / around / at / en / cerca de" whenSearchPlacesresolves it: a ZIP, a store's town or market, or any US town a store can reach — a distance filter, 30 mi (NaturalSearchParser::DEFAULT_RADIUS— the nearest breakpoint, so the chip matches the picker), nearest first (FM40). A town typed with no state that no store is within 240 mi of is not a place (see Towns): "truck at home" is not 30 mi around Home, KS — the word is shown unused. "in white" / "en rojo" are colours, "in stock" is not a town; - the inventory's models however they are spelled ("f150", "crv", "grand cherokee", "tacomas", "silverado" → every Silverado size), with a trim right after its model, in any of the feeds' spellings of it (the page then shows it in the site's spelling); a model that is an everyday word ("fit", "edge", "escape") only beside its make or in a very short search;
- the vocabulary through
VehicleSearch::interpretKeywords()(body, condition, colour, drivetrain, fuel, cab, bed, engine, features — English or Spanish; a row count — "3 row suv", "three row", "3rd row seating", "suv de 3 filas", "con 3ra fila" — is the third-row seating feature, never the keyword "3"); - the rest: a stock number / VIN or a word inside a carried make/model/trim ("trd") is kept as a keyword; a store's town or "Town, ST" with no "in" is a place (a one-word store name spelled like a store's town or market is that store); filler is ignored; anything else is returned unused, in the order typed — as is every word past the 24-word limit.
When nothing at all was read, the shopper's own words become the keyword search (the old behaviour), so "Bronco" with none in stock still says so and offers the alert.
A money word — grand, thousand, mil, k — left over right after a word the parser can't read (a
spelled-out number: "between twenty and thirty grand") is left unused with it, so Smart mode
hands both to the AI, never the keyword: the catalog's Grand Cherokee would otherwise make
"grand" the keyword q=grand, which no AI reading removes (the AI only adds to the parser's
reading). "grand" alone stays a keyword and "30 grand" is money, as before.
Towns — Database/Data/us-place-centroids.bin (Geo\PlaceCentroids): the US Census
Bureau's Places Gazetteer, 2026 vintage (public domain —
https://www2.census.gov/geo/docs/maps-data/data/gazetteer/2026_Gazetteer/2026_Gaz_place_national.zip),
32,120 incorporated and census-designated places at their internal points, keyed
{state}|{folded name} ("IL|springfield", the LSAD word — city, town, CDP — dropped), 1.2 MB in
the CentroidIndex format; a consolidated city-county is filed under its own name too
("TN|nashville"); "Saint"/"St", "Fort"/"Ft", "Mount"/"Mt" both work. Without a state, the stores'
states are tried first and, among several states, the one nearest a store wins — and the town
must lie within 240 mi of a store (the widest VehicleSearch::RADIUS_PRESETS breakpoint;
beyond it no radius on the picker could show a car). When the stores' states only hold a far
namesake, the nearest same-name town in any state gets a look before the word is refused; a refused
word is not a place — it is shown unused, and Smart mode hands it to the AI. A town whose state
was typed ("Home, KS") is always accepted, and a store's market counts only when one of its stores is
in the typed state — "Springfield, MO" is the Missouri town even where a "Springfield" market holds
Illinois stores; a typed state with no town of that name leaves the word unused, never another state's
town. With no store on the map, a town needs its state. The
rule lives in SearchPlaces, so the AI's place and the chat tool's location follow it too. A
two-letter state code that is
also a word ("in", "la", "me", "de", "or") counts only after a comma or in capitals, with or without
"in" before the town ("Franklin, IN", "Franklin IN"); capitals count only when the search is not
typed all in capitals ("SPRINGFIELD OR DECATUR" stays two Illinois towns). Rebuild with
php artisan automotive:build-geo-index place <2026_Gaz_place_national.txt>.
Modes (Settings → Plain-Language Search, automotive.nl_search.mode): Off — the parser
only, zero AI; Smart (default) — the AI only when the parser left words unexplained or a need
("for my 3 kids", "tow a boat", "good on gas"); Always — every search also goes to the AI.
Support/NaturalSearchAi makes one short call on the visitor-facing chat lane
(AiTextService::TASK_CHAT — whatever provider and model Settings → AI resolves for it, behind
the same availability gate as the chat bubble; never a hard-coded model), with the site Brief
(SiteKnowledge::forPrompt()) prepended, the vocabulary's values and up to 400 of the
inventory's models listed — the most-stocked first (by how many cars a shopper can buy of each,
one grouped count cached per inventory version), then printed by make and model, so a big group's
long tail of one-off names never pushes "Camry" out of a list the AI may only pick from — a
500-token budget and an 8-second timeout. Its answer is never trusted
(validate()): only values the vocabulary has, models the inventory carries and numbers in a
sane range survive — an invented "Hovercraft", a model not carried, a min year above the max
are dropped and recorded (FM66). The parser's reading always wins where both read the same
thing (its money rules are exact) — and when the parser read either end of a price or a mileage
the AI adds neither (a "from 20k" minimum plus an AI ceiling would be a one-price search, "from
20k miles" plus an AI max_mileage a one-mileage search; the prompt also tells the AI a lone
"from" is min_price / min_mileage, and the AI may answer min_mileage — a minimum over the
maximum is dropped). "Read" is the parser's own reading, so the AI's own min_price never
blocks its own max_price and an AI-read "$20k to $30k" stays a range (the same for mileage);
the AI only adds what is missing (an AI place that
is a store is that store only — the AI's place carries no "near" — and an AI place never adds a
distance over a store the parser read), and the words it used stop being "unused" only when it
actually added something. A reading is cached for 7 days under
(query, language, NaturalSearchAi::PROMPT_VERSION — 6 at this build — and the catalog
signature: every model the inventory lists + the vocabulary), so the same words in another language, or
after the
models change, are never answered from a stale reading (FM68). Provider calls are capped per day
(automotive.nl_search.ai_daily_cap, default 300, counted on the site's clock from the log, so a
cap raised mid-day applies at once) and per visitor (30 an hour). Counting today's calls and
claiming one on the search's log row happen together under one cache lock, so two searches racing
for the last slot cannot both see it free; a search that can't get the lock within 2 seconds makes
no AI call (FM67). A failure, timeout, prose answer or the cap simply leaves the parser's reading, and a
failure is never cached (FM70).
Sparse dimensions never filter. A filter keeps only the cars that list a matching value, so on
a feed that sends a seat count for 2% of its cars, "for my 3 kids" read as 5+ seats would hide the
other 98% of the lot. A reading — the parser's and the AI's alike — therefore filters on one of
these dimensions only when at least half of the shoppable cars (on the lot or in transit) carry a
value in its column (NaturalSearch::MIN_FILTER_COVERAGE, 0.5; NaturalSearch::COVERAGE_CHECKED):
seats, doors, minimum highway MPG (mpg_highway), cylinders, engine (engine_family),
drivetrain, fuel type, transmission and exterior colour family. Below that the filter is set aside
(NaturalSearch::setAsideSparse()), and the page names it beside the words it could not use,
above the filter chips: "Too few vehicles list these, so they were not used as filters: Seats"
(data-search-unfiltered, one data-unfiltered="{key}" per dimension, labelled with the filter
panel's own names). The AI's
filters are set aside before they are merged, so an AI reading whose only addition was set aside
added nothing and the shopper's words stay listed as not used; a parser reading set aside is not
an unexplained word, so in Smart mode it never sends the search to the AI on its own. Coverage is
measured per column in one query over the shoppable stock (NaturalSearchCatalog::coverage(),
cached ten minutes under the inventory version, automotive:filter-coverage:{version}); a column
nobody measured — an empty lot, or a catalog built from an array like the eval's — counts as fully
listed. Every other filter a reading sets (make, model, trim, body, condition, budget, year,
mileage, features, places…) is not measured. The filter panel still offers every facet: a shopper
who ticks Seats, or a link that carries seats[0]=7, filters on it. The header search (it reads
through the same NaturalSearch::read()) and the chat's search_vehicles tool (see AI chat)
apply the same rule.
The log (auto_search_parses, Models\SearchParse): one row per plain-language search —
the query (cut to 150 characters; emails and long digit runs masked), language, mode, the path
that read it (parser / ai / cache / keyword), ai_status, whether a provider call was
made, latency, the filters it produced, the unused words and any AI values dropped (a filter set
aside as sparse is recorded there too, as {key}:sparse — seats:sparse) — and
edited_at, set once when the shopper then changes a chip the reading produced (the misparse
signal; the page compares the criteria hash in dehydrate()). automotive:prune-search-parses
(Lazy Cron, daily) deletes rows older than 30 days, so the table stays bounded (FM69). The header
search and the eval write none.
The eval — tests/Fixtures/automotive/nl-search-eval.json: 200 shopper phrases (English and
Mexican Spanish; structured searches, places — a store's name among them, scored on
location_id —, keywords and needs) with the filters a person
would read, run against the fixture's own world (Acme stores in generic towns, a fixed model
list), never the install's stock. php artisan automotive:score-search --mode=off|smart|always|all [--misses] [--fresh] [--min-accuracy=N] [--yes] prints accuracy (overall and per kind), latency
(average / p95) and, for the AI modes, calls, tokens and cost per query (AiRateCard); Smart and
Always call the provider once per phrase that needs it, so they ask first. Off-mode accuracy at
this build: 193/200 (96.5%) — every structured, place and keyword phrase; the 7 misses are
needs only an AI reading meets. The phrases were written alongside the parser, so treat that
number as a regression floor (a test holds Off mode at 95% or better, with no miss outside the
needs), not a field accuracy — the log's edited_at is
the field signal.
Header search — the site's search bar and Cmd-K palette (PublicSearch) gain a Vehicles
group while the feature is on: stock number, VIN, model or the shopper's own words, read by the
parser alone (Off mode — never the AI, never logged), shoppable cars only; a query that names no
vehicle ("about us") lists none, and when more match than fit, a last row opens
/inventory?ask=… with the same words.
The vehicle card
What a card shows is decided by one class, VehicleCard
(VehicleCard::for($vehicle, $distance, $paymentInput, photoLimit:)), so every surface that
prints a card — the grid, the list row, the recently-viewed strip, the compare header and the
design-library vehicle rows — prints the same one. The grid card is
partials/search/vehicle-card.blade.php, the list row vehicle-row.blade.php, both with the
same params: $vehicle (eager-load location), $isSaved (true/false shows the save heart,
which calls toggleSave(id) on the enclosing Livewire component; absent = no heart),
$distance (miles from the search's origin to the car's store), $position (0-based),
$paymentInput (the search's down / term / credit), $headingTag (default h2; the grid
card only — the list row is always an h2) and $compare (default shown).
- Photos — a swipeable carousel (CSS scroll-snap, arrows on hover) of the car's own
photos, at most 5, from the warmed card widths (
VehiclePhotoCache::CARD_WIDTHS, so the WebP/AVIF variants the photo pass warmed are the ones requested). A store slide the photo mirror judged shared across cars of different models never joins a carousel (a stock photo only identical units share is the car's own); a car whose only photos are shared slides shows the first one alone; a car with no photo gets a "Photos coming soon" placeholder. The first three cards' lead photos load eagerly (the first row of a desktop grid) and the very first withfetchpriority="high"; every other photo is lazy. The card reads the gallery once (galleryPhotos()is memoised — see Vehicle photos), andphotoLimit(defaultMAX_PHOTOS, never more) is how many photos the layout prints — the list row, the recently-viewed mini card and the compare header show only the lead photo and build only that one, no srcset for photos nobody sees. - Badges — at most two, in this priority: In transit (whether you can buy it today) › Price drop −$X (a live cut, inside the price-drop window) › Certified › New arrival (arrived within 7 days) › 1-Owner › No accidents. The two history badges never show on a new car. Each test is the one the matching search filter applies.
- Price — the price (with a struck-through MSRP when it is higher), and the estimate
from
PaymentEstimator::forVehicle()with the search's own down / term / credit, so it agrees with a monthly-budget filter; its assumptions are printed under it. No estimate when estimates are off or there is nothing to finance (never "$0/mo"). A car with no price shows Call for price, linked to the store's sales phone when it has one. - Store — the store's name, and its distance from the search's ZIP or location ("12 mi away") when there is one.
- One call to action — Check availability (an in-transit car: Check arrival date),
linking to the vehicle page's inquiry form (
#check-availability); plus the save heart and a Compare checkbox.
Zero results
Never a dead end (partials/search/empty.blade.php): relax chips — the filters whose
removal would find cars, biggest gain first, each with the count it would show
(VehicleSearch::relaxSuggestions()); similar models in stock — when the search named
models or a body type, the models of the same body type the inventory carries within the rest
of the search (price, store, distance… kept; make/model/trim let go — a named model shows too
when only its trim or its make emptied the search), most stock first, up to six, each linking
to that search — its landing page where one fits (SimilarModels,
cached against the inventory version); and Notify me when one arrives — a member saves
the search; anyone else leaves an email address (below).
Compare (/inventory/compare)
Every card's Compare checkbox adds the car to a floating tray (up to four; a fifth is
refused with a note), and the tray links to /inventory/compare?ids=12,40,7 once two are
ticked. Below 1024 px the tray floats 8rem up, above the site's own corner widgets (the
dark-mode / language / cookie-settings cluster, the accessibility bubble, the AI chat bubble and
its frame) instead of under them; from 1024 px it sits at the bottom, centred and kept clear of
both corners (partials/search/compare-tray). The tray is the viewer's own — localStorage, in
resources/js/automotive-garage.js (Alpine.store('autoCompare')) — but the comparison
itself lives in the URL, so it is a link a shopper can send. The page lines the cars up from
the normalised fields: price, estimate, MSRP, condition, availability, year, mileage, body,
drivetrain, transmission, engine, fuel, MPG, colours, seats, doors, owners, accident history,
title, store, stock #, VIN — a row no car has anything for is dropped, and a row whose values
differ is set in bold — then one row per feature tag any of them has, ✓ or —. The link is read
defensively (VehicleCompare):
whole ids only, each once, the first 20 looked at; only cars still for sale render (on the
lot or in transit, not hidden), in the order given — a car sold or hidden since the link was
made drops out and the next car the link names takes its place — and once four are shown the
rest are trimmed, never an error. The note ("1 vehicle in this comparison is no longer available")
counts the cars the link named that dropped out — sold, pending, hidden or never a vehicle —
read in the link's order up to the fourth car shown (VehicleCompare::unavailableCount()); ids
after it are trimmed, not unavailable. The URL is rewritten to the cars shown. The page is
uncached and noindex; it is registered before inventory/{slug}, so the word is never read
as a vehicle.
Recently viewed
A strip of the cars this browser looked at last, at the foot of the search page and of every
vehicle page (widget/⚡recently-viewed.blade.php). The list is the viewer's own
(localStorage, newest first, 12 kept), so the server renders the strip empty on every
page — the response-cached vehicle page included — and the browser asks for the cards once
the strip scrolls into view, and only when it has a list. A vehicle page records its car and
leaves it out of its own strip. The ids come from the browser, so the server reads at most
12, whole numbers only, shows at most 8, and only cars still for sale; it hands back the ones
still for sale so the browser forgets the rest. Storage that is blocked, full, or holds
anything unexpected reads as an empty list — the strip just stays empty, and the Compare
boxes still work for the page they are on.
The keyword search (q — a stock number or VIN, an autocomplete pick, or the words a
plain-language reading kept as a keyword) is recorded to the analytics Search dashboard (source
automotive) once per new term. A search read wholly into filters is not recorded there — its
record is the plain-language log (see Plain-language search).
Landing pages (/inventory/used/toyota/camry)
Indexable search pages for a condition, a make and a model — /inventory/{condition},
/inventory/{condition}/{make}, /inventory/{condition}/{make}/{model} — for a make or a make
and model across every condition — /inventory/{make}, /inventory/{make}/{model} — and for
each store, /inventory/store/{slug} and the store in one condition,
/inventory/store/{slug}/{condition}. They are the ordinary search page (the same component, the
same filters, chips, Load more and phone sheets) with its filters taken from the path
(Support/InventoryLanding), plus a heading built from the path ("Used Toyota Camry for Sale",
the page's <title> and <h1>), a short intro written from the stock itself (how many, how many
stores, the price and model-year range), hub links (the next level down as clean paths — makes
on a condition page, models on a make page of either kind — the same car in the other
conditions, the all-conditions page (the make's or the store's) from a page in one condition, a
store's condition pages from the store page, and this page by trim, year, price tier and store
(a store's page by make instead of by store); only links that lead to stock), and ItemList
JSON-LD. The page-title row's breadcrumb shows the landing pages above it.
A make's all-conditions page is titled by what its stock is: "New and Used Toyota Tacoma for Sale" when it holds both (so it never reads as — or competes with — the condition pages), "New Toyota Tacoma for Sale" / "Used Toyota Tacoma for Sale" when it holds one (certified counts as pre-owned; that condition's page is then not indexed — see Which paths are indexed), and "Toyota Tacoma for Sale" with none in stock. Its intro says how the stock splits between new and pre-owned ("That is every condition: 12 new and 30 pre-owned.").
Which route. Each landing route's first segment is constrained to its own words — the store
words, the condition words, and for a make page the make spellings themselves
(InventoryLanding::makeRoutePattern(): each make's slug and squashed form and the vocabulary's
aliases, any letter case, a whole segment). They are disjoint from each other and from
compare, and all are registered before inventory/{slug}, so /inventory/toyota is the make
page while /inventory/toyota-tacoma-sr5 is still a vehicle. A vehicle slug that would BE a make
spelling, a condition word (any language, aliases included) or compare is never minted
(-vehicle is added — InventoryLanding::isReservedSlug()). A car minted with one before (a
make alone — "ram", "mini" — when year and model were missing) is re-slugged by the daily
automotive:prune-sold pass the way a new car would be (ram → ram-vehicle,
Vehicle::healReservedSlugs(); safe to re-run, and it runs whether or not pruning is on). Its
old URL stays the landing page — that route is matched first, so no 301 is possible — which lists
the car while it is in stock.
One breadcrumb trail. A landing page carries exactly one BreadcrumbList. The page-title
row's x-dl.breadcrumb usually writes it (Home › Inventory › Used › Toyota › Used Toyota Camry for
Sale) and, when it does, marks the request (Breadcrumbs::markJsonLdEmitted()). The landing's
own list (Inventory › … › this page, no Home) is pushed only when no list was written
(Breadcrumbs::jsonLdEmitted() is false) — a title row with no breadcrumb, a breadcrumb switched
off or not writing JSON-LD, or a page-title slot that resolves to nothing.
Which paths exist. A path answers 200 when it is curated: a condition word (new, used,
certified), a make from the vocabulary's canonical list, and a model the inventory carries now
or has carried (any vehicle row, whatever its status, or a pruned car's retired slug); a store in
one condition needs an active store and a condition word; a make page needs a vocabulary make
(and a carried model). Used lists every pre-owned car, certified included; certified lists
CPO alone. With nothing for sale today a curated page still answers 200, noindex, with the
zero-results alternatives (relax chips, similar models, Notify me — a guest's Notify me
subscribes the page's own search), so a model that sells out for a week keeps its links. Anything else — an unknown make,
a make outside the vocabulary (even one in stock), a model never listed, another make's model, a
junk word, a switched-off store — is a 404. Any other spelling of a curated path (a make, model or
store slug in capitals, chevy, landrover, Mercedes-Benz written differently, a condition
alias at a store) 301s to the canonical one, query kept; the condition and store words
themselves are matched in lower case only (/inventory/USED is a 404).
Which paths are indexed. A landing page asks to be indexed only while it lists at least
3 cars a shopper can buy (InventoryLanding::MIN_INDEXABLE; on the lot or in transit —
pending, sold and hidden don't count); below that it is noindex. A make page counts every car
of the make, whatever its condition. The sitemap lists exactly the landing paths that ask to be
indexed right now (InventoryLanding::sitemapPaths(), grouped queries over the live stock, counted the way the
page counts) — the make, make + model and store + condition pages under the same rule.
A condition page that narrows nothing is not indexed either. When one condition holds every
shoppable car of its all-conditions page — a make's (or make and model's) stock all pre-owned, a
store's all new — the condition page (/inventory/used/toyota, /inventory/used/toyota/camry,
/inventory/store/{slug}/used) shows exactly those cars, so it is noindex (still
self-canonical, still followed) and left out of the sitemap, and the all-conditions page
(/inventory/toyota, /inventory/store/{slug}) — the URL that stays put whichever condition the
stock is in — stands for them (InventoryLanding::duplicateOf(), read by isIndexable();
sitemapPaths() applies the same rule, so the two never disagree). It is indexed again as soon
as the condition narrows something (one new Toyota on the lot).
The canonical is the clean path itself. Its query-string twin
(/inventory?condition[0]=used&…&make[0]=Toyota) and a landing path with more filters on top
(/inventory/used/toyota?body[0]=SUV, /inventory/toyota?condition[0]=new) are filtered views
and carry noindex, follow, so neither competes with the clean path. /inventory?make[0]=Toyota
— a make with no condition — is the make page's query twin, and
InventoryLanding::urlForCriteria() links that search to /inventory/toyota.
Changing filters on a landing page. Filters other than the path's own are added as query
parameters on the clean path (Livewire is told the path's values are the URL's defaults, so it
never writes ?condition[0]=used onto /inventory/used; the path wins over the same filter in
the query string). Changing the path's own condition, make, model or store makes it a
different search, so the page sends the shopper to that search's URL —
InventoryLanding::urlForCriteria(): another landing path when the new search is one (unticking
Camry on /inventory/used/toyota/camry lands on /inventory/used/toyota, unticking Camry on
/inventory/toyota/camry lands on /inventory/toyota, unticking the store on
/inventory/store/{slug}/used lands on /inventory/used), else /inventory?… (two makes, a
condition set no path lists, a store and a make together). Ticking a condition on a make page is
not a change of the path's own filter: it stays on /inventory/toyota?condition[0]=new. Links
that point at a search — the zero-results similar models, a pruned car's retired-slug 301, the
Browse by Make and Shop by Brand rows' make links — go through the same builder; a store's
"inventory" link (Location::inventoryUrl()) and a sold car's 301 build the landing path
directly. All of them land on the clean path where there is one.
Language. The condition and store path words are per language
(InventoryLanding::CONDITION_WORDS, STORE_WORDS), and so, under a language prefix, is the
inventory word itself (INVENTORY_WORDS): /inventory/used/toyota is
/es/inventario/usados/toyota, new / certified / store are nuevos / certificados /
agencia, and the inventory word is inventario under /es/. /inventory/toyota/camry is
/es/inventario/toyota/camry (makes and models are not translated);
/inventory/store/{slug}/used is /es/inventario/agencia/{slug}/usados. See Spanish above.
Landing pages are response-cached for guests. Every inventory write clears the response cache:
the feed sync once per pass, and an Eloquent vehicle save or delete, the dashboard's bulk
mark-sold / delete and a CSV import (once for the whole file) through
InventoryVersion::forgetCachedPages() — so a dashboard price edit or a car marked sold reaches
the cached vehicle and landing pages at once, not when the cache expires.
Detail pages (/inventory/{slug})
Response-cached like product pages; what the page shows beyond photos and specs is decided in
Support/VehicleDetail. Gallery with lightbox (the hero is the LCP element and gets an eager
Glide srcset for media-library photos), then:
- Price lines — every amount says what it is. MSRP (struck through only when it is above the
price), the price ("Sale price" beside a higher MSRP, else "Price"; "Call for price" without
one), the saving off MSRP, a live price drop with what it was, and the estimated payment with
its assumptions (the same
PaymentEstimatorfigure the cards and the monthly-budget filter use; none on a sold car). Dealer cost (invoice_price) is never shown. - Condition, sale-pending and in-transit badges, and the history the feed reports (1-Owner / N owners, no accidents reported, clean title, personal use) — never on a new car.
- Free CARFAX report — the standard dealer link built from the selling store's own
carfax_partner_codeand the car's VIN (a real 17-character one); not for a new car, not when the store has no code. Window sticker link from the feed's URL. Both open in a new tab. Any URL from the feed is linked only when it is an http(s) address. - Description, a specs grid (mileage, condition, the feed's own body wording, transmission, drivetrain, fuel, MPG, engine, colours, doors, seats, VIN, stock #), the features checklist.
- Price history — an inline-SVG step chart (no chart library) of the prices the car has had, oldest first, ending at today's, with the dates on the site clock; hidden when there is only one price.
- Video — a YouTube, Vimeo or Loom URL embeds through the cookie-consent placeholder pattern
of
x-dl.videowhen consent is on (the iframe carriesdata-srconly; the poster is the same-originEmbedThumbnail; nothing loads from the provider before consent), and as a plain lazy iframe when consent is off. Any other video address is a "Watch video" link. - Payment estimator (
widget/⚡payment-estimator) — the car's price with the shopper's own down payment, term and credit tier, recomputed byPaymentEstimatoron the server (one maths); a down payment that covers the price says so rather than "$0/mo". Hidden when estimates are off, the car is sold or there is nothing to finance. Its foot carries Get pre-qualified — the soft pre-qualification lead (see Lead capture) — and, when the store has afinance_url, Apply for financing at {store}, a link out to the store's own lender portal. - Store card — name, address, every department with a phone (sales falls back to the main
line), the weekly hours on the store's own wall clock labelled with its timezone ("Hours
(Eastern Time)"), directions, the store page and the store's inventory page. Open now /
today's hours depend on the moment and the page is cached for days, so they come from a lazy
island (
widget/⚡store-status) on every view, on the store's own clock; a department that never posted hours says nothing. The links row adds Apply for financing (the store'sfinance_url, http(s) only). - The fine print: the selling store's own disclaimer first (advertised-price rules differ by
state), then the site's. The store's text prints once on the page: while the inquiry form
shows, it sits under the form (
data-lead-disclaimer) and the fine print below the car leaves it out; once the form is gone — the lead sent, or the car sold — the fine print carries it. The site's disclaimer always prints in the fine print (and a site disclaimer word for word the same as the store's is not printed a second time). - Share links, the inquiry form (its card carries the
#check-availabilityanchor a result card's call to action lands on; the call/text consent box appears once a phone number is typed), a similar-vehicles rail on the shared result card with a "See all" link to the landing page the car belongs on, then the shopper's recently viewed strip (see above). - Similar vehicles (
VehicleDetail::similar(), four cars on the lot — never in transit, pending, sold or hidden — and never the car itself), best match first: the same make + model in the same condition › the same make + model in any condition › the same body type within ±25% of the price (VehicleDetail::SIMILAR_PRICE_BAND) › the same make within ±25% › the rest of the lot, so a small lot still fills the rail. Inside each tier the nearest price leads (a car with no price last), then the lower id. A car with no price has no band, so its body-type and make tiers take any price; one with no make or model skips the tiers that need them. It is one ordered query with no randomness, so a cached page and a fresh one show the same cars. - Offers on this model (
data-vehicle-offers, just above the similar rail) — the live lease / APR / cash specials this car qualifies for: its make, model, trim, condition and store (see Specials & offers), each with its headline number, terms, end date and fine print. Never one past its end — the read checks the end itself, and the expiry sweep clears the cached page the moment an offer comes down.
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, for automotive.sold_retention_days (default 60; 0 = forever).
After that the URL 301s to the same make and model — its landing page
(/inventory/{its condition}/{make}/{model}, else /inventory/{its condition}/{make}, else the
ordinary search for an uncurated make), and the daily
automotive:prune-sold pass (which also re-slugs a car a landing path hides — see Landing
pages) deletes the row, keeping its slug in auto_retired_slugs so the
redirect outlives it (a retired slug doesn't remember the condition, so it lands on the make and
model's page in every condition — /inventory/{make}/{model}, /es/inventario/{make}/{model}
under /es/ — never a condition page that could call a new car used). A sold vehicle a lead still points at is kept (its page redirects all the
same) — the lead is the dealer's record of who asked about which car. Only feed vehicles
are pruned: a car the dealer entered by hand is never deleted, though its page redirects past
the window like any other. Hidden vehicles and unknown slugs 404.
Each page emits schema.org JSON-LD built by VehicleSchema: a Car (the type search
engines read vehicle listings from) with vehicleConfiguration (the trim plus the feed's own
body wording, "LT Crew Cab Short Box"), bodyType, and an Offer at the price the page
advertises (VehicleDetail::advertisedPrice(), the one the ad feeds read — a car with no price,
or $0, is "Call for price" on the page and has no Offer, never "$0, in stock") whose
seller is the rooftop as its own kind of business (AutoDealer for a dealership — name, address, sales phone,
geo, and its store page on this site, else its own website) with availableAtOrFrom pointing
at the same node. Availability follows the status, never collapsed to in stock / sold out:
available → InStock, pending → LimitedAvailability (a car with a deal pending is still for
sale until it closes), in transit → PreOrder (it can be reserved before it arrives), sold →
SoldOut. Plus canonical/OpenGraph meta (OG image = first photo).
Available vehicles are listed in the sitemap alongside the search page and the indexable
landing pages (in-transit, sold, pending and hidden are not). Their lastmod is the last real change, never updated_at: every nightly feed pass
upserts every row and bumps updated_at, which told search engines thousands of pages changed
every night (and teaches them to ignore lastmod). A feed vehicle's lastmod is the latest of the
feed's own modified stamp, its last price change, and when it first appeared; a hand-entered
vehicle's is its last edit; never in the future (Vehicle::sitemapLastModified()).
Specials & offers
Lease, APR, cash-back offers and service coupons are a seeded content type (specials), not
a module table — the CLAUDE.md rule for a content-type-shaped capability. The type is an entry in
SeedDemoDataJob::demoContentTypes() gated by the automotive flag (requires_feature, the
Events / Job Listings precedent), created when Automotive is switched on
(FeatureActivator::runPostEnableHooks() → Specials::ensureType(); an install that already had
Automotive on gets it once from the expiry sweep, and a type an admin deletes on purpose stays
deleted). It makes no public pages; offers surface through the Specials row and the vehicle
page. Like every seeded type it names its schema.org type — CreativeWork, the offer's write-up
(a lease deal is neither a Product nor an Event) — emitted only if an admin gives the type pages
of its own. Offers are edited in the ordinary content editor (Automotive → Specials, or Content →
Specials), so drafts, scheduled publishing, the featured image, revisions and translations are the
content system's own.
Fields: title · offer type (Lease / APR financing / Cash back / Service coupon) · offer
ends (expires_at, a datetime field on the site's clock; tick All day to run through the
end of that day; an import that stored a plain string with a time — "2026-11-30 09:00" — ends at
that time, and the card says so: the moment it comes down and the card's "Offer ends …" label
come from one reading, SpecialOffer::endOf()) · amount (the headline number — 299 → "$299/mo"
for a lease, 0.9 → "0.9% APR", 2500 → "$2,500 cash back", 39.95 → "$39.95 off"; rounded to cents
before choosing whole dollars or cents, so 299.999 is "$300", never "$300.00"; words are shown as
typed) · term (months) · due at
signing (lease) · make (the canonical make list) · model · trim · condition (none ticked = any) ·
stores (store pages — a rooftop's linked locations page; none = every store) · disclaimer;
plus the type's excerpt (the short line on a card). The text a card shows translated (title,
excerpt, disclaimer) follows the content item's translations bag; the amount prints as typed in
every language (SpecialOffer::headline() reads the stored value), and model / trim only choose
the cars.
A special's title, short description (the excerpt) and disclaimer are machine-translated by
the Automotive translation pass while the Translate vehicle descriptions switch is on
(SpecialOfferTranslation, run by the automotive-translate-vehicles task — see Spanish):
every live special (never a draft, a scheduled or an ended one) whose field has no
translation in a site language, first in each pass — for each special and language the
disclaimer, then the title, then the short description, so a pass the daily cap cuts short never
leaves a Spanish headline over English fine print — one call per field per language, each
counted against the same daily cap, and stamped with the English it came from. The write is a
quiet, conditional update of the translations alone (no updated_at bump, no publish hooks)
that lands only while the English is still the text translated, the field still has no
translation and nobody has saved the special since it was read; an empty field, one over 5,000
characters and one holding template syntax (either side) are never translated, nor is a short
description the type has switched off (its excerpt column keeps the old text). A field with no
translation shows its English on the Spanish page, never a blank. The pass buys missing
translations only: one someone typed in the special's Translations panel is never replaced, and
an edited field turns stale — under the default render policy the Spanish page shows the English
— and the site's own translation upkeep (the hourly translations:sync, Settings → Languages →
Front-End → Translation upkeep) translates it again. Saving a published special with any of the
three to fill launches a pass. While the site's "translate new content automatically" is on, a
save launches none and the Automotive pass never buys a special's texts at all: the site's
publish-time translator (which translates the whole special as it is published) and its nightly
fill own them, so nothing is paid for twice or written by two translators at once — the pass
still translates vehicle descriptions. The pass reads the live specials at the head of every
step, so a special published while a long pass walks the cars is translated on that pass's next
step, not after it. The rest of a special's text is not bought by this pass and is not shown
translated: amount words print as typed, and model / trim only match cars.
What is live (Support/Specials::live()): published, its publish time come, not past its
end, and at a store that still exists. Every surface reads through it — the row's preset, the
vehicle page rail, the has_offer search filter — so an ended offer never shows even before the
sweep runs. One reading (one query for the specials, one for their store names) serves until the
soonest of the next live offer's end, the next scheduled offer's start, and 60 seconds — a web
request reads once, while a long-lived process (a CLI task, a worker) still picks up an offer
whose publish time arrives, or a write another process made. automotive:expire-specials
(Lazy Cron, every 5 minutes, feature:automotive) then sets an ended special to unpublished
— walking the specials by id, never by offset page, since it changes the status it filters on —
and drops every response-cached page (a guest page cached before the offer ended would keep it
for days).
Who it is for (SpecialOffer::matchesVehicle(), with an SQL twin,
Specials::constrainToOffers(), that a test holds to the same answer car for car, raw spellings
included): a lease / APR / cash offer matches a car whose stored make, model and trim equal the
offer's (its make canonical), compared trimmed and case-blind on both sides — so a padded model or
a lower-case make left by a raw import gets the offer on the vehicle page and in the "Has a
special offer" filter alike — plus any ticked condition, at one of its stores. A service coupon is on no
car. Store limits fail closed: an
offer naming stores whose pages are all unlinked, or whose stores are all switched off, runs at
no store — never every store; deleting the only store page an offer names takes the offer
down first (the relation's pivot row would otherwise cascade away and widen it to the group).
Changes reach every surface: saving or deleting a special bumps
automotive.inventory_version (the "Has a special offer" facet counts are cached under it) and
clears the response cache.
Data source — Specials & offers (auto_specials): filters kind of offer (every kind /
vehicle offers / one type), make, store (one store, or This page's store — the store
behind the page's record; an offer limited to other stores never shows there, and a page about no
store shows none); tokens {item.auto.title}, {item.offer_type} (Lease offer…), {item.headline},
{item.detail} (for 36 months · $2,999 due at signing), {item.summary}, {item.expires}
("Offer ends Nov 30, 2026", site clock), {item.stores} (empty at every store), {item.disclaimer},
{item.auto.image}, {item.inventory_url} (the offer's condition / make / model landing page —
the make's all-conditions page when the offer names no condition, /inventory/toyota/camry —
else the filtered search; empty for a coupon).
Plain-language search reads deal(s), specials, special offer(s), incentive(s), rebate(s),
lease deal(s)/special(s) and oferta(s), en/con oferta, promoción/promociones, especiales as
has_offer (the parser and the AI prompt, since NaturalSearchAi::PROMPT_VERSION 2).
Ad feeds (Google Vehicle Ads, Meta automotive inventory)
Dashboard → Automotive → Ad feeds (admin). Turn on ad feeds mints a secret and shows two
links — /auto/ad-feeds/{token}/google.csv and /auto/ad-feeds/{token}/meta.csv — to paste into
Google Merchant Center and Meta Commerce Manager as scheduled feeds.
- Specs (read 2026-11-05, recorded in
Support/AdFeeds's docblock): Google's vehicle ads primary feed (support.google.com/merchants/answer/11192663) and Meta's vehicle catalog fields (developers.facebook.com/docs/marketing-api/auto-ads/reference). - What is listed — shoppable stock only (on the lot or in transit; never pending, sold or hidden) that a network can advertise: a price, a 17-character VIN, a photo of its own (never a store's shared branding slide), mileage on a used car (a new car with none lists 0), a Google store code and an exterior colour (Google) or an address on the map (Meta, where a car with no colour lists "Other"). Every car left out is counted by reason on the page.
- Parity with the vehicle page — the price is the page's own advertised price
(
VehicleDetail::advertisedPrice(), never the MSRP in its place), the link is the page's canonical URL (VehicleDetail::canonicalUrl(), which the page's<link rel=canonical>also uses; Google'slink_templateadds?store={store_code}), and mileage and VIN are the spec strip's. Dealer cost (invoice_price) is never read. - Google store codes — Google lists each car at its store, so each store needs its Business
Profile store code (case-sensitive, saved exactly as typed) on the Ad feeds page; cars at a store
with none are left out of the Google feed.
vehicle_fulfillment(option:store_code)is writtenin_store:{code}. Meta reads each store's address and map pin instead. - Rebuilt by the
automotive-ad-feedstask: at the end of every feed pass that changed inventory (beside the photo pass), once per request after dashboard vehicle saves / bulk actions, after a store-code save, and daily (automotive:build-ad-feeds). One build at a time (a change mid-build joins it, and the build goes round again — at most three rounds — when the inventory moved under it). Each file is written to{network}.csv.buildingand renamed into place, so a fetch mid-rebuild gets the whole previous feed; a step killed part-way re-runs from its saved cursor with the file cut back to its saved length. - The token is a site-issued secret: stored encrypted at rest (its Setting key carries
token), compared in constant time, rendered on the page from a method (never a Livewire property, so never inwire:snapshot). Make new links rotates it — the old links stop at once; Turn off ad feeds clears it and deletes the files. A wrong token, feeds switched off, an unknown network and a feed not built yet all answer the same 404. The route is rate-limited (30/min), never response-cached, servedtext/csvwithX-Robots-Tag: noindex, nofollow.
Lead capture
AutomotiveLeads::capture(string $type, array $attributes): Lead is the single capture path
(mirrors RealEstateLeads) — every lead form comes through it, and any new lead source (a chat
tool) must too, so routing, language, consent and the CRM forward apply to all of them alike. Types:
vehicle_inquiry, test_drive (the detail form's intent select), trade_in (the sell-your-car
widget), prequalify (the pre-qualify form), general; each maps to a store department
(Lead::TYPE_DEPARTMENTS — all sales today). Attributes: name, email, phone, message,
vehicle_id, location_id (a store the visitor picked — a vehicle's store always wins),
payload, member_id, marketing_opt_in, and the newer language (what the caller already
knows — a chat), contact_consent (['granted' => bool, 'text' => the exact wording shown]) and
page (the page the form is on, when the caller knows it better than the request).
In the shopper's request it only stores — the lead and its forward ledger in one transaction
(a stored lead always knows where it is going), the analytics lead goal, the CRM contact +
timeline interaction with the typed source automotive:{type} (re-firing source automations on
recapture; the contact gets the lead's language, fill-once), and the marketing opt-in checkbox
(subscribes + adds to the "Automotive Leads" CRM group; shown only when the Marketing feature is
on). Everything that talks to a provider runs afterwards as the automotive-lead-forward task —
see Forwarding below.
Language. auto_leads.language is read from the shopper's own words when they say it clearly
(the core local detector), else the language of the public page they are on, else the site's base
language (LeadTranslation::languageAtCapture()); a caller that already knows it (language)
wins. Recorded whatever the Customer Messages mode — it is a fact about the shopper.
Identity data never lands. Lead forms never ask for an SSN, a date of birth or an income (the
credit application is the store's own lender portal, finance_url). Every lead's message is
scrubbed on capture (SensitiveLeadText::scrub()) before anything stores, logs, emails or posts
it: an SSN shape (3-2-4 digits with the same separator twice or none, standing alone — never a
ZIP+4, a phone number or a VIN) and a date right after a date-of-birth word ("DOB", "date of
birth", "born", "fecha de nacimiento"…) become [removed]; a bare date (an appointment) is left
alone.
Call/text consent. Every lead form that asks for a phone shows, once a number is typed, an
unticked box: "Yes, {store} may call or text me at this number about my request, including by
automated means. Consent is not required to buy. Message and data rates may apply; reply STOP to
opt out." (LeadConsent::text() — the vehicle page and the pre-qualify form name the selling
store; the trade-in form, which can go to any store, names the business as a whole). A lead with a
phone keeps contact_consent: version (LeadConsent::VERSION, bumped when the wording
changes), granted, the exact text shown, phone, locale, at, ip, user_agent and
page. An unticked box still sends the lead — consent is not a condition — and is recorded
granted: false; a caller that showed no wording can never record consent. The dealer email, the
ADF comments and the Leads drawer all say which it was.
Pre-qualify. widget/⚡prequalify-form (at the foot of the vehicle page's payment estimator;
mountable with a locationId and no vehicle) is a soft lead: name, email, optional phone,
monthly budget, optional down payment and an optional note — no SSN, birth-date or income
field, and a note holding an SSN- or DOB-shaped value is refused with a message saying why. The
budget rides in the payload (monthly_budget, down_payment) and the ADF copy carries it as
finance terms. It sits beside the store's Apply for financing link; on a one-store site, a
band placed on a page about no store shows that store's lender link and fine print.
Fine print. The selling store's disclaimer shows under the vehicle page's lead form and the
pre-qualify form (once it is opened), and under the trade-in form for the store picked (or the
only store). On the vehicle page the lead form's copy stands in for the fine print's while the
form shows, so the page prints the store's text once (see Detail pages).
Every lead knows its store. A lead about a vehicle belongs to the vehicle's store, whatever
else was sent; a form with no vehicle can pass the store the visitor picked (the trade-in widget
offers a Preferred store select once there is more than one active store). Where it goes is
decided by one pure resolver, AutomotiveLeads::notificationEmails(?Location $location, string $department = 'sales'): list<string> — any new sender calls it rather than deciding again:
- the store department's own lead inbox,
- else the store's main email,
- else the group's addresses (
automotive.lead_notification_email), - else every Admin-or-above user — never nobody, and never a noreply fallback.
A lead that names no store — a pre-qualify band on a page about no store, a chat question, a
trade-in with no store picked — goes to the store when the site has exactly one active store (an
inactive store doesn't count; AutomotiveLeads::soleActiveStore()), so its email, its ADF copy and
the Leads page's store column all name that store. With two or more active stores it starts at step
3, as does a lead whose store is deactivated. Upgrading a single-lot install: the default lot has no
email, so nothing changes until someone gives the lot one — from then on that lot's leads, trade-ins
included, go to it ahead of the Settings list. The dealer email names the store.
The public forms carry a honeypot and a per-IP rate limit (5 per 10 minutes); CRM, analytics and forward failures are reported but never break the visitor flow.
Nothing private reaches the browser. Every public method on a Livewire component is an endpoint
a visitor can call ($wire.…), and Livewire 4 refuses lifecycle hooks, computed properties and
non-public methods but not with() or placeholder(). Shopper-facing components never send store
inboxes, CRM intake addresses or a car's raw feed record to the browser: their view data stays on
the server (the KeepsRenderHooksServerSide trait refuses a browser call to with / placeholder),
and any helper that returns a store or a member's record is not public. A new widget or public page
must use the trait — tests/Feature/Security/AutomotiveWidgetsLeakNoPrivateDataTest.php fails until
it does, and it calls every browser-callable method it finds by reflection. The refusal is now also
enforced app-wide by App\Support\Security\LivewireServerOnlyMethods (registered in
AppServiceProvider::boot()): a Livewire::listen('call', …) listener refuses a browser call to
with, placeholder or a legacy get{Name}Property on every component, with the error a missing
method gets, while the server's own render and lazy load still call them directly. The trait stays
as a second layer. Locked by tests/Feature/Security/LivewireRenderHooksNotCallableTest.php, which
also sweeps every visitor-facing module component that declares one of those names.
Forwarding: the dealer email, the CRM copy (ADF/XML), the webhook
LeadDeliveries::plan() writes the lead's ledger (auto_lead_deliveries) at capture: one
email row per address notificationEmails() names, and one adf row per CRM address —
the store department's adf_email (Locations → Departments), else the group's
automotive.adf_email, else ONE not_configured row, so a store with no CRM address shows "CRM
not configured" on the lead instead of being silently skipped. Only valid addresses count; a
deactivated store's go to the group.
The automotive-lead-forward task (LeadForwardTask, one per lead — idempotency key
automotive-lead-forward:{lead id}) then, each step:
- translates the message into the team's language when Customer Messages is set to
translate automatically (bounded by
LeadTranslation::ARRIVAL_BUDGET_SECONDS, counted against the daily cap, paid once however often the task runs). In Translate-button mode, or with AI off or failing, nothing blocks: the copies carry the original and say no translation is available; - announces the lead once to webhooks (
automotive.lead.created, below) — an announcement that could not be written never holds the sends back: they go out, then the step fails retryably so the announcement is tried again; - sends the ledger's due rows one at a time — each claimed (
pending|failed→sending, one conditional UPDATE), handed to the transport, and markedsentbefore the next is touched. Once a pass is through it looks again, so a Resend pressed while the task runs (it joins the lead's one task) goes out in that run rather than waiting for the retry clock.
The dealer email (LeadEmails::dealerNotification(), composed under
MailLocale::staff(...)): the lead's details and consent, then the message — for a lead in
another language, the translation first with "Translated from Spanish" and the shopper's own
words under it (the form-submission owner-email layout), or the words with why they are not
translated — and links to the vehicle and to the lead in the dashboard.
The CRM copy (AdfLead::xml(), sent as a plain-text email through
CampaignMailer::sendText() — dealer CRMs parse the text/plain body; it uses the same transport as
every other module email and works with the Marketing feature off): ADF 1.0, in the DTD's
element order with every element it requires — a CRM that validates against the ADF 1.0 DTD
rejects the whole lead otherwise (a content-model test pins it against the strict DTD).
<?adf version="1.0"?>, <prospect status="new"> with <id sequence="1" source="{site host}">, <requestdate> (ISO-8601
with the site's offset), the vehicle of interest (interest="buy|test-drive", status="new|used",
year/make/model/VIN/stock/trim/doors/body style/transmission/odometer/colours, asking price, then
a pre-qualify budget as <finance> amounts, then a "Certified Pre-Owned" comment on a certified
car — finance before the vehicle's comments), the shopper's car as
<vehicle interest="trade-in"> on every trade-in lead (whatever of it they gave — an unknown
year, make or model is written empty; it is the lead's only vehicle when the trade-in is about no
car of ours, since a prospect needs one) — no <condition>: the ADF 1.0 spec text gives the
trade-in grade an element after the odometer, but the DTD leaves it out of vehicle's content
model, so a strictly validating CRM would reject the lead; the grade rides in the comments
instead —, <customer><contact> (name parts — a contact always has
a name, empty if never given —, email, phone; no address: an address needs a street and the
forms ask for a ZIP at most) and <comments>, <vendor> (the store: its first dealer code, name, website, a
contact with the sales email — else the store's — and the sales phone — else the store's main
number —, falling back to the business's public number (business.phone) when the store has
neither, else an empty <phone>, since a contact needs an email or a phone; an address only when
the store has a street; a lead with no store names the business — the dealer name, else the site
name — with no dealer code or website and the business's public number) and <provider> (the
site, WebProCMS, and the vehicle page — or, for a lead about no car, the page the form was on
when the consent record has it, else the home page). Any other lead about no car (a general
question, a pre-qualify with no car picked) carries an empty vehicle of interest (no status,
empty year, make and model). Everything goes through XMLWriter after AdfLead::clean() removes
what XML 1.0 cannot hold (control characters, invalid UTF-8, lone surrogates), so no comment can
break the document. ADF has no language element and CRMs show comments verbatim, so
both languages live in <comments>: lead type, store, the shopper's ZIP ("ZIP code: …"), a
trade-in's condition ("Trade-in condition: Good" — only on a trade-in lead, and only for one of
the four grades the form offers), budget and consent lines, then
"Preferred language: Spanish (es)", the team-language translation, ---, "Original (Spanish):"
and the shopper's words — or, with no translation, the original and "The customer wrote in
Spanish; no English translation is available." The labels are English: the document is composed
under MailLocale::staff() with no one recipient, so the team language governs only the
translation.
Never twice, never dropped. A run killed between handing a row to the transport and marking
it leaves sending; once the row has sat there 10 minutes (LeadDeliveries::STRANDED_MINUTES)
the next run cannot know whether it went out, so it marks the row unconfirmed and does not
send it again — a duplicate lead in a CRM is the worse outcome (SendOnce, the Send-button
guard, is a short cache memory a killed process forgets; the ledger row is the memory). A younger
sending row is left alone: it may be another run's send still in the transport's hands, and
relabelling it would leave that run's failed mark (which matches sending only) nothing to
match — a refusal recorded nowhere and never retried. A refused send is failed and retried on a backoff (5 min, 15 min, 1 h,
3 h, 6 h) by the automotive:forward-leads Lazy Cron (every 15 minutes; it also picks up a row no
run has touched for 10 minutes), up to LeadDeliveries::MAX_ATTEMPTS (6) sends — then it stays
failed with its error until someone presses Resend.
The webhook. automotive.lead.created (Webhooks module) fires once per lead, after the
arrival translation: auto_leads.webhook_dispatched_at is claimed by a conditional UPDATE in the
same transaction that writes the webhook's own delivery rows (one per subscribed webhook; none
when webhooks are off, and the claim still lands), so a retried step, a Resend or the cron re-launch
never announces it again — and switching webhooks on later never replays old leads. The rows are
written through Webhooks::dispatchOrFail(), never the swallowing dispatch(): a payload that
cannot be built or a delivery row that cannot be written rolls the claim back. The store's copies
still go out, then the step fails retryably, so the runner tries the announcement again (its usual
three attempts — then the task is failed with the error). Payload:
id, type, name, email, phone, language, message (original, language,
translation — null when there is none —, translation_language, translated,
needs_translation), store (id, name, dealer_codes), vehicle (id, year, make, model,
trim, VIN, stock number, condition, price, url), details (the typed payload),
contact_consent, adf (the same ADF/XML document), dashboard_url, created_at.
AI chat
While the AI Chat feature and the Automotive feature are both on, the chat assistant gets four
dealership tools (Chat → Settings → Search vehicle inventory, ai_chat.vehicles_enabled,
default on — Support/ChatVehicleTools, offered by ChatToolbox):
search_vehicles— structured filters throughVehicleSearch(the same validation an AI search reading gets: a value the site has no such thing as is reported inignored_filters, not searched), a store / town / ZIP throughSearchPlaces(a store narrows to that store, pinned or not, with no distance — so does a store's name inquery; for cars near a store the bot gives the store's town; a town is a distance, 30 mi by default (radiusapplies to a town or ZIP); alocationit can't place — a town with no state that no store can reach included — is reported inignored_filters, every store is searched, and a note tells the bot the place is not a store, ZIP or town within reach of the stores and to ask which state a town is in). A filter on a dimension too few cars list — seats, doors, highway MPG, cylinders, engine, drivetrain, fuel, transmission, colour; the same 50% rule as a search reading (see Sparse dimensions under Plain-language search) — is left out of the search and named innot_filtered, with anot_filtered_notetelling the bot it would have hidden most of the stock and to suggest checking each car's details if it matters to the visitor. Shoppable cars only — on the lot or in transit, each with itsstatus("in transit — arriving soon"); never sold, pending or hidden (FM31). Each car carries the exactprice("Call for price" when none) andestimated_payment— the card's ownVehicleCard::payment()with the visitor's down payment — to quote, its store and its URL; plustotal,browse_urland aprice_note(the site's own vehicle disclaimer from Settings — not a store's — else "Prices exclude tax, title, registration and dealer fees…").vehicle_details— one car by stock number, VIN, slug or page URL (never a hidden one): a sold or pending car comes back as such, with no price and a link to similar cars; an available one with its price lines, specs, features, history and its store — department phones, today's hours and open-now on the store's clock, the service scheduler and finance links.list_locations— active stores, by name / town / state / market / brand, matched on whole words only ("Ford" is not Stratford, "Ram" not Framingham). While the vehicle tools are on and at least one store is active (ChatVehicleTools::ownsStoreFacts()), it is the chat's one tool for store facts: the AI Chat feature's content-typeget_locationsand its prompt guidance are not offered beside it (both claimed to be the only source of addresses and hours, and only this one has the department phones and the store's own clock). AI Chat → Settings says so under Look up location details on such a site: the switch has no effect there, and a note (data-chat-locations-answered-by-stores) points to Automotive → Locations — worked out with the sameownsStoreFacts()test, so the note and the toolbox always agree. With no active store, or the vehicle tools off,get_locationsis offered and the note is not shown.request_vehicle_lead—vehicle_inquiry/test_drive(a shoppable car, so the car's store) orgeneral/prequalify(the store the visitor chose, else the group), throughAutomotiveLeads::capture()— so store routing, the conversation'slanguage, translation, the dealer email and the ADF/CRM copy all apply and the tool sends no mail itself (FM32).payloadrecordschannel: chat, the conversation's uuid, the preferred time when given (asked for a test drive) and, for pre-qualify, the monthly budget and down payment. A request with a phone number returnsneeds_consentwith the exactLeadConsent::text()wording in the conversation's language (resolved under the conversation'slanguage, so a Spanish chat gets thelang/es.jsonwording and the bot never translates it) until the bot sends a real answer inphone_consent— a boolean, or the word "true" / "false"; missing, null, blank or anything else is no answer (a strict function-calling provider fills every optional parameter, so anullon the first call means the visitor was never asked). The lead records consent only when that wording appears in an assistant turn of this conversation and the visitor agreed — otherwise "not consented" (the request is still sent). A yes or no to wording the conversation never showed — a paraphrase, or the bot's own translation — is not an answer: the tool returnsneeds_consentagain, telling the bot to quote the wording word for word on its own line. After two such re-asks in one conversation (CONSENT_REASKS, counted per conversation in the cache) the request is sent as not consented, so it is never lost; a no after the wording was shown is sent at once. The stored consent record keeps that wording (when it was shown) and the conversation's language as itslocale. The same request from one conversation within 30 minutes is sent once; 5 chat leads per visitor per hour.
Every URL carries the conversation's language prefix and words (/es/inventario/…). The system prompt adds
a dealership block: use these tools for inventory, quote prices, estimates and availability
only from their results, say in-transit cars are arriving, never offer a sold or pending car,
say prices exclude tax/title/fees, never promise financing.
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 (inline — a member's
request never reads the response cache, and the cached landing pages hold no visitor state) 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). A car staff hide drops off this page, the Vehicle Alerts page and the account dashboard's counts until it's shown again — the saved row and the watch stay. - 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's "Notify me") — for a member — stores the current filter set inVehicleSearchlanguage with a name + frequency (instant/daily/weekly) and the site language it was saved in. The name starts as the search in a few words — up to two makes, up to two models, up to two trims, the body type when no make is ticked, the condition when only one is, and the price range or monthly budget ("Toyota · Tacoma · SR5 · Up to $15,000"; "All inventory" when nothing is set); a member can change it in the Save search box, and a guest's Notify me (below) takes it as is. So does a guest's toolbar Save search. The alert emails print it. 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. Each search is re-read just before its email goes out (it must still bedeliverable()), so one deleted — on the Guest alerts page, say — or stopped while a run is under way is not mailed. Both alert emails send through CampaignMailer using the Settings sender/reply-to and link back to the vehicle/search plus the manage page. - Notify me / Save search without an account — on a zero-results search ("Notify me"), and
from the results toolbar on any search ("Save search", which for a guest opens an email-only
box instead of sending them to sign in — a "Sign in" link keeps that path, back to the same
search), a visitor who isn't signed in can leave an email address for an email-only saved
search (daily). Both forms are one path (
subscribeGuest()on the search page) — one row, one per-visitor limit, one honeypot, the same caps and language — so a guest can't double up by using the other form. The toolbar box refuses a search that narrows nothing — no filters, only a sort, only a ZIP or location with no radius (or a radius around a ZIP no dataset knows), only payment inputs — and says why ("Pick a make, model, price or another filter first …"); the zero-results Notify me takes any search. A signed-in member never sees the box (they get their own Save search). It is confirm-first (GuestSearchAlerts): the row is stored unconfirmed and the address gets exactly one email, the confirmation (naming the search by its default name — trim included, so a Tacoma SR5 alert never reads as any Tacoma, and its price range in the chip's words, "$20,000 and up" — with the one-tap stop link andList-Unsubscribeheaders). No alert goes out until the address confirms (SavedSearch::scopeDeliverable()), so typing someone else's address sends them one email and nothing more. The same address asking for the same search again — a double click, another tab, different capitals, the filters in another order — is the same row (criteria_hash, backed by a unique index that settles a race); a repeat inside 10 minutes sends nothing, a later one re-sends with a new link (the older one stops working); one address can be sent at most 3 confirmations a day and hold at most 10 such searches — counting only the ones it still holds, confirmed or waiting on a link still in date, so a stopped search or one whose link expired unconfirmed frees its slot; both forms carry a honeypot and share one per-visitor limit (5 per 10 minutes); and whatever happened — sent, already waiting, already subscribed, stopped, at the cap — the page says the same thing, so it can't be used to find out who is subscribed. Only the SHA-256 of the confirmation token is stored; the raw token is only in the email. The link (auto/alerts/confirm/{search}/{token}, throttled) opens a page that asks — its button confirms through a Livewire action (normal CSRF, no exemption), so a mail scanner fetching the link confirms nothing; confirming twice is harmless, and the link expires after 7 days. A forged, mistyped or superseded link and an expired one read the same, and only a genuine link's page names the search. Stopping uses the same signed stop link as member alerts, and stopped means stopped: the form — which anyone can fill in with anyone's address — never mails a stopped search again, not even a confirmation (it answersSTOPPEDand sends nothing); only a click on a confirmation link still in date, from that inbox, turns it back on. The guest stop page says so: "You won't get any more emails about this search — not even a confirmation if someone asks for it again." Confirmed guest searches go out through the sameautomotive:send-vehicle-alertspass (without the manage-alerts link, which needs an account), and every full pass (not a--searchrun) starts withGuestSearchAlerts::prune(): it deletes a guest search still waiting on confirmation 7 days (CONFIRM_TTL_DAYS) after its link was sent (or after it was made, if no link ever went out) — the address never said yes — and a stopped one, confirmed or not, 365 days (STOPPED_RETENTION_DAYS) after it was stopped (updated_at); until then that row is what keeps the form from mailing the address. A member's search is never pruned. /members/vehicle-alerts— saved searches (frequency select, pause/resume, delete with confirm) and price watches (subscribed vs current price with the drop delta, remove). Each search's summary names every filter it holds, in the words the search page's filter chips use: the words searched, the distance and place, every list filter inSearchUrl::LISTS(vocabulary labels, store names — read in one query per render, "Store no longer listed" for one that's gone — seats / cylinders / doors, availability; a single value from an older row reads as a one-value list), the price and monthly budget, year, mileage, MPG, the toggles, city and state — or "All inventory".- Stop these alerts — every price-drop and new-arrival email carries a signed link
to that one alert's stop page (
auto/alerts/stop/price/{id},auto/alerts/stop/search/{id}), and the same URL in itsList-Unsubscribeheaders so mail clients offer one-click unsubscribe (RFC 8058; the POST is CSRF-exempt). Opening the link shows a Stop these alerts button — only a POST stops the alert, so a mail gateway that fetches every link on delivery can't stop it. The signature covers the path and query only (not the scheme or host), so the link survives the site being reached by another scheme or host. It works without signing in and stays live with the feature switched off, so an email already in someone's inbox can always be stopped. Built onStopAlerts.
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). Every row builds its links through AutomotiveLinks or a localized
x-dl.link field-url, prints money through VehicleFormat and vocabulary through
VehicleVocabulary::label(), so a row on /es/ links inside /es/ in Spanish path words and
reads $28,995, never 28.995 US$.
Search and shopping rows
-
Hero with Inventory Search (
hero-inventory-search) — full-bleed photo hero with the plain-language search box (a plain GET form postingask, the same words/inventory?ask=reads — see Plain-language search;maxlength=NaturalSearchParser::MAX_LENGTH), live make / model / stock-number suggestions, a Price | Monthly payment budget toggle (maxPriceormaxMonthly; the monthly select starts disabled so a no-script submit sends one budget, and is not rendered at all while payment estimates are off; the new / certified / used assumptions printed under it come fromPaymentEstimator::assumptions()), and All / New / Used / Certified links to the landing pages (/inventory/new…,/es/inventario/nuevos…). -
Hero with Step-by-Step Search (
hero-search-wizard) — the alternative to the search box: the same photo hero with a six-step search card instead — body style → make & model → budget → color → options → where — showing "N vehicles match" on every step, searchable from any step, every step after the first skippable, and handing off to/inventorywith each choice as an editable chip. See The step-by-step search hero below. Also offered as the Motorline — Home (step-by-step search) page design. -
Hero with Quick Filters — condition, make, a dependent model select (populated from live inventory, no round-trip), body type (the vocabulary's buckets, labelled in the reader's language), and a budget by price or by monthly payment, submitting straight to a filtered inventory search. It sends plain search parameters, each a single value —
condition,make,model,bodyandmaxPriceormaxMonthly(/inventory?condition=used&make=Toyota&model=Tacoma&body=Truck), nevermake[]/body[]— and the search page reads a single value as a one-value list, then writes its own list form into the URL (make[0]=Toyota). Rows bake in when they are inserted: a copy on a page whose selects are namedmake[]/body[]still searches correctly, at the cost of one 301 to the indexed form (make[0]=…, see Public inventory search) — re-insert the row to get the plain names.How both search forms submit. The Inventory Search and Quick Filters heroes prevent the native submit and send the browser to the search page with a query built from
FormData, leaving blank values out — no field is disabled or otherwise touched, so a page the browser restores from its back/forward cache after Back is still a working form. Without script the form submits natively (blanks included; the monthly select still starts disabled, so only one budget is sent). A page that inserted either row before this keeps the old handler, which disabled blank fields at submit, until the row is re-inserted. -
Featured Vehicles / Latest Arrivals / Price Drops — collection rows over the Vehicles preset (featured-flagged, newest arrivals, recent price cuts) that print the inventory card —
partials/search/vehicle-cardthroughVehicleCard, the one the search page, the vehicle page's similar rail and the recently-viewed strip print: the photo carousel at the warmed widths, at most two badges, price and the monthly estimate, call-for-price, the store, the Compare box. The card's look is the module's, not the row's: the editor styles the grid and the row around it, not each card. Only cars on the lot show (sold, pending and hidden never do). In the editor, before any vehicle exists, a grey stand-in card previews the layout. -
Browse by Body Type — editable quick-link tiles (SUVs, trucks, sedans…), each pre-filtering the search. Browse by Body Type (Repeater) is the same tiles as a repeater, so a tile can be added or removed from the editor.
-
Browse by Make — tiles for the makes on the lot (the first 12 by default) with live counts (Makes preset), each linking to the make's own page (
/inventory/{make},InventoryLanding::urlForCriteria()), or to the search filtered to it for a make outside the vocabulary. -
Specials & Offers (
specials-grid) — live offer cards from the Specials preset: the offer's photo (linking to its matching vehicles), kind badge, title, headline number, terms, short line, stores, end date, a "See matching vehicles" button and the fine print; an ended offer drops off on its own. Header link to/inventory?hasOffer=1. -
Payment Calculator — a pure client-side Alpine loan estimator (a New / Certified / Used switch, price, down payment, APR, term → monthly figure) with a browse-inventory CTA and disclaimer. Figures are formatted
toLocaleString('en-US'), so a visitor's own browser locale can't print32.000. It starts at the site's own payment assumptions, so its default monthly figure is the one a card and the vehicle page print for the same price: the down payment and term fromPaymentEstimator::terms()and the APR fromPaymentEstimator::apr('new')— the new-car rate at the default credit tier — rendered asdata-down/data-term/data-rateon the section and read in the component'sinit()(no inline script). A New / Certified / Used switch (three real radio buttons in a fieldset with an editable legend, "Vehicle condition"; New picked by default) moves the APR to that condition's rate —PaymentEstimator::apr('certified')/apr('used')at the default credit tier, the rate a card and the vehicle page print for a car in that condition — fromdata-rate-new/data-rate-certified/data-rate-used. While payment estimates are switched off site-wide the switch and those three rates are left out (the rest of the calculator is unchanged). The monthly figure rounds likePaymentEstimator: to the cent, then up to the next dollar, at least $1, and $0 once the down payment covers the price. The term select offersPaymentEstimator::TERMS(24–84 months) plus the site's own term, which starts selected; the down-payment slider reaches $50,000 (or the site's down payment, if higher) and the APR slider 15% (or the highest of the site's three condition rates, rounded up). The price starts at $32,000. With the built-in defaults ($2,000 down, 72 months, 7.4%) that is $518/mo, and the server-rendered figures (before Alpine starts) match. A Settings change to the payment assumptions clears the cached pages, so the row follows it. Rows bake in when they are inserted, so a copy without the switch gains it only when the row is re-inserted. -
Sell Your Car Lead — the two-column trade-in lead magnet embedding the trade-in widget (below).
Store rows (for a group with several stores; every one lists active stores only — a store switched off leaves them all at once)
- Dealer Locations (
dealer-locations) — a card per store (six by default): photo, kind, name (linking to its page, else its inventory), brands, address, today's hours, a live open-now line, the sales / service / parts lines (tel:), and View inventory (the store's own/inventory/store/{slug}page), Store details (its page, when it has one) and Directions. - Store Directory by Market (
store-directory) — the Store markets preset: one band per market (a store'smarket, else its city; spelled any way, one group; stores with neither come last under "Other locations"), each headed with its store count and a link to that market's inventory, then a card per store — kind, name, brands, address, its weekly sales hours, a live open-now line, sales and service lines, Shop inventory / Store details / Directions. Markets keep the dashboard's store order. - Shop by Brand (Stores) (
shop-by-brand-stores) — the Store brands preset: a card per brand the stores list (canonical names, A–Z), with its vehicles in stock, every store that sells it (each linking to its page, else its inventory), and Shop new / Shop all links — the make's new page (/inventory/new/{make}) and its all-conditions page (/inventory/{make}), built byInventoryLanding::urlForCriteria(). - Store Hero (
store-hero) — for a store's own page: kind and brands, the name as the page's H1, address, today's hours, a live open-now line, every department's line, Shop our inventory / Schedule service / Apply for financing / Visit our website (each only when the store has it), a map of the store (thex-dl.mapcomponent, so it waits for marketing consent like every map), and the sales, service and parts weeks ("Mon–Fri 9:00 AM – 7:00 PM · Sat 9:00 AM – 6:00 PM · Sun Closed",Location::weekSummary()). - Store Inventory Rail (
store-inventory-rail) — for a store's own page: "Shop {store}", a link to all of its vehicles, and up to eight of them as the inventory card — on the lot or in transit, what the store's inventory page lists — in a swipeable rail on phones, four across on desktop. - Financing & Pre-Qualify Band (
finance-prequalify-band) — budget-first copy beside the soft Get pre-qualified widget (contact details and a budget, never a credit application; a lazy island). On a store's page the request goes to that store and its finance application link shows beside the form; anywhere else it goes to the group. - Service Scheduling Band (
service-scheduling-band) — dark band: book-a-visit copy beside the store's service card — its service week, Schedule service (the store's online scheduler) and a tap-to-call service line. It shows this page's store; on a group page set its Store filter to Every store (it keeps its Has a department: Service filter) to list each store's service desk.
The store-bound rows (Store Hero, Store Inventory Rail, Service Scheduling Band) show this
page's store — the store whose Page link points at the page (or a vehicle page's store); on a
page about no store they show nothing, never the first store or the whole group. Only the
design-library picker previews them with a stand-in (VehiclesPreset::previewStoreId(), for the
Vehicles and Dealership Locations presets' This page's store): the active store with the most
filled in — photo, sales hours and line, address, city, map pin, brands, page, website and
finance links, stock for sale, and each service / parts / collision department with a line,
scheduler or hours — a real store before a demo one, then dashboard order (one query a request,
over at most 200 stores). The page editor and the public page never use it.
Nothing in a row's HTML depends on the moment. A guest page is response-cached for days, so
no row prints whether a store is open (it would be stale within the hour); rows print the week
and today's hours, which the site's midnight clear (pages:sync-time-boundaries) refreshes. A
store on another timezone's "today" can trail by the hour or two between its midnight and the
site's. The live open-now line on Dealer Locations, Store Directory by Market and Store Hero
is worked out in the visitor's browser: the row holds only the sales department's week
(Location::weeklyHours('sales') — the store's main hours when sales has none) and the store's
own timezone (localTimezone(); blank = the site's) as data attributes, and
Alpine.data('autoOpenNow') (resources/js/automotive-open-now.js, registered in public.js
and app.js, via the module partial partials/stores/open-now) reads them on the store's clock
with Intl, re-checking every minute — "Open now", "Opens at 9:00 AM" when it opens later
today, else "Closed now", in the page's language. The line is empty in the cached HTML; a store
with no hours, or a timezone the browser doesn't know, shows nothing, never "Closed". One rule
in the browser and on the server (Location::isOpenNow() — the vehicle page's store-status
island, the chat tools, the open_now / open_status tokens): a window is open from its start
up to its end, and a window ending 23:59 — how the dashboard's time input stores "until
midnight" — stays open through that last minute; so a store whose hours are 22:00–23:59 plus
00:00–02:00 the next day reads open straight through midnight everywhere. A window that doesn't
end after it starts is never saved, and is dropped if a hand edit left one. The line's wrapper
(card_open_now / store_open_now) is an editable item that keeps its height while empty; a
page whose copy of the row has no such line gains it when the row is re-inserted. The vehicle page's store-status
island renders its own status on every view.
How these store rows relate to the core Locations page designs (Location Detail - Store
Overview, Locations Directory): those read the store page's own fields (the content item —
its copy, the brands / inventory_url / service_phone keys the store pushes onto it, translated
there) and search by name or brand; the Automotive store rows read the store record itself
(auto_locations through the Dealership Locations preset) — every department's phone and week,
today's hours on the store's clock, the scheduler and finance links — and group by market or
brand. Use the core designs for a store page's editorial content, these for its live store data.
Six data presets back the collection rows and are available to any custom row — the
Specials & offers preset is described under Specials & offers; the others:
Vehicles (status — available, on the lot or in transit, sold, all — /condition/featured/
price-drop/make/body-type filters, plus Store — one store, or This page's store, the
rooftop behind the record the page is about: a store's location page, the store itself, or a
vehicle's store; on a page about no store it shows nothing, never the whole group — and
Brand, checked against the canonical make list rather than today's stock, so a "Chevrolet"
rail keeps its filter through a week with no Chevrolets — and a free-text Search; newest,
featured-first, price, mileage, and year orders;
tokens for title, price, MSRP, prior price, drop amount, mileage, specs, photo, URL, lot, the
lot's page URL), Dealership Locations (filters: a search by name, kind of store, carries brand, Store — one
store or this page's store, like Vehicles — and Has a department — a service line or online
scheduling, a parts line, a collision line; tokens: kind, brands, address, market, main / sales /
service / parts / collision phones + tel: links, today's hours and the service department's
today, the week for the store and for sales / service / parts, open now + "Open now / Closed
now" — all on the store's own clock, and not for a cached row —, service scheduling link, photo,
directions URL, store page URL, website, finance link, the filtered-search URL, the store's own
inventory page, "its page, else its inventory", and its count of vehicles for sale — on the lot
or in transit), Vehicle Makes (distinct makes on the lot with live counts, each linking to the make's own
page — the filtered search only for a make outside the vocabulary),
and the two static Store markets / Store brands presets (StoreGroupsPreset, filterable by kind of store;
markets keep store order, brands run A–Z: one record per market or brand — name, store count, "3 stores", inventory URL; brands add the new-inventory
URL and their count in stock — each carrying its active stores as stores, looped with a nested
<x-dl.collection source="relation:stores" preset="auto_locations">). Store filters keep
deactivated stores as options, so a row filtered to a store that was switched off stays filtered
instead of widening to every store: a Dealership Locations row then shows nothing, and a
Vehicles row keeps showing whatever of that store's stock is still listed.
The step-by-step search hero
resources/design-library/rows/automotive/hero-search-wizard.blade.php + the autoSearchWizard
Alpine component (resources/js/automotive-wizard.js, registered in both public.js and
app.js — the editor preview loads app.js) + Support/SearchWizard + the count endpoint.
The steps (each a <fieldset> with a <legend>, real checkboxes / radios, Back / Next keep
earlier choices; the editor preview shows every step at once so each can be styled):
- Body style — Cars / SUVs / Trucks / Vans tiles (
BODY_GROUPS), multi-select. Ticking a tile ticks its narrow choices that are in stock: Cars → Sedan / Coupe / Convertible / Hatchback / Wagon (untick any), Vans → Minivan / Van, Trucks → Regular / Extended / Crew cab. All cabs ticked is any truck (no cab sent, so a truck whose cab the feed never named stays in); unticking the last cab unticks Trucks. An empty cab list reads as every cab only while a truck is ticked: with no truck ticked, ticking a cab ticks just that cab, and Truck with it. A body style is needed to go on from this step (Search works without one). - Make & model (optional) — Any by default; the makes in stock for the choices so far, and each ticked make's models, come from the endpoint.
- Budget (optional) — the search hero's Price | Monthly payment choice, default No max.
Monthly uses
/inventory's own down / term / credit defaults (PaymentEstimator), printsPaymentEstimator::assumptions()beside it, and is not rendered while estimates are off. - Color (optional) — swatches by colour family (
VehicleVocabulary::COLOR_SWATCHES, which the/inventorycolour filter reads too); several = any of them. After make/model and budget on purpose: shoppers bend on colour most. - Options (optional) — engine (family), fuel, transmission, drivetrain, and features (the "Most popular" shortlist first, the rest behind Show every feature); every ticked feature must be on the car.
- Where — Use my location (only where the browser can answer, only when pressed, 2
decimals) or a ZIP, and a distance from
RADIUS_PRESETS, default 60. Until a location is given every count covers every store; once it resolves the line reads "12 within 60 mi · 48 at all our stores", with an editable transfer note under it (default "We can move any of these to the store nearest you."; hidden on a one-store site). No resolved location (none given, an unknown ZIP) → "Any distance — counting every store" ("Any distance" on a one-store site), and the hand-off carries no radius. A browser location stands until a whole ZIP replaces it, so a half-typed ZIP keeps the count and the hand-off measured from it; every ZIP edit is recounted (an edit that leaves the query as it was is never asked twice), and part of a ZIP typed over a whole one counts for no origin — the count always matches what Search hands off.
At zero matches the card names up to three choices to loosen (SearchWizard::MAX_RELAX) —
relaxSuggestions(), limited to the wizard's own choices, with what each brings back — and Next / Search wait until one is taken
(when nothing can help, Search goes on to /inventory's own zero-results page).
The count endpoint — GET /auto/search-count (automotive.search-count,
SearchCountController → SearchWizard::answer()), inside the feature:automotive group, so
it 404s while the feature is off. It takes the query parameters /inventory reads
(SearchUrl::PARAMS / TOGGLES — the lists body, cab, make, model, color, engine,
fuel, transmission, drivetrain, features, then maxPrice, maxMonthly, zip or lat +
lng, radius, …) plus step (body · make · budget · color · options · where), and answers:
{count, count_label, show_label, all_stores, distance_label, origin (none|resolved|unknown),
radius, origin_note, stores, options: {facet: [{value, label, count, parent?}]},
relax: [{filter, value, label, count, count_label}], url}
count is VehicleSearch::count() on the shoppable inventory — exactly what /inventory lists
(FM88) — and url is the same filters' search link, root-relative and in the page's language
(SearchUrl::path(), /es/inventario?…): the answer is cached for every host the site answers on,
so it must not carry the first visitor's host to the next. all_stores is counted only when it is
shown — null unless a radius resolved and the site has more than one active store.
options holds the step's facets (from the one cached facet query) with stock or ticked, plus
body_group totals on the body step; on the budget and where steps (and with no step) it is
empty and no facet query runs. relax (only at zero matches) names only filters the hero can
loosen, restricted before the 12-variant cap (see Zero results under Search). Bounded before
anything reaches a query (FM89): list values must name a vocabulary value (one folded-name lookup
per word), a store or a store's market, a make the canonical list or the inventory knows, or a
model / trim the inventory's tree knows (seats, cylinders and doors are held to their own small
sets); a model asked for under
makes the site doesn't know is dropped with them, and a trim under unknown models likewise (a
model with no make at all is every make's, as on /inventory); at most 20 per list; numbers held
to sane ranges; ZIPs to 5 digits, coordinates rounded to 2 decimals; the free-text filters (q,
city, state, near) are not read. The answer is cached
ten minutes per (inventory version, language, step, normalised filters), the route is throttled
120 / minute per IP and carries DoNotCacheResponse (the homepage around it is
response-cached; the count must be live), and the row asks at most once per 300 ms pause while the
shopper changes choices (at once on moving to another step) and never twice for the same choices
on the same step — a failed ask can be asked again.
The hand-off. Search goes to the answer's own url while that answer is for the choices on
screen, else to the same query the row builds itself (wizardParams() in
automotive-wizard.js — also the count request's query). Every list is written indexed
(body[0]=SUV&body[1]=Truck), the spelling /inventory keeps, so the hand-off lands without a
redirect. Only the row's no-script form sends body[]=… — its lists are checkboxes, which a
plain GET form can't index — and /inventory 301s that once to the indexed form.
Nothing in the row's HTML depends on stock (FM91): it prints the vocabulary (every body
style, cab, colour, engine, fuel… with its label — Spanish on /es/, from the vocabulary's own
table) and a data-wizard config (endpoint + search URLs, the body groups, the default radius,
whether Monthly is offered); which options have stock, every count and the store count come from
the endpoint at runtime.
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 (and, on a site
with several active stores, which store, with that store's disclaimer under the form) —
captured through AutomotiveLeads::capture('trade_in') with the vehicle details in the lead
payload, the call/text consent (see Lead capture) 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, canonical facet columns + body_style, history flags, invoice_price, window_sticker_url, last_seen_pass, the photo mirror's photos_local record + photos_fetch_attempts / photos_fetch_failed_at, and translations — the description per site language with its __src provenance) |
auto_vehicle_features |
one row per vehicle + feature tag — the index the features facet counts (kept in step with feature_tags) |
auto_locations |
rooftops: kind, brands, address + coordinates (+ geo_source, geocoded_at), market, timezone, main phone/email/hours, departments (per-department phone, email, lead inbox, ADF address, scheduler link, hours), website_url, finance_url, carfax_partner_code, disclaimer, dealer_codes, content_item_id (its store page, unique) |
auto_feeds |
feed configs (URL, format, field map + field_preset, encrypted credentials, the remote server's non-secret remote settings — host, port, path, trusted SFTP host key — cursor, schedule + next_sync_at, remove_behavior, max_bytes, the guard's last_pass_rows + settle_held_rows (a held pass's count), the sync_fingerprint a pass must match to skip an unchanged file, last_imported_at + stale_alerted_at) |
auto_feed_sync_runs |
per-pass history (pass_id, counts incl. price changes + sold, status, error, warnings) |
auto_retired_slugs |
slugs of pruned sold vehicles + the make/model their URL 301s to |
auto_leads |
vehicle inquiries, test-drive requests, trade-in and pre-qualify leads — each with its store (location_id; leads predating the column took their vehicle's store), the shopper's language, the call/text contact_consent record, and webhook_dispatched_at; its translation lives in message_translations (field message) |
auto_lead_deliveries |
the forward ledger: one row per recipient per channel (email = the dealer email, adf = the CRM copy) — status (pending / sending / sent / failed / unconfirmed / not_configured), attempts, not_before, last_error, sent_at, payload_sha, resent_at / resent_by; leads recorded before it existed have none |
auto_saved_vehicles |
member favorites (one row per member+vehicle) |
auto_saved_searches |
saved searches (criteria json + criteria_hash, frequency, alert cursor, language) — a member's (member_id), or an email-only guest alert from Notify me or a guest's Save search (member_id NULL, email, confirmed_at, confirmation_sent_at, the token's confirm_token_hash; unique per email + criteria; pruned once its link expires unconfirmed, or a year after it was stopped) |
auto_price_alerts |
price watches (subscribe baseline, last notified price, language) |
content_items (type_slug = specials) |
specials & offers — fields in data, store picks in content_item_relation (field stores) |
auto_search_parses |
the plain-language search log: query (≤ 150 chars, masked), language, mode, path, ai_status, ai_called (the daily cap counts these), latency, filters, unused words, dropped AI values and sparse filters set aside ({key}:sparse), edited_at; pruned after 30 days |
Feed vehicles cascade with their feed (mirror, not archive); manual vehicles persist.
Staged feed files live in storage/app/private/automotive-feeds/ and are deliberately not
backed up (a scratch copy of the provider's data); deleting a feed removes its staging
directory and cancels its live pass. Drop folders (storage/app/private/automotive-drops/)
are not backed up either; deleting a feed deletes its drop folder too (a bind-mounted folder is
emptied — the mount and the SFTP account are the server's to remove). The photo mirror (storage/app/public/auto-photos/) is
skipped by snapshots — it is re-derived from the feed, and a missing copy serves the feed URL.
Member rows cascade with their vehicle; member_id is a plain indexed column.
The ad feed files (storage/app/private/automotive-ad-feeds/{google,meta}.csv) are derived and
not backed up; the token, the Google store codes and the last build's counts are Settings
(automotive.ad_feeds_token — encrypted —, automotive.ad_feeds_store_codes,
automotive.ad_feeds_status).