Search
Document always uses Scout's Searchable trait — laravel/scout is a
required dependency of this package, not optional, because a trait use
inside a class body is resolved at class-load time. There's no way to guard
that with class_exists(), so making Scout optional would mean Document
fatals for any consumer that doesn't have it installed, even one that never
searches.
No config/scout.php setup is required to get working search, either.
Scout's own default driver (SCOUT_DRIVER, unset) is collection — an
in-memory engine that needs no external service, no API keys, and no
migrations. It's not the most efficient option at scale, but for a
documentation site's document count it's a reasonable zero-setup default.
Excluding a document from search
Every document has a searchable column (true by default, overridable via
searchable: false in a document's YAML front matter). This is not
enforced automatically — add the constraint yourself wherever you call
Document::search():
Document::search('installation')->where('searchable', true)->get();
This is deliberate rather than an oversight: Scout's database engine (see
below) never consults shouldBeSearchable(), so that method can't be the
single source of truth for per-document opt-out without behaving
differently across engines. A where() clause is the one mechanism every
Scout engine honors identically.
shouldBeSearchable() still exists, but only as a global switch:
public function shouldBeSearchable(): bool
{
return (bool) config('docs.search.enabled');
}
Set DOCS_SEARCH_ENABLED=false to disable indexing entirely — no Scout
driver needs to be configured in that case, and no search-related network or
database calls will be made. This only matters for engines that maintain a
separate index (Algolia, Meilisearch, collection); the database engine has
no index to push to, so it's a no-op there regardless.
Switching to the database engine
For anything beyond a handful of documents, switch to Scout's database
engine (SCOUT_DRIVER=database) — it searches your existing tables directly
via LIKE/full-text queries, no external service or separate index required.
This package is built with it in mind:
toSearchableArray()only returns realdocumentscolumns (title,body,section,version_id). The database engine executes its queries directly against columns named in that array, so anything relation-derived (the parent project's slug, the version's name) can't appear there — it would reference a column that doesn't exist on thedocumentstable. Scope a search to a specific version with->where('version_id', $version->id)instead; there's noproject_idcolumn to filter by directly, so scoping by project means resolving its version IDs first (->whereIn('version_id', $project->versions()->pluck('id'))).version_idis included even though the database engine can already filter by any real column without it — Algolia/Meilisearch only let you filter on fields present in the indexed record, so this keepswhere('version_id', ...)working the same way across every engine.bodyholds raw markdown (see the README), not HTML-stripped plain text — the database engine reads the column value directly, so the stripped/rendered form used to matter only for third-party orcollectionengines and isn't worth computing on every index write anymore.bodyis annotated with#[SearchUsingFullText(['body'])], and thedocumentsmigration adds a matching full-text index on MySQL/MariaDB/PostgreSQL (skipped on SQLite, which has no full-text index support — thecollectionengine used in this package's own tests doesn't need one).titleandsectionare annotated with#[SearchUsingPrefix(['title', 'section'])], matchingfoo%from the start of the string instead of%foo%anywhere within it — not just a stylistic choice fortitle: Postgres/MySQL full-text search (Scout'splainto_tsquery/websearch_to_tsqueryon Postgres) matches whole, stemmed words, so a search-as-you-type query like "insta" would never surface a document titled "Installation" under full-text.sectionis a short category label ("Getting Started"), so matching from the start is both more intuitive and — backed by the plainsectionindex the migration adds — much cheaper than an unindexable substring scan.titlehas no matching index yet (a future migration, if this needs to scale past a smalldocumentstable); on PostgreSQL specifically, a plain btree index only accelerates a prefixLIKEunder theClocale or avarchar_pattern_ops/text_pattern_opsindex — check your database's collation before adding one.version_idgets neither attribute, on purpose. It's a real column included solely so->where('version_id', ...)works the same way across every engine (see above) — nobody should type a version's numeric ID into a search box expecting relevant document matches. Scout doesn't offer a "filterable but excluded from free-text matching" option, soversion_idstill participates in the defaultLIKEstrategy as an accepted, low-impact side effect (occasionally matching a search term that happens to be a substring of some row's ID).
searchableAs() returns config('docs.search.index_prefix') . 'documents',
though it has no effect under the database engine, which always searches the
model's table directly.
