apntalk / esl-core
Framework-agnostic, transport-neutral, typed FreeSWITCH ESL protocol library for PHP with replay-safe protocol primitives
Requires
- php: ^8.1
- ext-dom: *
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.58
- phpstan/phpstan: ^1.11
- phpunit/phpunit: ^10.5 || ^11.0
README
Framework-agnostic, transport-neutral, typed FreeSWITCH ESL protocol substrate for PHP with replay-safe primitives.
What this package is
apntalk/esl-core is a protocol substrate for FreeSWITCH Event Socket Layer (ESL) clients.
It provides:
- A truthful ESL wire model (framing, parsing, serialization)
- Deterministic message classification (auth, command replies, events, bgapi)
- Typed command and reply objects
- A normalized event model with selective typed event families and safe degradation for unknown event types
- Correlation and session metadata primitives
- Replay-safe protocol envelopes and reconstruction-oriented hook contracts
- Capability declaration of supported surfaces
- Canonical protocol/core truth vocabulary for queue, retry, drain, lifecycle, terminal-publication, corpus-row, bounded-variance, and replay-adjacent blocker-family terms
- A minimal transport abstraction for testing and smoke-path use
It is designed to sit below runtime, framework, and replay packages such as
apntalk/esl-react, apntalk/laravel-freeswitch-esl, and
apntalk/esl-replay.
What this package is not
This package does not provide:
- Laravel service container bindings
- ReactPHP or Amp event loop integration
- Reconnect or supervision logic
- Worker assignment or routing
- Cluster or multi-PBX orchestration
- Database-backed registry behavior
- Durable replay execution engines
- Replay re-injection or replay scheduling
- Health endpoints
Those concerns belong in upper-layer packages that depend on this one:
apntalk/esl-react owns runtime/reconnect behavior,
apntalk/laravel-freeswitch-esl owns Laravel integration and persistence
concerns, and apntalk/esl-replay owns replay execution/re-injection.
Requirements
- PHP 8.1 or higher
ext-domenabled- No runtime framework dependencies
Installation
composer require apntalk/esl-core
Stability
This package follows SemVer, but it is still pre-1.0.0.
- Public namespaces are documented in
docs/public-api.md - The supported inbound decode surface is now
Apntalk\EslCore\Inbound\InboundPipeline - Internal parser/classifier implementations remain intentionally unstable before
1.0.0 - Replay envelopes and reconstruction-oriented contracts should be treated as provisional surfaces until
1.0.0
See docs/stability-policy.md for full details.
The canonical vocabulary surfaces are documented in
docs/canonical-truth-vocabulary.md.
Architecture overview
The library is organized in layers:
| Layer | Responsibility |
|---|---|
| Wire | Bytes, headers, body, framing, parsing, serialization |
| Classification | Session/auth state, message category, reply vs event distinction |
| Typed domain | Commands, replies, normalized events, correlation metadata |
| Canonical vocabulary | Capability, queue/retry/drain, lifecycle, terminal-publication, corpus-row, bounded-variance, and replay-adjacent truth terms |
| Replay substrate | Replay envelopes, capture policies, reconstruction hooks |
| Transport boundary | Minimal read/write contracts, in-memory transport |
See docs/architecture.md for the full architecture description.
Quick start
The supported public surface is centered on typed commands, the inbound decoding facade, normalized/typed events, correlation metadata, replay envelopes, capabilities, and the minimal transport boundary.
For new integrations, start from InboundPipeline::withDefaults() for raw byte decoding, SocketTransportFactory for endpoint/stream transport construction, and InboundConnectionFactory when a listener/runtime has already accepted a stream and needs one supported bootstrap bundle.
Direct InboundPipeline::__construct(...) collaborator injection and parser/classifier/reply-factory composition remain available for advanced fixture-backed work, but they are not the preferred downstream ingress path at this checkpoint and are not being soft-deprecated in this release line. For advanced callers that need parser/classifier replacement through public contracts, use InboundPipeline::withContracts(...); treat that as an advanced extension seam, not a co-equal integration route.
For one concise downstream integration map, see docs/downstream-integration.md.
Downstream integration map
For packages such as apntalk/laravel-freeswitch-esl, the supported integration choices are:
| Downstream need | Preferred public seam | Ownership that stays outside esl-core |
|---|---|---|
| Open a client connection from host/port settings | SocketTransportFactory::connect() + InboundPipeline::withDefaults() |
reconnect/backoff, read loops, auth/session policy, event subscription policy |
| Bootstrap one already-accepted inbound stream | InboundConnectionFactory::prepareAcceptedStream() |
listener ownership, accept loops, per-session supervision |
| Compose directly from frames / normalized events | ReplyFactory::fromFrame(), ReplyFactory::fromClassification(), EventFactory, EventClassifier, lower-level contracts |
byte-ingress defaults, stable constructor ergonomics, protection from provisional coupling |
Use CorrelationContext after decode when your upper layer needs per-session ordering or derived job/channel correlation. Use ReplayEnvelopeFactory only for replay-safe capture/export hooks; storage, scheduling, replay execution, and replay re-injection stay in upper layers such as apntalk/esl-replay.
Preferred ingress facade
Use InboundPipeline::withDefaults() when you need the supported raw-byte decode path without coupling to the current parser/classifier implementation details.
use Apntalk\EslCore\Commands\AuthCommand; use Apntalk\EslCore\Correlation\ConnectionSessionId; use Apntalk\EslCore\Correlation\CorrelationContext; use Apntalk\EslCore\Inbound\InboundPipeline; use Apntalk\EslCore\Replay\ReplayEnvelopeFactory; use Apntalk\EslCore\Transport\InMemoryTransport; // InMemoryTransport is a test/smoke transport, not a runtime owner. $transport = new InMemoryTransport(); $transport->write((new AuthCommand('ClueCon'))->serialize()); $inbound = InboundPipeline::withDefaults(); $transport->enqueueInbound("Content-Type: auth/request\n\n"); $messages = $inbound->decode($transport->read(4096) ?? ''); $messages[0]->isServerAuthRequest(); // true $sessionId = ConnectionSessionId::generate(); $correlation = new CorrelationContext($sessionId); $replay = ReplayEnvelopeFactory::withSession($sessionId);
Preferred transport construction seam
Use SocketTransportFactory when you need core to create or wrap a real byte-stream transport while keeping lifecycle policy outside esl-core.
use Apntalk\EslCore\Transport\SocketEndpoint; use Apntalk\EslCore\Transport\SocketTransportFactory; $socketFactory = new SocketTransportFactory(); $transport = $socketFactory->connect(SocketEndpoint::tcp('127.0.0.1', 8021));
TransportInterface::read() returns '' as a non-blocking "no data yet"
signal only when the underlying transport is configured for non-blocking reads.
SocketTransportFactory preserves the current blocking mode of the PHP stream
it connects or wraps; if your runtime polls, configure the stream accordingly.
For this release line, TransportInterface::write() assumes the wrapped stream
is currently writable and is expected to be used with a blocking stream or with
runtime-managed write readiness. Core does not implement async retry,
would-block buffering, or write scheduling; non-writable writes fail with
TransportException.
Accepted-stream bootstrap seam
Use InboundConnectionFactory when your listener/runtime has already accepted a PHP stream and now needs the supported core bootstrap bundle.
use Apntalk\EslCore\Inbound\InboundConnectionFactory; [$acceptedPhpStream] = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, 0); $acceptedFactory = new InboundConnectionFactory(); $prepared = $acceptedFactory->prepareAcceptedStream($acceptedPhpStream); $prepared->pipeline()->push($prepared->transport()->read(4096) ?? '');
In production, $acceptedPhpStream is provided by your listener/runtime layer after accept. If prepareAcceptedStream() is called without a ConnectionSessionId, core generates one for that connection and binds it to the returned CorrelationContext. That bootstrap step still does not imply listener ownership, a read loop, replay bootstrap integration, or any higher-level session supervision.
If you need the current low-level parser/classifier implementations directly, they are still available in the repository and fixture-backed, but they remain pre-1.0 unstable implementation surfaces rather than the disciplined public API boundary.
Upper layers should prefer InboundPipeline::withDefaults() instead of composing FrameParser, InboundMessageClassifier, ReplyFactory, and EventFactory directly, unless they intentionally need frame-level control and accept lower-level coupling. Use InboundPipeline::withContracts(...) when that customization must be expressed through public parser/classifier contracts.
For that advanced composition path, the current staged migration posture is:
- consume classified output through
Contracts\\ClassifiedMessageInterface - pass it to
ReplyFactory::fromClassification()when you need typed replies - implement
InboundMessageClassifierInterfaceagainst the publicClassifiedMessageInterfaceresult contract when you need custom classification
For typed events, the built-in event wrappers currently expose a public readonly
$normalized property and also implement
Contracts\ProvidesNormalizedSubstrateInterface. Treat the interface or
DecodedInboundMessage::normalizedEvent() as the supported substrate seam for
downstream code; a similarly named property on custom wrappers is not enough to
participate in correlation/replay substrate extraction.
Preferred vs advanced seam posture
| Posture | What to build on first |
|---|---|
| Preferred public seams | InboundPipeline::withDefaults(), SocketTransportFactory, InboundConnectionFactory, typed commands/replies/events, CorrelationContext, ReplayEnvelopeFactory |
| Advanced public seams | InboundPipeline::withContracts(...), InboundPipeline::__construct(...), ReplyFactory::fromFrame(), ReplyFactory::fromClassification(), ReplyFactory::fromClassified(), EventFactory, EventClassifier, Contracts\ClassifiedMessageInterface, Contracts\CompletableFrameParserInterface, Contracts\ProvidesNormalizedSubstrateInterface, Contracts\FrameSerializerInterface, lower-level Contracts\* parser/classifier interfaces |
| Internal or provisional implementation details | Parsing\*, Internal\*, most of Protocol\* other than Frame and HeaderBag |
Current release scope
- Typed commands and replies for auth, command replies,
api, andbgapi - Stable inbound byte-stream decoding via
InboundPipeline - Stable accepted-stream inbound bootstrap via
InboundConnectionFactory+PreparedInboundConnection - Normalized events for
text/event-plainandtext/event-json - Provisional normalized event decoding for
text/event-xml - Selective typed event families: background job, channel lifecycle, bridge, hangup, playback, and custom events
- Correlation/session metadata and replay-safe envelopes
- Canonical truth vocabulary under
Apntalk\EslCore\Vocabularyfor blocker-family terms needed by downstream runtime/replay packages - Minimal in-memory transport and explicit failure taxonomy
- Stable public socket transport construction via
SocketEndpoint+SocketTransportFactory - Internal-only stream/socket smoke-path validation over a real PHP stream resource
- Fixture-backed behavior, PHPUnit coverage, PHPStan, and capability verification
Still provisional or deferred from this release:
- live-backed
text/event-xmlevidence beyond constructed fixtures - framework/runtime integrations
- broader transport runtime expansion beyond the minimal socket construction seam
- replay storage, scheduling, execution, re-injection, or orchestration
- downstream queue execution, retry scheduling, drain orchestration, lifecycle projection state machines, and terminal-publication dispatch
Smoke check
For a fast confidence pass that the current substrate composes cleanly on its happy paths, run:
composer smoke
This smoke path exercises the supported inbound facade together with the typed command/reply and async event pipelines, including correlation/session metadata and replay-envelope creation.
Maintainer verification
Use the narrowest useful check first:
composer unitfor low-level value-object and wire-model regressionscomposer contractfor public seam and fixture-backed behavior checkscomposer integrationfor composed in-memory/socket/inbound-path verificationcomposer smokefor a fast supported-path sanity passcomposer checkfor the full local release gate (cs-check,analyse, andtest)composer validate --strictwhen changing package metadata or Composer scripts
Live tools/smoke/* helpers remain optional operator validation support for fixture work and PBX-side evidence gathering. They are not part of the package API or the default local release gate.
Current release-line status
The repository is currently positioned as a small pre-1.0.0 release line with the core seams in place and residual provisional surfaces explicitly documented.
That means:
- the supported ingress contract is explicit and documented around
InboundPipeline - XML event decoding exists, but is still declared provisional pending broader evidence
- stream/socket validation is stronger, but remains internal smoke support only
- residual pre-1.0 gaps are documented rather than hidden
For shipped version history and current unreleased changes, treat
CHANGELOG.md plus the published git tags/GitHub releases as
the release source of truth. Historical draft notes under docs/releases/
remain maintainer context only.
Documentation
docs/architecture.mddocs/protocol-model.mddocs/protocol-state.mddocs/fixtures.mddocs/replay-primitives.mddocs/public-api.mddocs/downstream-integration.mddocs/stability-policy.mddocs/capabilities.mddocs/release-checklist.md
License
MIT. See LICENSE.