Symfony Bundle · PHP 8.2+ · Symfony 7+

Stop writing integration code twice.

Every integration your team ships follows the same predictable standard. New developers understand any existing API in minutes — not days. Automatic OAuth2, parallel requests and typed DTOs built in. One Symfony bundle — distilled from three years of production integrations.

✓ OAuth2, Bearer & API Key✓ Parallel requests✓ Typed DTOs✓ Symfony native
Copied! composer require carlosgude/integration-engine
See the pattern GitHub
Latest version · PHP 8.2+ · Symfony 7+
The Problem

Integration debt accumulates by default.

Each new API added without a standard costs your team days to set up and compounds with every new integration. Hardcoded URLs, duplicated OAuth logic, arrays leaking into domain code — the next one is always harder than the last.

✗ Without a standard
700-line god classes
OAuth logic duplicated everywhere
Arrays leaking into domain
Sequential HTTP calls
✓ With Integration Engine
One typed action per endpoint
Auth declared once in YAML
Typed DTOs from every response
Parallel execution built in
Why it matters

The cost of no standard compounds.

Days → Hours

Time from zero to a working, tested integration — including OAuth, parallel calls and typed responses.

1 command

Scaffold the action, mapper and response for any endpoint. Your whole team generates the same structure, every time.

Zero rewrites

Installs alongside your existing code. New endpoints follow the standard; legacy integrations migrate at your pace.

Parallel Requests

Stop waiting for APIs one by one.

sendManyOrFail() dispatches all requests concurrently. Total time ≈ the slowest single request — regardless of how many you send. In production, a Booking.com availability search requires 4 parallel queries for a small city and 17 for Paris — per customer. This handles it.

Sequential (foreach)
4.2s
10 requests × 420ms each
Parallel (sendManyOrFail)
0.8s
10 requests, runs concurrently
PHP
$requests = [];
foreach ($stationIds as $key => $params) {
    $requests[$key] = EngineRequest::create(
        actionName: GetStationByIdAction::getName(),
        context:    DefaultActionContext::create($params),
    );
}

// All dispatched concurrently — total time ≈ slowest request
$results = $this->engine->sendManyOrFail($requests);
Get started

Three steps. First integration running.

Installs alongside your existing code. No big-bang rewrite — use the pattern for the next new endpoint and migrate legacy at your own pace.

1

Install

composer require carlosgude/integration-engine
2

Scaffold

php bin/console make:integration MyApi GetUser

Add the logic and 3 lines to MyApi.yaml — done.

3

Go deeper

Dynamic auth, batch requests and custom contexts are all in the documentation.

Read the docs →
MAKE:INTEGRATION OUTPUT
$ php bin/console make:integration MyApi GetUser

MyApi/
├─ MyApi.yaml                    ← add your endpoint entry here
└─ GetUser/
   ├─ Request/GetUserAction.php    ← HTTP method, path, auth
   └─ Response/
      ├─ GetUserResponse.php       ← typed DTO
      └─ GetUserMapper.php         ← raw array → DTO

# New endpoint, same integration:
$ php bin/console make:integration MyApi CreateOrder
# → Adds CreateOrder/ alongside GetUser/. Existing files are never overwritten.
What goes inside the generated files? See the docs →
Real Example

A Stripe integration in under 30 lines.

One YAML entry. One mapper. One typed response. OAuth2 token refresh is handled automatically — no token logic in your application code.

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

CreatePaymentIntent:
    action: App\...\CreatePaymentIntentAction
    method: POST
    path:   /v1/payment_intents
    authorization:
        type:         dynamic
        action:       GetToken
        token_field:  access_token
        ttl:          3600
CreatePaymentIntentMapper.php
final class CreatePaymentIntentMapper extends AbstractMapper { public static function getAction(): string { return CreatePaymentIntentAction::class; } protected static function transform( AbstractAction $a, array $r ): ResponseInterface { return new CreatePaymentIntentResponse( id: $r['id'], secret: $r['client_secret'], status: $r['status'], ); } }
PaymentService.php — OAuth2 token is fetched, cached and refreshed automatically
$intent = $this->stripe->createPaymentIntent(amount: 2000, currency: 'eur'); assert($intent instanceof CreatePaymentIntentResponse); echo $intent->id; // pi_3OqfK8LnFoNEqOv0abc123 echo $intent->secret; // pi_3OqfK8..._secret_XYZ echo $intent->status; // requires_payment_method
Built to extend

Replace any part. Keep the rest.

Every infrastructure boundary is an interface. Swap the HTTP client, customise path resolution, or add batch support — without touching the engine.

ClientInterface

Replace the HTTP client. Tag your implementation and the engine discovers it automatically via Symfony DI.

PathResolvableContextInterface

Complex path logic beyond {placeholders}. Return null to fall back to the default placeholder resolver.

BatchClientInterface

Mark your client as batch-capable for concurrent dispatch. The built-in REST client already implements this.

FakeClient · FakeCache

Built-in test doubles. Test your mappers and actions in isolation — no mocks, no real HTTP required.

Ready to add the pattern to your next project?

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.
Before you go

Thanks for reading.

I’ve been using this pattern in production for three years — integrating Booking.com, Iberia, Lleego, Hostalia and more. Booking.com availability alone requires 4 parallel queries for a small city, 17 for Paris, per customer. An earlier version of this engine handled that without breaking a sweat. This bundle is what those three years taught me: made explicit, tested, and open.

Get in touch

Start building your next integration today.

Drop us a line, open a GitHub Discussion, or install it and give it a try.