Getting Started
The layout of a connector package, the service provider that wires it in, and the ports every connector binds.
A connector is an ordinary Composer package that depends on subscriby/connector-sdk. Three things make it a connector: a connector.json at its root, a service provider that extends the SDK's, and a Connector class that binds the ports the package implements.
Start from an empty Composer package and pull the SDK in:
composer require subscriby/connector-sdkThe SDK is published from github.com/envigoinnovations/subscriby-connector-sdk, a read-only mirror of Subscriby's monorepo; its CHANGELOG.md lists what each version added.
Package layout
my-connector/
├── connector.json the manifest — what the connector is and can do
├── composer.json requires subscriby/connector-sdk, declares the provider
├── config/ optional: connector-<key>.php and any client config
├── database/migrations/ optional: the connector's own tables, prefixed <key>_
├── lang/ optional: <locale>.json translations for the connector's strings
├── resources/views/ optional: slot views, published under connector-<key>::
├── routes/
│ ├── inbound.php optional: the platform's webhook routes
│ └── web.php optional: browser routes (a sign-in callback, a health probe)
├── src/
│ ├── MyConnectorServiceProvider.php
│ ├── MyConnector.php
│ └── Ports/… one class per port
└── tests/The provider file must sit at src/<Provider>.php: the SDK finds the package root as its grandparent, which is how it locates the manifest and everything else.
composer.json
{
"name": "acme/subscriby-connector-example",
"require": {
"php": "^8.4",
"illuminate/contracts": "^13.0",
"illuminate/support": "^13.0",
"subscriby/connector-sdk": "^1.0"
},
"autoload": {
"psr-4": { "Acme\\Connectors\\Example\\": "src/" }
},
"extra": {
"laravel": {
"providers": ["Acme\\Connectors\\Example\\ExampleConnectorServiceProvider"]
}
}
}The sdk constraint inside connector.json ("sdk": "^1.0") is checked as well: the registry refuses a package built against an SDK the application does not run.
The service provider
Extend Subscriby\Connector\ConnectorServiceProvider and return your connector from connector(). The base class does the rest when the application boots:
- reads and validates
connector.json, listing every problem at once with its dotted path; - registers the connector with the application's registry under that manifest;
- loads
database/migrations,resources/views(as theconnector-<key>namespace),lang/*.json,routes/inbound.php(behind the core'sconnector.inboundmiddleware, which authenticates each call through yourInboundGateway, drops a replayed event and refuses a paused connector),routes/web.php(behindweb) and the console commands you return frompackageCommands(); - binds the listeners you return from
packageListeners()to the SDK's events (Subscriby\Connector\Events\*); - hands the seeders you return from
packageSeeders()to the registry, and the application's database seeder runs them after its currencies and countries and before its demo projects, so a connector ships its own demo rows (its bots, its chats, its native currency); - merges every
config/*.phpthe package ships under the file's own name, soconfig/connector-example.phpbecomesconfig('connector-example.…').
<?php
declare(strict_types=1);
namespace Acme\Connectors\Example;
use Subscriby\Connector\ConnectorServiceProvider;
use Subscriby\Connector\Contracts\Connector;
final class ExampleConnectorServiceProvider extends ConnectorServiceProvider
{
protected function connector(): Connector
{
return $this->app->make(ExampleConnector::class);
}
}The Connector class
Subscriby\Connector\Contracts\Connector has one method. It receives a ConnectorRegistrar and binds each port the package implements by its interface name:
final class ExampleConnector implements Connector
{
public function __construct(
private readonly Ports\ExampleInstallationLifecycle $lifecycle,
private readonly Ports\ExampleIdentityResolver $identities,
// …
) {}
public function register(ConnectorRegistrar $registrar): void
{
$registrar->port(InstallationLifecycle::class, $this->lifecycle);
$registrar->port(IdentityResolver::class, $this->identities);
$registrar->port(InboundGateway::class, $this->gateway);
$registrar->port(FailureClassifier::class, $this->failures);
$registrar->port(TextRenderer::class, $this->renderer);
$registrar->port(SettingsSchema::class, $this->settings);
$registrar->port(UiSlots::class, $this->slots);
$registrar->port(Messenger::class, $this->messenger);
$registrar->port(AccessController::class, $this->access);
}
}What the connector is never lives in this class: the manifest describes it, and the conformance kit fails a package whose registered manifest differs from its file.
Required and optional ports
Every connector binds these seven, whatever the platform:
| Port | Why it is required |
|---|---|
InstallationLifecycle | Connecting, verifying, disconnecting and describing an installation. |
IdentityResolver | Who an inbound event is from; what the platform knows about an account. |
InboundGateway | Authenticating and decoding what the platform sends. |
FailureClassifier | Reading why the platform refused a call, in the core's failure vocabulary. |
TextRenderer | Turning the core's canonical HTML into what the platform accepts. |
SettingsSchema | The install and settings forms as data (usually bound for you from the manifest's fields). |
UiSlots | The connector's UI contributions, even when it fills none. |
Every other port is bound when the manifest declares the matching capability, and the registry refuses a manifest that declares a capability without its port or binds a port without its capability. Ports has one page per port.
SettingsSchema comes from the manifest
A connector whose install and settings forms are plain fields declares them in connector.json (install.fields, install.settings_fields) and binds nothing: the core wires the SDK's ManifestSettingsSchema over those fields, translated through the package's language files. Write your own SettingsSchema only when the fields depend on the installation.
Reading and writing the core's rows
A connector never queries the application's database. It records and reads installations, identities, spaces, grants and support messages through the Core API, the contracts under Subscriby\Connector\Core\* that the core binds in its container and hands to any class your package builds. Everything they take and return is an SDK value object; anything a connector keeps of its own (a bot row, a token, a cached chat title) goes into its own tables under the data rules, with a storage_ref string on the core row pointing at it. Core API has one page per contract.
The worked example
The SDK ships a complete connector to copy from: the fake connector under Subscriby\Connector\Testing, with its own connector.json beside it. It deliberately violates every assumption a Telegram-shaped core would make (280-character messages, no files, membership grants instead of invite links, a resource kind the creator fulfils by hand, no early admission, three admin commands), so a core path that still assumes one platform fails against it. Its port fakes keep what they were asked in memory and offer assertions, which makes them the doubles to write your own connector's tests against too.
Next
How is this guide?