A thin, composable Saloon v3 connector for the Qdrant vector database.
the-shit/vector wraps the Qdrant HTTP API behind a clean PHP interface without adding ceremony or abstraction layers you didn't ask for. It's built on Saloon v3, so you get first-class mock support, middleware, and the full Saloon ecosystem out of the box.
The package ships a VectorClient interface and a concrete Qdrant implementation, a fluent filter builder, five typed readonly DTOs, and a Laravel ServiceProvider with zero-config auto-discovery. You can use it standalone or drop it into any Laravel 11/12 app.
The goals are simple: stay thin, stay typed, stay testable.
QdrantConnector— Saloon connector with API key auth, configurable connect/request timeouts, and JSON headers wired by default- 7 request classes — create, delete, and get collections; upsert, search, scroll, and delete points
QdrantFilterbuilder — fluent chainable filters:must,mustNot,should,mustAny,mustRange, andfullText- 5 readonly DTOs —
Point,ScoredPoint,CollectionInfo,UpsertResult,ScrollResultwithfromArrayhydration VectorClientinterface — type-hint against the contract; swap implementations in tests without friction- Laravel ServiceProvider — auto-discovery, singleton bindings, config publishing, and environment variable support
composer require the-shit/vectoruse TheShit\Vector\Qdrant;
use TheShit\Vector\QdrantConnector;
$client = new Qdrant(
new QdrantConnector('http://localhost:6333', apiKey: 'your-key')
);
// Create a collection for 1536-dimensional OpenAI embeddings
$client->createCollection('documents', size: 1536);
// Upsert a point
$client->upsert('documents', [
new Point('doc-1', $embedding, ['title' => 'Hello World']),
]);
// Search
$results = $client->search('documents', $queryEmbedding, limit: 5);
foreach ($results as $hit) {
echo $hit->score . ' — ' . $hit->payload['title'] . PHP_EOL;
}- PHP 8.2+
- Qdrant running locally or via cloud
- Composer
composer require the-shit/vectorThe package auto-discovers. Publish the config if you want to commit it:
php artisan vendor:publish --tag=vector-configAdd to .env:
QDRANT_URL=http://localhost:6333
QDRANT_API_KEY=your-api-key// Create a collection (Cosine distance, 1536 dims)
$client->createCollection('documents', size: 1536);
// Other distance metrics
$client->createCollection('images', size: 512, distance: 'Dot');
$client->createCollection('audio', size: 768, distance: 'Euclid');
// Inspect a collection
$info = $client->getCollection('documents');
echo $info->status; // "green"
echo $info->pointsCount; // 4200
echo $info->indexedVectorsCount; // 4200
// Delete
$client->deleteCollection('old-collection');Pass Point DTOs or raw arrays — both are accepted:
use TheShit\Vector\Data\Point;
// Using Point DTOs (recommended)
$client->upsert('documents', [
new Point('doc-1', $embedding1, ['title' => 'Article One', 'category' => 'tech']),
new Point('doc-2', $embedding2, ['title' => 'Article Two', 'category' => 'science']),
]);
// Using raw arrays
$client->upsert('documents', [
['id' => 'doc-3', 'vector' => $embedding3, 'payload' => ['title' => 'Article Three']],
]);
// Check result
$result = $client->upsert('documents', $points);
$result->completed(); // true when status === 'completed'
$result->status; // "completed"
$result->operationId; // 42// Basic search
$results = $client->search('documents', $queryVector, limit: 10);
foreach ($results as $hit) {
echo $hit->id; // "doc-1"
echo $hit->score; // 0.94
echo $hit->payload['title'];
}
// Search with a filter
$filter = QdrantFilter::where('category', 'tech')->toArray();
$results = $client->search('documents', $queryVector, limit: 10, filter: $filter);Scroll through all points in a collection without a query vector. Supports cursor-based pagination:
// First page
$page = $client->scroll('documents', limit: 100);
foreach ($page->points as $point) {
echo $point->id . PHP_EOL;
}
// Paginate
while ($page->hasMore()) {
$page = $client->scroll('documents', limit: 100, offset: $page->nextOffset);
foreach ($page->points as $point) {
// ...
}
}// Delete by IDs
$client->delete('documents', ids: ['doc-1', 'doc-2', 42]);
// Delete by filter (e.g. archive sweep)
$filter = QdrantFilter::where('status', 'archived')->toArray();
$client->delete('documents', filter: $filter);QdrantFilter builds Qdrant filter payloads with a fluent interface. Call toArray() to get the raw array to pass to search, scroll, or delete.
use TheShit\Vector\Filters\QdrantFilter;
// Single condition (static entry point)
$filter = QdrantFilter::where('category', 'music')->toArray();
// Chain multiple must conditions
$filter = QdrantFilter::where('type', 'track')
->must('status', 'active')
->toArray();
// Must not
$filter = (new QdrantFilter)
->mustNot('status', 'archived')
->toArray();
// Should (OR semantics)
$filter = (new QdrantFilter)
->should('genre', 'rock')
->should('genre', 'jazz')
->toArray();$filter = (new QdrantFilter)
->mustAny('genre', ['rock', 'punk', 'metal'])
->toArray();// Between 0.5 and 1.0
$filter = (new QdrantFilter)
->mustRange('energy', gte: 0.5, lte: 1.0)
->toArray();
// Greater than 120 BPM
$filter = (new QdrantFilter)
->mustRange('tempo', gt: 120.0)
->toArray();Supported bounds: gte, lte, gt, lt. Null values are omitted from the output.
$filter = (new QdrantFilter)
->fullText('description', 'punk rock')
->toArray();$filter = QdrantFilter::where('type', 'track')
->must('status', 'active')
->mustNot('explicit', true)
->mustAny('genre', ['rock', 'metal'])
->mustRange('energy', gte: 0.6)
->should('mood', 'hype')
->toArray();Empty condition arrays are stripped automatically — a new QdrantFilter with nothing added returns [].
The VectorClient interface is bound to Qdrant as a singleton. Type-hint against the interface anywhere Laravel resolves dependencies:
use TheShit\Vector\Contracts\VectorClient;
class EmbeddingService
{
public function __construct(
private readonly VectorClient $vector,
) {}
public function similar(array $embedding): array
{
return $this->vector->search('documents', $embedding, limit: 5);
}
}After publishing, config/vector.php:
return [
'url' => env('QDRANT_URL', 'http://localhost:6333'),
'api_key' => env('QDRANT_API_KEY'),
'timeout' => [
'connect' => (int) env('QDRANT_CONNECT_TIMEOUT', 10),
'request' => (int) env('QDRANT_REQUEST_TIMEOUT', 30),
],
];| Variable | Description | Default |
|---|---|---|
QDRANT_URL |
Qdrant base URL | http://localhost:6333 |
QDRANT_API_KEY |
API key (optional for local) | — |
QDRANT_CONNECT_TIMEOUT |
Connection timeout in seconds | 10 |
QDRANT_REQUEST_TIMEOUT |
Request timeout in seconds | 30 |
src/
├── Qdrant.php # VectorClient implementation
├── QdrantConnector.php # Saloon connector (auth, base URL, timeouts)
├── VectorServiceProvider.php # Laravel auto-discovery, singleton bindings
│
├── Contracts/
│ ├── VectorClient.php # Primary interface for DI
│ └── FilterBuilder.php # Contract for toArray()
│
├── Filters/
│ └── QdrantFilter.php # Fluent filter builder
│
├── Data/ # Readonly DTOs
│ ├── Point.php
│ ├── ScoredPoint.php
│ ├── CollectionInfo.php
│ ├── UpsertResult.php
│ └── ScrollResult.php
│
└── Requests/ # Saloon request classes
├── Collections/
│ ├── CreateCollectionRequest.php
│ ├── DeleteCollectionRequest.php
│ └── GetCollectionRequest.php
└── Points/
├── UpsertPointsRequest.php
├── SearchPointsRequest.php
├── ScrollPointsRequest.php
└── DeletePointsRequest.php
HTTP flow:
Application
│
▼
VectorClient (interface)
│
▼
Qdrant ─────▶ QdrantConnector ─────▶ Qdrant HTTP API
(operations) (Saloon, auth, (localhost:6333
timeouts) or cloud)
The suite uses Pest v4 with Saloon's built-in MockClient — no HTTP calls, no running Qdrant instance required.
./vendor/bin/pestMock individual request classes against canned responses:
use Saloon\Http\Faking\MockClient;
use Saloon\Http\Faking\MockResponse;
use TheShit\Vector\Qdrant;
use TheShit\Vector\QdrantConnector;
use TheShit\Vector\Requests\Points\SearchPointsRequest;
$mock = new MockClient([
SearchPointsRequest::class => MockResponse::make([
'result' => [
['id' => 'doc-1', 'score' => 0.95, 'payload' => ['title' => 'Result']],
],
'status' => 'ok',
]),
]);
$connector = new QdrantConnector('http://localhost:6333', 'test-key');
$connector->withMockClient($mock);
$client = new Qdrant($connector);
$results = $client->search('documents', [0.1, 0.2], limit: 1);
expect($results[0]->score)->toBe(0.95);
$mock->assertSent(SearchPointsRequest::class);Bind a mock in your TestCase or individual test:
use TheShit\Vector\Contracts\VectorClient;
$this->mock(VectorClient::class)
->shouldReceive('search')
->once()
->andReturn([]);# Linting
./vendor/bin/pint
# Static analysis / refactoring
./vendor/bin/rector --dry-rungit clone https://github.com/the-shit/vector.git
cd vector
composer install./vendor/bin/pest --coverage- Strict types on every file (
declare(strict_types=1)) - Readonly DTOs throughout
- No external dependencies beyond
saloonphp/saloonat runtime - Laravel framework is a dev dependency only — this package works standalone
- Fork the repository
- Create a feature branch:
git checkout -b feature/your-feature - Write tests for any new behaviour
- Run
./vendor/bin/pest,./vendor/bin/pint, and./vendor/bin/rector --dry-run - Open a pull request
Bug reports and feature requests welcome via GitHub Issues.
MIT — see LICENSE.
Built by Jordan Partridge. Powered by Saloon and Qdrant.