Skip to content

Laravel connector

A Composer package that streams a Laravel app's Eloquent models to the bAInquet ingestion API, mapping each model to the canonical IngestItem shape, signing every request with the bq.connector.hmac.v1 HMAC scheme, and POSTing idempotent batches.

Stable HMAC parity-tested against the project-wide known-good signing vector.

What it maps

Laravel apps have no fixed schema, so mapping is data-driven and default-deny: a model is ingested only if it appears in the mappings config or implements the Mappable contract. Nothing else is ever sent, which limits accidental ingestion of private data.

Each mapped model becomes one IngestItem whose type is a bAInquet SourceType (page, post, product, category, collection, faq, review, event, location, profile, doc, media, custom).

Install

bash
composer require bainquet/laravel
php artisan vendor:publish --tag=bainquet-config

The service provider auto-discovers through extra.laravel.providers.

Configuration

Put secrets in .env, never in config/bainquet.php (which only references env()):

dotenv
BAINQUET_CONNECTOR_TOKEN=connectorId.secret
BAINQUET_CONNECTOR_WEBSITE_ID=ws_xxx
BAINQUET_SITE_DOMAIN=example.com
BAINQUET_API_URL=https://api.bainquet.online/v1/ingest

BAINQUET_CONNECTOR_WEBSITE_ID is the website the token is scoped to; it is the HKDF salt for signing. BAINQUET_SITE_DOMAIN is sent as X-Site-Domain, and every emitted url host must equal it.

WARNING

The connector token is shown once when issued. Store it in .env or a secrets manager. Do not commit it.

Mapping pattern

You have two ways to declare a model's mapping. Both feed the same ModelMapper.

1. Config-driven (no code)

Add a Model::class => MappingSpec entry under mappings in config/bainquet.php:

php
'mappings' => [
    \App\Models\Post::class => [
        'type'       => 'post',           // a SourceType enum value
        'id'         => 'id',
        'id_prefix'  => 'post',           // stable id => "post:<id>"
        'url'        => 'permalink',
        'title'      => 'title',
        'html'       => 'body_html',
        'language'   => 'locale',         // attribute, or a literal like 'en'
        'updated_at' => 'updated_at',
        'json'       => ['slug', 'author_name'],
    ],
    \App\Models\Product::class => [
        'type'       => 'product',
        'id_prefix'  => 'product',
        'url'        => 'url',
        'title'      => 'name',
        'html'       => 'description',
        'json'       => ['sku', 'in_stock'],
        'prices'     => [                 // typed price split
            'price' => ['value' => 'price_amount', 'currency' => 'currency'],
        ],
    ],
],

prices produces a typed { value, currency } object rather than a concatenated string like "$19.99", so the price is machine-readable downstream.

2. The Mappable contract (full control)

Implement Mappable and either use the MapsToBainquet trait for the config-driven default toBainquet(), or write your own:

php
use Bainquet\Laravel\Contracts\Mappable;
use Bainquet\Laravel\Concerns\MapsToBainquet;
use Bainquet\Laravel\Sdk\Checksum;

class Post extends Model implements Mappable
{
    use MapsToBainquet; // config-driven default toBainquet()

    // ...or write your own:
    public function toBainquet(): array
    {
        $item = [
            'type'  => 'post',
            'id'    => 'post:' . $this->id,
            'url'   => route('posts.show', $this),
            'title' => $this->title,
            'html'  => $this->body_html,
            'language'   => 'en-US',
            'updated_at' => $this->updated_at->utc()->format('Y-m-d\TH:i:s\Z'),
            'json'  => ['slug' => $this->slug],
        ];
        $item['checksum'] = Checksum::of($item);
        return $item;
    }
}

Checksum::of() produces the deterministic sha256: checksum the server uses to skip unchanged items. An unchanged model yields the same checksum, so the server returns skipped.

Incremental sync

The package registers a ModelObserver on every mapped model and listens for Eloquent saved / restored / deleted events:

  • A save or restore upserts the item to POST /v1/ingest/item.
  • A delete POSTs a tombstone to POST /v1/ingest/delete keyed by the model's stable id.

Single-item upserts are sent synchronously on the model event in the current release; a queued, debounced outbox is planned.

Backfill

bash
php artisan bainquet:sync                          # all mapped models
php artisan bainquet:sync --model="App\Models\Post" # one model
php artisan bainquet:sync --dry-run                # build + print, send nothing
php artisan bainquet:status                        # heartbeat + verification state

bainquet:sync chunks each mapped model and batch-POSTs to POST /v1/ingest/batch (1 to 500 items per batch). --dry-run builds the exact would-send batch and prints it without posting, so you can inspect what leaves your app before going live. bainquet:status sends a heartbeat and prints the server's verification state.

Signing

Every request is signed exactly as the server verifies it:

signingKey  = HKDF-SHA256(secret, salt = websiteId, info = "bq.connector.hmac.v1", 32 bytes)
canonical   = METHOD\npath\nsha256(body)\ntimestamp\nnonce\nwebsiteId
X-Signature = hex HMAC-SHA256(signingKey, canonical)

The client signs the exact serialized bytes it sends (it uses ->withBody($raw, 'application/json'), never ->post($url, $array), so the body is never re-encoded). Retries on 5xx / 429 reuse the same Idempotency-Key with a fresh nonce and timestamp. Headers sent: Authorization: Bearer <token>, X-Site-Domain, X-Connector-Type: laravel, X-Connector-Version, X-Signature, X-Timestamp, X-Nonce, X-Body-Sha256, Idempotency-Key.

See Ingestion and signing for the full scheme.

Out of scope

  • A queued outbox with debounce and coalescing is a later-phase enhancement; the current release sends single-item upserts synchronously on the model event.
  • Visibility is the developer's responsibility: only mapped models, returning only the records your query scopes expose, are ingested. Use --dry-run to verify exactly what would be sent.

Verifying the signer

A standalone parity test asserts the signer is byte-compatible with the server, with no Laravel boot:

bash
php tests/SignerParityTest.php
php tests/MapperTest.php

Owner-controlled structured data for AI.