Laravel Docs

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.

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 real documents columns (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 the documents table. Scope a search to a specific version with ->where('version_id', $version->id) instead; there's no project_id column to filter by directly, so scoping by project means resolving its version IDs first (->whereIn('version_id', $project->versions()->pluck('id'))). version_id is 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 keeps where('version_id', ...) working the same way across every engine.
  • body holds 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 or collection engines and isn't worth computing on every index write anymore.
  • body is annotated with #[SearchUsingFullText(['body'])], and the documents migration adds a matching full-text index on MySQL/MariaDB/PostgreSQL (skipped on SQLite, which has no full-text index support — the collection engine used in this package's own tests doesn't need one).
  • title and section are annotated with #[SearchUsingPrefix(['title', 'section'])], matching foo% from the start of the string instead of %foo% anywhere within it — not just a stylistic choice for title: Postgres/MySQL full-text search (Scout's plainto_tsquery/ websearch_to_tsquery on Postgres) matches whole, stemmed words, so a search-as-you-type query like "insta" would never surface a document titled "Installation" under full-text. section is a short category label ("Getting Started"), so matching from the start is both more intuitive and — backed by the plain section index the migration adds — much cheaper than an unindexable substring scan. title has no matching index yet (a future migration, if this needs to scale past a small documents table); on PostgreSQL specifically, a plain btree index only accelerates a prefix LIKE under the C locale or a varchar_pattern_ops/text_pattern_ops index — check your database's collation before adding one.
  • version_id gets 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, so version_id still participates in the default LIKE strategy 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.