Step-by-Step Installation & Configuration
This guide connects a Laravel application to SearchJet using Laravel's built-in HTTP client. SearchJet has no Laravel package, so there's nothing to composer require. You add your two keys to .env, register them in config/services.php, write a small service class, and add a model observer that keeps your index in sync.
Step 1: Get your keys
In the dashboard at app.searchjetengine.com:
- Public search key: shown on the Sites list as "Public Key". It's read-only, and you use it for
GET /search. - Private key: generate it under Sites → API Keys. It's 32 characters long and shown only once, so copy it straight into
.env. You need it for indexing, deleting, analytics and AI.
API Key Setup covers rotation and storage.
Step 2: Add the keys to your environment
# .env (never commit this file)
SEARCHJET_URL=https://app.searchjetengine.com/api/v1
SEARCHJET_PUBLIC_KEY=your_public_search_key
SEARCHJET_PRIVATE_KEY=your_32_character_private_key
Register them in config/services.php, so they still work after php artisan config:cache:
// config/services.php
'searchjet' => [
'url' => env('SEARCHJET_URL', 'https://app.searchjetengine.com/api/v1'),
'public_key' => env('SEARCHJET_PUBLIC_KEY'),
'private_key' => env('SEARCHJET_PRIVATE_KEY'),
],
Step 3: Write a small service class
This class belongs to your application, not to a vendor package. Put it at app/Services/SearchJet.php:
withToken(config("services.searchjet.$key"))
->acceptJson()
->timeout(15);
}
/** Search with the public key. Cached, because server-side searches share one IP rate limit. */
public function search(string $query, array $params = []): array
{
$cacheKey = 'searchjet:' . md5($query . serialize($params));
return Cache::remember($cacheKey, 300, fn () =>
$this->client('public_key')->get('/search', ['q' => $query] + $params)->throw()->json()
);
}
/** Add or replace documents (POST). At most 1,000 per call. */
public function index(array $documents): array
{
return $this->client('private_key')->post('/index/bulk', ['documents' => $documents])->throw()->json();
}
/** Merge fields into existing documents (PATCH). */
public function update(array $documents): array
{
return $this->client('private_key')->patch('/index/bulk', ['documents' => $documents])->throw()->json();
}
/** Delete one or more documents by id (max 500). */
public function delete(array $ids): array
{
return $this->client('private_key')->delete('/index/delete', ['ids' => array_values($ids)])->throw()->json();
}
}
acceptJson() matters: without it, validation errors come back as redirects instead of JSON. search() returns the full response, with hits in results. On Free, hits contain id, title, url, type, thumbnail, price, currency and sku, with no excerpt. index() returns a queued task containing taskUid.
Step 4: Verify the connection
// php artisan tinker
app(\App\Services\SearchJet::class)->search('test');
If you get an array with a results key, the integration works. A 401 means the key is wrong or is the wrong type: search needs the public key. Run php artisan config:clear after you edit .env.
Step 5: Index your models with an observer
Push each indexing request onto a queue, so the network call never slows down a web request:
postId);
try {
if (! $post || ! $post->is_published) {
$searchjet->delete(['post-' . $this->postId]);
return;
}
$searchjet->index([[
'id' => 'post-' . $post->id,
'title' => $post->title,
'content' => strip_tags($post->body),
'url' => route('posts.show', $post),
'excerpt' => $post->excerpt,
'type' => 'post',
'author' => $post->author?->name,
'date' => $post->published_at?->toDateString(),
'tags' => $post->tags->pluck('name')->all(),
]]);
} catch (RequestException $e) {
$error = $e->response->json('code') ?? $e->response->json('error');
if (in_array($error, ['quota_exceeded', 'Abuse detected'], true)) {
$this->fail($e); // retrying won't help
return;
}
throw $e; // rate limits and 5xx: retried with $backoff
}
}
}
id);
}
public function deleted(Post $post): void
{
SyncPostToSearchJet::dispatch($post->id);
}
}
Register the observer in AppServiceProvider::boot() with Post::observe(PostObserver::class);, or add the #[ObservedBy] attribute to the model.
Each document needs an id plus at least one other non-empty field. Use only letters, digits, - and _ in ids. Indexing is asynchronous, so a saved post shows up in search a few seconds later. To confirm it, check GET /index/tasks/{taskUid}. On Free, private-key requests are limited to 10 per minute, so let the queue's backoff absorb bursts of edits.
Step 6: Index existing records
Backfill in batches of up to 1,000 documents. On Free and Pro, sending more than 1,000 documents per minute triggers abuse detection, so wait about a minute between batches:
use App\Models\Post;
use App\Services\SearchJet;
Post::query()->where('is_published', true)->chunkById(1000, function ($posts) {
app(SearchJet::class)->index($posts->map(fn ($post) => [
'id' => 'post-' . $post->id,
'title' => $post->title,
'content' => strip_tags($post->body),
'url' => route('posts.show', $post),
'type' => 'post',
])->all());
sleep(61);
});
Close to your plan's page limit, the API counts re-sent ids as new documents. Check GET /index/count before you run a large backfill.
Next steps
- Basic Usage: search, filter and paginate
- Index Management API: every indexing endpoint
- Error Handling: retries and fallbacks