the-shit / vector
Thin, composable Saloon connector for the Qdrant vector database
Requires
- php: ^8.3
- saloonphp/saloon: ^4.0
Requires (Dev)
- laravel/framework: ^11.0|^12.0
- laravel/pint: ^1.0
- nunomaduro/pao: dev-main
- opis/json-schema: ^2.6
- orchestra/testbench: ^9.0|^10.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-arch: ^4.0
- rector/rector: ^2.0
- spatie/invade: ^2.1
This package is auto-updated.
Last update: 2026-04-06 04:04:13 UTC
README
A thin, composable Saloon v3 connector for the Qdrant vector database.
Overview
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.
Features
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
Quick Start
composer require the-shit/vector
use 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; }
Installation
Prerequisites
- PHP 8.2+
- Qdrant running locally or via cloud
- Composer
Install
composer require the-shit/vector
Laravel Setup
The package auto-discovers. Publish the config if you want to commit it:
php artisan vendor:publish --tag=vector-config
Add to .env:
QDRANT_URL=http://localhost:6333 QDRANT_API_KEY=your-api-key
Usage
Collections
// 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');
Upserting Points
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
Searching
// 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);
Scrolling
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) { // ... } }
Deleting Points
// 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);
Filters
QdrantFilter builds Qdrant filter payloads with a fluent interface. Call toArray() to get the raw array to pass to search, scroll, or delete.
Basic Matching
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();
Match Any (IN clause)
$filter = (new QdrantFilter) ->mustAny('genre', ['rock', 'punk', 'metal']) ->toArray();
Range
// 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.
Full-Text Search
$filter = (new QdrantFilter) ->fullText('description', 'punk rock') ->toArray();
Combining Conditions
$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 [].
Laravel Integration
Dependency Injection
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); } }
Configuration
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), ], ];
Environment Variables
| 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 |
Architecture
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)
Testing
The suite uses Pest v4 with Saloon's built-in MockClient — no HTTP calls, no running Qdrant instance required.
./vendor/bin/pest
Writing Tests
Mock 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);
In Laravel Tests
Bind a mock in your TestCase or individual test:
use TheShit\Vector\Contracts\VectorClient; $this->mock(VectorClient::class) ->shouldReceive('search') ->once() ->andReturn([]);
Code Quality
# Linting ./vendor/bin/pint # Static analysis / refactoring ./vendor/bin/rector --dry-run
Development
Local Setup
git clone https://github.com/the-shit/vector.git
cd vector
composer install
Running the Full Suite
./vendor/bin/pest --coverage
Project Standards
- 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
Contributing
- 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.
License
MIT — see LICENSE.
Credits
Built by Jordan Partridge. Powered by Saloon and Qdrant.