Symfony Bundle · PHP 8.2+ · Symfony 7+

You already have integrations.
The next one shouldn’t make it worse.

After the third API, the god class already exists. The inconsistencies already exist. IntegrationEngine doesn’t ask you to rewrite them — it gives you the pattern so the next integration isn’t another problem waiting to happen.

Copied! composer require carlosgude/integration-engine
Explore the pattern Source code
The Problem

Why integrations degenerate

You already know this. Each integration added without a standard has made the next one a little harder to maintain, test, and hand off to another developer.

🆕

The god class is already there

It might be called StripeService or SalesforceClient. It has 600 lines, three developers have touched it, and nobody wants to add the next endpoint.

🆕

Implicit contracts

Responses travel as array<string, mixed>. Every layer that touches them has to know the exact field names of the API.

🆕

Sequential batch

The foreach blocks: each request waits for the previous one. 10 items = 10× the time of one. It doesn’t scale, and nothing warns you.

The Solution

One predictable structure for every integration

If you know one integration, you know them all. The engine enforces the same shape across every API you integrate.

ACTION MAP (YAML)
# RailwayStations.yaml — contract visible at a glance

GetStats:
    action: App\...\GetStatsAction
    method: GET
    path:   /stats

GetStationsByCountry:
    action: App\...\GetStationsByCountryAction
    method: GET
    path:   /photoStationsByCountry/{country}

GetStationById:
    action: App\...\GetStationByIdAction
    method: GET
    path:   /photoStationById/{country}/{stationId}
TYPICAL DIRECTORY
src/Infrastructure/Integrations/RailwayStations/
├── RailwayStationsIntegration.php  ← facade
├── RailwayStations.yaml             ← action map
├── GetStats/
│   ├── Request/GetStatsAction.php
│   └── Response/
│       ├── GetStatsResponse.php
│       └── GetStatsMapper.php
└── GetStationById/
    ├── Request/GetStationByIdAction.php
    └── Response/
        ├── GetStationByIdResponse.php
        └── GetStationByIdMapper.php
DYNAMIC AUTH (OAUTH2 · BEARER · API KEY)
# Token fetched once, cached 60 min, retried automatically on 401

GetToken:
    action: App\...\GetTokenAction
    method: POST
    path:   /oauth/token

GetStationById:
    action: App\...\GetStationByIdAction
    method: GET
    path:   /photoStationById/{country}/{stationId}
    authorization:
        type:         dynamic
        action:       GetToken
        token_field:  access_token  ← field in the token response
        ttl:          3600           ← cached per integration, shared across workers
Incremental adoption

Start with the next one

No big-bang rewrite. IntegrationEngine installs alongside your existing code. Each new API follows the pattern — existing ones migrate only when you choose.

Zero coupling

Your existing services and HTTP clients keep working. The bundle adds no runtime dependency on your current integrations.

🕐

Migrate at your pace

New integration? Use the pattern. Legacy god class? Leave it until the next feature touches it. No forced cutover.

📁

Self-contained

Every integration lives in its own directory with its own YAML contract. The full surface of an API visible at a glance.

The Pattern

Five antipatterns the engine solves

The same endpoints, two implementations. Each section shows the real classes from the project.

1

Integration configuration

✗ The base URL and paths live hardcoded in each method. There’s no single place to see which endpoints exist.
✓ One YAML file per integration declares base_url, paths and auth. Complete contract at a glance.
Without pattern
src/Traditional/RailwayApiService.php
namespace App\Traditional; use Symfony\Contracts\HttpClient\HttpClientInterface; class RailwayApiService { // Base URL lives here, not in any config file. private const BASE = 'https://api.railway-stations.org'; public function fetchStats(): array { $r = $this->http->request('GET', self::BASE . '/stats'); return $r->toArray(); } public function fetchStations(string $countryCode): array { $raw = $this->http ->request('GET', self::BASE . '/photoStationsByCountry/' . $countryCode) ->toArray(); $base = $raw['photoBaseUrl']; $stations = []; foreach ($raw['stations'] as $s) { $s['_photoBase'] = $base; $s['_hasPhoto'] = isset($s['photos'][0]); $s['_photoUrl'] = isset($s['photos'][0]) ? $base . $s['photos'][0]['path'] : null; $stations[] = $s; } return $stations; } public function fetchStation(string $cc, string $id): ?array { $raw = $this->http ->request('GET', self::BASE . '/photoStationById/' . $cc . '/' . $id) ->toArray(); return $raw['stations'][0] ?? null; } }
Engine pattern
src/Engine/Infrastructure/Integrations/RailwayStations/RailwayStations.yaml
GetStats: action: App\...\GetStatsAction method: GET path: /stats GetStationsByCountry: action: App\...\GetStationsByCountryAction method: GET path: /photoStationsByCountry/{country} GetStationById: action: App\...\GetStationByIdAction method: GET path: /photoStationById/{country}/{stationId}
config/packages/integration_engine.yaml
integration_engine: integrations: railway_stations: base_url: 'https://api.railway-stations.org' config_path: '%kernel.project_dir%/src/Engine/ Infrastructure/Integrations/ RailwayStations/RailwayStations.yaml'
Why it matters: with 20 endpoints, finding which one calls which URL requires reading every method of the God class. With YAML, a new developer opens one file and sees the complete contract. If you change base_url or add authentication, there is a single point of change.
2

Route building with parameters

✗ Concatenating strings to build URLs is prone to silent typos. A null produces a valid but semantically incorrect URL.
{placeholder} templates in YAML resolved by DefaultActionContext. The engine throws an immediate exception if a parameter is missing.
Without pattern
src/Traditional/RailwayApiService.php
// One parameter in the path public function fetchStations(string $countryCode): array { $raw = $this->http->request( 'GET', self::BASE . '/photoStationsByCountry/' . $countryCode )->toArray(); } // Two parameters in the path public function fetchStation(string $countryCode, string $stationId): ?array { $raw = $this->http->request( 'GET', self::BASE . '/photoStationById/' . $countryCode . '/' . $stationId )->toArray(); } // If $stationId === null: // → /photoStationById/de/ // → HTTP 404 with no descriptive exception. // The error surfaces late, far from the source.
Engine pattern
src/Engine/Infrastructure/Integrations/RailwayStations/RailwayStationsIntegration.php
public function getStationById(string $country, string $stationId): GetStationByIdResponse { $response = $this->engine->send( actionName: GetStationByIdAction::getName(), context: DefaultActionContext::create([ 'country' => $country, 'stationId' => $stationId, ]), ); \assert($response instanceof GetStationByIdResponse); return $response; } // If 'stationId' is missing: immediate, descriptive exception // before the HTTP call is made.
Why it matters: string concatenation fails silently. The engine’s placeholders are contracts: if one is missing, the error is immediate and descriptive, not a mysterious 404 two layers below.
3

Response mapping

✗ Raw API fields ('title', 'photos', 'photoBaseUrl') leak to all layers. If the API renames a field, the error appears in multiple files.
✓ One Mapper accesses the raw fields. The rest of the code talks to typed DTOs.
Without pattern
src/Traditional/Controller/GetStationsByCountryController.php
foreach ($stations as $s) { $result[] = [ 'id' => $s['id'], 'title' => $s['title'], // raw API field 'lat' => $s['lat'], 'lon' => $s['lon'], // not 'lng', not 'longitude' 'has_photo' => $s['_hasPhoto'], // private convention 'photo_url' => $s['_photoUrl'], // private convention ]; } // If the API renames 'title' to 'name': search and fix EVERY // file that accesses the array. How many are there?
Engine pattern
src/Engine/.../GetStationsByCountryMapper.php
final class GetStationsByCountryMapper extends AbstractMapper { protected static function transform( AbstractAction $action, array $response ): ResponseInterface { $photoBaseUrl = $response['photoBaseUrl']; // only place $stations = array_map( fn(array $s) => StationDto::fromApiData($s, $photoBaseUrl), $response['stations'], // only place ); return new GetStationsByCountryResponse($stations); } }
src/Engine/.../StationDto.php
public static function fromApiData(array $station, string $photoBaseUrl): self { $firstPhoto = $station['photos'][0] ?? null; // only place return new self( id: $station['id'], title: $station['title'], lat: (float) $station['lat'], lon: (float) $station['lon'], hasPhoto: $firstPhoto !== null, photoUrl: $firstPhoto !== null ? $photoBaseUrl . $firstPhoto['path'] : null, ); } // If the API renames 'title' to 'name': only this line changes. // No other file touches raw API fields.
Why it matters: without a mapper, knowledge of the API fields leaks into every class that processes the response. With the engine, StationDto::fromApiData() is the only point of contact. If the API renames a field, there is exactly one place to fix.
4

Anti-Corruption Layer

✗ The controller imports the HTTP client directly. Changing the API provider means touching every controller that consumes it.
StationService is the only boundary between the domain and the integration. Controllers only see their own domain objects.
Without pattern
src/Traditional/Controller/GetStationsByCountryController.php
namespace App\Traditional\Controller; use App\Traditional\RailwayApiService; class GetStationsByCountryController { public function __construct(private RailwayApiService $api) {} #[Route('/traditional/stations/{country}')] public function __invoke(string $country): JsonResponse { $stations = $this->api->fetchStations($country); // maps raw _hasPhoto, _photoUrl conventions... return new JsonResponse($result); } } // Switch API → touch this controller, // and all others that do the same.
Engine pattern
src/Engine/Controller/GetStationsByCountryController.php
namespace App\Engine\Controller; use App\Engine\Application\StationService; final class GetStationsByCountryController { public function __construct(private readonly StationService $service) {} #[Route('/engine/stations/{country}')] public function __invoke(string $country): JsonResponse { $stations = $this->service->getStationsByCountry($country); return new JsonResponse( array_map(fn(Station $s) => $s->toArray(), $stations) ); } } // Switch API → StationService absorbs the change. // This controller does not change.
src/Engine/Application/StationService.php
final class StationService { public function __construct( private readonly RailwayStationsIntegration $integration, ) {} public function getStationsByCountry(string $country): array { $response = $this->integration->getStationsByCountry($country); return array_map( fn(StationDto $dto) => $this->toDomain($dto), $response->stations, ); } private function toDomain(StationDto $dto): Station { return new Station( id: $dto->id, title: $dto->title, lat: $dto->lat, lon: $dto->lon, hasPhoto: $dto->hasPhoto, photoUrl: $dto->photoUrl, ); } }
Why it matters: without an ACL, the controller is coupled to RailwayApiService and its private conventions (_hasPhoto). With StationService as the only boundary, controllers only import domain objects and the cost of switching providers is reduced to a single file.
5

Request batching

✗ The sequential foreach blocks: each request waits for the previous one. Total time scales linearly.
sendManyOrFail() dispatches all in parallel. Total time ≈ the slowest request, regardless of the number of items.
Without pattern
src/Traditional/Controller/GetStationsBatchController.php
foreach ($pairs as $pair) { [$country, $stationId] = explode('/', $pair, 2) + ['', '']; // HTTP request — others wait here, blocked $s = $this->api->fetchStation($country, $stationId); $result[$pair] = ['title' => $s['title'], 'lat' => $s['lat']]; } // 3 stations × 250ms = ~750ms // 10 stations × 250ms = ~2500ms ← scales linearly
Engine pattern
src/Engine/.../RailwayStationsIntegration.php
public function getManyStationsById(array $stations): array { $requests = []; foreach ($stations as $key => $params) { $requests[$key] = EngineRequest::create( actionName: GetStationByIdAction::getName(), context: DefaultActionContext::create([ 'country' => $params['country'], 'stationId' => $params['stationId'], ]), ); } // all go out at the same time — total time ≈ the slowest return $this->engine->sendManyOrFail($requests); } // 3 stations → ~250ms (the slowest, not the sum) // 10 stations → ~250ms (does not scale)
Why it matters: individual failures never abort the batch — each key resolves independently. sendMany() returns a BatchResultCollection where you inspect each outcome; sendManyOrFail() throws on the first failure after the full batch has run. The default REST client already implements BatchClientInterface via lazy Symfony HttpClient responses — zero additional configuration.
Summary

Without pattern vs Engine pattern

Concept Without pattern Engine pattern
Endpoint declaration ✗ Scattered across God class methods ✓ One YAML file per integration
URL building ✗ String concatenation, fails silently {placeholders} validated at runtime
API fields in code ✗ Leaked to all layers ✓ Encapsulated in Mapper + DTO
Return type array<string, mixed> + _ conventions ✓ Typed ResponseInterface
Anti-Corruption Layer ✗ None — controller coupled to HTTP client StationService as the only boundary
Auth (Bearer, Basic, OAuth2) Manual headers in each request() ✓ Declared in YAML, managed by the engine
Adding a new endpoint Method + URL + parsing + mapping scattered ✓ Action + Mapper + Response + 3 YAML lines
Batch ✗ Sequential foreach, linear time sendManyOrFail(), constant time
Get in touch

Questions? Ideas? Feedback?

Drop us a line or join the conversation on GitHub Discussions.