Symfony Bundle · PHP 8.2+ · Symfony 7+

Deja de escribir código de integración dos veces.

Cada integración que entrega tu equipo sigue el mismo estándar predecible. Los desarrolladores nuevos entienden cualquier API en minutos — no en días. OAuth2 automático, peticiones en paralelo y DTOs tipados incluidos. Un solo bundle de Symfony — destilado de tres años de integraciones en producción.

✓ OAuth2, Bearer & API Key✓ Peticiones en paralelo✓ DTOs tipados✓ Symfony nativo
¡Copiado! composer require carlosgude/integration-engine
Ver el patrón GitHub
Latest version · PHP 8.2+ · Symfony 7+
El Problema

La deuda de integración se acumula por defecto.

Cada API añadida sin un estándar le cuesta a tu equipo días de configuración y se acumula con cada nueva integración. URLs hardcodeadas, lógica OAuth duplicada, arrays filtrándose al dominio — la siguiente siempre es más difícil que la anterior.

✗ Sin un estándar
God classes de 700 líneas
Lógica OAuth duplicada en todos lados
Arrays filtrándose al dominio
Llamadas HTTP secuenciales
✓ Con Integration Engine
Una acción tipada por endpoint
Auth declarada una vez en YAML
DTOs tipados en cada respuesta
Ejecución en paralelo incluida
Por qué importa

El coste de no tener un estándar se acumula.

Días → Horas

Tiempo desde cero hasta una integración funcionando y testeada — incluyendo OAuth, llamadas en paralelo y respuestas tipadas.

1 comando

Genera la action, el mapper y la respuesta para cualquier endpoint. Todo el equipo genera la misma estructura, siempre.

Sin reescrituras

Se instala junto al código existente. Los nuevos endpoints siguen el estándar; las integraciones legacy migran a tu ritmo.

Peticiones en paralelo

Deja de esperar a las APIs una a una.

sendManyOrFail() despacha todas las peticiones en paralelo. El tiempo total ≈ la petición más lenta — independientemente de cuántas envíes. En producción, una búsqueda de disponibilidad en Booking.com requiere 4 consultas en paralelo para una ciudad pequeña y 17 para París — por cliente. Esto lo gestiona.

Secuencial (foreach)
4,2s
10 peticiones × 420ms cada una
En paralelo (sendManyOrFail)
0,8s
10 peticiones, ejecución concurrente
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);
Empieza

Tres pasos. Primera integración funcionando.

Se instala junto al código existente. Sin reescritura masiva — usa el patrón en el próximo endpoint nuevo y migra el código legacy a tu ritmo.

1

Instala

composer require carlosgude/integration-engine
2

Genera

php bin/console make:integration MyApi GetUser

Añade la lógica y 3 líneas al MyApi.yaml — listo.

3

Profundiza

Auth dinámica, batch requests y contextos personalizados están en la documentación.

Leer la documentación →
MAKE:INTEGRATION OUTPUT
$ php bin/console make:integration MyApi GetUser

MyApi/
├─ MyApi.yaml                    ← añade aquí el entry del endpoint
└─ GetUser/
   ├─ Request/GetUserAction.php    ← método HTTP, path, auth
   └─ Response/
      ├─ GetUserResponse.php       ← DTO tipado
      └─ GetUserMapper.php         ← array crudo → DTO

# Nuevo endpoint, misma integración:
$ php bin/console make:integration MyApi CreateOrder
# → Añade CreateOrder/ junto a GetUser/. Los ficheros existentes nunca se sobreescriben.
¿Qué va dentro de los ficheros generados? Ver la documentación →
Ejemplo real

Una integración con Stripe en menos de 30 líneas.

Un entry en YAML. Un mapper. Una respuesta tipada. El refresco del token OAuth2 se gestiona automáticamente — sin lógica de tokens en tu código de aplicación.

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
Diseñado para extender

Reemplaza cualquier parte. Conserva el resto.

Cada frontera de infraestructura es una interfaz. Cambia el cliente HTTP, personaliza la resolución de rutas o añade soporte batch — sin tocar el engine.

ClientInterface

Reemplaza el cliente HTTP. Etiqueta tu implementación y el engine la descubre automáticamente vía Symfony DI.

PathResolvableContextInterface

Lógica de rutas más compleja que los {placeholders}. Devuelve null para caer al resolver por defecto.

BatchClientInterface

Marca tu cliente como batch-capable para despacho concurrente. El cliente REST incluido ya lo implementa.

FakeClient · FakeCache

Test doubles incluidos. Testea mappers y actions en aislamiento — sin mocks, sin HTTP real.

¿Listo para añadir el patrón a tu próximo proyecto?

El Patrón

Cinco antipatrones que el engine resuelve

Los mismos endpoints, dos implementaciones. Cada sección muestra las clases reales del proyecto.

1

Configuración de la integración

✗ La URL base y los paths viven hardcodeados en cada método. No hay un sitio donde ver qué endpoints existen.
✓ Un fichero YAML por integración declara base_url, paths y auth. Contrato completo en un vistazo.
Sin patrón
src/Traditional/RailwayApiService.php
namespace App\Traditional; use Symfony\Contracts\HttpClient\HttpClientInterface; class RailwayApiService { // La URL base vive aquí, no en ningún fichero de config. 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'
Por qué importa: con 20 endpoints, encontrar cuál llama a qué URL requiere leer cada método de la God class. Con el YAML, un desarrollador nuevo abre un fichero y ve el contrato completo. Si cambias la base_url o añades autenticación, hay un único punto de cambio.
2

Construcción de rutas con parámetros

✗ Concatenar strings para construir la URL es propenso a typos silenciosos. Un null produce una URL válida pero semánticamente incorrecta.
✓ Plantillas {placeholder} en el YAML resueltas por DefaultActionContext. El engine lanza excepción inmediata si falta un parámetro.
Sin patrón
src/Traditional/RailwayApiService.php
// Un parámetro en la ruta public function fetchStations(string $countryCode): array { $raw = $this->http->request( 'GET', self::BASE . '/photoStationsByCountry/' . $countryCode )->toArray(); } // Dos parámetros en la ruta public function fetchStation(string $countryCode, string $stationId): ?array { $raw = $this->http->request( 'GET', self::BASE . '/photoStationById/' . $countryCode . '/' . $stationId )->toArray(); } // Si $stationId === null: // → /photoStationById/de/ // → HTTP 404 sin excepción descriptiva. // El error aparece tarde, lejos del origen.
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; } // Si falta 'stationId': excepción inmediata y descriptiva // antes de que se haga la llamada HTTP.
Por qué importa: la concatenación de strings falla en silencio. Los placeholders del engine son contratos: si falta uno, el error es inmediato y descriptivo, no un 404 misterioso dos capas más abajo.
3

Mapeo de la respuesta

✗ Los campos crudos de la API ('title', 'photos', 'photoBaseUrl') se filtran a todas las capas. Si la API cambia un nombre de campo, el error aparece en múltiples ficheros.
✓ Un único Mapper accede a los campos crudos. El resto del código habla con DTOs tipados.
Sin patrón
src/Traditional/Controller/GetStationsByCountryController.php
foreach ($stations as $s) { $result[] = [ 'id' => $s['id'], 'title' => $s['title'], // campo crudo de la API 'lat' => $s['lat'], 'lon' => $s['lon'], // no 'lng', no 'longitude' 'has_photo' => $s['_hasPhoto'], // convención privada 'photo_url' => $s['_photoUrl'], // convención privada ]; } // Si la API cambia 'title' por 'name': hay que buscar y corregir // en TODOS los ficheros que acceden al array. ¿Cuántos son?
Engine pattern
src/Engine/.../GetStationsByCountryMapper.php
final class GetStationsByCountryMapper extends AbstractMapper { protected static function transform( AbstractAction $action, array $response ): ResponseInterface { $photoBaseUrl = $response['photoBaseUrl']; // único sitio $stations = array_map( fn(array $s) => StationDto::fromApiData($s, $photoBaseUrl), $response['stations'], // único sitio ); return new GetStationsByCountryResponse($stations); } }
src/Engine/.../StationDto.php
public static function fromApiData(array $station, string $photoBaseUrl): self { $firstPhoto = $station['photos'][0] ?? null; // único sitio 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, ); } // Si la API cambia 'title' por 'name': solo cambia esta línea. // Ningún otro fichero toca los campos crudos de la API.
Por qué importa: sin un mapper, el conocimiento de los campos de la API se filtra a cualquier clase que procese la respuesta. Con el engine, StationDto::fromApiData() es el único punto de contacto. Si la API cambia un campo, hay exactamente un sitio que tocar.
4

Anti-Corruption Layer

✗ El controller importa directamente el cliente HTTP. Cambiar de proveedor de API implica tocar cada controller que la consume.
StationService es la única frontera entre el dominio y la integración. Los controllers solo ven objetos del dominio propio.
Sin patrón
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); // mapea convenciones privadas _hasPhoto, _photoUrl... return new JsonResponse($result); } } // Cambias de API → tocas este controller, // y todos los demás que hagan lo mismo.
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) ); } } // Cambias de API → StationService absorbe el cambio. // Este controller no cambia.
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, ); } }
Por qué importa: sin ACL, el controller está acoplado a RailwayApiService y sus convenciones privadas (_hasPhoto). Con StationService como única frontera, los controllers solo importan objetos del dominio y el coste de cambiar de proveedor queda reducido a un solo fichero.
5

Batch de peticiones

✗ El foreach secuencial bloquea: cada petición espera a que termine la anterior. El tiempo total escala linealmente.
sendManyOrFail() despacha todas en paralelo. El tiempo total ≈ la petición más lenta, independientemente del número de items.
Sin patrón
src/Traditional/Controller/GetStationsBatchController.php
foreach ($pairs as $pair) { [$country, $stationId] = explode('/', $pair, 2) + ['', '']; // petición HTTP — las demás esperan aquí bloqueadas $s = $this->api->fetchStation($country, $stationId); $result[$pair] = ['title' => $s['title'], 'lat' => $s['lat']]; } // 3 estaciones × 250ms = ~750ms // 10 estaciones × 250ms = ~2500ms ← escala linealmente
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'], ]), ); } // todas salen al mismo tiempo — tiempo total ≈ la más lenta return $this->engine->sendManyOrFail($requests); } // 3 estaciones → ~250ms (la más lenta, no la suma) // 10 estaciones → ~250ms (no escala)
Por qué importa: los fallos individuales nunca abortan el batch — cada clave se resuelve de forma independiente. sendMany() devuelve un BatchResultCollection donde inspeccionas cada resultado; sendManyOrFail() lanza en el primer fallo después de que todo el batch haya ejecutado. El cliente REST por defecto ya implementa BatchClientInterface mediante las lazy responses de Symfony HttpClient — cero configuración adicional.
Antes de irte

Gracias por llegar hasta aquí.

Llevo tres años usando este patrón en producción — integrando Booking.com, Iberia, Lleego, Hostalia y más. Solo la disponibilidad de Booking.com requiere 4 consultas en paralelo para una ciudad pequeña, 17 para París, por cliente. Una versión anterior de este engine lo gestionaba sin inmutarse. Este bundle es lo que esos tres años me enseñaron: hecho explícito, testeado y abierto.

Contacto

Empieza a construir tu próxima integración hoy.

Escríbenos directamente, abre una GitHub Discussion, o instálalo y pruébalo.