Skip to content

Symfony connector

A Symfony bundle that streams an app's Doctrine entities to the bAInquet ingestion API, mapping each entity 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

Any Doctrine entity declared under mappings, read through its getX() / isX() / x() accessors. The mapping is default-deny: only entities whose fully qualified class name appears under mappings are ever ingested, which limits over-collection.

  • The stable id is Fqcn#pk (for example App\Entity\Article#42), so it survives a host move with the same database.
  • price_fields produce typed { value, unit, currency } objects, never a concatenated string.
  • A DateTimeInterface updated_at is normalized to RFC3339 UTC Z; language is normalized to BCP-47.

Each entity's source_type must be a bAInquet SourceType (page, post, product, category, collection, faq, review, event, location, profile, doc, media, custom).

Install

bash
composer require bainquet/symfony-bundle

Register the bundle (Symfony Flex does this automatically) in config/bundles.php:

php
Bainquet\SymfonyBundle\BainquetBundle::class => ['all' => true],

Configuration

Add config/packages/bainquet.yaml. Secrets resolve from env or the Symfony secrets vault, never inline:

yaml
bainquet:
  token: '%env(BAINQUET_CONNECTOR_TOKEN)%'        # connectorId.secret
  website_id: '%env(BAINQUET_CONNECTOR_WEBSITE_ID)%'
  site_domain: '%env(BAINQUET_SITE_DOMAIN)%'      # -> X-Site-Domain
  api_base: 'https://api.bainquet.online/v1/ingest'
  mappings:
    'App\Entity\Article':
      source_type: post                            # a SourceType enum value
      id_property: id                              # stable id => "App\Entity\Article#<id>"
      url: url                                     # getUrl()/getUrl property
      title: title
      html: body
      language: locale                            # property, or a literal like 'en'
      updated_at: updatedAt                       # \DateTimeInterface or timestamp
      json: [slug, authorName]
    'App\Entity\Product':
      source_type: product
      url: url
      title: name
      html: description
      json: [sku]
      price_fields:                                # typed price split
        price: { value: priceAmount, currency: currency }

The config tree is validated at container-compile time: an unknown key or a mapping missing source_type fails the build, not a runtime request.

WARNING

The connector token is shown once when issued. Resolve it from env or the secrets vault. Do not inline it in the YAML.

Mapping pattern

The bundle ships a DoctrineEntityMapper driven by the mappings config above. To take full control, replace the mapper by aliasing the Bainquet\SymfonyBundle\Mapper\ItemMapperInterface service to your own implementation (for example, to map an API Platform resource source instead of a raw entity):

yaml
# config/services.yaml
services:
  Bainquet\SymfonyBundle\Mapper\ItemMapperInterface:
    alias: App\Bainquet\MyItemMapper

Your implementation receives the entity and returns an IngestItem array with a computed sha256: checksum.

Incremental sync

A Doctrine event listener (DoctrineChangeSubscriber) reacts to entity lifecycle events:

  • postPersist / postUpdate upsert a mapped entity to POST /v1/ingest/item.
  • preRemove POSTs a tombstone to POST /v1/ingest/delete keyed by the entity's stable id.

Single-item upserts are sent synchronously on the Doctrine event in the current release; a Messenger-backed outbox with debounce is planned.

Backfill

bash
bin/console bainquet:sync                 # all mapped entities
bin/console bainquet:sync --type=product  # one source type
bin/console bainquet:sync --dry-run       # build + print, send nothing
bin/console bainquet:verify               # heartbeat + verification state

bainquet:sync walks each mapped entity in chunks and batch-POSTs to POST /v1/ingest/batch. --dry-run builds and prints the would-send batch without posting. bainquet:verify sends a heartbeat and prints the server's verification state.

Signing

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 exact serialized bytes are signed and sent (the HttpClient body option, never json, 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: symfony, 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 Messenger-backed outbox with debounce is a later-phase enhancement; the current release sends single-item upserts synchronously on the Doctrine event.
  • Twig, route, and API Platform extractors are future work. This bundle ships the Doctrine entity mapper (the conformance burden) plus the full transport and glue.

Verifying the signer

Standalone parity tests assert the signer is byte-compatible with the server, with no Symfony boot:

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

Owner-controlled structured data for AI.