amplify/sayt
Sayt adds search-as-you-type to Laravel, returning live suggestions as users type. Wire it to your models or query builder, tune matching and ranking, and drop results into any Blade or API response.
amplify/sayt is a small add-on module for Amplify CMS that adds a search-as-you-type autocomplete endpoint (and the JS widget that calls it) on top of the CMS's content index. It is not a general-purpose search package — it assumes you already have Amplify installed and a populated search index.
Install and wire it up
composer require amplify/sayt
# config + any migrations (morph map / index table, if the build ships one)
php artisan vendor:publish --provider="Amplify\Sayt\SaytServiceProvider"
# build/refresh the index the autocomplete reads from
php artisan sayt:index
Then confirm the route exists before touching any front-end:
php artisan route:list | grep -i sayt
curl -s "https://your-app.test/api/sayt?q=about" -H "Accept: application/json" | jq
Where to look first
config/sayt.php — minimum query length, result limit, debounce hint, indexed content types, cache TTL. This is the file you'll actually change 90% of the time.vendor/amplify/sayt/src/Http/Controllers/ — the endpoint's request/response shape. Copy this contract into your front-end rather than guessing.vendor/amplify/sayt/src/Contracts/ — the result transformer interface; the extension point for shaping titles/URLs/excerpts.First use case — replace the slow submit-and-reload search in the Amplify admin with a typeahead that returns grouped results (Entries, Pages, Media) after two keystrokes, and jumps straight to the editor on selection. That's the whole value proposition: fewer page loads for editors, not a public-facing search engine.
Treat the endpoint as the product, not the widget. The bundled JS is thin; most teams either use it as-is in the admin or throw it away and call the route from their own Alpine/Livewire component. Pin down the JSON contract in a feature test first:
it('returns grouped suggestions', function () {
$this->getJson('/api/sayt?q=abo')
->assertOk()
->assertJsonStructure([
'data' => [['type', 'title', 'url', 'excerpt']],
'meta' => ['query', 'took_ms'],
]);
});
Front-end: debounce + abort the previous request. This is the pattern that makes or breaks perceived latency:
let controller, timer;
input.addEventListener('input', (e) => {
const q = e.target.value.trim();
clearTimeout(timer);
controller?.abort(); // kill the in-flight request
if (q.length < 2) return render([]); // mirror `min_length` in config
timer = setTimeout(async () => {
controller = new AbortController();
const res = await fetch(`/api/sayt?q=${encodeURIComponent(q)}`, {
signal: controller.signal,
headers: { Accept: 'application/json' },
});
render((await res.json()).data);
}, 150); // ~150–250ms feels right
});
Keep the index fresh from model events, on the queue, after commit. The single most common bug in this class of package is indexing a record that a transaction later rolls back.
protected static function booted(): void
{
static::saved(fn ($m) => \Amplify\Sayt\Facades\Sayt::indexModel($m)->afterCommit()->onQueue('search'));
static::deleted(fn ($m) => \Amplify\Sayt\Facades\Sayt::forgetModel($m)->afterCommit());
}
Scope results to what the user is allowed to see. The autocomplete is an enumeration oracle — a draft entry showing up in an editor's typeahead is a content leak. Apply visibility filters in a custom transformer or a thin wrapper service so every caller gets the same rules:
Sayt::query($term)
->types(config('sayt.types'))
->whereIn('status', ['published', 'review']) // or gate() per model
->limit(8)
->get();
Cache aggressively per locale and per role bucket. Autocomplete traffic is bursty and repetitive (people type the same two prefixes). A 60–300s cache keyed on sha1($q . $locale . $roleBucket) cuts DB work to near zero and is trivially safe to bust on save.
Reindex in CI and in deploys. Add php artisan sayt:index to your deploy script after migrations, and a sayt:index --fresh job when the schema or weighting config changes. Do not rely on the index being right by accident.
composer.json, read the controller before trusting it, and wrap it behind your own thin service class. That way a fork or a replacement package is a one-file change.amplify-plugin/sayt-module but Packagist may resolve to amplify/sayt; if composer require fails, check the name field in the upstream composer.json rather than guessing.web with session auth, an unauthenticated fetch from a different host (or a missing X-CSRF-TOKEN) gives you an empty, silent 419. Decide deliberately: admin-only behind auth + throttle:60,1, or public behind a stricter throttle and a visibility filter.throttle: a single impatient user can generate hundreds of requests a minute. Rate-limit by IP and user, and log 429s — they mean your debounce is too tight.onConnection('sync')) or have the widget re-query after save. The afterCommit pattern above prevents ghosts; queue lag needs separate handling.AbortController (above) handles most of it; add a sequence number if you can't rely on abort.min_length and your JS guard drifting apart yields "why does it fetch but show nothing" bug reports. Read it from config and expose it to the view.Model::query()->update([...]) and raw SQL bypass model events, so the index silently desyncs. Any mass update needs a follow-up sayt:index --fresh (or a ->chunkById() loop over save()).took_ms at debug level behind a config flag. When results are wrong, the question is always "is it the index or the query?" — sayt:index --dry-run (if available) or a direct query with ->toSql() answers that in one step.vendor/, you've picked the wrong seam.How can I help you explore Laravel packages today?