scafera/integration

External system integration for the Scafera framework

Maintainers

Package info

github.com/scafera/integration

Type:symfony-bundle

pkg:composer/scafera/integration

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.2 2026-04-16 11:58 UTC

This package is auto-updated.

Last update: 2026-04-16 12:18:23 UTC


README

External system communication for the Scafera framework. Provides an enforceable gateway pattern for calling third-party APIs — all behind Scafera-owned types.

Internally adopts symfony/http-client. Userland code never imports Symfony HttpClient types — boundary enforcement blocks it at build time. All alternative HTTP mechanisms (cURL, file_get_contents with HTTP URLs) are also blocked.

Provides: External system communication for Scafera — a gateway pattern where each class wraps one third-party system with business-level methods. HttpClient (5 methods: get/post/put/patch/delete) and Response (statusCode/json/body/headers/header) are Scafera-owned types; gateways wire up via #[Integration]. All HTTP escape hatches (Symfony HttpClient, cURL, file_get_contents/fopen with HTTP URLs) are blocked outside Integration/.

Depends on: A Scafera host project with an Integration/ layer (e.g. src/Integration/ under App\Integration). Per-integration config under integration: in config/config.yaml; secrets belong in config.local.yaml (git-ignored).

Extension points:

  • Attribute — #[Integration('name')] resolves to the configured HttpClient; #[Integration('name', 'key')] resolves to a per-integration config value (ADR-065)
  • User gateways — one class per external system in Integration/..., class name must end with Gateway (enforced by GatewayNamingValidator), business-level methods only
  • Config — integration: section in config/config.yaml declares each integration's base_url, auth, and any custom keys (injectable via the two-arg attribute)
  • Testing — HttpClient constructor accepts an optional HttpClientInterface (e.g. MockHttpClient) for test doubles; the bundle never passes it in production

Not responsible for: Raw HTTP outside Integration/ (Symfony HttpClient, cURL, file_get_contents/fopen with HTTP URLs — blocked by HttpClientLeakageValidator, HttpClientBoundaryValidator) · auto-throwing on HTTP errors (Response never throws; the gateway decides) · full URLs in gateway methods (relative paths only; base URL from config) · secret storage (belongs in config.local.yaml, not config.yaml) · unused integration: config keys (flagged by UnusedIntegrationConfigValidator).

This is a capability package. It adds optional external system integration to a Scafera project. It does not define folder structure or architectural rules — those belong to architecture packages.

What it provides

  • HttpClient — wraps Symfony HttpClient with 5 methods (get, post, put, patch, delete)
  • Response — wraps Symfony response with 5 methods (statusCode, json, body, headers, header)
  • #[Integration('name')] — attribute for wiring gateways to configured HttpClient instances
  • #[Integration('name', 'key')] — attribute for injecting integration-specific config values into gateways (ADR-065)
  • HttpClientLeakageValidator — blocks all HTTP escape hatches in src/
  • GatewayNamingValidator — enforces *Gateway naming in Integration/
  • HttpClientBoundaryValidator — ensures HttpClient is only used in Integration/ layer
  • IntegrationConfigBoundaryValidator — ensures #[Integration('name', 'key')] config values are only used in Integration/ layer
  • UnusedIntegrationConfigValidator — flags config values defined in integration: but not referenced in any gateway

Design decisions

  • Gateway, not HTTP client wrapper — a wrapped HTTP client would scatter $http->post() calls across the codebase. Gateways enforce one class per external system with business-level methods (ADR-064).
  • #[Integration] attribute for wiring — extends Symfony's #[Autowire], same pattern as #[Config] in the kernel. Explicit at the injection site, greppable, validatable, refactor-safe (ADR-064). With a second argument, resolves integration-specific config values (ADR-065).
  • Relative paths only — gateways use endpoint paths ('/charges'), not full URLs. Base URL is configured once in config.yaml.
  • All HTTP escape hatches blocked — Symfony HttpClient, cURL, file_get_contents with HTTP URLs, fopen with HTTP URLs. If you need HTTP, you go through a gateway.

Installation

composer require scafera/integration

The bundle is auto-discovered via Scafera's symfony-bundle type detection. No manual registration needed.

Requirements

  • PHP >= 8.4
  • scafera/kernel

Configuration

# config/config.yaml
integration:
    stripe:
        base_url: 'https://api.stripe.com/v1'
        auth: ''
    mailgun:
        base_url: 'https://api.mailgun.net/v3'
        auth: ''
# config.local.yaml (not committed)
integration:
    stripe:
        auth: 'Bearer sk_live_real_secret'
    mailgun:
        auth: 'Basic key-real_secret'

If an integration has different base URLs per environment (e.g., sandbox vs production), override base_url in config.local.yaml as well.

Gateway

A gateway is one class per external system with business-level methods:

namespace App\Integration\Stripe;

use Scafera\Integration\HttpClient;
use Scafera\Integration\Attribute\Integration;

final class PaymentGateway
{
    public function __construct(
        #[Integration('stripe')]
        private HttpClient $http,
    ) {}

    public function createPayment(int $amount, string $currency): array
    {
        return $this->http->post('/charges', [
            'amount' => $amount,
            'currency' => $currency,
        ])->json();
    }

    public function refund(string $chargeId): array
    {
        return $this->http->post('/refunds', [
            'charge' => $chargeId,
        ])->json();
    }
}

Gateway rules

  • Class name must end with Gateway — enforced by validator
  • One class per external system
  • Business-level methods only — createPayment(), not post()
  • No HTTP types in public method signatures — return arrays or domain objects
  • No full URLs — endpoint paths only, base URL from config

Using a gateway in a service

Services inject gateways via constructor — same as any Scafera dependency:

namespace App\Service\Order;

use App\Integration\Stripe\PaymentGateway;

final class PlaceOrder
{
    public function __construct(
        private PaymentGateway $payment,
    ) {}

    public function handle(int $amount): array
    {
        return $this->payment->createPayment($amount, 'usd');
    }
}

Integration-specific configuration

Integrations can carry arbitrary config values beyond base_url and auth. These are registered as container parameters and injected via the same #[Integration] attribute with a second argument:

# config/config.yaml
integration:
    linkedin:
        base_url: 'https://api.linkedin.com/v2'
        auth: ''
        contract_id: ''
        seat_limit: 500
# config.local.yaml (not committed)
integration:
    linkedin:
        auth: 'Bearer token_secret'
        contract_id: 'CONTRACT-2026-XYZ'

The gateway receives config values alongside the HttpClient:

namespace App\Integration\LinkedIn;

use Scafera\Integration\HttpClient;
use Scafera\Integration\Attribute\Integration;

final class PremiumGateway
{
    public function __construct(
        #[Integration('linkedin')]
        private HttpClient $http,
        #[Integration('linkedin', 'contract_id')]
        private string $contractId,
        #[Integration('linkedin', 'seat_limit')]
        private int $seatLimit,
    ) {}

    public function inviteSeat(string $userId): array
    {
        return $this->http->post('/premium/invites', [
            'contract_id' => $this->contractId,
            'user_id' => $userId,
        ])->json();
    }
}

#[Integration('name')] without a second argument resolves to the HttpClient service. #[Integration('name', 'key')] resolves to the config value. The HttpClient itself has no awareness of these values — they are resolved by the container before the gateway is constructed.

HttpClient API

$this->http->get(string $path, array $options = []): Response;
$this->http->post(string $path, array $data = [], array $options = []): Response;
$this->http->put(string $path, array $data = [], array $options = []): Response;
$this->http->patch(string $path, array $data = [], array $options = []): Response;
$this->http->delete(string $path, array $options = []): Response;

The $data array is sent as JSON body. For other content types (form-encoded, multipart), use the $options array directly (e.g., $this->http->post('/upload', [], ['body' => $formData])).

Response API

$response->statusCode(): int;
$response->json(): array;
$response->body(): string;
$response->headers(): array;
$response->header(string $name): ?string;

HTTP errors do not throw automatically — error handling is the gateway's responsibility.

Testing

Testing services that use gateways

Mock the gateway, not the HTTP client:

final class PlaceOrderTest extends WebTestCase
{
    public function testPlacesOrder(): void
    {
        // Mock PaymentGateway — stable, behavior-focused
        // No HTTP client mocking, no request/response faking
    }
}

Testing gateways themselves

The HttpClient constructor accepts an optional HttpClientInterface for testing — the bundle never passes it (engine stays hidden in production):

use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

$mock = new MockHttpClient([new MockResponse('{"id": 1}')]);
$http = new HttpClient('https://api.example.com', null, $mock);

$gateway = new PaymentGateway($http);
$result = $gateway->createPayment(1000, 'usd');

Boundary enforcement

Blocked Use instead
Symfony\Contracts\HttpClient\* Scafera\Integration\HttpClient in a Gateway class
Symfony\Component\HttpClient\* Scafera\Integration\HttpClient in a Gateway class
curl_* functions Scafera\Integration\HttpClient in a Gateway class
file_get_contents with HTTP URLs Scafera\Integration\HttpClient in a Gateway class
fopen with HTTP URLs Scafera\Integration\HttpClient in a Gateway class
Scafera\Integration\HttpClient outside Integration/ Inject the gateway, not the HTTP client
#[Integration('name', 'key')] outside Integration/ Integration config values belong in gateways only
Unused config keys under integration: Remove unused keys or reference them in a gateway

Enforced via validators (scafera validate). The HttpClient and integration config values are only allowed inside the Integration/ layer — services and controllers inject gateways, not HTTP clients or integration config.

License

MIT