Skip to content
SearchJet
Updated October 1, 2026

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

Was this article helpful?

Ready to add search to your project?

Start free. No credit card required. Deploy in under 10 minutes.