A multi-client middleware platform built with Symfony 7.4 LTS that receives Shopify webhooks, processes them asynchronously, normalises payloads per client, and syncs order data to downstream ERP/WMS systems.
Built as a hands-on Symfony learning project mirroring the architecture of real-world integration platforms.
Shopify (or any webhook source)
│
│ POST /webhook/order
│ X-Shopify-Hmac-Sha256: <signature>
▼
┌─────────────────────────────┐
│ Nginx (port 8080) │
└─────────────┬───────────────┘
│
▼
┌─────────────────────────────┐
│ WebhookController │
│ 1. Verify HMAC signature │
│ 2. Check idempotency key │
│ 3. Persist WebhookEvent │
│ 4. Dispatch to queue │
│ 5. Return 202 immediately │
└─────────────┬───────────────┘
│
│ Redis queue
▼
┌─────────────────────────────┐
│ OrderReceivedMessageHandler│ ← async worker
│ 1. Resolve client config │
│ 2. Transform payload │
│ 3. POST to WMS/ERP │
│ 4. Update status │
└─────────────────────────────┘
│
▼
┌─────────────────────────────┐
│ REST API (/api/*) │
│ GET /api/sync-events │ ← authenticated, client-scoped
│ GET /api/sync-events/{id} │
└─────────────────────────────┘
| Layer | Technology |
|---|---|
| Framework | Symfony 7.4 LTS |
| Language | PHP 8.2 |
| ORM | Doctrine ORM |
| Queue | Symfony Messenger + Redis |
| Database | MySQL 8 |
| Cache/Queue Transport | Redis |
| Web Server | Nginx |
| Runtime | PHP 8.2-FPM |
| Containerisation | Docker + Docker Compose |
| Testing | PHPUnit 11 |
| Logging | Monolog |
sync_bridge/
├── app/
│ ├── src/
│ │ ├── Command/ # Console commands
│ │ │ ├── GenerateWebhookSignatureCommand.php
│ │ │ ├── SyncRetryFailedCommand.php
│ │ │ └── SyncStatusCommand.php
│ │ ├── Controller/ # HTTP controllers
│ │ │ ├── MockWmsController.php
│ │ │ ├── SyncEventController.php
│ │ │ └── WebhookController.php
│ │ ├── Entity/ # Doctrine entities
│ │ │ └── WebhookEvent.php
│ │ ├── EventListener/ # Symfony event listeners
│ │ │ └── ApiAuthListener.php
│ │ ├── Integration/ # Per-client integration layer
│ │ │ ├── ClientAIntegration.php
│ │ │ ├── ClientBIntegration.php
│ │ │ ├── ClientIntegrationInterface.php
│ │ │ └── ClientIntegrationResolver.php
│ │ ├── Message/ # Messenger message classes
│ │ │ └── OrderReceivedMessage.php
│ │ ├── MessageHandler/ # Messenger handlers
│ │ │ └── OrderReceivedMessageHandler.php
│ │ ├── Repository/ # Doctrine repositories
│ │ │ └── WebhookEventRepository.php
│ │ ├── Security/ # Security services
│ │ │ ├── ApiTokenResolver.php
│ │ │ └── ShopifySignatureVerifier.php
│ │ └── Service/ # Domain services
│ │ ├── SyncBridgeLogger.php
│ │ └── WmsClient.php
│ ├── config/
│ │ ├── packages/
│ │ │ ├── messenger.yaml # Queue transport + routing config
│ │ │ ├── monolog.yaml # Logging channels
│ │ │ └── syncbridge.yaml # API tokens config
│ │ └── services.yaml # DI container config
│ ├── migrations/ # Doctrine migrations
│ └── tests/
│ ├── Functional/ # Full HTTP stack tests
│ └── Unit/ # Isolated unit tests
├── docker/
│ ├── php/Dockerfile
│ └── nginx/default.conf
└── docker-compose.yml
- Docker Desktop
- VS Code (recommended)
git clone https://github.com/albertovalinocarro/sync_bridge.git
cd sync_bridgedocker-compose up -d --buildFour services start:
| Container | Description | Port |
|---|---|---|
syncbridge_php |
PHP 8.2-FPM + Symfony CLI | 9000 (internal) |
syncbridge_nginx |
Nginx reverse proxy | 8080 |
syncbridge_mysql |
MySQL 8 | 3306 |
syncbridge_redis |
Redis | 6379 |
docker exec -it syncbridge_php bash
cd app
composer installcp app/.env app/.env.localDefault .env is pre-configured for Docker. Key values:
DATABASE_URL="mysql://syncbridge:syncbridge@mysql:3306/syncbridge?serverVersion=8.0.0&charset=utf8mb4"
MESSENGER_TRANSPORT_DSN=redis://redis:6379/messages
SHOPIFY_WEBHOOK_SECRET=syncbridge_fake_shopify_secret_for_devdocker exec -it syncbridge_php bash
cd app
php bin/console doctrine:database:create --if-not-exists
php bin/console doctrine:migrations:migrateVisit http://localhost:8080 — Symfony welcome page confirms the stack is running.
All Symfony commands run inside the PHP container:
docker exec -it syncbridge_php bash
cd appPOST /webhook/order
Content-Type: application/json
X-Shopify-Hmac-Sha256: <hmac-sha256-signature>
Request body:
{
"event": "order/created",
"client_id": "client_a",
"order_id": 1001,
"amount_cents": 5000,
"currency": "EUR"
}Responses:
| Status | Meaning |
|---|---|
202 Accepted |
Webhook queued for processing |
200 OK |
Duplicate — already received |
400 Bad Request |
Invalid JSON payload |
401 Unauthorized |
Invalid HMAC signature |
Generating a valid signature (dev only):
php bin/console app:webhook:generate-signature '{"event":"order/created","client_id":"client_a","order_id":1001}'All endpoints require a Bearer token in the Authorization header.
Available tokens (dev):
| Token | Access |
|---|---|
syncbridge_client_a_token_secret |
client_a events only |
syncbridge_client_b_token_secret |
client_b events only |
syncbridge_internal_token_secret |
All clients |
GET /api/sync-events
Authorization: Bearer <token>
Query parameters:
| Parameter | Type | Description |
|---|---|---|
client_id |
string | Filter by client (internal token only) |
status |
string | Filter by status |
limit |
int | Results per page (max 100, default 20) |
offset |
int | Pagination offset |
Event statuses:
| Status | Meaning |
|---|---|
pending |
Persisted, awaiting async processing |
processed |
Normalised, WMS sync not yet confirmed |
synced |
Successfully synced to WMS/ERP |
failed |
WMS sync failed — eligible for retry |
skipped |
No integration found for client |
GET /api/sync-events/{id}
Authorization: Bearer <token>
Client tokens can only access their own events. Returns 404 for events belonging to other clients.
php bin/console app:sync:status
php bin/console app:sync:status --client=client_aphp bin/console app:sync:retry-failed
php bin/console app:sync:retry-failed --client=client_b
php bin/console app:sync:retry-failed --dry-runphp bin/console messenger:consume async -vv
php bin/console messenger:consume async --limit=10 -vvEach client implements ClientIntegrationInterface:
interface ClientIntegrationInterface
{
public function getClientId(): string;
public function transform(array $payload): array;
}To add a new client:
- Create
src/Integration/ClientXIntegration.php - Tag it in
config/services.yaml:App\Integration\ClientXIntegration: tags: ['app.client_integration']
- Add an API token in
config/packages/syncbridge.yaml - No other changes needed — the resolver picks it up automatically
Every incoming webhook is verified using HMAC-SHA256:
X-Shopify-Hmac-Sha256: base64(hmac_sha256(raw_body, SHOPIFY_WEBHOOK_SECRET))
REST API endpoints use Bearer token authentication scoped per client.
php bin/phpunit --testdoxTest database setup (first time):
docker exec syncbridge_mysql mysql -u root -proot -e "CREATE DATABASE IF NOT EXISTS syncbridge_test; GRANT ALL PRIVILEGES ON syncbridge_test.* TO 'syncbridge'@'%'; FLUSH PRIVILEGES;"
php bin/console doctrine:migrations:migrate --env=test --no-interactiontail -f var/log/syncbridge.logAll entries include webhook_event_id, client_id, and duration_ms for filtering and alerting.
- Docker environment
- Symfony 7.4 LTS scaffold
- Webhook endpoint with HMAC verification
- Doctrine entity + migrations
- Symfony Messenger async processing
- Multi-client integration layer
- Outbound WMS sync via HttpClient
- REST API with filtering and pagination
- Idempotency — duplicate webhook detection
- PHPUnit test suite — unit + functional
- Console commands — status dashboard + retry
- API token authentication — client-scoped
- Structured logging — dedicated Monolog channel
- Query optimisation — composite indexes
- Supervisor config for production worker
- Inventory sync webhook support
- Fulfilment status callback webhook
MIT
Alberto Valiño Carro.