Symfony Bundle · PHP 8.2+ · Packagist
Every API becomes a different shape, a different structure, a different way of thinking. After a few months, you no longer have integrations. You have a zoo. IntegrationEngine forces every integration to look the same.
Why not HttpClient?
Use HttpClient for one or two simple calls. Use IntegrationEngine when the API is part of your architecture and you want clear contracts, typed responses, and an Anti-Corruption Layer between the provider and your domain.
The Problem
Inconsistent API clients, scattered HTTP logic, repeated mapping boilerplate. The codebase fragments and every new API means starting from scratch.
Every API ends up with its own HTTP client and its own structure.
HTTP calls scattered across services with no shared contract.
The same transformation code written from scratch per endpoint.
No predictable layout means archaeology every time you touch an integration.
Every new API is a new decision. No structure carries over.
How It Works
A single entry point. Every step has a clear responsibility.
OAuth, sessions, API keys. The engine resolves and caches them automatically.
/employees/{id} is resolved at call time. Explicit failure if a parameter is missing.
YAML → auth → call layer. Each layer overrides the previous. No magic.
Every action defines its own Response DTO with a guaranteed contract.
sendMany() runs N requests concurrently. Individual failures never abort the batch.
make:integration generates Mapper, Response DTO and YAML in seconds.
One command generates everything
The command asks the questions. You only write the logic.
$ php bin/console make:integration MyApi GetEmployee
The Call Site
No HTTP clients. No request builders. No mappers. Just your integration's facade.
// Action path: GET /employees $this->engine->send( GetEmployeesAction::getName() ); // → GET /employees
// Action path: GET /employees/{id} $response = $this->engine->send( actionName: GetEmployeeAction::getName(), context: DefaultActionContext::create(['id' => $id]), ); // → GET /employees/42 \assert($response instanceof GetEmployeeResponse); // GetEmployeeResponse { id: 42, name: 'John Doe', department: 'Engineering' }
// Action path: POST /employees $response = $this->engine->send( actionName: CreateEmployeeAction::getName(), body: CreateEmployeeBody::create(['name' => 'John Doe']), headers: new CorrelationHeaders($correlationId), ); // → POST /employees { "name": "John Doe" } \assert($response instanceof CreateEmployeeResponse); // CreateEmployeeResponse { id: 99, name: 'John Doe', status: 'active' }
// Action endpoint: POST /graphql $response = $this->engine->send( actionName: GetUserAction::getName(), body: GetUserBody::create(['id' => $id]), ); // → POST /graphql { "query": "query { user(id: $id) { name } }", "variables": { "id": 42 } } \assert($response instanceof GetUserResponse); // GetUserResponse { id: 42, name: 'John Doe' }
// Parallel fan-out — one request per employee ID $requests = []; foreach ($ids as $id) { $requests[$id] = EngineRequest::create( GetEmployeeAction::getName(), DefaultActionContext::create(['id' => $id]), ); } $results = $this->engine->sendMany($requests); // BatchResultCollection — each key resolves independently if ($results->hasFailures()) { throw array_values($results->errors())[0]; } return array_map( fn($dto) => Employee::fromDto($dto), $results->responses(), );
For the full pattern (facade → service → domain) → README →
Layered Design
Three levels that emerge naturally. Use whichever you need.
| Class | Responsibility | Scope |
|---|---|---|
| GetEmployeeAction | Only declares the method, path and response DTO. No HTTP logic. | Concrete action |
| MyApiIntegration | Auth, base URL and common headers for MyApi. Reused by all its actions. | Integration |
| AbstractAction | Base contract provided by the engine. Extensible without touching the core. | Bundle |
The make:integration command creates the config, classes and YAML in a single step.
No boilerplate. No arbitrary decisions. Just your business logic.