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 exampleApp\Entity\Article#42), so it survives a host move with the same database. price_fieldsproduce typed{ value, unit, currency }objects, never a concatenated string.- A
DateTimeInterfaceupdated_atis normalized to RFC3339 UTCZ;languageis 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
composer require bainquet/symfony-bundleRegister the bundle (Symfony Flex does this automatically) in config/bundles.php:
Bainquet\SymfonyBundle\BainquetBundle::class => ['all' => true],Configuration
Add config/packages/bainquet.yaml. Secrets resolve from env or the Symfony secrets vault, never inline:
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):
# config/services.yaml
services:
Bainquet\SymfonyBundle\Mapper\ItemMapperInterface:
alias: App\Bainquet\MyItemMapperYour 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/postUpdateupsert a mapped entity toPOST /v1/ingest/item.preRemovePOSTs a tombstone toPOST /v1/ingest/deletekeyed 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
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 statebainquet: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:
php tests/SignerParityTest.php
php tests/MapperTest.php