the-shit/vector

Thin, composable Saloon connector for the Qdrant vector database

Maintainers

Package info

github.com/the-shit/vector

pkg:composer/the-shit/vector

Statistics

Installs: 2

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.1 2026-04-06 04:02 UTC

README

Tests Coverage PHP License

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
  • QdrantFilter builder — fluent chainable filters: must, mustNot, should, mustAny, mustRange, and fullText
  • 5 readonly DTOsPoint, ScoredPoint, CollectionInfo, UpsertResult, ScrollResult with fromArray hydration
  • VectorClient interface — 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/saloon at runtime
  • Laravel framework is a dev dependency only — this package works standalone

Contributing

  1. Fork the repository
  2. Create a feature branch: git checkout -b feature/your-feature
  3. Write tests for any new behaviour
  4. Run ./vendor/bin/pest, ./vendor/bin/pint, and ./vendor/bin/rector --dry-run
  5. 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.