vimatech/laravel-integrations
Composer 安装命令:
composer require vimatech/laravel-integrations
包简介
Config-driven ports & adapters foundation for integrating external providers in Laravel: capability drivers, context routing and normalized webhooks.
README 文档
README
A config-driven ports & adapters foundation for integrating external providers in Laravel.
Adding a provider means writing one isolated adapter class and a config entry — you never touch
business logic, routing, or the webhook pipeline. It generalizes Laravel's own Manager/driver pattern
with context routing (route a capability to a driver by country, tenant, …) and a normalized
inbound webhook pipeline (verify → translate → de-duplicate → dispatch canonical events).
This package is intentionally domain-free: it ships no concrete vendor and no business logic. The capabilities (what an adapter actually does) are contracts defined by your application or by consumer packages.
Table of contents
- Why
- Installation
- Core concepts
- Configuration
- Adding a driver
- Resolving & routing drivers
- Per-tenant overrides
- Webhooks
- Credentials & secure storage
- The
integrations:listcommand - Testing
- Octane & FrankenPHP
- Quality
Why
External providers leak into business logic in predictable ways: a match ($country) here, a
hard-coded SDK client there, bespoke webhook controllers everywhere. This package gives you a single,
boring seam:
Business logic ──▶ Integrations::for('einvoice')->resolve(['country' => $invoice->country])
│
┌───────────────┴───────────────┐
▼ ▼
ChorusProAdapter SdiAdapter ← swap/add via config only
Your business logic depends on a capability contract; the concrete provider is selected by configuration and runtime context.
Installation
composer require vimatech/laravel-integrations
The service provider is auto-discovered. Publish the config (and, if you use the database idempotency store, the migration):
php artisan vendor:publish --tag=integrations-config
php artisan vendor:publish --tag=integrations-migrations # only for the "database" webhook store
Requires PHP 8.3+ and Laravel 11, 12 or 13.
Core concepts
| Concept | Description |
|---|---|
| Capability | A named contract owned by a consumer (e.g. einvoice, payments). This package never sees it. |
| Driver | An adapter implementing a capability. Marked with Vimatech\Integrations\Contracts\Driver. |
IntegrationManager |
Resolves a driver by capability + key from config, à la Illuminate\Support\Manager. |
ContextRouter |
Resolves a driver by default or by a context array (['country' => 'FR']). |
ResolvesTenantDriver |
Optional contract to override the driver per tenant from your database. |
WebhookTranslator |
Verifies an inbound request and translates it into canonical events. |
CanonicalEvent |
Provider-agnostic event base class with a stable idempotency key. |
Configuration
config/integrations.php (abbreviated — see the published file for full comments):
return [ 'credentials' => [ 'store' => env('INTEGRATIONS_CREDENTIAL_STORE', 'config'), // 'config' | 'encrypted' ], 'webhooks' => [ 'prefix' => env('INTEGRATIONS_WEBHOOK_PREFIX', 'integrations/webhooks'), 'middleware' => ['api'], 'event_store' => env('INTEGRATIONS_WEBHOOK_STORE', 'cache'), // 'cache' | 'database' 'event_table' => 'integration_webhook_events', 'event_ttl' => 86400, ], 'capabilities' => [ 'einvoice' => [ 'default' => env('EINVOICE_DRIVER', 'chorus_pro'), 'routing' => [ 'by' => 'country', // the context dimension to route on 'map' => [ 'FR' => 'chorus_pro', // contextValue => driverKey 'IT' => 'sdi', ], ], 'drivers' => [ 'chorus_pro' => [ 'class' => \App\Integrations\ChorusProAdapter::class, 'api_key' => env('CHORUS_PRO_KEY'), ], 'sdi' => [ 'class' => \App\Integrations\SdiAdapter::class, 'api_key' => env('SDI_KEY'), ], ], 'webhooks' => [ 'enabled' => true, 'translator' => null, // null = use the driver if it is a WebhookTranslator ], ], ], ];
Adding a driver
1. Define the capability contract (in your app or a consumer package). It extends the Driver
marker:
use Vimatech\Integrations\Contracts\Driver; interface EInvoiceNetwork extends Driver { public function send(Invoice $invoice): string; }
2. Write the adapter. Adapter constructors receive the resolved config array:
final class ChorusProAdapter implements EInvoiceNetwork { public function __construct(private array $config) {} public function send(Invoice $invoice): string { // talk to Chorus Pro using $this->config['api_key'] } }
3. Register it in config under the capability's drivers map. That's it — no business-logic
changes.
Need bespoke construction (a pre-built SDK client, etc.)? Register a factory:
app(IntegrationManager::class)->extend('einvoice', 'chorus_pro', function (array $config) { return new ChorusProAdapter(ChorusClient::make($config['api_key'])); });
Resolving & routing drivers
use Vimatech\Integrations\Facades\Integrations; // Explicit key $driver = Integrations::driver('einvoice', 'sdi'); // Capability default $driver = Integrations::driver('einvoice'); // Route by context — uses the capability's `routing.by` dimension $driver = Integrations::for('einvoice')->resolve(['country' => $invoice->country]);
Context resolution order for for($capability)->resolve($context):
- A bound
ResolvesTenantDriver(per-tenant override). - Static routing on the configured
routing.bydimension. - The capability
default. - Otherwise
UnresolvableDriveris thrown.
Need to fail instead of falling back to the default when context doesn't match? Use
resolveStrict():
Integrations::for('einvoice')->resolveStrict(['country' => 'DE']); // throws UnresolvableDriver
Other router methods: default(), via('sdi'), and key($context) (returns the resolved driver key
without instantiating).
Per-tenant overrides
Bind an implementation of ResolvesTenantDriver to let the database decide. Return null to defer to
static routing:
use Vimatech\Integrations\Contracts\ResolvesTenantDriver; final class TenantDriverResolver implements ResolvesTenantDriver { public function resolveDriverKey(string $capability, array $context): ?string { return Tenant::find($context['tenant'] ?? null) ?->integrationKey($capability); } } // In a service provider: $this->app->bind(ResolvesTenantDriver::class, TenantDriverResolver::class);
Integrations::for('einvoice')->resolve(['tenant' => $tenant->id, 'country' => 'FR']);
Webhooks
A single generic inbound route is registered:
POST {prefix}/{capability}/{driver?}
For each request, the pipeline:
- Checks that webhooks are enabled for the capability (else
404). - Resolves a
WebhookTranslator— the configuredwebhooks.translator, or the resolved driver if it implementsWebhookTranslator. - Calls
verify($request). On failure it dispatchesWebhookRejectedand returns403. - Dispatches
WebhookReceived. - Calls
translate($request)and, for eachCanonicalEvent, enforces idempotency via the event key store before dispatching it through Laravel's event system.
Make a driver translate its own webhooks:
use Illuminate\Http\Request; use Vimatech\Integrations\Contracts\Driver; use Vimatech\Integrations\Contracts\WebhookTranslator; use Vimatech\Integrations\Webhooks\CanonicalEvent; final class ChorusProAdapter implements EInvoiceNetwork, WebhookTranslator { public function __construct(private array $config) {} public function verify(Request $request): bool { return hash_equals( $this->config['webhook_secret'], (string) $request->header('X-Signature'), ); } public function translate(Request $request): iterable { foreach ($request->input('events', []) as $raw) { yield new InvoiceDelivered($raw['id']); } } }
Define canonical events your application listens for. The idempotencyKey() must be stable across
redeliveries:
use Vimatech\Integrations\Webhooks\CanonicalEvent; final class InvoiceDelivered extends CanonicalEvent { public function __construct(public readonly string $invoiceId) {} public function idempotencyKey(): string { return "einvoice:delivered:{$this->invoiceId}"; } }
Idempotency store is configurable via webhooks.event_store:
cache(default) — uses the atomicCache::add()operation.database— uses a unique index on the publishedintegration_webhook_eventstable.
Idempotency is claimed before dispatch. An event key is marked as seen as soon as it is accepted, so a redelivery is skipped even if a listener fails. Make your canonical-event listeners queued (
ShouldQueue): the webhook returns200immediately, and listener failures are retried by the queue rather than by the provider re-sending the webhook. Keep listeners idempotent on your own side too.
Credentials & secure storage
By default credentials are read straight from config (store => 'config'). Set
store => 'encrypted' to decrypt the keys listed in a driver's encrypted array using Laravel's
encrypter:
'drivers' => [ 'chorus_pro' => [ 'class' => ChorusProAdapter::class, 'api_key' => env('CHORUS_PRO_KEY'), // ciphertext at rest 'encrypted' => ['api_key'], ], ],
To store credentials with vimatech/laravel-secure-fields or any
other backend, bind your own CredentialStore — the package never assumes a vendor:
use Vimatech\Integrations\Contracts\CredentialStore; $this->app->singleton(CredentialStore::class, SecureFieldsCredentialStore::class);
The integrations:list command
php artisan integrations:list
Prints every configured capability with its drivers, default, routing map and webhook status.
Testing
Swap the manager for a fake and assert which drivers your code used:
use Vimatech\Integrations\Facades\Integrations; it('uses the SDI driver for Italian invoices', function () { $fake = Integrations::fake(); app(InvoiceSender::class)->send($italianInvoice); $fake->assertDriverUsed('einvoice', 'sdi'); });
Integrations::fake() keeps the real routing logic (so context routing still resolves to the right
key) while returning recording doubles. Provide your own capability fakes when you need behaviour:
$fake = Integrations::fake([ 'einvoice:sdi' => new FakeEInvoiceNetwork(), // your double implementing the capability contract ]);
Available assertions on the fake: assertDriverUsed(), assertDriverNotUsed(), assertNothingUsed(),
and used() for the raw record.
Octane & FrankenPHP
The package is built for long-lived workers. It keeps no static or global state; the only mutable
state is the per-key driver instance cache on the IntegrationManager singleton — which is a
performance win under workers, since each adapter is built once and reused across requests.
Three rules keep it safe and fast in worker mode:
-
Keep adapters stateless per request. Read from the injected
$config; never store request-bound state (the current user, theRequest, a cart) on an adapter, or it will leak into the next request. Driver resolution itself is just array lookups plus a one-time container build. -
Queue your canonical-event listeners (
ShouldQueue) — see the webhook idempotency note. The worker returns200immediately and retries happen on the queue. -
Per-tenant credentials via
extend()? The instance cache is keyed bycapability:key, not by tenant. That is correct when credentials come from config (static per key). Only if you register anextend()factory that captures per-tenant credentials do you need to avoid the shared cache — resolve those per tenant in your own code instead.
If (and only if) you intentionally keep request state on an adapter, flush the cache each request:
use Laravel\Octane\Events\RequestReceived; use Vimatech\Integrations\IntegrationManager; Event::listen(RequestReceived::class, fn () => app(IntegrationManager::class)->forgetDrivers());
Leave this off otherwise — it discards the build cache that makes workers fast.
env() is only ever read inside config/integrations.php, and routes are registered once, so the
package is fully compatible with config:cache and route:cache.
Quality
composer test # Pest + orchestra/testbench composer format # Laravel Pint composer analyse # PHPStan (level max) + Larastan
Contributing
Contributions are welcome.
Please ensure:
- Tests pass (
composer test) - PHPStan passes (
composer analyse) - Code style is formatted with Pint (
composer format)
Please see CONTRIBUTING for details.
Security Vulnerabilities
Please review our Security Policy for reporting vulnerabilities.
License
The MIT License (MIT). Please see the License File for more information.
Credits
Built and maintained by Vimatech. Created by Adel Zemzemi.
统计信息
- 总下载量: 0
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 1
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-06-26