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) andResponse(statusCode/json/body/headers/header) are Scafera-owned types; gateways wire up via#[Integration]. All HTTP escape hatches (Symfony HttpClient, cURL,file_get_contents/fopenwith HTTP URLs) are blocked outsideIntegration/.Depends on: A Scafera host project with an
Integration/layer (e.g.src/Integration/underApp\Integration). Per-integration config underintegration:inconfig/config.yaml; secrets belong inconfig.local.yaml(git-ignored).Extension points:
- Attribute —
#[Integration('name')]resolves to the configuredHttpClient;#[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 withGateway(enforced byGatewayNamingValidator), business-level methods only- Config —
integration:section inconfig/config.yamldeclares each integration'sbase_url,auth, and any custom keys (injectable via the two-arg attribute)- Testing —
HttpClientconstructor accepts an optionalHttpClientInterface(e.g.MockHttpClient) for test doubles; the bundle never passes it in productionNot responsible for: Raw HTTP outside
Integration/(Symfony HttpClient, cURL,file_get_contents/fopenwith HTTP URLs — blocked byHttpClientLeakageValidator,HttpClientBoundaryValidator) · auto-throwing on HTTP errors (Responsenever throws; the gateway decides) · full URLs in gateway methods (relative paths only; base URL from config) · secret storage (belongs inconfig.local.yaml, notconfig.yaml) · unusedintegration:config keys (flagged byUnusedIntegrationConfigValidator).
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.
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 insrc/GatewayNamingValidator— enforces*Gatewaynaming inIntegration/HttpClientBoundaryValidator— ensuresHttpClientis only used inIntegration/layerIntegrationConfigBoundaryValidator— ensures#[Integration('name', 'key')]config values are only used inIntegration/layerUnusedIntegrationConfigValidator— flags config values defined inintegration:but not referenced in any gateway
- 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 inconfig.yaml. - All HTTP escape hatches blocked — Symfony HttpClient, cURL,
file_get_contentswith HTTP URLs,fopenwith HTTP URLs. If you need HTTP, you go through a gateway.
composer require scafera/integrationThe bundle is auto-discovered via Scafera's symfony-bundle type detection. No manual registration needed.
- PHP >= 8.4
- scafera/kernel
# 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.
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();
}
}- Class name must end with
Gateway— enforced by validator - One class per external system
- Business-level methods only —
createPayment(), notpost() - No HTTP types in public method signatures — return arrays or domain objects
- No full URLs — endpoint paths only, base URL from config
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');
}
}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.
$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->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.
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
}
}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');| 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.
MIT