carlosgude / integration-engine
Connect external APIs to Symfony through configurable adapters, without writing repetitive clients.
Package info
github.com/CarlosGude/integrationEngine
Type:symfony-bundle
pkg:composer/carlosgude/integration-engine
Requires
- php: >=8.2
- psr/log: ^2.0|^3.0
- symfony/console: ^7.0|^8.0
- symfony/dependency-injection: ^7.0|^8.0
- symfony/http-client: ^7.0|^8.0
- symfony/yaml: ^7.0|^8.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.0
- infection/infection: ^0.33.2
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^11.0
- symfony/framework-bundle: ^7.0|^8.0
- symfony/http-kernel: ^7.0|^8.0
This package is auto-updated.
Last update: 2026-06-28 12:39:52 UTC
README
Website: integrationengine.dev
External integrations tend to rot in Symfony projects.
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.
Core idea
An integration is not a client.
It is a collection of predictable endpoints.
Every endpoint has exactly two responsibilities:
- Request (what goes in)
- Response (what comes out)
Nothing else is allowed to sprawl.
What this solves
IntegrationEngine removes:
- Inconsistent API clients across services
- Ad-hoc HTTP logic scattered in services
- Repeated mapping boilerplate per endpoint
- "How does this API work again?" moments
- Integration archaeology after months
Quick usage
$response = $dummyRestApi->getEmployee(123); // typed Response DTO $employee = $dummyRestApiGateway->find(123); // domain object, via the ACL Gateway
No HTTP clients. No request builders. No mappers. Just integrations.
See it wired into a real Symfony app — REST, GraphQL, and dynamic OAuth2 — in the demo repository.
Installation
composer require carlosgude/integration-engine
Requires PHP 8.2+ and Symfony 7.0+. The bundle registers itself automatically via Symfony Flex.
Scaffolding
Generate a full integration skeleton with the built-in command:
# New integration + first action php bin/console make:integration MyApi GetEmployee # Add an action to an existing integration php bin/console make:integration MyApi CreateEmployee
The command is interactive — it asks for base URL, client type, HTTP method, and path.
It generates the Action, Mapper, Response, and updates the YAML action map.
Structure
Each integration follows the same predictable directory layout:
src/Infrastructure/Integrations/{Name}/
├── {Name}Integration.php ← facade + NAME constant
├── {Name}.yaml ← action map (path, method, class)
└── {ActionName}/
├── Request/
│ └── {ActionName}Action.php
└── Response/
├── {ActionName}Response.php
└── {ActionName}Mapper.php
Shared DTOs used by multiple actions go in a Dto/ directory at the integration root.
If you know one integration, you know them all.
Configuration
Register each integration in config/packages/integration_engine.yaml:
integration_engine: integrations: my_api: base_url: 'https://api.example.com' config_path: '%kernel.project_dir%/src/Infrastructure/Integrations/MyApi/MyApi.yaml' headers: # optional — sent with every request X-Api-Version: '2' cache_service: ~ # optional — defaults to cache.app client: rest # optional — "rest" (default) or "graphql" client_service: ~ # optional — fully custom ClientInterface, overrides client
The action map YAML maps each action name to its class, method, and path:
GetEmployee: action: App\Infrastructure\Integrations\MyApi\GetEmployee\Request\GetEmployeeAction method: GET path: /employees/{id} CreateEmployee: action: App\Infrastructure\Integrations\MyApi\CreateEmployee\Request\CreateEmployeeAction method: POST path: /employees
Sending a request
The engine is accessed via IntegrationRegistry. Always wrap registry calls in an
integration facade — never call the registry directly from a controller or service:
use IntegrationEngine\Core\Batch\EngineRequest; use IntegrationEngine\Core\Contract\Action\DefaultActionContext; use IntegrationEngine\Core\IntegrationEngine; use IntegrationEngine\Core\Registry\IntegrationName; use IntegrationEngine\Core\Registry\IntegrationRegistry; // 1. Facade (infrastructure layer) — returns typed Response DTOs, nothing else final class MyApiIntegration implements IntegrationName { public const string NAME = 'my_api'; private IntegrationEngine $engine; public function __construct(IntegrationRegistry $registry) { $this->engine = $registry->get(self::NAME); } // Single request public function getEmployee(int $id): GetEmployeeResponse { $response = $this->engine->send( actionName: GetEmployeeAction::getName(), context: DefaultActionContext::create(['id' => $id]), ); \assert($response instanceof GetEmployeeResponse); return $response; } // Parallel fan-out — N requests at once, results keyed like the input public function getManyEmployees(array $ids): array { $requests = []; foreach ($ids as $id) { $requests[$id] = EngineRequest::create( GetEmployeeAction::getName(), DefaultActionContext::create(['id' => $id]), ); } $results = $this->engine->sendMany($requests); // BatchResultCollection if ($results->hasFailures()) { throw array_values($results->errors())[0]; } return $results->responses(); // array<int, GetEmployeeResponse> } } // 2. Gateway (the Anti-Corruption Layer) — the only class that knows both // the integration's DTOs and the domain model final class MyApiGateway { public function __construct(private MyApiIntegration $integration) {} public function find(int $id): Employee // domain object { $dto = $this->integration->getEmployee($id)->employee; return new Employee(id: $dto->id, name: $dto->name); } /** @return list<Employee> */ public function findMany(int ...$ids): array { $employees = []; foreach ($this->integration->getManyEmployees($ids) as $id => $response) { $employees[$id] = new Employee(id: $response->employee->id, name: $response->employee->name); } return $employees; } }
Controllers and other application code depend only on the Gateway — never on the integration facade or its DTOs directly. Without this boundary, a breaking change in the external API propagates straight into your domain model; with it, adapting to an API change means updating one Gateway class.
sendMany() returns a BatchResultCollection — one result per key, successes and
failures independent. Real concurrency requires the client to implement
BatchClientInterface: the default rest client does, graphql does not. For GraphQL
or SOAP with real concurrency, use client_service: and implement BatchClientInterface
yourself. See DOCUMENTATION.md → Batch / Parallel Requests for
the full API, failure-handling patterns, and concurrency details.
Path parameters and query strings
Path segment parameters ({id}) are resolved automatically from context:
GetEmployee: path: /employees/{id}
DefaultActionContext::create(['id' => 42]) // → /employees/42
For optional query string filters, implement PathResolvableContextInterface in a
custom context — path logic lives in the context, the action stays declarative:
use IntegrationEngine\Core\Contract\PathResolvableContextInterface; final readonly class FilterEmployeesContext implements PathResolvableContextInterface { private function __construct(private array $filters) {} public static function create(array $data): self { return new self($data); } public function toArray(): array { return $this->filters; } public function resolvePath(string $path): ?string { $allowed = ['status', 'department', 'page']; $params = array_filter( array_intersect_key($this->filters, array_flip($allowed)), static fn(mixed $v): bool => '' !== (string) $v, ); // null → fall back to the default {placeholder} resolver return empty($params) ? null : $path . '?' . http_build_query($params); } }
For required query string params, declare them as placeholders directly in the YAML path:
FilterByStatus: path: /employees?status={status} # throws if 'status' is missing from context
Authorization
Static (API key, bearer token, basic auth)
Declare in the action entry in {Name}.yaml:
GetOrders: action: App\...\GetOrdersAction method: GET path: /orders authorization: type: bearer token: '%env(MY_API_TOKEN)%'
Supported types: bearer, basic (username + password), api_key (header + token,
optional prefix).
Dynamic (OAuth 2.0, session tokens)
The engine calls a token action automatically, caches the result, and injects it as static auth on all protected actions. No manual token management needed:
FetchToken: action: App\...\FetchTokenAction method: POST path: /oauth/token GetOrders: action: App\...\GetOrdersAction method: GET path: /orders authorization: type: dynamic action: FetchToken # calls this action to obtain the token token_field: access_token ttl: 3600 header: Authorization # optional — header carrying the token prefix: Bearer # optional — defaults to Bearer for Authorization, none for custom headers
The token action is a regular action and requires its own Action, Mapper, and Response.
The response must expose the token field via toArray().
If a cached token is rejected with HTTP 401 before its TTL expires (revoked or expired server-side), the engine evicts it from the cache and retries the request once with a freshly fetched token. No manual token invalidation needed.
Cache scope. The default cache backend is
cache.app, which is process-local under PHP-FPM. Each worker fetches its own token on first warm-up. For APIs with strict rate limits on the token endpoint, configurecache_servicewith a shared Redis pool.
HTTP adapters
Two adapters are included:
| Type | Key | Use case |
|---|---|---|
SymfonyHttpClientAdapter |
rest |
Standard REST APIs |
GraphQLClientAdapter |
graphql |
GraphQL endpoints |
Select one with the client key — no client_service needed for either built-in:
integration_engine: integrations: rick_and_morty: base_url: 'https://rickandmortyapi.com/graphql' client: graphql # switches to GraphQLClientAdapter; no method/path per action
For GraphQL actions, the body must implement GraphQLBodyInterface:
use IntegrationEngine\Core\Contract\GraphQLBodyInterface; final class GetUserBody implements GraphQLBodyInterface { public function getQuery(): string { return 'query { user(id: $id) { name } }'; } public function getVariables(): array { return ['id' => $this->id]; } public function toArray(): array { return ['query' => $this->getQuery(), 'variables' => $this->getVariables()]; } public static function create(array $data): self { return new self((int) $data['id']); } }
Custom adapters
Implement ClientAdapterInterface and tag the service — the bundle discovers it automatically:
use IntegrationEngine\Core\Contract\AbstractAction; use IntegrationEngine\Core\Contract\ClientAdapterInterface; final class SoapClientAdapter implements ClientAdapterInterface { public static function getClientType(): string { return 'soap'; } public static function requiresPath(): bool { return false; } public static function requiresMethod(): bool { return false; } public function send(AbstractAction $action, ...): array { ... } }
# services.yaml App\Infrastructure\Http\SoapClientAdapter: tags: - { name: integration_engine.client_adapter }
Project adapters override bundle built-ins when registered with the same getClientType().
Custom middleware
Every outgoing request passes through an ordered middleware pipeline before reaching the
HTTP adapter. You can inject your own layers — rate limiting, retry logic, circuit
breaking, custom logging — by extending AbstractClientMiddleware and tagging the
service:
use IntegrationEngine\Core\Contract\Client\AbstractClientMiddleware; use IntegrationEngine\Core\Contract\Action\AbstractAction; use IntegrationEngine\Core\Contract\Action\ActionContextInterface; use IntegrationEngine\Core\Contract\Client\RequestHeadersInterface; final class RateLimitMiddleware extends AbstractClientMiddleware { public function process( AbstractAction $action, ?ActionContextInterface $context, ?RequestHeadersInterface $headers, callable $next, ): array { $this->limiter->consume(); // your rate-limiting logic return $next($action, $context, $headers); } }
# services.yaml App\Infrastructure\Http\RateLimitMiddleware: tags: - { name: integration_engine.middleware } # integration_engine.yaml integration_engine: integrations: my_api: middlewares: - App\Infrastructure\Http\RateLimitMiddleware # outermost first
The middlewares: key under each integration controls injection order — first entry is
outermost (runs before the HTTP call). The bundle guarantees CachingMiddleware is
always prepended as the outermost layer and TracingMiddleware (debug only) is always
the innermost built-in; your middlewares sit between them.
Override processMany() as well if you need batch-aware behaviour (e.g. splitting a
batch by rate-limit bucket). The default passthrough from AbstractClientMiddleware is
correct for most cases.
Dynamic base URL per request
Some integrations don't have one fixed base URL — for example, an installable app where
each store/customer lives on its own domain. For those cases, pass baseUrl to send():
$engine->send('get_orders', context: $context, baseUrl: $tenant->domain());
It's optional and fully backward-compatible: omit it and the engine keeps using the
base_url from configuration, exactly as before. Both built-in adapters
(SymfonyHttpClientAdapter, GraphQLClientAdapter) support it; a custom client ignores
it silently unless it implements DynamicBaseUrlClientInterface. The bundle does not
resolve or persist that URL — that's the calling code's responsibility.
Symfony Profiler integration
In dev/test, every outgoing call made through a configured integration shows up
automatically in the Symfony Toolbar — integration, action, method, path, duration, and
status, with no configuration needed:
IntegrationEngine 3 calls · 184.2 ms
GetEmployee GET /api/v1/employee/42 62.1 ms 200
GetArtist GET /v1/artists/4Z8W... 91.0 ms 200
FetchToken POST /api/token 31.1 ms 200
It's purely additive: in prod the TracingMiddleware is not wired, so there is zero profiling overhead.
Further reading
DOCUMENTATION.md— deeper guide: engine pipeline, all configuration options, and links to per-topic references.ARCHITECTURE.md— design decisions: why actions are stateless, the mapper invariant, cache behaviour under PHP-FPM, and the DTO/domain boundary.TESTING.md— test philosophy, suite structure, and what each test protects.CONTRIBUTING.md— setup, code quality tools, and how to run the test suite.docs/— per-topic references: actions, authorization, batch requests, clients, context and path resolution, mappers and responses.integrationEngine-use-example— full working demo app showing the bundle wired into a real Symfony project.
When NOT to use it
- You only have 1–2 simple API calls
- You need full low-level HTTP control everywhere
- You don't want enforced structure in your codebase