Skip to content

Split Pub/Sub SDK: ably/pubsub-server package, Ably\PubSub namespace, server door - #223

Draft
umair-ably wants to merge 8 commits into
integration/v2from
pubsub-split/restructure
Draft

Split Pub/Sub SDK: ably/pubsub-server package, Ably\PubSub namespace, server door#223
umair-ably wants to merge 8 commits into
integration/v2from
pubsub-split/restructure

Conversation

@umair-ably

Copy link
Copy Markdown
Contributor

Implements PDR-091b (PubSub package split) for the PHP SDK — plan steps 11, 12, 13, 14, 14b, 14c of plan.md, which is row 1 of the PR stack in step 10b. The PHP surface is fixed by PDR-091b2, whose PHP row reads verbatim: "Repo: ably-php → ably-pubsub-php. New package: ably/pubsub-server. Factory door: createHttpClient(...) only."

Siblings: ably-js#2293, ably-ruby#453, ably-python#683.

What and why

Today an application installs ably/ably-php and gets one package regardless of where it runs. PDR-091 makes the package name the declaration of the side: the client you reach for is the one whose package matches where your code runs, and — because a server connection is exempt from monthly-active-user counting — that declaration has to reach the wire, not just the README.

PDR-091b settles how: new majors on a new core, not thin wrappers over the old package. ably/ably-php is not touched, is not re-exported, and reaches EOL a year after GA; fixes for it ship from a maintenance branch.

So this repo stops publishing ably/ably-php and starts publishing ably/pubsub-server, at 2.0.0, on a PHP ^8.1 floor.

Why one package and no ably/pubsub-core (deviation from the other SDKs)

Every other SDK in this programme ships a core plus one or two side packages. PHP ships one package, and this is a decision rather than an omission — PDR-091b2's PHP row already names only ably/pubsub-server. Five reasons, in ascending order of how hard they are to argue with:

  • There is no second side to share a core with. PHP is server-only and REST-only: there is no realtime client and no device package. A core exists to be shared, and here there is exactly one consumer of it.
  • PHP cannot re-export types. There is no export … from; the nearest thing is class_alias, which does not satisfy a type hint written against the aliased name in a way any static analyser will follow. So a separate core would force consumers to write use Ably\PubSub\Core\Models\Message; in their own code — type-hinting classes out of a package whose description says "internal, do not depend on this". A boundary that every consumer has to reach across is not a boundary.
  • The Laravel wrappers can depend on the server package directly. PDR-091c's reason for having high-level SDKs depend on core types is that they must not pick a side. That concern does not arise when there is exactly one side: ably/ably-php-laravel and ably/laravel-broadcaster are server-side by construction, so they depend on ably/pubsub-server: ^2.0 and construct through the door.
  • Packagist makes each extra package cost a whole extra repository. Verified against the composer/packagist and composer/composer sources: every Packagist package bound to a repository indexes every Composer-valid tag of that repository, whatever that tag's composer.json name says. Composer's VcsRepository::preProcess deliberately overwrites each version's name with the default branch's name ("this ensures that a package can be renamed in one place and that all old tags will still be installable using that new name"), and Packagist's Updater then stamps its own package name on every version. There is no name-based filtering of versions anywhere in the path. So two Composer packages cannot share one repository, and each one needs its own distribution mirror. A core would mean a second mirror repo, a subtree split to populate it, and a second Packagist entry to operate — for no consumer benefit.
  • Consequently there is no core//server/ directory split either. With one package it would be cosmetic (Packagist needs composer.json at the root) and it would enlarge the move diff for no packaging gain.

Consequence for this repository

ably/pubsub-server is published from a read-only distribution mirror, not from here. ably/ably-php stays bound to this repo (its ~8.7M downloads put it behind Packagist's PopularPackageSafetyValidator, so its URL cannot be moved to a different repository without Packagist support, and a repo-ID change would freeze it) and keeps indexing 1.x tags from the maintenance branch.

Which gives the one rule that must never be broken here:

A plain 2.x.y tag must never be pushed to this repository. ably/ably-php would index it and serve 2.x code — new namespace, new package name — as its own latest version, and every ably/ably-php: * or >=1.1 consumer would upgrade into a broken install. Nothing in Packagist prevents this; only not doing it does.

Releases here are tagged pubsub-server/2.0.0, which Composer skips as an invalid version name (VcsRepository::validateTag); the plain 2.0.0 tag exists only on the mirror. PR 2 enforces this in the release pre-flight and in a release-dry-run job on every PR.

Layout

Same flat layout, new identity:

composer.json          # ably/pubsub-server; php ^8.1; psr-4 "Ably\PubSub\": "src/"
.gitattributes         # export-ignore: tests/, ably-common/, .github/, phpunit.xml, plan.md, .ably/
src/
  Server.php           # the door
  AblyRest.php …       # today's src/**, namespace Ably\ -> Ably\PubSub\
  Defaults.php         # LIB_VERSION = '2.0.0' — still the only version site
tests/                 # namespace tests\ unchanged; use Ably\PubSub\…
ably-common/           # submodule stays (test fixtures)

Two things worth calling out:

  • The namespace is Ably\PubSub\, not Ably\. The legacy package owns the PSR-4 prefix Ably\src/. Composer will install ably/ably-php and ably/pubsub-server side by side — a Laravel app mid-migration, or a transitive dependency still on 1.x — and if both declared Ably\ the autoloader would resolve Ably\AblyRest to whichever prefix path happens to be searched first. A packaging test asserts no file under src/ declares a namespace outside Ably\PubSub.
  • Class names do not change here. AblyRest stays AblyRest, so this diff is a restructure and nothing else. The 091d public-API rename (AblyRestHttpClient) is a later, mechanical PR (step 18) and is not blocking on this.

The door

use Ably\PubSub\Server;

$ably = Server::createHttpClient(['key' => 'xxx:yyy']);

final class Ably\PubSub\Server, private constructor, one static method. It accepts exactly what the client constructor accepts — an options array, a ClientOptions instance, or a string holding an API key or a token — reusing the same normalisation rather than a copy of it: the constructor's inline strpos(':') disambiguation moved to ClientOptions::normalizeConstructorArgument() and both call it. It stamps the side entry, constructs, returns. Nothing else; it is a door, not a room.

No createRealtimeClient and no device door, per PDR-091b2: PHP has no realtime client, so a stub would only invite the question.

The client constructor stays public — a final static door over a private constructor is not idiomatic for a class this library and its tests construct internally — but is marked @internal pointing at the door. The consequence, stated in the docblock and asserted in a test: a bare new AblyRest(...) declares no side, and will be rejected on MAU accounts once pricing is live.

Agent header

Ably-Agent: ably-pubsub-php/2.0.0 php/8.3.4 ably-pubsub-server

with a wrapper:

Ably-Agent: ably-pubsub-php/2.0.0 php/8.3.4 laravel/11.0.0 laravel-broadcaster/1.0.4 ably-pubsub-server

Note the ordering: plan step 13 sketches the wrapper case with the side entry before the wrapper entries, but the rule it also states — the side entry is merged last so it wins a collision — puts it after them. The rule wins; the sketch was illustrative. Order carries no meaning to the classifier, only presence does.

  • The family identifier is renamed ably-phpably-pubsub-php, still versioned with LIB_VERSION. It lands here, before any prerelease, so even prerelease traffic partitions cleanly from legacy ably-php/* traffic. The maintenance branch keeps ably-php.
  • ClientOptions gains an additive agents option (array<string, string|null>). ablyAgentHeader() becomes an instance method reading it, rendering each entry as name/version or as a bare name when the version is null or '' — matching how the registry records entries that carry no version of their own, like browser. src/AblyRest.php still sends the header from exactly one place.
  • The door stamps ably-pubsub-server with no version, applied last so it wins a collision on its own identifier: the side is the package's to declare, not the caller's to redefine. Caller entries are otherwise preserved in order, so an SDK layered on top keeps its attribution. A ClientOptions argument is cloned rather than mutated.
  • The versionless form is the registered shape per ably-common#361, not a fallback for a missing version. A test asserts no ably-pubsub-server/ token is ever emitted, for a caller version of 'x', '' and null — the PHP shape of the name/undefined regression ably-js#2297 guards against.
  • The -server suffix is load-bearing: realtime grants the MAU server exemption on API-key auth by matching an agent entry ending in it. There is a comment saying exactly that where SERVER_AGENT_IDENTIFIER is defined, copied in spirit from ably-ruby's server.rb and ably-python's server/__init__.py, and a test asserts the suffix survives.

Both identifiers are registered in ably-common#361 (open). .ably/capabilities.yaml already declares Agent Identifier: Agents, so it needed no change — that declaration is now true per-client rather than per-process.

What was removed, and why

Removed Why
AblyRest::$agents, setAblyAgentHeader() Process-global mutable state that leaks between client instances is the wrong shape for a per-client declaration. Replaced by the agents option. Not currently @deprecated, so this is a stated owner decision, recorded in plan step 13 and in the migration table.
AblyRest::setLibraryFlavourString() Already @deprecated; superseded by the same option.
ably-loader.php A hand-rolled autoloader for the Ably\ prefix. Composer is the only supported install path (the README already says so) and the prefix has moved.
demo/, Procfile An unmaintained Heroku demo constructing new \Ably\AblyRest directly — exactly the shape the door replaces.
PHP 7.2–8.0 support EOL upstream; Laravel 10+ already requires 8.1. A major is the only place to raise a floor.

The only known callers of the static setters are the two Laravel wrappers, which are being re-released against this package anyway (plan step 27) — so their PRs must be open before PHP GA, not after.

rybakit/msgpack also gains an upper bound (^0.9.1 || ^1.0). The old >=1.0 style unbound constraint is what composer validate --strict rejects, and CI now runs --strict.

Commits

Deliberately split so each is diffable on its own:

Re-identify the package as ably/pubsub-server composer name, PSR-4 root, php floor, support URLs, .gitattributes
Move the namespace Ably\ -> Ably\PubSub\ pure mechanical move, no behaviour change
Remove the hand-rolled autoloader, the Heroku demo and its Procfile
Make the agent header per-client and rename the SDK identifier agents option, static removal, ably-pubsub-php, LIB_VERSION
Add the factory door Ably\PubSub\Server::createHttpClient() the new public surface
Assert the agent header and the packaging invariants plan steps 14b and 14c
CI: test 8.1-8.5, cover integration/v2, validate strictly

The namespace commit leaves the tree temporarily unloadable between commits 1 and 2 (the composer PSR-4 root moves first), and the agent assertions are red between commits 4 and 6. Both are deliberate: keeping the mechanical move free of behaviour is worth more to a reviewer than every commit building.

Verification

Run on macOS via Docker (composer:2, php:8.3-cli, php:8.1-cli) against sandbox.

  • composer validate --strict — passes. It failed first on the unbound rybakit/msgpack constraint, which is why that changed.
  • composer install — clean on the ^8.1 floor.
  • Full suite, PROTOCOL=json212 tests, 1331 assertions, 0 failures on php:8.3-cli, in about 3m02s. That is the whole sandbox suite: TestApp creates a real app against ABLY_ENV=sandbox, so REST publish, history, presence, auth, token issuing, push admin, crypto and the stats fixtures from the ably-common submodule all ran.
  • Full suite, PROTOCOL=msgpack212 tests, 1331 assertions, 0 failures, in about 3m24s.
  • PHP 8.1 (php:8.1-cli) — 212 tests, 1331 assertions, 0 failures (PROTOCOL=json), in about 3m19s — the new floor is real, not just declared.
  • Agent assertions (tests/HttpTest.php, 10 tests / 26 assertions): the plain header on the new identifier; the same header from a second client (no state leaks now that the static map is gone); wrapper attribution via the agents option; the door's header against ^ably-pubsub-php/\d+\.\d+\.\d+(\S*)? php/\S+ ably-pubsub-server$; no ably-pubsub-server/ token for a caller version of 'x', '' or null; a bare new AblyRest declaring no side; caller entries surviving and preceding the side entry; the door accepting an array, a ClientOptions, an API-key string and a token string, and not mutating a ClientOptions it was handed.
  • Packaging invariants (tests/PackagingTest.php, 7 tests): composer name; PSR-4 mapping only Ably\PubSub\; no file under src/ declaring a namespace outside it; Defaults::LIB_VERSION matching the top CHANGELOG heading; every class under src/ resolving through the generated autoloader; the door final, non-instantiable and still -server-suffixed. Each was checked to fail when violated, not just to pass — e.g. dropping a namespace Ably; file into src/ reports it by name.
  • grep -rn 'Ably\\[A-Z]' src tests | grep -v 'Ably\\PubSub' — empty, so the move left nothing behind in the bare namespace.

Out of scope / follow-ups

  • PR 2 — release tooling (plan steps 15, 15b, 15c): release.yml with the mirror push, the namespaced tag here, the Packagist visibility poll, and the pre-flight that refuses a Composer-valid 2.x tag in this repo; plus the release-dry-run job and the CONTRIBUTING.md release section. This repo has no release workflow at all today — publishing is manual and tag-driven.
  • PR 3 — release.yml on main, so workflow_dispatch is registered while the split still lives on integration/v2. Gated on the 1.x maintenance branch being cut first (step 24).
  • PR 5 — docs: README.md, UPDATING.md and the 2.0.0 CHANGELOG body are untouched here. This PR adds only the ## [2.0.0] (unreleased) heading, because the version-site invariant test reads it.
  • PR 6 — 091d rename: AblyRestHttpClient and the deprecated-surface deletions, gated on that DR being decided.
  • Repo rename ably-phpably-pubsub-php (step 17) and #222. composer.json's support.source/support.issues already point at the renamed URL, which resolves once the rename happens.
  • Laravel wrappers (step 27): new majors of ably/ably-php-laravel and ably/laravel-broadcaster depending on ably/pubsub-server: ^2.0, released in the same GA window.
  • The mirror repo and the Packagist registration (steps 9, 15c) are owner/infra tasks, not code.

🤖 Generated with Claude Code

umair-ably and others added 7 commits September 9, 2026 10:59
Per PDR-091b2's PHP row this repository now produces the Pub/Sub server
package rather than ably/ably-php:

- name becomes ably/pubsub-server and the description names the door, so
  `composer info` points at the only supported entry point;
- PSR-4 root becomes `Ably\PubSub\` (the legacy package keeps `Ably\`, so
  the two can be installed side by side without the autoloader resolving
  `Ably\AblyRest` to whichever prefix path is searched first);
- the PHP floor moves to ^8.1. 7.2-8.0 are EOL upstream and Laravel 10+
  already requires 8.1; a major is the only place to raise this;
- keywords gain pubsub/server, and support.source/support.issues point at
  the development repo (ably-pubsub-php) rather than the distribution
  mirror the package is published from;
- rybakit/msgpack gains an upper bound. The old `>=0.9.1` is an unbound
  constraint, which `composer validate --strict` rejects, and CI is moving
  to --strict.

The namespace move itself is the next commit, so this one leaves the tree
temporarily unloadable.

Also adds .gitattributes: Packagist dist archives are GitHub zipballs and
honour export-ignore, so consumers stop downloading the test suite, the
ably-common fixture submodule and the CI/planning files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nge)

Mechanical rename of every `Ably\`-rooted symbol reference across src/ and
tests/: namespace declarations, `use` statements, fully-qualified type
hints, the fully-qualified class-name strings passed to PaginatedResult
and HttpPaginatedResponse, and docblock types.

No behaviour changes and no renamed classes: AblyRest is still AblyRest,
only its namespace moved. Kept as its own commit so the door and agent
commits that follow can be reviewed without this noise.

Verified: `grep -rn 'Ably\\[A-Z]' src tests | grep -v 'Ably\\PubSub'` is
empty, and every namespace declared under src/ is Ably\PubSub or a child
of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- ably-loader.php was a hand-rolled PSR-0-ish autoloader for the `Ably\`
  prefix. Composer is the only supported install path, so the loader is
  both unmaintained and now wrong (the prefix moved to `Ably\PubSub\`).
- demo/ and Procfile were an unmaintained Heroku demo constructing
  `new \Ably\AblyRest` directly, which is exactly the shape the door now
  replaces.

All three are listed as removals in the migration guide (docs PR).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The `Ably-Agent` header is what billing reads to classify a connection, so
it becomes a per-client declaration rather than process-global state:

- ClientOptions gains an `agents` option (array<string, string|null>).
  An entry renders as `name/version`, or as a bare `name` when the version
  is `null` or `''`. The versionless form is not a fallback for a missing
  version: it is how the ably-common registry declares flags, and
  `ably-pubsub-server` is registered that way (ably-common#361).
- `ablyAgentHeader()` becomes an instance method reading
  `$this->options->agents`. `src/AblyRest.php` still sends the header from
  exactly one place.
- The static `$agents` map, `setAblyAgentHeader()` and the deprecated
  `setLibraryFlavourString()` are removed. A process-global mutable map
  leaks between client instances, which is the wrong shape for a per-client
  declaration; the only callers are the two Laravel wrappers, which are
  being re-released against this package anyway (plan step 27).
- The family identifier becomes `ably-pubsub-php`, so even prerelease
  traffic partitions cleanly from legacy `ably-php/*` traffic. It now lives
  in a named constant rather than inline in the header builder.
- `Defaults::LIB_VERSION` is `2.0.0`, still the only version site.

The string/array/ClientOptions normalisation the constructor did inline
moves to `ClientOptions::normalizeConstructorArgument()` so the factory
door in the next commit accepts exactly the same arguments from the same
code rather than a copy of it. The constructor is marked `@internal`
pointing at that door.

The agent assertions in tests/HttpTest.php still target the old static API
and are rewritten two commits later, with the new tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The single entry point of the ably/pubsub-server package. It accepts
exactly what the client constructor accepts (an options array, a
ClientOptions instance, or a string holding an API key or a token, reusing
the same normalisation), stamps the agent entry that declares the server
side, and returns the client. Nothing else: it is a door, not a room.

`final class` with a private constructor, and the side identifier lives in
`SERVER_AGENT_IDENTIFIER` carrying the comment that says why the `-server`
suffix must survive any future rename: realtime grants the MAU server
exemption on API-key auth by matching an agent entry ending in `-server`.

The side entry is merged last, so a caller passing
`agents => ['ably-pubsub-server' => 'x']` gets the versionless flag
anyway: which side the package declares is the package's to state. Caller
entries are otherwise preserved in order. A ClientOptions argument is
cloned rather than mutated, so passing one instance to the door twice does
not accumulate agents on the caller's object.

No realtime door and no device door, per PDR-091b2's PHP row: PHP has no
realtime client, so a stub would only invite the question.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Ably-Agent header is what billing reads, so these assertions are exact
rather than substring-tolerant. tests/HttpTest.php now checks:

- the header a plain client sends, on the new `ably-pubsub-php` identifier,
  and that a second client renders the same one (no state leaks between
  instances now that the static map is gone);
- wrapper attribution through the `agents` option, replacing the old
  static-setter assertions;
- the door's header against
  `^ably-pubsub-php/\d+\.\d+\.\d+(\S*)? php/\S+ ably-pubsub-server$`;
- that no `ably-pubsub-server/<anything>` token is ever sent, for a caller
  version of `'x'`, `''` and `null`. The registry declares the identifier
  as a flag and the versioned form does not classify; this is the PHP shape
  of the `name/undefined` regression ably-js#2297 guards against;
- that a bare `new AblyRest(...)` declares no side, so the door is provably
  the only stamping path;
- that caller entries survive and precede the side entry;
- that the door accepts an array, a ClientOptions, an API-key string and a
  token string, and does not mutate a ClientOptions it was handed.

tests/PackagingTest.php adds the invariants that otherwise fail silently
until a consumer hits them: the composer name, PSR-4 mapping only
`Ably\PubSub\`, no file under src/ declaring a namespace outside it (a file
missed by the move would collide with the legacy package in a mixed
install), `Defaults::LIB_VERSION` matching the top CHANGELOG heading, every
class under src/ resolving through the generated autoloader, and the door
being final, non-instantiable and still carrying the `-server` suffix.

CHANGELOG.md gains the 2.0.0 heading the version-site check reads. The docs
PR fills in its body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- The check matrix follows the new floor: 8.1 through 8.5. 7.2-8.0 are EOL
  upstream and no longer installable requirements of this package. 8.5 is
  stable in shivammathur/setup-php, so the top of the matrix is the current
  release rather than the previous one.
- check.yml and features.yml also run on pushes to integration/v2, so the
  merged state of the split branch is checked and not just each PR into it.
  Both already run on pull_request regardless of base.
- `composer validate` becomes `--strict`, which is what caught the unbound
  rybakit/msgpack constraint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 9, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

1.1.12 removed curl_close() from the SDK for PHP 8.5, but the sandbox
test-app factory still called it, so every 8.5 CI job printed a
deprecation notice from tests/factories/TestApp.php. The handle is
released when it goes out of scope; the floor is 8.1.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant