tag1 / scolta-laravel
Scolta AI Search for Laravel — zero-infrastructure AI-powered search on a Pagefind-compatible index built in PHP
Requires
- php: ^8.2
- illuminate/console: ^11.0|^12.0|^13.0
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/routing: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
- tag1/scolta-php: ^1.3.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.0
- orchestra/testbench: ^9.0|^10.0|^11.0
- phpunit/phpunit: ^10.0|^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main / 2.x-dev
- 1.3.0
- 1.2.0
- 1.1.0
- 1.0.5
- 1.0.4
- 1.0.3
- 1.0.2
- 1.0.1
- 1.0.0
- 1.0.0-rc4
- 1.0.0-rc3
- 1.0.0-rc2
- 1.0.0-rc1
- 0.3.10
- 0.3.9
- 0.3.8
- 0.3.7
- 0.3.6
- 0.3.5
- 0.3.4
- 0.3.3
- 0.3.2
- 0.3.1
- 0.3.0
- 0.2.4
- 0.2.3
- 0.2.0
- dev-title-dedup
- dev-claude/interesting-gates-8cb210
- dev-docs/rebuild-from-migration
- dev-chore/unfork-ai-pr-review
- dev-claude/metadata-boosts
- dev-chore/track-scolta-php-1.5-dev
- dev-docs/maintaining
- dev-chore/drop-version-sync
- dev-feature/facet-mode
- dev-release/scolta-php-1.2.0
- dev-fix/scolta-php-floor-refuses-1-1-0
- dev-chore/version-coherence-check
- dev-fix/memory-budget-test-pins-a-process-dependent-chunk-size
- dev-feature/manual-provider-and-amazee-opt-in
- dev-release/1.1.0
- dev-release/1.1.0-constraint-and-changelog
- dev-fix/ai-opt-in-only
- dev-fix/empty-app-key-skips-integrity-tagging
- dev-feature/sayt
- dev-feat/hide-empty-facets-and-filter-descriptions
- dev-fix/composer-github-auth-ci
- dev-update-ai-pr-review-workflow-defaults
- dev-add-ai-pr-review
- dev-docs/scolta-js-preload-note
- dev-chore/open-next-dev
- dev-release/v1.0.4
- dev-feat/health-anonymous-status-only
- dev-chore/quality-quick-wins
- dev-fix/content-gathering-unification
- dev-fix/security-defaults
- dev-chore/open-1.0.4-dev
- dev-release/v1.0.3
- dev-fix/preset-fallthrough
- dev-chore/release-notes-only
- dev-docs/config-default-single-source
- dev-docs/tuning-preset-voice
- dev-docs/laravel-11-eol-note
- dev-test/laravel-component-render
- dev-refactor/drop-budget-overrides
- dev-fix/asset-cache-busting
- dev-feat/156-subword-deny-list
- dev-fix/156-subword-guard-sync
- dev-fix/decouple-scolta-php
- dev-fix/pre-1.0-final-cleanup
- dev-fix/blade-flat-index-detection
- dev-fix/packagist-prep
- dev-fix/release-zip-test-dir
- dev-fix/external-services-readme
- dev-feat/show-attribution-config
- dev-fix/auto-rebuild-default-true
- dev-feature/generic-sort-filter-prompts
- dev-fix/voluntary-restart-oom
- dev-fix/tag1-urls
- dev-chore/vendor-cleanup-and-branding
- dev-fix/code-quality-cleanup
- dev-fix/auto-publish-assets-on-build
- dev-fix/release-prerelease-tags
- dev-fix/amazee-ui-parity
This package is auto-updated.
Last update: 2026-09-17 15:33:45 UTC
README
Built and maintained by Tag1 Consulting — technology leadership since 2007.
Laravel 11/12/13 package — Artisan commands, Searchable trait for Eloquent models, and AI-powered search built on Pagefind.
Status
Scolta 1.0 — the API documented here is stable. Breaking changes follow semantic versioning: no removal or signature change without a major version bump and a deprecation cycle. File bugs at the repo issue tracker.
What Is Scolta?
Scolta is a scoring, ranking, and AI layer built on Pagefind. Pagefind is the search engine: it builds a static inverted index at publish time, runs a browser-side WASM search engine, produces word-position data, and generates highlighted excerpts. Scolta takes Pagefind's result set and re-ranks it with configurable boosts — title match weight, content match weight, recency decay curves, and phrase-proximity multipliers. No search server required. Queries resolve in the visitor's browser against a pre-built static index.
This package is the Laravel adapter. It provides Artisan commands for building and maintaining the index, a Searchable trait for Eloquent models, a <x-scolta::search /> Blade component, change tracking via an observer pattern, and REST API endpoints for the AI features. The actual scoring, indexing logic, memory management, and AI communication live in scolta-php, which this package depends on. Scoring runs client-side via the scolta.js browser asset and the pre-built WASM module shipped with scolta-php.
The LLM tier — query expansion, result summarization, follow-up questions — is optional. When enabled, it sends the query text and selected result excerpts to a configured LLM provider (Anthropic, OpenAI, or a self-hosted Ollama endpoint). The base search tier shares nothing with any third party.
Running Example
The examples in this README and the other Scolta repos use a recipe catalog as the concrete data set. Recipes are a good showcase because recipe vocabulary has genuine cross-dialect mismatches:
- A search for
aubergine parmesanshould surface Eggplant Parmigiana. - A search for
chinese noodle soupshould surface Lanzhou Beef Noodles, Wonton Soup, and Dan Dan Noodles. - A search for
gluten free pastashould surface Zucchini Spaghetti with Pesto and Rice Noodle Stir-Fry. - A search for
quick dinner under 30 minshould surface Pad Kra Pao, Dan Dan Noodles, and Steak Frites.
Here is how to model and index the recipe catalog in Laravel:
// app/Models/Recipe.php use Tag1\Scolta\Export\ContentItem; use Tag1\ScoltaLaravel\Searchable; class Recipe extends Model { use Searchable; public function toSearchableContent(): ContentItem { return new ContentItem( id: "recipe-{$this->id}", title: $this->name, bodyHtml: "<p>{$this->description}</p>" . "<h2>Ingredients</h2><ul>" . implode('', array_map(fn($i) => "<li>{$i}</li>", $this->ingredients)) . "</ul><p>Tags: {$this->tags}, {$this->regional_synonyms}</p>", url: "/recipes/{$this->slug}", date: $this->updated_at->format('Y-m-d'), siteName: config('scolta.site_name', config('app.name')), metadata: ['type' => $this->getMorphClass()], ); } public function scopeSearchable($query) { return $query->where('published', true); } }
Register the model in config/scolta.php:
'models' => [App\Models\Recipe::class],
Build the index:
php artisan scolta:build
Then add <x-scolta::search /> to any Blade template and visit the page. A search for aubergine parmesan surfaces Eggplant Parmigiana because the body HTML includes both the American term "eggplant" and the Italian name. Scolta's title boost lifts it above pages that mention aubergine only in passing.
The recipe fixture HTML files live in scolta-php at tests/fixtures/recipes/ if you want a pre-built data set to index without a database.
Quick Install
# 1. Install composer require tag1/scolta-laravel:^1.0 tag1/scolta-php:^1.0 # 2. Publish config, migrations, and assets php artisan vendor:publish --tag=scolta-config --tag=scolta-migrations --tag=scolta-assets # 3. Run migrations php artisan migrate # 4. Add the Searchable trait to your models and register them in config/scolta.php # 5. Build the search index php artisan scolta:build # 6. Add <x-scolta::search /> to any Blade template # 7. Set your API key to unlock AI features
In .env:
SCOLTA_API_KEY=sk-ant-...
With an API key configured, search queries are automatically expanded with related terms, results include an AI summary, and visitors can ask follow-up questions.
Verify It Works
php artisan scolta:check-setup
This verifies the PHP version, AI provider configuration, and browser WASM assets.
php artisan scolta:status
Add --json to get the same report as one JSON document on stdout, with the decorative output suppressed, for deploy scripts and monitoring:
php artisan scolta:status --json | jq '.pagefind_index'
The build section describes whatever is in flight: how many jobs are waiting on the scolta queue, and — when an unfinished build left a manifest in the state directory — its activity (gathering, merging, or publishing), segment, pages processed, lock holder, and why its last segment stopped. progress appears only while gathering, since the ratio describes the gather and says nothing about the merge. Jobs queued with nothing building means no worker is listening to the scolta queue, and the report says so. activity: interrupted means the manifest says building but no live process holds the lock: a segment died, and the build is waiting for php artisan scolta:build --resume or the next queued TriggerRebuild.
Sections and fields match drush scolta:status wherever both adapters report the same thing, so one script can read either. Drush emits YAML; JSON is valid YAML, so a YAML parser handles both.
The health endpoint also reports current state: GET /api/scolta/v1/health
What Scolta Is Built For
Scolta is designed for content search on Laravel applications: articles, documentation, product catalogs, knowledge bases, and other Eloquent model content indexed at build time. Laravel powers SaaS products, enterprise applications, API platforms, and content-driven sites — and Scolta is tuned for the content search needs of these applications.
The static-index architecture means no Elasticsearch or Solr server to provision. Scolta replaces hosted search SaaS (Algolia, Coveo, SearchStax) and Solr/Elasticsearch backends for Laravel applications where the search use case is full-text relevance, recency, and phrase matching. The index is built in PHP, so it runs on managed hosting where binary execution is restricted.
Migrating from Laravel Scout
Scout and Scolta solve different problems. Scout drives external search servers (Algolia, Meilisearch, Typesense). Scolta runs Pagefind, which produces a static browser-side index — no search server required. Scolta then re-ranks Pagefind's results and optionally adds an AI layer.
Replace toSearchArray() with toSearchableContent() and scopeSearch() with scopeSearchable(). Remove Scout from composer.json, publish Scolta's config and migrations, and replace Scout search calls with <x-scolta::search />.
What you gain: no external search service bill, AI query expansion and summarization, works on shared and managed hosting. What you give up: Scout's per-record real-time index updates and its driver flexibility.
Memory and Scale
The default memory profile is conservative, which targets a peak RSS under 96 MB and works on shared hosting with a 128 MB PHP memory_limit. Scolta never silently upgrades to a larger profile.
The admin interface shows the detected PHP memory_limit and suggests a profile. The profile selection is always left to the admin.
Pass the profile via the Artisan CLI:
php artisan scolta:build --memory-budget=balanced
Available profiles: conservative (default, ≤96 MB), balanced (≤200 MB), aggressive (≤384 MB). Higher budget means fewer, larger index chunks and faster builds.
Or set it in .env:
SCOLTA_MEMORY_BUDGET=balanced
Tested ceiling at the conservative profile: 50,000 pages. Higher counts likely work; not certified yet.
Builds that continue themselves
A corpus too large to index in one process yields when RSS approaches the PHP memory_limit, and scolta:build finishes the job in fresh processes. It runs each resume segment as a child in the foreground and streams its output, so one command drives the whole build and its exit code describes the whole build: 0 only once the index is published and verified on disk.
The flags that shape a build travel with it: --memory-budget, --chunk-size and --force are all passed to each segment, so a forced build stays forced for its whole length rather than only for its first segment.
Two things bound the chain. A segment that hits the memory limit without committing a single page gets no successor, because another one would do exactly the same; and no build may use more than 50 segments in total. Either way the command fails with a message naming the page count reached and the PHP memory_limit it ran under, and The index has not been republished: raise memory_limit, or lower the per-chunk footprint with --memory-budget=conservative / --chunk-size, then re-run with --restart.
Deploying to PaaS Platforms
On PaaS platforms — including Laravel Cloud, Forge with push-to-deploy, Vapor, Railway, and Render — the filesystem is rebuilt from your repository on every deploy. Any files written outside the repo at install time are wiped, including assets published by vendor:publish.
php artisan vendor:publish --tag=scolta-assets must run as part of your build pipeline, not just during initial setup.
Wiring it automatically via post-autoload-dump
Add it to your application's composer.json scripts:
"scripts": { "post-autoload-dump": [ "@php artisan package:discover --ansi", "@php artisan vendor:publish --tag=scolta-assets --force --ansi" ] }
Composer runs post-autoload-dump on every composer install and composer update, which PaaS platforms execute automatically on each deploy. The --force flag is required so assets are refreshed even when the destination directory already exists from a previous build cache.
Important: Composer only runs scripts from the root package — your application. Scripts in a dependency's
composer.json(including Scolta's own) are never executed for consumers. You must add the script to your owncomposer.json.
Platform-specific steps
Laravel Cloud: Runs composer install on each deployment. The post-autoload-dump script above runs automatically.
Laravel Vapor: Runs composer install in the Lambda package build step. The post-autoload-dump script above runs automatically.
Laravel Forge (push-to-deploy): Add the publish command to your Forge deployment script, after the composer install line:
php artisan vendor:publish --tag=scolta-assets --force
Indexing after a deploy
Nothing has to run after a deploy. Ensure a queue worker runs in every environment that should index (see Keeping the Index Fresh): the first request that finds no index queues a build, content changes queue their own updates, and a build a deploy interrupted is resumed by the next request rather than restarted. A release that changes the index format says so in its notes and tells you what to run.
For a deploy that must have search live before it serves traffic, php artisan scolta:build is still the tool. It is synchronous and verified: it blocks until the index is built and exits 0 only when a usable index is live on disk, so a deploy that gates on its exit code can trust it. If the build cannot produce a valid index, the command exits non-zero — fail your deploy on that rather than serving dead search.
Do not pass
--queuein a deploy step unless you have a worker that finishes before traffic is served.--queuedefers the build to the queue: on an asynchronous connection it returns a distinct deferred exit code (3) without building the index — the index only appears once a worker (php artisan queue:work) drains the chain. It is intended for large-corpus background rebuilds, not for the deploy-time index your first requests depend on. An interrupted or never-drained rebuild degrades to the previous index (stale), never to an empty one.
AI Features and Privacy
Scolta's AI tier is optional. When enabled:
- The LLM receives: the query text, and the titles and excerpts of the top N results (default: 10, configurable via
ai_summary_top_n). - The LLM does not receive: the full index contents, full page text, user session data, or visitor identity.
- Which provider receives the query data depends on your
SCOLTA_AI_PROVIDERsetting:anthropic,openai, or a self-hosted endpoint viaSCOLTA_AI_BASE_URL.
The base search tier — Pagefind index lookup and Scolta WASM scoring — runs entirely in the visitor's browser with no server-side involvement beyond serving static index files.
Configuration
All settings live in config/scolta.php with .env overrides. After editing config/scolta.php, run php artisan config:clear.
AI Provider
| Setting | .env key |
config/scolta.php key |
Default | Description |
|---|---|---|---|---|
| Provider | SCOLTA_AI_PROVIDER |
ai_provider |
(none) | anthropic, openai or amazee. No default: while none is selected, AI features are off and search works exactly as it does now. |
| API key | SCOLTA_API_KEY |
ai_api_key |
— | Authentication for AI features |
| Model | SCOLTA_AI_MODEL |
ai_model |
claude-sonnet-4-5-20250929 |
LLM model identifier |
| Expansion model | SCOLTA_EXPANSION_MODEL |
ai_expansion_model |
'' (same as ai_model) |
Optional separate model for query expansion. When set, expand-query uses this model while summarize and followup use ai_model. Empty means all AI operations use ai_model. |
| Base URL | SCOLTA_AI_BASE_URL |
ai_base_url |
provider default | Custom endpoint for proxies or Azure OpenAI |
| Query expansion | SCOLTA_AI_EXPAND |
ai_expand_query |
true |
Toggle AI query expansion on/off |
| Visitor expansion switch | SCOLTA_EXPANSION_TOGGLE |
expansion_toggle |
true |
Render a switch in the results header letting each visitor turn expanded terms off for themselves. The choice lives in browser storage and only ever narrows — it can never re-enable expansion that ai_expand_query has turned off, and no switch is drawn where expansion is unavailable. Set to false to make expansion a site-level decision only |
| Summarization | SCOLTA_AI_SUMMARIZE |
ai_summarize |
true |
Toggle AI result summarization on/off |
| Summary top N | — | ai_summary_top_n |
10 |
How many top results to send to AI for summarization |
| Summary max chars | — | ai_summary_max_chars |
4000 |
Max content characters sent to AI per request |
| Max follow-ups | SCOLTA_MAX_FOLLOWUPS |
max_follow_ups |
3 |
Follow-up questions allowed per session |
| AI languages | SCOLTA_AI_LANGUAGES |
ai_languages |
['en'] |
Languages the AI responds in (matches user query language) |
In .env:
SCOLTA_AI_PROVIDER=anthropic # required — there is no default; omit it and AI features stay off SCOLTA_API_KEY=sk-ant-... SCOLTA_AI_MODEL=claude-sonnet-4-5-20250929 SCOLTA_AI_EXPAND=true SCOLTA_AI_SUMMARIZE=true SCOLTA_EXPANSION_TOGGLE=true
For multilingual sites:
// config/scolta.php 'ai_languages' => ['en', 'fr', 'de'],
Search Scoring
Scoring settings live under the scoring key in config/scolta.php.
| Setting | .env key |
config/scolta.php path |
Description |
|---|---|---|---|
| Title match boost | — | scoring.title_match_boost |
Boost when query terms appear in the title |
| Title all-terms multiplier | — | scoring.title_all_terms_multiplier |
Extra multiplier when ALL terms match the title |
| Content match boost | — | scoring.content_match_boost |
Boost for query term matches in body/excerpt |
| Title dedup | SCOLTA_TITLE_DEDUP |
scoring.title_dedup |
Hide results whose title nearly matches a higher-ranked one (default false). Turn on only when the same content is reachable at several URLs; otherwise distinct pages with similar titles disappear |
| Expand primary weight | — | scoring.expand_primary_weight |
Weight for original query results vs AI-expanded results (higher = original query dominates; raise to 0.7+ if you want literal keyword matches to win) |
| Recency strategy | SCOLTA_RECENCY_STRATEGY |
scoring.recency_strategy |
exponential, linear, step, none, or custom |
| Recency boost max | — | scoring.recency_boost_max |
Maximum positive boost for very recent content |
| Recency half-life days | — | scoring.recency_half_life_days |
Days until recency boost halves |
| Recency penalty after days | — | scoring.recency_penalty_after_days |
Age before content gets a penalty (~5 years) |
| Recency max penalty | — | scoring.recency_max_penalty |
Maximum negative penalty for very old content |
| Language | SCOLTA_LANGUAGE |
scoring.language |
ISO 639-1 code for stop word filtering |
| Custom stop words | — | scoring.custom_stop_words |
Extra stop words beyond the language's built-in list |
| Specificity weighting | SCOLTA_SPECIFICITY_WEIGHTING |
scoring.specificity_weighting |
Weight each partial match by how rare its term is in the corpus (default true), so a match on a rare intent-bearing term outranks a match on a ubiquitous one. This is what stops a common word, typed or leaked from an expansion phrase, from flooding the head of the result list. false restores flat sub-query weighting |
| Specificity floor | SCOLTA_SPECIFICITY_FLOOR |
scoring.specificity_floor |
Floor for a ubiquitous term's specificity weight (0-1, default 0.15). Damped rather than dropped, so recall is preserved; lower is more aggressive damping |
| Specificity strong match | SCOLTA_SPECIFICITY_STRONG_MATCH |
scoring.specificity_strong_match |
Specificity at or above which a match counts as strong and on-intent (0-1, default 0.55), which stops the partial-match banner and the AI summary framing a good result set as a failure |
| Co-occurrence bonus | SCOLTA_SPECIFICITY_COOCCURRENCE |
scoring.specificity_cooccurrence |
Multiplier on the bonus a result earns for agreeing with several query and expansion terms at once rather than matching one strongly (0-5, default 0.9). Set to 0 to score each result purely by its single best-matching sub-query |
| Co-occurrence gate | SCOLTA_SPECIFICITY_AGREEMENT_GATE |
scoring.specificity_agreement_gate |
Specificity a term must clear to count toward the agreement bonus (0-1, default 0.45), so a near-ubiquitous word earns none |
| Co-occurrence decay | SCOLTA_SPECIFICITY_AGREEMENT_DECAY |
scoring.specificity_agreement_decay |
Geometric factor applied to each successive agreeing term (0-5, default 1.0). Below 1 the bonus saturates, so a long enumerative page cannot out-accumulate a focused one through breadth alone |
| Metadata boosts | SCOLTA_METADATA_BOOSTS |
scoring.metadata_boosts |
Score multipliers by fragment meta key, then exact meta value: ['type' => ['post' => 1.4]]. The default toSearchableContent() writes the model's morph class as type; an override carries the key forward with metadata: ['type' => $this->getMorphClass()]. Different keys multiply; exact match only. Default [] |
| Expansion combine mode | SCOLTA_EXPANSION_COMBINE_MODE |
scoring.expansion_combine_mode |
How multi-term expansion sub-query results are combined for the AI summary: relevance_union or round_robin. Preset-defaulted in scolta-php (round_robin on the content_catalog/blog/ecommerce presets, relevance_union otherwise); an explicit value overrides the preset |
Defaults and the full reference: scolta-php docs/CONFIG_REFERENCE.md.
News site (recency matters a lot):
// config/scolta.php 'scoring' => [ 'recency_boost_max' => 0.8, 'recency_half_life_days' => 30, 'recency_penalty_after_days' => 365, 'recency_max_penalty' => 0.5, ],
Documentation site (recency doesn't matter, titles matter a lot):
'scoring' => [ 'recency_strategy' => 'none', 'title_match_boost' => 2.0, 'title_all_terms_multiplier' => 2.5, ],
Recipe catalog (no recency, title precision matters):
'scoring' => [ 'recency_strategy' => 'none', 'title_match_boost' => 1.5, 'title_all_terms_multiplier' => 2.0, ],
Display
Display settings are top-level keys in config/scolta.php.
| Setting | config/scolta.php key |
Description |
|---|---|---|
| Excerpt length | excerpt_length |
Characters shown in result excerpts |
| Results per page | results_per_page |
Results shown per page |
| Max Pagefind results | max_pagefind_results |
Total results fetched from index before scoring |
| Show attribution | show_attribution |
Render "Powered by Scolta" below the search widget |
Defaults and the full reference: scolta-php docs/CONFIG_REFERENCE.md.
Site Identity
| Setting | .env key |
config/scolta.php key |
Default | Description |
|---|---|---|---|---|
| Site name | SCOLTA_SITE_NAME |
site_name |
app name | Included in AI prompts so the AI knows what site it's searching |
| Site description | — | site_description |
website |
Brief description for AI context |
Custom Prompts
Override prompts via the top-level keys prompt_expand_query, prompt_summarize, and prompt_follow_up in config/scolta.php, or use an event listener:
// app/Listeners/EnrichScoltaPrompt.php use Tag1\ScoltaLaravel\Events\PromptEnrichEvent; class EnrichScoltaPrompt { public function handle(PromptEnrichEvent $event): void { if ($event->promptName === 'summarize') { $event->resolvedPrompt .= "\n\nFocus on dietary information and cuisine type."; } } }
Register in EventServiceProvider:
protected $listen = [ \Tag1\ScoltaLaravel\Events\PromptEnrichEvent::class => [ \App\Listeners\EnrichScoltaPrompt::class, ], ];
Preset
Getting fewer results than you expect on a recipe, product, or catalog site? Set SCOLTA_PRESET=content_catalog and rebuild your index — the Recipe & Content Catalog preset widens search breadth so ingredient, technique, and product-attribute searches return the fuller set of matches you'd expect.
A preset is the recommended way to tune scoring: pick the one that matches your site instead of setting individual numbers. Set SCOLTA_PRESET in .env (or edit config/scolta.php) to apply one. Any explicit values in the scoring array still override the preset.
| Preset | Best for |
|---|---|
content_catalog |
Recipe sites, product/content catalogs, wikis |
reference |
Documentation, knowledge bases, encyclopedias |
ecommerce |
Online stores, product catalogs |
blog |
Blogs, news, editorial content |
none |
No preset (default) — all values from the scoring array |
SCOLTA_PRESET=content_catalog
For the evidence behind each preset — the scoring sweeps and per-parameter data — see scolta-php's docs/TUNING.md.
Indexer and Memory
| Setting | .env key |
config/scolta.php key |
Default | Description |
|---|---|---|---|---|
| Memory budget | SCOLTA_MEMORY_BUDGET |
memory_budget.profile |
conservative |
conservative, balanced, or aggressive |
| Chunk size | SCOLTA_CHUNK_SIZE |
memory_budget.chunk_size |
profile default | Pages per chunk during a build |
| Incremental updates | SCOLTA_INCREMENTAL_ENABLED |
incremental.enabled |
true |
Whether a queued rebuild updates the published index in place before falling back to a full build. Same key and default as scolta-drupal |
| Incremental ceiling | SCOLTA_INCREMENTAL_MAX_ITEMS |
incremental.max_changed_items |
100 |
Tracked changes above which a queued rebuild runs a full build instead; 0 disables the ceiling. Same key and default as scolta-drupal |
Pagefind
| Setting | .env key |
config/scolta.php path |
Default | Description |
|---|---|---|---|---|
| Output dir | SCOLTA_OUTPUT_DIR |
pagefind.output_dir |
public/scolta-pagefind |
Pagefind index output directory |
Caching and Rate Limiting
| Setting | .env key |
config/scolta.php key |
Default | Description |
|---|---|---|---|---|
| Cache TTL | SCOLTA_CACHE_TTL |
cache_ttl |
2592000 (30 days) |
AI response cache TTL in seconds |
| Rate limit | SCOLTA_RATE_LIMIT |
rate_limit |
30 |
Max API requests per minute per IP |
Routes and Middleware
| Setting | config/scolta.php key |
Default | Description |
|---|---|---|---|
| Route prefix | route_prefix |
api/scolta/v1 |
Prefix for all Scolta API routes |
| API middleware | middleware |
['api'] |
Middleware for AI API routes |
| Health middleware | health_middleware |
['api'] |
Middleware for the health check endpoint |
| Amazee route prefix | amazee_route_prefix |
scolta/amazee |
Prefix for Amazee.ai admin settings routes |
| Amazee middleware | amazee_middleware |
['web'] |
Middleware for Amazee.ai settings routes. With the default, the routes are not registered — set it beyond the bare ['web'] group (e.g. ['web', 'auth']) to enable the admin UI |
Auto Rebuild
| Setting | .env key |
config/scolta.php key |
Default | Description |
|---|---|---|---|---|
| Auto rebuild | SCOLTA_AUTO_REBUILD |
auto_rebuild |
true |
Dispatch rebuild to queue on content changes |
| Rebuild delay | SCOLTA_AUTO_REBUILD_DELAY |
auto_rebuild_delay |
300 |
Debounce delay in seconds |
Sort and Filter Fields
| Setting | config/scolta.php key |
Default | Description |
|---|---|---|---|
| Sortable fields | sortable_fields |
[] |
Field names for data-pagefind-sort attributes |
| Sort descriptions | sortable_field_descriptions |
[] |
Human-readable sort field descriptions for LLM |
| Filter fields | filter_fields |
[] |
Pagefind filter dimension names |
| Filter descriptions | filter_field_descriptions |
[] |
Human-readable filter descriptions for LLM |
| Hide empty facets | hide_empty_facets |
true |
Hide a facet value with no results for the current query, and drop a filter group whose values are all zero; an active value stays visible so it can be unchecked. Set false (or SCOLTA_HIDE_EMPTY_FACETS=false) to render every value, showing a zero-count one as a disabled "(0)" row |
Search as You Type
Typing in the search box populates a suggestions dropdown under it. The full search — AI query expansion, the AI summary, follow-ups — still runs only on Enter, on the search button, or when a visitor picks a suggestion. It is on by default and needs no index rebuild: suggestions read the index you already have.
| Setting | .env key |
config/scolta.php key |
Default | Description |
|---|---|---|---|---|
| Suggestions | SCOLTA_SAYT_ENABLED |
sayt_enabled |
true |
Master switch. false restores the pre-1.1.0 widget exactly: no dropdown node, no combobox ARIA roles, no browser storage, no suggest searches |
| Minimum characters | SCOLTA_SAYT_MIN_CHARS |
sayt_min_chars |
2 |
Characters typed before suggestions are requested, counted in graphemes so an emoji is one character. CJK sites commonly want 1 |
| Typing debounce | SCOLTA_SAYT_DEBOUNCE_MS |
sayt_debounce_ms |
150 |
Trailing debounce in milliseconds before a suggest cycle fires |
| Max suggestions | SCOLTA_SAYT_MAX_SUGGESTIONS |
sayt_max_suggestions |
6 |
Most suggestions shown, and the cap on fragment loads per pass |
| Recent searches | SCOLTA_SAYT_RECENT_SEARCHES |
sayt_recent_searches |
true |
Offer the visitor's own recent searches, kept in their browser under a single localStorage key. false reads and writes nothing |
| Max recent searches | SCOLTA_SAYT_MAX_RECENT |
sayt_max_recent |
3 |
Most recent searches shown above the content suggestions |
| AI enrichment | SCOLTA_SAYT_EXPAND |
sayt_expand |
true |
Enrich suggestions with AI query expansion. Inert with no AI endpoints configured or with ai.expand_query off |
| AI enrichment cap | SCOLTA_SAYT_EXPAND_PER_MINUTE |
sayt_expand_per_minute |
6 |
Expansion calls per visitor per minute. SAYT expansions share the AI flood budget with committed searches, so an uncapped suggest path would spend a visitor's allowance on prefixes and starve the search they actually ran. Over the cap the dropdown degrades to keyword-only suggestions |
| AI enrichment delay | SCOLTA_SAYT_EXPANSION_DELAY_MS |
sayt_expansion_delay_ms |
500 |
Idle milliseconds before an enrichment call. Longer than the typing debounce on purpose |
| Suggestion action | SCOLTA_SAYT_SUGGESTION_ACTION |
sayt_suggestion_action |
navigate |
navigate goes straight to the result; search puts the title in the box and runs the full search. A recent search always searches |
If you have already published config/scolta.php, check it before relying on these defaults. The service provider merges the package config with mergeConfigFrom(), which is a shallow array_merge() of the package file under your published one. That is why all ten are top-level keys rather than a sayt group: a top-level key missing from your published file still picks up the package default, while a published sayt group would have replaced the package's group whole and taken every default in it with it. Two cases still need your attention:
- You want to change a value. Add the key to your published
config/scolta.php. Editing the package file undervendor/is not persistent. - You run
php artisan config:cache.mergeConfigFrom()is skipped entirely when the configuration is cached, so a cached config built before this release carries none of these keys. Re-runphp artisan config:cacheafter upgrading.
Full behaviour, including the browser events and the theming custom properties: scolta-php docs/SAYT.md.
Amazee.ai Integration
Amazee.ai provides a managed LiteLLM proxy. Try it for AI-powered search with a free demo, no email required; sign in with your email to set up an account and keep it when the demo credit runs out.
Connecting is an explicit action, through either the CLI command or the admin settings page below, and there are exactly two of them:
- Try the demo — one action, no email, no account, no card. Runs until the demo's included credit is used up. One-time per site: once it has been used, the settings page and the command both point you at the account path instead of failing opaquely.
- Enter your Amazee credentials — sign in with the email address on your amazee.ai account. Amazee emails a verification code, you pick a region, and your account's credentials are stored for you. If you do not have an account yet, this creates one. You never generate or paste an API key: this mirrors amazee.ai's own
ai_provider_amazeeiomodule, so there is deliberately no bring-your-own-key form. This flow needs a browser, so it lives on the settings page rather than in the command.
Nothing connects on your behalf: with no SCOLTA_API_KEY and no stored connection, search runs with AI features off (queries are not expanded and no summary is generated) until you take one of those actions. Configuring SCOLTA_API_KEY takes precedence and clears any stored Amazee.ai connection, so a leftover connection can never shadow your own key.
The settings page and php artisan scolta:status state which of the two actions established the current connection, because that is recorded when it happens rather than inferred afterwards; a connection made before Scolta recorded it says only "Connected to Amazee.ai".
CLI provisioning:
php artisan scolta:amazee:provision # the free demo — no email needed php artisan scolta:amazee:provision user@example.com # optionally bind the demo to an address
Admin UI: The admin settings UI at /scolta/amazee (configurable via amazee_route_prefix) provides the multi-step connection flow. Its routes can disconnect stored AI credentials, so they are disabled by default — they are only registered when you configure amazee_middleware with protection beyond the bare ['web'] group:
// config/scolta.php 'amazee_middleware' => ['web', 'auth'],
With the shipped default (['web']), requests to these routes return 404 and the CLI command above is the way to enable Amazee.ai.
Routes (when enabled):
| Method | Path | Description |
|---|---|---|
| GET | /scolta/amazee |
Settings page |
| POST | /scolta/amazee/trial |
Start free trial |
| POST | /scolta/amazee/request-code |
Request OTP code |
| POST | /scolta/amazee/verify-code |
Verify OTP code |
| GET | /scolta/amazee/regions |
List available regions |
| POST | /scolta/amazee/connect |
Complete connection |
| DELETE | /scolta/amazee/disconnect |
Disconnect |
Migrations
Scolta uses two database tables. Publish and run migrations during installation:
php artisan vendor:publish --tag=scolta-migrations php artisan migrate
| Table | Description |
|---|---|
scolta_tracker |
Change tracking for Eloquent models. The ScoltaObserver writes here when models are created, updated, or deleted. The queued rebuild reads it to update only what changed, and drains the rows it covered. |
scolta_config |
Key/value config store for Amazee.ai credentials and auto-configured model settings. Tokens are encrypted via Laravel's Crypt facade. |
Re-run both commands after upgrading the package: migrations are added over
time, and 1.4.0 adds scolta_tracker.item_id.
item_id holds the value your model's toSearchableContent() returned as
ContentItem::$id — the id the index is keyed by. content_id is the Eloquent primary key, which is a different
thing and cannot locate any of them once the record is gone. The observer
captures item_id when it records a deletion, so a hard-deleted record can
still have its page removed by an incremental update. Until the migration runs
the column is simply absent; an incremental update that meets a deletion it
cannot resolve says so and falls back to a full rebuild, which derives deletions
from the index itself and does not need the mapping.
Rebuilding from a migration
When indexed output goes stale because the code that produces it changed rather
than the content — an edited toSearchableContent(), a new field in the
metadata — queue a forced full rebuild from the migration that ships the change,
so every environment rebuilds when it deploys and nobody has to remember to:
use Tag1\ScoltaLaravel\Jobs\TriggerRebuild; public function up(): void { TriggerRebuild::dispatch(force: true); }
force is what makes it unconditional: an unforced request applies the tracked
changes incrementally, and a full build it falls back to is skipped when the
corpus fingerprint is unchanged. The flag rides on the job, so a build that
outgrows one worker run stays forced across every resumed segment, and a build
already in progress when the migration runs is left alone; the request waits for
the lock and runs after it. Nothing under the build state directory needs to be
touched. php artisan scolta:build --queue --force queues the same job by hand,
and php artisan scolta:build --force runs it inline.
On the sync queue connection the job runs inside php artisan migrate, for as
long as the build takes. This is the Laravel counterpart of scolta-drupal's
scolta_queue_full_rebuild($reason, TRUE) for update hooks.
Debugging
"AI features not working"
- Verify API key:
php artisan scolta:check-setup - Clear stale cache:
php artisan scolta:clear-cache - Clear config cache:
php artisan config:clear - Confirm the model name in
config/scolta.php
"AI summary says 'I don't have enough context'"
The defaults (10 results, 4000 chars) are already tuned for curation. If still insufficient, increase further:
// config/scolta.php 'ai_summary_top_n' => 15, 'ai_summary_max_chars' => 6000,
"AI responses are in the wrong language"
Set ai_languages to match your site's language(s):
'ai_languages' => ['de'], // or ['en', 'fr', 'de'] for multilingual
"Expanded queries return irrelevant results"
Raise expand_primary_weight (default: 0.5) to make original query terms dominate more, or disable expansion:
// config/scolta.php 'scoring' => [ 'expand_primary_weight' => 0.8, // closer to 1.0 = original query dominates ], // or: 'ai_expand_query' => false,
"No search results"
- Check index status:
php artisan scolta:status - Run a full rebuild:
php artisan scolta:build - Verify published assets:
php artisan vendor:publish --tag=scolta-assets --force - Confirm the Pagefind output directory is web-accessible (must be under
public/)
"Models not being indexed"
Run php artisan scolta:discover to find Searchable models not registered in config/scolta.php. The observer only tracks models listed there.
"The build stalled at N pages" / "did not complete within 50 resume segments"
The build continued itself in fresh processes and the chain was stopped — a segment committed nothing, or the 50-segment allowance ran out. The index was not republished and the previous one is still serving; every segment's output is on the console above the message.
- Give each process more room and rebuild:
php artisan scolta:build --memory-budget=balanced --restart - If
memory_limitcannot be raised, shrink the chunks instead:--chunk-size=25
Add the Searchable Trait
use Tag1\Scolta\Export\ContentItem; use Tag1\ScoltaLaravel\Searchable; class Article extends Model { use Searchable; public function toSearchableContent(): ContentItem { return new ContentItem( id: "article-{$this->id}", title: $this->title, bodyHtml: $this->body, url: "/articles/{$this->slug}", date: $this->updated_at->format('Y-m-d'), siteName: config('scolta.site_name', config('app.name')), metadata: ['type' => $this->getMorphClass()], ); } // Optional: filter which records to index public function scopeSearchable($query) { return $query->where('published', true); } }
Register the model in config/scolta.php:
'models' => [ App\Models\Article::class, App\Models\Page::class, ],
Artisan Commands
php artisan scolta:build # Full build: synchronous and verified (exit 0 = index built and live) php artisan scolta:build --queue # Defer the build to the queue (index is NOT built until a worker drains the chain) php artisan scolta:build --incremental # Deprecated no-op: this command is always a full build php artisan scolta:build --memory-budget=balanced # Use balanced memory profile php artisan scolta:build --resume # Resume an interrupted PHP index build php artisan scolta:build --restart # Discard interrupted state and rebuild from scratch (also discards the page-table ledger) php artisan scolta:build --reset-ledger # Discard the page-table ledger under a plain build, inline or --queue (escape hatch for a duplicate page ordinal) php artisan scolta:request-build # Queue one rebuild request for the worker; nothing is added when one is already waiting php artisan scolta:status # Show tracker, content, index, and AI status php artisan scolta:status --json # Same report as one JSON document on stdout (pipe to jq) php artisan scolta:discover # Find Searchable models not yet in config php artisan scolta:inspect /posts/123 # Show what the index holds for the record at that path: URL, indexed text, filters, metadata php artisan scolta:inspect --model=Post --id=123 # The same, by model and primary key php artisan scolta:inspect /posts/123 --json # The same, as one JSON document on stdout php artisan scolta:clear-cache # Clear Scolta AI response caches php artisan scolta:cleanup # Remove stale index artifacts, orphaned index fragments, and retired indexes php artisan scolta:cleanup --dry-run # Show what would be removed without deleting php artisan scolta:cleanup --retired-only # Sweep retired indexes only; skip the orphaned-fragment pass php artisan scolta:cleanup --max-seconds=60 # Stop sweeping retired indexes after 60 seconds php artisan scolta:memory-budget # Show the current memory budget profile php artisan scolta:memory-budget --set=balanced # Set profile: conservative, balanced, or aggressive php artisan scolta:check-setup # Verify PHP, AI provider, and browser assets php artisan scolta:amazee:provision {email} # Enable Amazee.ai with a free trial php artisan scolta:amazee:provision {email} --force # Provision even if a provider is already configured
Retired-index cleanup
Publishing a new index renames the outgoing one to a .scolta-trash-* directory beside pagefind/ and deletes it after the swap, rather than unlinking it file by file inside the swap. On NFS-backed storage that inline deletion ran at single-digit files per second, so a finished build looked hung for hours while the new index was already live. A rename is O(1), and the deletion afterwards is parallelized (16 concurrent rm workers) under a CLI process; environments without process spawning fall back to serial deletion automatically.
A successful build sweeps its own trash: scolta:build and the queued FinalizeIndex job both go through the orchestrator, which sweeps right after the swap. What is left over for a backstop is a build that failed or was killed during the merge: each retry retires the previous attempt's staging directory into trash.
Scolta schedules that backstop for you. The service provider registers a daily scolta:cleanup --retired-only on your application's scheduler, so nothing needs wiring beyond the schedule:run cron entry Laravel already asks for. It shows up under its own name in php artisan schedule:list. Each run spends at most cleanup.cron_seconds (default 180, SCOLTA_CLEANUP_CRON_SECONDS) deleting trash and then stops; the next run resumes on whatever is left. Set it to 0 to register no task at all, and schedule your own if you want different timing:
// routes/console.php — only needed if you set SCOLTA_CLEANUP_CRON_SECONDS=0 use Illuminate\Support\Facades\Schedule; Schedule::command('scolta:cleanup --retired-only --max-seconds=180')->hourly();
On demand, and unbounded unless you ask otherwise:
php artisan scolta:cleanup
php artisan scolta:cleanup --dry-run # List what would be deleted, delete nothing
Cleanup is always safe: the live pagefind/ index is never touched, .scolta-new and .scolta-building are left alone because a build may be using them right now, and a directory that cannot be deleted is left for the next run. .scolta-trash-* directories are also safe to remove by hand at any time. --retired-only restricts the command to this sweep; without it, it also clears orphaned index-fragment files from a partially built output directory, which a build still in flight may own — that is why the scheduled run passes it. The build state directory is never touched: chunk files live under builds/<generation>/ and scolta-php purges a dead build's directory itself at the start of the next build. The build lock (<state_dir>/lock) is never touched under either flag: scolta-php's BuildState owns that file, keeps it at a fixed path because it is flock()ed, and clears a genuinely stale lock itself. Do not delete it by hand either: unlinking an flock()ed file lets a second process lock a fresh file at the same path while the first still holds the old one, so a manual rm can put two builds into one state directory. When the command cannot resolve pagefind.output_dir, or the directory is not there, it writes to the Laravel log as well as stdout, so a scheduled run that is quietly doing nothing is visible in storage/logs/.
Incremental builds
Content edits update the index incrementally on their own. scolta:build is always a full
build. That split matches drush scolta:build in the Drupal adapter, and it puts the cheap
operation where the frequent one is.
Saving or deleting a model writes a scolta_tracker row and — with auto_rebuild on — queues a
debounced TriggerRebuild. That job applies the tracked changes to the index that is already
published rather than rebuilding the corpus: it rewrites only the fragments and index chunks the changed pages touch, reusing the page ordinals the
existing index already assigned. Nothing has to be scheduled or typed for this to happen; it needs
a queue worker, like every other part of auto-rebuild. php artisan scolta:request-build queues the
same job by hand.
The job is only ever in the queue while a build is requested or in progress, and it decides from the
build state directory what it is doing: an interrupted full build on disk — a worker killed
mid-build, a segment that yielded on memory pressure — is continued from where it stopped before
anything else, with further segments run as scolta:build --resume child processes so no segment
runs in a heap the previous one fragmented. A build that fails for a reason resuming cannot fix
fails the job, so it lands in Laravel's failed-jobs table.
The queued rebuild falls back to a full rebuild, writing the reason to the application log, whenever the update cannot be applied exactly:
scolta.incremental.enabledisfalse(SCOLTA_INCREMENTAL_ENABLED);- change tracking is unavailable, because the
scolta_trackermigration has not been run; - nothing is pending —
ScoltaObserver::afterBulkUpdate()asks for a rebuild without naming what changed, and so does the first-run auto-build; - there is no published index with a page-table ledger to update against (an incremental update applies to an index, it does not create one);
- the change set is larger than
scolta.incremental.max_changed_items(default 100, orSCOLTA_INCREMENTAL_MAX_ITEMS), above which a full rebuild is the cheaper of the two; - a tracked row names a record that has left the database, so the index item ids it owned can no longer be derived — a full rebuild derives deletions from the index itself and does not need that mapping, so a hard delete is applied by the fallback rather than by the update;
- the indexer itself refuses, for instance when a changed page's previous token data is no longer cached and its stale postings cannot be located.
A fallback is correct but slow, never wrong: the index is rebuilt and published either way.
Note: the queued full rebuild maintains the page-table ledger itself — each
ProcessIndexChunkassigns its pages' ordinals from it and populates the token cache, andFinalizeIndexreleases the rows the corpus no longer yields. So a fallback leaves the fast path available: the next content edit is an in-place update again, and a site whose only builds are queued gets incremental updates without ever runningscolta:build. Ordinals also survive the rebuild, so the fragment files an earlier build wrote still name the same pages.One thing only
scolta:builddoes is prune the token cache. The queued chain and the in-place update add entries and never remove them, so on a site that only ever builds through the queue the cache grows by one stale entry per content edit until it reaches its cap, after which new pages are no longer cached and their next edit falls back to a full rebuild. Schedulingscolta:buildoccasionally (see below) keeps it swept.
--incremental remains accepted on scolta:build and does nothing but print a deprecation warning,
so a deploy script that still passes it keeps working.
A rebuild of either kind clears the tracker rows it covered — those recorded before it gathered its
content, so an edit made while it ran survives for the next run. That is what keeps scolta:status
and /api/scolta/v1/health reporting a truthful pending_index.
API Endpoints
| Method | Path | Middleware | Description |
|---|---|---|---|
| POST | /api/scolta/v1/expand-query |
api, throttle:scolta | Expand a search query |
| POST | /api/scolta/v1/summarize |
api, throttle:scolta | Summarize search results |
| POST | /api/scolta/v1/followup |
api, throttle:scolta | Continue a conversation |
| GET | /api/scolta/v1/health |
api | Health check (status only when anonymous) |
| GET | /api/scolta/v1/build-progress |
api, auth:sanctum | Build status: idle, or building with its phase (progress only while gathering) |
| POST | /api/scolta/v1/rebuild-now |
api, auth:sanctum | Dispatch a rebuild job |
Route prefix and middleware are configurable via route_prefix and middleware in config/scolta.php.
The build-progress and rebuild-now endpoints require Sanctum authentication and are intended for admin dashboards. The AI endpoints (expand-query, summarize, followup) use the configurable middleware array and are typically public-facing.
Health detail authorization
GET /api/scolta/v1/health always answers monitoring tools: anonymous requests
get {"status": "ok"} (or "degraded"), HTTP 200. The full diagnostic payload
(AI provider, index integrity, tracker counts, asset staleness) requires the
scolta.health-detail Gate. By default any authenticated user passes. To change
who sees the detail, redefine the gate in your AuthServiceProvider:
Gate::define('scolta.health-detail', fn (User $user) => $user->isAdmin());
The detail payload comes from scolta-php's HealthChecker::check(), with the
Laravel-specific index, tracker and assets_* fields merged on top. This
package adds one fault of its own: an index that exists but fails the integrity
spot check (index.integrity.valid: false, with the specifics in
index.integrity.issues) reports status: degraded. Against a scolta-php that
reports status_reasons — the list of machine-readable fault keys, empty
exactly when the status is ok, added in scolta-php 1.5.0 — that failure also
appends index_integrity_invalid to the list, so the reasons never contradict
the status. An older scolta-php produces no status_reasons key and the
adapter does not add one.
Note: Amazee.ai admin routes (
/scolta/amazee/*) usewebmiddleware and are documented in the Amazee.ai Integration section below.
Searchable Trait API
| Method | Default | Description |
|---|---|---|
toSearchableContent() |
column heuristic | Return a ContentItem for indexing |
scopeSearchable($query) |
all records | Filter which records to index |
getSearchableType() |
morph class | Content type identifier for tracking: the class name, or its Relation::morphMap() alias. An override may return either; anything else cannot be resolved back to a model |
shouldBeSearchable() |
true |
Whether this instance should be indexed |
Optional Upgrades
Keeping the Index Fresh
When auto_rebuild is enabled (SCOLTA_AUTO_REBUILD=true in .env), a ScoltaObserver watches the models listed in config/scolta.php and dispatches a debounced TriggerRebuild job whenever a model is saved or deleted (default delay: 5 minutes). php artisan scolta:request-build dispatches the same job by hand. Everything below is about running a worker to drain it; run one in every environment that should index.
Every Scolta job runs on its own queue, named scolta. A worker that does not listen to scolta will never index — there is no error to read, the jobs simply sit there. Pass --queue=scolta to every command below.
A persistent worker (recommended)
php artisan queue:work --queue=scolta --tries=3
For production, use Supervisor or Laravel Forge to keep the worker running. Forge configures this automatically.
A worker from cron, for hosts without a daemon
Start a worker every minute and let it exit once the queue is empty:
* * * * * cd /var/www/html && php artisan queue:work --queue=scolta --stop-when-empty 2>&1 | logger -t scolta
Or, if the Laravel scheduler already has its cron entry, schedule the same command from routes/console.php:
* * * * * cd /var/www/html && php artisan schedule:run 2>&1 | logger -t scolta
use Illuminate\Support\Facades\Schedule; Schedule::command('queue:work --queue=scolta --stop-when-empty')->everyMinute()->withoutOverlapping();
A minute with nothing queued costs a process that exits at once. A build too large for one process yields, and the request stays queued, so the next minute continues it.
Do not schedule the job itself.
Schedule::job(new TriggerRebuild)->everyMinute()->withoutOverlapping()enqueues a job every minute whether or not anything changed, and a queue nobody is draining piles them up. The job belongs in the queue only while a build is requested or in progress; the worker is what runs every minute.
retry_after and the connection Scolta's worker uses
A build segment may hold its worker for the build lock's 3600 seconds, so the connection that worker reads must have a retry_after greater than that — otherwise a worker killed mid-build has its job handed to a second worker while the first one's lock is still held. retry_after is a property of the connection, not of the queue, so raising it on redis/database raises it for every other job in the application too: an hour before a killed worker's mail job is retried. If that is not acceptable, give Scolta its own connection in config/queue.php (a second entry pointing at the same backend, with the long retry_after) and start its worker with --connection=scolta-connection --queue=scolta. Your application's default connection then keeps whatever retry_after it had.
scolta:build is a full build: schedule it rarely, if at all, as the backstop for changes that bypass Eloquent events (query-builder mass updates) and to prune the token cache, which the queued path only ever adds to. It takes the same build lock as the queued job, so the two never run beside each other.
Schedule::command('scolta:build')->weeklyOn(0, '03:00');
Requirements
- Laravel 11, 12, or 13
- PHP 8.1+
Laravel 11 and end-of-life versions
Scolta runs on Laravel 11, 12, and 13, and its test suite covers all three so apps on older releases aren't stranded. Be aware of what that means for Laravel 11 specifically:
Laravel 11 reached end of life on 12 March 2026. The Laravel team no longer ships security patches for it, so advisories against laravel/framework 11.x stay open with no fix. To see which advisories affect the version you're actually running, run composer audit in your project — it reports the affected and fixed version ranges from the same database Scolta's CI uses. The published list is maintained at Laravel's security advisories.
Scolta installs and tests cleanly on Laravel 11 because it deliberately allows these upstream framework advisories in its own CI, which keeps the 11.x compatibility check green. That does not make your application secure — the Laravel version your app runs is your responsibility, and on 11.x you are running an unsupported framework with known, unpatched holes.
If you are on Laravel 11, upgrade to Laravel 12 (security-supported through 24 February 2027) or Laravel 13. If you must stay on 11 for now, treat it as temporary and plan the upgrade.
Testing
Unit tests (no Laravel bootstrap required):
cd packages/scolta-laravel
./vendor/bin/phpunit
Integration tests (requires DDEV):
cd test-laravel-12 ddev exec php vendor/bin/phpunit --testsuite=Integration
Coding standards:
cd packages/scolta-laravel composer lint # Laravel Pint composer format # Auto-fix violations composer semgrep # Security scan — same tool the AI PR reviewer runs (needs `brew install semgrep`)
Testing site
The repo ships a runnable demo site — a Laravel app (via Testbench Workbench) with SQLite and a Recipe model seeded from the 20 recipe fixtures in scolta-php's test suite, so what this adapter indexes is directly comparable with what scolta-php's own tests index:
composer install composer testing-site
That migrates, seeds, publishes assets, builds the index, and serves the site at http://127.0.0.1:8000. Search for aubergine parmesan — Eggplant Parmigiana should surface first. Faceting (cuisine, diet, cook time) works out of the box.
Notes:
- The fixtures come from scolta-php's
tests/directory, so this repo'scomposer.jsoninstallstag1/scolta-phpfrom source (preferred-install) — a git clone rather than a dist zip. - The site lives in
workbench/; environment defaults are inworkbench/.env.example. For local tweaks (e.g. an AI provider key to exercise expansion and summaries), copy it toworkbench/.env— git-ignored, and it takes precedence. - The served app runs inside Testbench's skeleton under
vendor/. Running the phpunit suite clears its public directory — just runcomposer testing-siteagain; the rebuild takes seconds.
Architecture
scolta-laravel (this package) scolta-php scolta-core (browser WASM)
Artisan commands ──────────> IndexBuildOrchestrator cleanHtml()
ScoltaAiService ───────────> AiClient
ScoltaServiceProvider ─────> ScoltaConfig
Searchable trait ──────────> DefaultPrompts (runs in browser)
ScoltaObserver ────────────> PhpIndexer scoreResults()
LaravelCacheDriver ────────> CacheDriverInterface mergeResults()
This package handles Laravel-specific concerns: Artisan commands, Eloquent model observation, Blade components, route registration, publishable config/migrations, and middleware. It depends on scolta-php and never on scolta-core directly. Scoring runs client-side via WebAssembly loaded by scolta.js.
src/
ScoltaServiceProvider.php Service provider (auto-discovered)
Searchable.php Trait for Eloquent models
Commands/BuildCommand.php artisan scolta:build
Commands/StatusCommand.php artisan scolta:status
Commands/DiscoverCommand.php artisan scolta:discover
Commands/InspectCommand.php artisan scolta:inspect
Http/Controllers/ExpandQueryController.php
Http/Controllers/SummarizeController.php
Http/Controllers/FollowUpController.php
Http/Controllers/HealthController.php
Models/ScoltaTracker.php Change tracking model
Observers/ScoltaObserver.php Auto-tracking observer
Services/ScoltaAiService.php AI service wrapper
Services/ContentSource.php Eloquent content source
Services/IndexLocator.php Finds the built index and its page count
config/scolta.php Publishable configuration
database/migrations/ Tracker table migration
routes/api.php API route definitions
resources/views/components/search.blade.php <x-scolta::search /> component
External Services
Scolta connects to external services under specific conditions. No data is sent automatically — all connections are triggered by developer action or explicit configuration.
AI Provider APIs
When: A visitor performs a search and AI features are enabled (SCOLTA_AI_EXPAND=true or SCOLTA_AI_SUMMARIZE=true in .env). AI features are disabled by default and require an API key to be configured.
What is sent: The user's search query text and selected page content excerpts (for result summarization) are sent to the configured AI provider's API endpoint. See AI Features and Privacy for full details on what is and is not transmitted.
Providers: The specific provider depends on the SCOLTA_AI_PROVIDER setting:
- Anthropic (Claude) — processes search queries and page excerpts. Terms of Service: https://www.anthropic.com/legal/consumer-terms Privacy Policy: https://www.anthropic.com/legal/privacy
- OpenAI — processes search queries and page excerpts. Terms of Use: https://openai.com/policies/terms-of-use Privacy Policy: https://openai.com/policies/privacy-policy
- OpenAI-compatible endpoints (including self-hosted Ollama and other providers) — any endpoint configured via
SCOLTA_AI_BASE_URL. Review the terms and privacy policy of your chosen provider.
No AI API calls are made unless SCOLTA_API_KEY is set and AI features are enabled.
About Tag1 Consulting
Scolta is designed, built, and maintained by Tag1 Consulting. Tag1 has been delivering technology leadership since 2007 and is one of the leading open-source consulting firms in the world.
Tag1 offers AI strategy, architecture, and implementation consulting — from evaluating whether AI search is right for your organization, to production deployment and ongoing tuning. If you need help integrating Scolta, customizing scoring for your content model, or connecting it to your AI provider of choice, get in touch.
Credits
Scolta is built on Pagefind by CloudCannon. Without Pagefind, Scolta has no search to score — the index format, WASM search engine, word-position data, and excerpt generation are all Pagefind's. Scolta's contribution is the layer that sits on top: configurable scoring, multi-adapter ranking parity, AI features, and platform glue.
License
MIT
Related Packages
- scolta-core — Rust/WASM scoring, ranking, and AI layer that runs in the browser.
- scolta-php — PHP library that indexes content into Pagefind-compatible indexes, plus the shared orchestration and AI client.
- scolta-drupal — Drupal 10/11 Search API backend with Drush commands, admin settings form, and a search block.
- scolta-wp — WordPress 6.x plugin with WP-CLI commands, Settings API page, and a
[scolta_search]shortcode.