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
composer require bainquet/laravel
php artisan vendor:publish --tag=bainquet-configThe service provider auto-discovers through extra.laravel.providers.
Configuration
Put secrets in .env, never in config/bainquet.php (which only references env()):
BAINQUET_CONNECTOR_TOKEN=connectorId.secret
BAINQUET_CONNECTOR_WEBSITE_ID=ws_xxx
BAINQUET_SITE_DOMAIN=example.com
BAINQUET_API_URL=https://api.bainquet.online/v1/ingestBAINQUET_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:
'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:
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/deletekeyed 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
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 statebainquet: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-runto 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:
php tests/SignerParityTest.php
php tests/MapperTest.php