Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Integration Engine Laravel Package

carlosgude/integration-engine

IntegrationEngine forces every Symfony integration into the same predictable shape: each endpoint is just a Request and a Response, with facades, mappers, and YAML action maps generated for you. No ad-hoc HTTP clients or scattered mapping logic.

View on GitHub
Deep Wiki
Context7

Getting Started

Before anything else: check the framework fit. IntegrationEngine is a Symfony bundle (PHP 8.2+, Symfony 6.4/7/8), auto-registered via Flex. If you're in Laravel, it will not install-and-run: there is no service provider, no config/packages/integration_engine.yaml loader, and IntegrationRegistry is a container-managed service. Your realistic options are (a) port the concept — action map + typed Response DTOs + Gateway ACL — into a Laravel package of your own, or (b) pull the Core namespace in manually and hand-wire the registry, adapters, and middleware in a ServiceProvider. Decide this first; everything below assumes you've chosen one.

Minimal steps (Symfony):

composer require carlosgude/integration-engine
php bin/console make:integration MyApi GetEmployee

Then three things, in order:

  1. config/packages/integration_engine.yaml — register my_api with base_url and config_path.
  2. src/Infrastructure/Integrations/MyApi/MyApi.yaml — the action map (the scaffold writes this for you).
  3. A facade class implementing IntegrationName with a NAME constant, plus a Gateway that exposes domain objects.

Where to look first, in priority order:

  • The demo repo (integrationEngine-use-example) — REST, GraphQL, and dynamic OAuth2 wired end-to-end. Faster than the README for understanding the facade/gateway boundary.
  • DOCUMENTATION.md → Batch / Parallel Requests — the sendMany() contract is the least obvious part and the one you'll lean on most.
  • docs/lifecycle.md → Timing semantics — now the canonical reference for the event pipeline (RequestSent → ResponseReceived → ResponseMapped) and what each duration actually measures.
  • The Structure section of the README — the directory convention is the whole selling point. Read it before you invent your own layout.

First use case: a single read endpoint with a path parameter. GetEmployee(123) → GetEmployeeResponse DTO → Employee domain object via the Gateway. Get that loop working end-to-end before touching batching, auth, or custom adapters. The package's value is predictability, not features — validate that the shape feels right on one action before scaffolding ten.

Implementation Patterns

The facade/gateway split is the actual architecture. Everything else is plumbing. Enforce it in review:

  • Facade (MyApiIntegration) is final, owns a private IntegrationEngine, and returns only Response DTOs. It must never leak IntegrationEngine types.
  • Gateway (MyApiGateway) is the only class importing both DTO and domain types. Controllers and services depend on the Gateway, never the facade.
  • Never inject IntegrationRegistry into application code. If you see it outside Infrastructure/Integrations/, that's a boundary violation.
// Controller: domain-only
final class EmployeeController
{
    public function __construct(private MyApiGateway $gateway) {}
    public function __invoke(int $id): Response
    {
        return new JsonResponse($this->gateway->find($id));
    }
}

Path parameters: pick body or context deliberately. Resolution is body-then-context for body-declared actions. If the identifier is genuinely part of the payload (PUT /employees/{id} with {"id":42,"name":"Ada"}), declare body: on the action — the engine strips id from the serialized JSON so it isn't duplicated. If it's a pure route parameter (GET /employees/{id}), use context. Mixing both for the same value is the common mistake; it "works" but you'll send id in the body of a GET. Note that v9.0 reworked path resolution to be context-oriented — if you're porting v8 integration YAML rather than scaffolding fresh, read docs/upgrade-9.0.md (published in 9.4.0) before assuming v8 behaviour.

Optional query filters belong in a custom context, not in the action. Implement PathResolvableContextInterface and return null to fall back to the default placeholder resolver. Allowlist the filter keys explicitly — user input never reaches http_build_query unfiltered.

Required query params go in the YAML path (/employees?status={status}). This is what gives you a hard failure when the caller forgets status, instead of a silently unfiltered request. Prefer this over optional contexts when the API requires the param.

Auth: static in YAML, dynamic via a token action. Static bearer/basic/api_key goes directly on the action entry. For OAuth2, declare type: dynamic, point action: at a normal action you scaffolded, and expose the token in its Response toArray() as token_field. The engine handles fetch, cache, and injection — and self-heals on a 401 by evicting and retrying once.

Batching is where you get real throughput — but only on rest. sendMany() accepts EngineRequest objects keyed by your own keys and returns a BatchResultCollection with per-key successes and failures. Check hasFailures() and decide policy at the facade (throw the first error, collect partials, or return a result union). Concurrency requires BatchClientInterface: the default rest adapter implements it, graphql does not, so sendMany() on a GraphQL integration is sequentially executed. If you need real concurrency on GraphQL or SOAP, register a client_service: implementing BatchClientInterface yourself.

Custom adapters and middleware are tagged services. Implement ClientAdapterInterface, declare getClientType(), requiresPath(), requiresMethod(), and tag integration_engine.client_adapter. A project adapter with a matching getClientType() overrides the built-in — useful for swapping in a retrying HTTP client without touching config. Middleware extends AbstractClientMiddleware, takes (action, context, headers, callable $next), and is tagged similarly; ordering in the pipeline is your composition order, so register rate limiting before retry, not after.

GraphQL bodies implement GraphQLBodyInterface. getQuery() + getVariables() + toArray(), plus the static create() factory (the docs example was missing it until 9.4.0 — copy from the demo repo, not from memory). No method/path in the YAML for GraphQL actions.

Observability: split transport time from mapping time (new in 9.4.0). ResponseMapped::durationMs measures the whole action, so it tells you which action is slow but not where the time goes. Subscribe to ResponseReceived — emitted once the client has returned, before mapping — and subtract:

#[AsEventListener]
public function received(ResponseReceived $event): void
{
    // everything before mapping: resolution, auth, middleware, transport
    $this->metrics->timing('integration.pre_mapping', $event->durationMs, ['action' => $event->action]);
}

#[AsEventListener]
public function mapped(ResponseMapped $event): void
{
    // mapping time = ResponseMapped::timestamp - ResponseReceived::timestamp
}

Emit both into the same metric namespace keyed by action name. The split is the fastest way to decide whether to optimise a mapper (DTO hydration on fat payloads) or the transport (upstream latency, middleware chain, token resolution). It fires for send() under both static and dynamic auth and once per sendMany() item, so per-item timings fall out for free in batch workloads.

Gotchas and Tips

Framework mismatch is the headline risk for a Laravel shop. This is a Symfony bundle with IntegrationRegistry, Flex auto-registration, and YAML config under config/packages/. There's no Laravel bridge, no facade, no service provider. Budget for a port, not an install. The good news: the pattern (facade → action map → typed DTO → Gateway ACL) is framework-agnostic and portable to Laravel's container and config in a day or two.

Maturity and bus-factor. 10 stars, 0 dependents, MIT, single maintainer. Pin an exact version in composer.json, read the changelog before every bump, and treat it as a dependency you may need to fork. The listed last-release date is in the future relative to typical package metadata — verify the tag actually exists and that semver discipline holds before relying on ^x.y.

assert($response instanceof X) is not runtime enforcement. If zend.assertions=-1 (typical production), those checks vanish. The engine's send() returns mixed; your facade's declared return type will throw a TypeError after the assert is skipped, so you still get a failure — but the message is opaque. In high-traffic paths, use an explicit if (!$response instanceof X) { throw new UnexpectedResponse(...); }.

Use ResponseReceived to triage every RequestFailed. The two-event combination is the diagnostic: ResponseReceived then RequestFailed means the upstream answered and your mapper blew up (bad DTO shape, unexpected payload, missing field) — not the network. A bare RequestFailed with no preceding ResponseReceived is transport. And the 401 that triggers a token refresh emits neither ResponseReceived nor a terminal failure for that attempt, so don't alert on the absence of the pair as a timeout signal without checking the refresh path first.

Event-sequence assertions will break silently. If you have test doubles or listeners asserting an exact ordering, ResponseReceived now sits between RequestSent and ResponseMapped as of 9.4.0. It restores the pre-mapping split that v7 gave you via HttpResponseReceived and the v8 rework dropped — so if you wrote workarounds for the v8 gap, delete them rather than running both.

debug:integration <name> is your first-line tool — and it works again. It stopped being usable on YAML that declares the 9.3.0 defaults: section ("Unable to inspect actions"); 9.4.0 fixes it by skipping defaults: and webhooks: like any non-action key. If you were avoiding defaults: as a workaround, you can adopt it now. Run it before you run the integration.

Token cache scope will bite you. cache_service defaults to cache.app, which is process-local under PHP-FPM. Every worker fetches its own token on cold start. Against a rate-limited OAuth endpoint with 20 workers, that's 20 token requests at deploy. Configure a shared Redis pool via cache_service: before going live. Tokens are cached per connection for multi-connection setups, so this is safe to share.

The 401 self-heal retries exactly once. If the refreshed token is also rejected, the request fails. Don't build a retry loop on top expecting the engine to keep cycling — wrap at the Gateway if you need backoff.

Path param priority order is body-first, and the engine mutates your payload. The consumed key is stripped before serialization. If you want id in the JSON body and in the path, this design fights you — declare the action without body: and pass id via context, then add it back in a custom context or let the API tolerate the omission.

Silent omissions are the worst failure mode. A missing placeholder in context throws — good. But an optional filter you forgot to add to your PathResolvableContextInterface allowlist is silently dropped. Log the resolved path in a middleware during integration bring-up; it's the fastest way to see what actually went over the wire.

Read response headers through lowercase keys. Symfony HttpClient normalises header names to lowercase, so $response->getHeader('Content-Type') misses. The docs showed the wrong casing until 9.4.0 — fix it in your own mappers before you spend an afternoon debugging a "missing" header.

Check named arguments against the source, not the docs. The docs shipped ExponentialBackoff(maxRetries: …); the real name is maxAttempts. Copy resilience configuration from the demo repo or the class signature — the 9.4.0 doc pass fixed several of these, but treat named args in prose as unverified.

graphql + sendMany() looks parallel but isn't. It's sequential. Budget accordingly, or supply a BatchClientInterface adapter.

Middleware rate limiting must be cross-process. An in-memory limiter inside RateLimitMiddleware counts per worker, not per integration. Back it with Redis or Symfony's RateLimiter configured on a shared store.

Webhooks are synchronous by default. An accepted event is dispatched to Messenger before the 202 is sent (this is now documented correctly), so with the default sync bus the consumer runs inside the webhook request — slow consumers become webhook timeouts. Put the transport on an async bus before you rely on webhook throughput.

Testing seam is the Gateway, not the engine. No test doubles ship with the package. Mock your MyApiGateway in application tests (domain-in, domain-out). For facade tests, stub IntegrationEngine::send() or register a fake ClientAdapterInterface tagged as the default rest type. Don't hit real APIs in CI — the dynamic auth path makes that flaky fast.

The scaffolder is interactive. make:integration prompts for base URL, client type, method, and path — fine locally, awkward in scripts. Generate once, commit the output, and treat the generated Action/Mapper/Response as owned code from that point.

The directory layout is convention, not enforcement. Nothing stops a teammate from putting a Mapper in Request/ or skipping the Dto/ root for shared types. If you're adopting this for a team, add a CI check or a PR checklist — the package's entire value proposition collapses the moment layouts drift.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
andydefer/laravel-notification
adachsoft/cli-process-tester
jhaskell13/laraflow
sebastienheyd/boilerplate-media-manager
ccast/tagixo-primix
pretendtrue/laravel-builder
sawmainek/laravel-scout-tntsearch-driver
tomshaw/laravel-calendar-table
dibakar/laravel-ownership
steadrun/laravel-monitor
labyrinthe/connect-laravel
elmogy/layan
drewdan/sentinel-client
dskripchenko/laravel-llm
fatryst/nuonuo
stevenay/myanfont
gmostafa/php-graphql-client
xeviant/paystack
snowsoft/faker
meditationsteps/blogetc