Every WebProCMS install ships with built-in search powered by Laravel Scout. Without any setup the dashboard searches blog posts, events, locations, custom content type entries, media library files, form submissions, users, and pages — across every configured language — using the connected database. Front-end site search corrects visitor misspellings out of the box (see Typo correction below). MySQL users can opt into FULLTEXT indexes for ranked, word-aware results, and members can upgrade to the Advanced Search add-on for a Meilisearch-powered cmd-K command palette and front-end site search.
What gets indexed
Five core model types participate in search out of the box. Each model defines a toSearchableArray() returning the fields the engine sees.
| Model | Source | Indexed fields |
|---|---|---|
| ContentItem | content_items table — blog posts, events, locations, and every other content type share this table, distinguished by type_slug |
title, stripped-HTML JSON-encoded data, JSON-encoded translations |
| MediaItem | media_items table |
filename, alt, caption |
| FormSubmission | form_submissions table |
JSON-encoded data, ip_address |
| User | users table |
name, email |
| PageSearchEntry | page_search_entries (denormalized) |
title, concatenated content from page editor overrides |
The first four are normal Eloquent models with the WebProSearchable trait. Pages are the odd one out — page content lives across many content_overrides rows rather than on a single model — so the PageSearchIndexer flattens each page into one row per (page, language) combo in page_search_entries. That table is rebuilt automatically by the ContentOverrideObserver whenever a page edit is saved, and on demand via php artisan pages:reindex.
Multi-language by default
WebProCMS uses two parallel translation systems (see translations.md) and search respects both:
ContentItem— the model with atranslationsJSON column (covering blog posts, events, locations, and every other content type) includes the JSON in its searchable payload. A query in any language matches text in any other language because the JSON is searched as a string.- Pages, which use the
key__{lang}suffix convention incontent_overrides, get one search entry per language. Searching "amazing" returns the English page entry, searching "increíble" returns the Spanish entry — both linking to the same/dashboard/pages/{slug}/editURL, with the language switcher pre-set to the matching locale (cmd-K only; covered in the add-on).
This means a multilingual site is fully searchable from day one, regardless of the active search mode.
Three search modes
WebProCMS exposes three engines via the same Scout API. The active mode is set on Dashboard → Settings → Search; flipping between modes is instant — no code changes, no reindex needed (for the database modes).
Mode 1 — Standard Search (like) — default
The default. Scout's database driver runs WHERE col LIKE '%query%' against every column listed in toSearchableArray(), ORed together. Works on MySQL and MariaDB (what the installer provisions) and on the SQLite used for local development and tests. No setup.
Best for: the typical WebProCMS install. Sites under a few thousand posts barely notice the difference between this and FULLTEXT, and the zero-setup path is a real win.
Trade-off: no ranking — results come back in the model's default order. LIKE '%query%' matches word fragments anywhere in the string, which is sometimes a feature ("rest" matches "restaurant") and sometimes noise.
Mode 2 — MySQL FULLTEXT (mysql_fulltext) — free upgrade for MySQL users
Scout still uses the database driver, but the model's databaseFullTextColumns() are wrapped in MATCH(col) AGAINST(query IN NATURAL LANGUAGE MODE) instead of LIKE. MySQL ranks matches by relevance, respects word boundaries, and is materially faster on large tables.
Activation requires installing FULLTEXT indexes once:
- Go to Settings → Search, switch the mode to "MySQL FULLTEXT", and click Save. (The radio is greyed out on non-MySQL connections.)
- Click Install under "Install FULLTEXT Indexes". This runs
php artisan search:install-fulltext, which adds a FULLTEXT index per searchable model. Idempotent — safe to re-run any time, e.g. after adding a new searchable model.
What you get:
- Ranked results — MySQL's relevance score sorts matches by quality, not insertion order.
- Word-level matching — searching "amazing" doesn't match the substring "razzamazzing" (which
LIKEwould). - Fast on large tables — FULLTEXT is an actual index.
LIKE '%query%'always scans every row.
Trade-offs:
- MySQL only. SQLite, which local development uses, has no FULLTEXT support.
- Word boundary semantics differ.
LIKEmatches anywhere in a string; FULLTEXT matches whole words (configurable in MySQL but not yet exposed). A search for "rest" finds "restaurant" under LIKE but not under FULLTEXT. - Stop words. MySQL's default FULLTEXT stop list strips short common words (
the,is,and, ...) — so a search for "the office" effectively becomes "office".
Mode 3 — Meilisearch (meilisearch) — Advanced Search add-on
Scout switches to the Meilisearch driver and queries against an external Meilisearch instance. See the add-on doc for instant as-you-type search, per-keystroke typo tolerance, faceting, and the cmd-K command palette. (The database engines have their own typo correction — see below — so Meilisearch's edge here is when it tolerates typos: on every keystroke, inside every ranked query, rather than as a correction pass.)
Federated result grouping
Both search surfaces return results grouped by source, not as one flat list — so a visitor scanning results (or an editor in the cmd-K palette) sees a "Pages" heading, a "Blog" heading, a "Products" heading, and so on, rather than an undifferentiated stack.
- Front-end site search (
/search, PublicSearch) groups: Pages, one group per content type (Blog, Events, Services, …, each labelled with the type's display name), Products (E-Commerce), Documentation (Documentation add-on), and Listings (Real Estate add-on). - cmd-K palette (dashboard, GlobalSearch) groups: Go to (dashboard nav), Pages, Custom content, Products, Documentation, Listings, Media library, Form submissions, and Users.
- Meaning mode adds a Matched by meaning group to either surface — see Meaning-based search below.
A group may order itself. The engine ranks each group by default, but a source that knows better may override it: the Products group leads with the products matching every typed term (name, SKU, and the derived search_terms bag), then tops up with the engine's own hits — the same rule the shop grid filters by, so the suggestions and the grid a shopper lands on agree. That exists because FULLTEXT relevance is length-normalised and was ranking a short name sharing one weak token above a product containing the whole query; see E-Commerce → Product search.
Each feature-specific group only appears when its feature is enabled — a site without the Documentation or Real Estate add-on never runs those queries. Documentation results come from published articles in the default docs version (front-end) or every article including drafts (cmd-K, since admins edit them); listing results come from visible, non-duplicate inventory via ListingSearch so they behave exactly like the /properties search box.
Meaning-based search (the Keywords / Meaning toggle)
Both search surfaces are keyword by default — instant, and the right answer for a query that names what it wants. Where the Semantic Search member feature is on and a provider can embed, both also carry a Keywords / Meaning toggle for the query that doesn't: "my lights keep flickering" on a site whose page says "electrical repair".
- Site-search overlay (the header search icon / ⌘K): the toggle sits in the footer. Two switches on Settings → Search decide whether visitors get it at all (
search.semantic_visitor, on by default) and whether search opens on Meaning (search.semantic_default, off by default — meaning mode takes a moment longer, so keyword stays the default until the owner decides otherwise). The choice sticks for the tab, and Enter carries it onto/search?mode=semantic. - Header search bars and the /search page's own bar: the same toggle, as the last row of the typeahead dropdown that
public.jsinjects under every/searchform — no header row changes. In meaning mode the form submits with a hiddenmodeinput; on the results page the toggle re-submits so the page re-renders in the other engine. Shop headers that post to the product grid keep keyword only. - Dashboard ⌘K palette: the same toggle, whenever the layer is usable; the meaning group points at each record's editor with the matched passage as the subtitle. The choice sticks for the session.
Meaning mode leads with a Matched by meaning group (one row per page or record, ranked by its best passage) and drops those URLs from the keyword groups that follow, so switching never loses a result; pinned results still lead. When nothing matches by meaning the keyword results stand unchanged and the surface says so. The mechanics and the safety properties are in semantic-search.md.
Pinned results (curation)
Editors can force a specific result to the top of front-end search for an exact search term — the classic "when someone searches hours, send them straight to the Contact page" curation. Managed at Settings → Search → Pinned Results (an Add Pin button captures the search term, a result title, a URL, and an optional snippet), or jump straight there from Analytics → Search where each recorded term row has a bookmark shortcut that pre-fills the term.
Pins are a core feature — they work under every engine (LIKE, FULLTEXT, Meilisearch) because they're a curation layer applied over whatever the engine returns, not an engine setting:
- Terms are stored and matched normalized (lowercased, whitespace-collapsed), so a pin for "Store Hours" matches a search for " store hours ".
- Matching pins render first, under a Featured group heading.
- A pinned URL that would also appear organically is de-duplicated out of its normal group, so it never shows twice.
- A pin makes an otherwise zero-result query non-empty, so curated answers surface even for terms nothing else matches.
Stored in the search_pins table via the SearchPin model.
Typo correction on the database engines
A visitor who types plummber into site search on a site whose every page says plumber should land on the plumbing page, not a dead end. Both free engines (like and mysql_fulltext) do this out of the box — no setup, no configuration:
- Auto-correct on zero results. When the typed query finds nothing and a confidently-corrected spelling finds something, the corrected results are shown with a notice: No results for "plummber" — showing results for "plumber" instead.
- "Did you mean" on partial results. When the typed query finds something (under FULLTEXT, the other words of a multi-word query still match) but one word looks misspelled, the results are left exactly as ranked and the corrected query is offered as a link.
The design guarantee is that a correctly-spelled query is never made worse: only words absent from the site's own vocabulary are ever considered typos — and a word that merely opens one is read as unfinished, not misspelled — results are swapped only when the original search found nothing at all, and a suggestion is only offered when it actually leads somewhere.
How it works (SearchSpelling + SearchVocabulary):
- Corrections come from the site's own words. The vocabulary is a capped term-frequency map built from exactly the text public search federates over — page copy (per language, public pages only), content items, and the enabled feature sources (products, docs, forum, menu, directory, courses), plus the editor's synonym words. Admin-only models (users, media, form submissions) are deliberately excluded, so their words can never surface as suggestions to a visitor.
- Edit-distance thresholds are conservative (Meilisearch's calibration): words under 4 letters are never corrected, 4–5 letters allow one edit, 6+ allow two, and a two-letter swap ("hosue") counts as the single slip it is. Ties break toward the longest shared prefix (typos rarely hit a word's opening letters), then corpus frequency.
- A word spelled the way it sounds corrects even past the edit budget.
fisicalsis three edits fromphysicals— out of reach for distance alone — but folding both sides through a handful of English sound-alike rules (ph→f,y→i, doubled letters collapsed, silentkn/wr/whopenings) makes them identical, and exact equality after folding is required, so it never fires on a word that merely looks similar. The literal edit-distance pass runs first and always wins when it finds anything. - An unfinished word is not a typo. A token that opens a longer word the site uses —
inconton a catalog full ofincontinence— is left alone. Both database engines already match it (LIKE searches for the whole query as a substring, and FULLTEXT retries under LIKE whenever it finds nothing), so correcting it could only trade a working query for a same-length neighbour:incontis two edits frominfantand six from the word the shopper was typing, and distance alone picksinfant. It is also what keeps the header typeahead quiet — firing per keystroke means every correctly-spelled word arrives as a run of prefixes before it ever arrives whole. - The vocabulary follows content automatically. The
search:vocabularylazy-cron task compares a cheap change signature every ~10 minutes and rebuilds only when content actually moved — same no-queue-worker pattern as semantic search. Manual rebuild:php artisan search:vocabulary --force. - Where it applies: the
/searchpage, the public cmd-K palette, and the AI chat bot's site-search tool (the model is told when a correction was applied so it can say so). The dashboard cmd-K palette is deliberately excluded — it searches admin models the public vocabulary doesn't cover. - Off switch: the
search.typo_correctionSetting (default on) disables the layer entirely; it is also inert under Meilisearch, which corrects typos natively per keystroke.
Multilingual sites get per-language correction: page words bucket by language, so a Spanish query corrects against Spanish page copy (accent-insensitively — "fontaneria" is one edit from "fontanería", not three bytes).
Implementation notes
SearchService(app/Support/Search/SearchService.php) is the single source of truth.SearchService::activeMode()validates the stored mode against current availability — if a user picks Meilisearch and then disables the add-on, the resolver gracefully falls back tolikeinstead of erroring.WebProDatabaseEngine(app/Support/Search/WebProDatabaseEngine.php) subclasses Scout'sDatabaseEngineto make FULLTEXT column resolution dynamic. Scout's stock engine reads PHP attributes (which are static metadata); ours asks the model fordatabaseFullTextColumns()and only returns them when the active mode ismysql_fulltext. The same model class works correctly under all three modes.WebProSearchable(app/Models/Concerns/WebProSearchable.php) is the trait every searchable model uses. It re-exports Scout'sSearchableand adds a default implementation ofdatabaseFullTextColumns()(returns the keys oftoSearchableArray()).- The
pages:reindexcommand is admin-runnable from Settings → Search → Rebuild Page Index, useful after a database restore, bulk content import, orcontent_overridesedit done outside the page editor. - FULLTEXT relevance is length-normalised, which matters when a model indexes a long derived column alongside short ones. A match inside a long field scores well below a short
namethat shares a single weak token, so a model whosedatabaseFullTextColumns()includes a wide bag of derived terms should not rely onMATCH … AGAINSTorder alone — rank the precise matches yourself and let the engine top up (what the Products group does).
Adding a new searchable model
use App\Models\Concerns\WebProSearchable;- Implement
toSearchableArray(): array— keys are column names the database driver will query; values are what Meilisearch indexes. - Override
databaseFullTextColumns(): arrayif you want to narrow the FULLTEXT column list (e.g. you index atranslationsJSON blob for Meilisearch but only want clean text columns in the FULLTEXT index). - If the user is on MySQL FULLTEXT mode, add the new model to
InstallFulltextCommand::modelsAndColumns()and re-runsearch:install-fulltext.
That's it. The model is now searchable across all three modes and every configured language.