PHPackages                             innis/nostr-relay-selection - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Utility &amp; Helpers](/categories/utility)
4. /
5. innis/nostr-relay-selection

ActiveLibrary[Utility &amp; Helpers](/categories/utility)

innis/nostr-relay-selection
===========================

Pure-function PHP library for Nostr outbox-model publish/read routing: NIP-65/17/50/57, zero runtime dependencies.

v0.1.0(2mo ago)00MITPHP ^8.3

Since May 19Compare

[ Source](https://github.com/johninnis/nostr-relay-selection-php)[ Packagist](https://packagist.org/packages/innis/nostr-relay-selection)[ RSS](/packages/innis-nostr-relay-selection/feed)WikiDiscussions Synced 3w ago

READMEChangelogDependencies (3)Versions (2)Used By (0)

Nostr Relay Selection
=====================

[](#nostr-relay-selection)

[![CI](https://github.com/johninnis/nostr-relay-selection-php/actions/workflows/ci.yml/badge.svg)](https://github.com/johninnis/nostr-relay-selection-php/actions/workflows/ci.yml)

A PHP library for routing Nostr events to and from relays. Implements outbox-model publish routing, read routing, author-set-cover, relay hint selection, NIP-65 inbox/outbox/DM list parsing, NIP-17 DM-inbox handling, and URL classification — as pure functions, with zero runtime dependencies.

Why this library?
-----------------

[](#why-this-library)

When a client publishes a kind 1 reply, *which* relays should it actually send to? When it queries an author's notes, which relays will return them? When it picks a relay hint for an `e` tag, which one will work for the recipient? These are not trivial questions in the outbox model.

`innis/nostr-relay-selection` answers them as pure functions over the user's relay-list events. It does not open WebSocket connections, does not depend on a relay pool, does not depend on `innis/nostr-core`, and does not depend on any other Nostr library. Feed it events and a context, get back a deterministic list of relays.

Design reasoning: a spec, not an engine
---------------------------------------

[](#design-reasoning-a-spec-not-an-engine)

The Nostr ecosystem already has several relay-selection implementations — NDK's `OutboxTracker`, rust-nostr's `gossip` crate, go-nostr's `sdk` hints DB, Coracle's `welshman/router`. They are all *engines*: stateful, heuristic, async, coupled to a pool and to learned data. They make pragmatic, useful tradeoffs, and none of them are deterministic.

This library makes the opposite tradeoff. It is a *policy specification*:

- **Pure functions.** Every operation is a stateless static method. Same inputs, same outputs. No I/O, no time, no randomness.
- **Deterministic by construction.** No `Math.random()` tie-breaks (welshman), no time-decay scoring (go-nostr), no `received_events` counters (rust-nostr), no batch-popularity sort (NDK), no hardcoded fallback URLs.
- **NIP-derived behaviour only.** Every routing decision is grounded in a NIP — NIP-65 for kind 10002, NIP-17 for kind 10050, NIP-57 for zap requests, NIP-50 for search relays. No empirical heuristics that drift from the spec.
- **Zero runtime dependencies.** Requires only PHP 8.3. Suitable for embedding in any Nostr client, relay, indexer, or back-end without dragging in transport, crypto, caches, or framework code.
- **Clean Architecture, domain-only.** All logic lives in the Domain layer. There is no Application or Infrastructure layer because there is nothing external to coordinate.
- **Behaviour locked to a JSON corpus.** The test vectors under `tests/corpus/` are the spec. Any implementation in any language that passes every vector is conformant. The PHP and TypeScript ports share the corpus; a Go, Kotlin, or Rust port would too.

Engines and specs compose. This library is the policy; an engine wraps it with caching, pool state, fallbacks, scoring, or whatever else a runtime needs. The two layers stay separate so the policy stays portable and auditable.

Requirements
------------

[](#requirements)

- PHP 8.3 or higher

No PHP extensions are required. No system libraries. No Composer dependencies.

Installation
------------

[](#installation)

```
composer require innis/nostr-relay-selection
```

Quick Start
-----------

[](#quick-start)

All services are pure static methods. Inputs are typed context objects; outputs are typed route objects (with a branch enum + a list of `RelayUrl`) or lists of `RelayUrl` directly.

For a runnable end-to-end demonstration against live relay-list events from four real Nostr identities (loaded from `tests/corpus/real-world/`), see [`example.php`](example.php):

```
php example.php
```

### Constructing typed objects from raw JSON

[](#constructing-typed-objects-from-raw-json)

Every type the routing services consume has a static `fromRaw(mixed): ?self` factory that validates the JSON-shaped input and returns `null` on malformed data:

```
use Innis\Nostr\RelaySelection\Domain\Entity\Event;
use Innis\Nostr\RelaySelection\Domain\Entity\Filter;
use Innis\Nostr\RelaySelection\Domain\ValueObject\Identity\PublicKey;
use Innis\Nostr\RelaySelection\Domain\ValueObject\Protocol\RelayUrl;
use Innis\Nostr\RelaySelection\Domain\ValueObject\Tag;

$event  = Event::fromRaw(json_decode($rawJson, true));   // ?Event
$filter = Filter::fromRaw(['kinds' => [1], 'search' => 'hi']);  // ?Filter
$tag    = Tag::fromRaw(['p', $pubkeyHex]);               // ?Tag
$pubkey = PublicKey::fromHex($hex);                      // ?PublicKey
$relay  = RelayUrl::fromString($url);                    // ?RelayUrl
```

Use these at your application's adapter boundary. The lib never returns `null` from happy-path routing — `null` from `fromRaw`/`fromHex`/`fromString` always means "the input you gave me was not a valid X."

### Route a publish

[](#route-a-publish)

Given an event and the user's relay-list events (kind 10002 / 10050), decide which relays to publish to. Returns a `PublishRoute` whose `getBranch()` reports the policy applied and whose `getRelays()` lists the targets (which may be empty, or `null` for the `Dm` branch — see below).

- `PublishBranch::General` — NIP-65 outbox routing for kind 1/6/7/16/24/1111/9802 etc. Fans out to recipient inboxes (capped per recipient). Adds `indexerRelays` for indexed kinds (0, 3, 10002, 10050) — the events indexers like `purplepag.es` harvest.
- `PublishBranch::Dm` — NIP-17 routing for kind 1059 gift-wraps. Targets recipients' kind-10050 inbox relays only. **Returns `Dm` with `relays = null` if no recipient has a kind 10050 (or every DM relay is blocked)** — per NIP-17, clients SHOULD NOT publish if the recipient has not signalled readiness. There is no fallback.
- `PublishBranch::Draft` — kinds 30024, 30403, 31234. Routes to caller-supplied `privateContentRelays` if any; otherwise falls back to the user's outbox.

```
use Innis\Nostr\RelaySelection\Domain\Service\RoutePublishService;
use Innis\Nostr\RelaySelection\Domain\ValueObject\Context\PublishContext;
use Innis\Nostr\RelaySelection\Domain\ValueObject\Identity\PublicKey;
use Innis\Nostr\RelaySelection\Domain\ValueObject\Route\PublishBranch;

$context = new PublishContext(
    userPubkey: PublicKey::fromHex($userHex),
    relayListEvents: $cachedKind10002And10050Events,
    privateContentRelays: [],   // caller's pre-extracted kind 10013 URLs (see NIP-37 note below)
    indexerRelays: [],
    blockedRelays: $callerBlockedRelays,   // caller's pre-extracted kind 10006 URLs
);

$route = RoutePublishService::route($event, $context);
match ($route->getBranch()) {
    PublishBranch::General => /* NIP-65 outbox fan-out */,
    PublishBranch::Dm      => /* NIP-17 DM inboxes (or null if recipient has no 10050) */,
    PublishBranch::Draft   => /* private content relays or user outbox fallback */,
};
foreach ($route->getRelays() ?? [] as $relay) {
    $pool->publish((string) $relay, $event);
}
```

The lib does **not** implement NIP-37 itself — kind 10013's relay list is NIP-44-encrypted inside `content`, and decryption requires a signer + crypto, both out of scope here. Callers that want NIP-37-aware draft routing must fetch and decrypt kind 10013 themselves and pass the resulting URLs into `PublishContext::$privateContentRelays`.

### Route a read

[](#route-a-read)

Given a set of filters, decide which relays to subscribe to. Returns a `ReadRoute` with a `ReadBranch` enum and a list of relays.

- `ReadBranch::Search` — any filter with a `search` field. Unions caller-supplied `searchRelays` (from the user's kind 10007 list) with `callerRelays`.
- `ReadBranch::DmInbox` — every filter is `{kinds: [1059], #p: [singleRecipient]}` and the recipient matches across filters. Targets the recipient's kind-10050 inbox relays.
- `ReadBranch::General` — everything else. Unions the user's relays with `callerRelays`.

```
use Innis\Nostr\RelaySelection\Domain\Entity\Filter;
use Innis\Nostr\RelaySelection\Domain\Service\RouteReadService;
use Innis\Nostr\RelaySelection\Domain\ValueObject\Context\ReadContext;
use Innis\Nostr\RelaySelection\Domain\ValueObject\Route\ReadBranch;

$context = new ReadContext(
    userRelayUrls: $userOutboxRelays,
    callerRelays: $relaysPassedToTheQuery,
    filters: [new Filter(kinds: [1], pTags: null, search: null)],
    relayListEvents: $cachedRelayLists,
    blockedRelays: $callerBlockedRelays,    // pre-extracted kind 10006
    searchRelays: $callerSearchRelays,      // pre-extracted kind 10007
);

$route = RouteReadService::route($context);
match ($route->getBranch()) {
    ReadBranch::Search   => /* subscribe to search-capable relays */,
    ReadBranch::DmInbox  => /* subscribe to recipient's DM inboxes */,
    ReadBranch::General  => /* subscribe to user + caller relays */,
};
```

### Filter pattern detection

[](#filter-pattern-detection)

`RouteReadService` internally calls `FindFilterPatternService::classify($filters)` to map a filter set to a `ReadBranch` (`Search`, `DmInbox`, `General`). The primitive is exposed so callers can inspect or branch on the pattern without invoking the full router.

```
use Innis\Nostr\RelaySelection\Domain\Enum\Route\ReadBranch;
use Innis\Nostr\RelaySelection\Domain\Service\FindFilterPatternService;

$branch = FindFilterPatternService::classify($filters);
// ReadBranch::Search | ReadBranch::DmInbox | ReadBranch::General
```

### Author read: greedy set cover

[](#author-read-greedy-set-cover)

Given a list of author pubkeys, decide which outbox relays cover them. Uses greedy set-cover so two authors who share a relay are queried together; chunks each plan if the author count exceeds `maxAuthorsPerFilter`; falls back to caller-supplied relays for authors with no NIP-65 list.

```
use Innis\Nostr\RelaySelection\Domain\Service\RouteAuthorReadsService;
use Innis\Nostr\RelaySelection\Domain\ValueObject\Context\AuthorReadRouteContext;

$context = new AuthorReadRouteContext(
    authorPubkeys: $followedPubkeys,
    relayListEvents: $cachedRelayLists,
    fallbackRelays: $defaultRelays,
    maxAuthorsPerFilter: 200,
    redundancy: 3,
    blockedRelays: $callerBlockedRelays,
);

foreach (RouteAuthorReadsService::route($context) as $route) {
    foreach ($route->getAuthorChunks() as $chunk) {
        $pool->subscribe(
            array_map('strval', $route->getRelays()),
            ['kinds' => [1], 'authors' => array_map(fn ($pk) => $pk->toHex(), $chunk)],
        );
    }
}
```

### Pick a relay hint

[](#pick-a-relay-hint)

For `e` / `p` / `q` tags, pick a single relay URL the recipient is likely to read. Prefers the intersection of the user's outbox with the target's inbox, falls back to either side's first relay, returns `null` if neither side has a list.

### URL classification

[](#url-classification)

Pure predicates on `RelayUrl` for use in caller-side filtering. The library does not apply these itself — they're exposed so consumers can compose filters without re-implementing host detection.

```
$url = RelayUrl::fromString('ws://192.168.1.11:7777');
$url->isOnion();      // false
$url->isLoopback();   // false (true for localhost, 127.0.0.0/8, ::1)
$url->isLocalAddr();  // true  (loopback OR RFC1918 OR .local mDNS)
$url->isInsecure();   // true  (ws:// AND not onion)
```

### Caller-owned lists (blocked, search, private content)

[](#caller-owned-lists-blocked-search-private-content)

Three kinds of user-owned data are passed as pre-extracted URL lists rather than as raw events:

KindNIPField on contextNotes10006NIP-65`blockedRelays` (on every context)Subtracted from every route output uniformly.10007NIP-50`searchRelays` (on `ReadContext`)Unioned into the `Search` branch alongside caller-supplied relays.10013NIP-37`privateContentRelays` (on `PublishContext`)Encrypted content — caller must decrypt before passing.For 10006 and 10007 the caller pre-extracts URLs from their cached event using `RelayListExtractor::blocked($event->getTags())` or `::search($event->getTags())`. For 10013, the caller does NIP-44 decryption themselves and passes the resulting URLs.

This mirrors the existing `userRelayUrls` pattern on `ReadContext`: user-owned data is the caller's responsibility to extract; library policy is to apply.

### Other operations

[](#other-operations)

ServicePurpose`SelectAuthorRelaysService::inbox`Pick inbox relays for one author (kind 10002 `read`/`both` markers).`SelectAuthorRelaysService::outbox`Pick outbox relays for one author (kind 10002 `write`/`both` markers).`SelectAuthorRelaysService::dm`Pick DM relays for one author (kind 10050 `relay` tags).`SelectZapRequestRelaysService`Merge zapper and recipient inbox relays for a zap request.`SelectRelayHintService`Pick one relay URL hint for an `e`/`p`/`q` tag.`FindFilterPatternService::classify`Classify a filter set as `Search`, `DmInbox`, or `General`.`FindFilterPatternService::sharedGiftWrapRecipient`If every filter is `{kinds: [1059], #p: [singleRecipient]}` with the same recipient, return that `PublicKey`; otherwise `null`. Useful for detecting DM-target reads without invoking the full router.`MissingRelayListPubkeysService`For inbox-fanout events, list `p`-tagged pubkeys whose relay list you do not yet have cached.`EventSelector::newestByPubkeyAndKind`Find the newest event for a given `(pubkey, kind)` tuple in a heterogeneous event array. Used internally by every routing service and exposed for callers building their own cache layers. Returns `?Event`.`RelayListExtractor::inbox/outbox/dm`Parse `r` and `relay` tags from kind 10002 / 10050 events.`RelayListExtractor::blocked/search`Parse `relay` tags from kind 10006 / 10007 events.`RelaySetBuilder::build`Merge any number of relay sources into one deduplicated list, preserving first-seen order.`RelaySetBuilder::subtract`Remove URLs in a blocklist from a relay set.`RelayUrl::fromString`Normalise an arbitrary URL string. Lowercases scheme and host, strips default ports and trailing slashes. Rejects non-wss(?), fragments, `%20` in paths, malformed hostnames, out-of-range ports, concatenated URLs, and inputs over 200 chars.`RelayUrl::isOnion/isLoopback/isLocalAddr/isInsecure`Pure URL classification predicates.`RelayUrl::equals`Compare two `RelayUrl` instances for canonical-string equality.`Event::fromRaw` / `Filter::fromRaw` / `Tag::fromRaw`Validate and construct typed objects from JSON-shaped arrays. Return `null` on malformed input.`PublicKey::fromHex` / `PublicKey::toHex` / `PublicKey::equals`Construct from / serialise to / compare hex pubkeys.Routing rules
-------------

[](#routing-rules)

The complete policy in one place. Each rule is encoded in the source and locked by a corpus vector.

### What goes in `EventKind`

[](#what-goes-in-eventkind)

A kind appears in `EventKind` **if and only if the routing policy distinguishes it from arbitrary unknown kinds.** Concretely, a kind belongs in the enum when at least one of these is true:

- It triggers a branch in `RoutePublishService` (a `match` arm or a helper predicate).
- It triggers a branch in `RouteReadService` or `FindFilterPatternService`.
- It is parsed by `RelayListExtractor` (kind 10002 / 10006 / 10007 / 10050).
- It drives a dedicated service (kinds parsed from the relay-list events array).

The rule is **drives a branch in `RoutePublishService::route`**, not "has any routing rule." Two NIPs define routing rules for kinds that are nonetheless absent from `EventKind`, and that's deliberate:

- **`Deletion` (5)** — NIP-09 says deletion events should publish to every relay the original event was on. The lib has no event-publication history and tracking that would require state (out of scope: no I/O, no caches). So kind 5 falls through to General → user's outbox, the best a stateless pure-policy library can offer. Adding a `Deletion` case to `EventKind` would imply a dedicated branch the lib cannot honestly implement.
- **`ZapRequest` (9734)** — NIP-57 routing (zapper-inbox ∪ recipient-inbox) **is** implemented, but as a dedicated service: `SelectZapRequestRelaysService::select` over `ZapRequestContext`. The kind doesn't belong in `EventKind` because that enum is specifically what `RoutePublishService::route` branches on, and zap requests don't flow through `routePublish` — they're sent to an LNURL HTTP callback per NIP-57 §3, not published through the normal pool. Kind 9734 is in the routing spec; it just enters via a different door.

Pure vocabulary-only constants — `Report` (1984), `LiveActivity` (30311), `Job` (5000-5999), etc. — never enter the routing spec at all. A future kind registry package (or `innis/nostr-core`) is the right home for those names. (`ProfileMetadata` (0) and `FollowList` (3) earned their place by being indexed kinds; the rule remains "drives a routing decision, or out.")

### Publish branches

[](#publish-branches)

`RoutePublishService::route($event, $context)` dispatches on event kind into one of three branches. Every output also has the user's `blockedRelays` subtracted.

BranchTriggering kindsOutput relays`Dm`1059 (`GiftWrap`)Per recipient (`p` tag), the recipient's newest kind-10050 inbox relays. **Empty** if the recipient has no kind 10050 (per NIP-17 "shouldn't try").`Draft`30024 (`LongformDraft`), 30403 (`ClassifiedListingDraft`), 31234 (`DraftEvent`)`privateContentRelays` if non-empty, otherwise user's outbox (from newest kind 10002, `write`/`both` markers).`General`Everything elseUser's outbox, plus — for `isInboxFanout` kinds — recipient inbox fanout (capped per recipient), plus — for `isIndexed` kinds — `indexerRelays`.`isInboxFanout` includes: 1 (`ShortNote`), 6 (`Repost`), 7 (`Reaction`), 16 (`GenericRepost`), 24 (`PublicMessage`), 1111 (`Comment`), 9802 (`Highlight`). For these kinds, every `p`-tagged recipient's newest kind-10002 inbox (`read`/`both` markers; unmarked entries count as `both`) is unioned in. If the recipient has no usable inbox — either because kind 10002 is absent OR because the cached kind 10002 has no `read`/`both` entries (write-only) — the lib falls back to the position-`[2]` relay hint on the `p` tag, if any. Per-recipient cap defaults to 3 (`PublishContext::DEFAULT_PER_RECIPIENT_CAP`).

`isIndexed` includes: 0 (`ProfileMetadata`), 3 (`FollowList`), 10002 (`RelayList`), 10050 (`DmRelayList`). When publishing one of these, `indexerRelays` are unioned into the General output so well-known indexers (`purplepag.es`, `user.kindpag.es`, `relay.nos.social`, etc.) see the updated event. The set matches welshman's `INDEXED_KINDS`.

### Read branches

[](#read-branches)

`RouteReadService::route($context)` dispatches on filter shape. Pattern detection is also exposed as a primitive via `FindFilterPatternService::classify($filters)`, which returns `ReadBranch` directly. Every output has `blockedRelays` subtracted.

BranchTriggering filter shapeOutput relays`Search`Any filter has a non-null `search` field`searchRelays` ∪ `callerRelays`.`DmInbox`**Every** filter is exactly `{kinds: [1059], #p: [singleRecipient]}` and the recipient is the same across filtersRecipient's newest kind-10050 inbox relays, or `null` if none are available (see below).`General`Anything else (including no filters)`userRelayUrls` ∪ `callerRelays`.### DM branches return null with no fallback

[](#dm-branches-return-null-with-no-fallback)

The DM branches — `ReadBranch::DmInbox` and `PublishBranch::Dm` — are the only branches whose `relays` can be `null`. Both return null when the recipient has no cached kind-10050 event, when the kind-10050 event extracts to no relays, or when blocking removes every DM relay the recipient declared. There is **no fallback** to `callerRelays`, `userRelayUrls`, or any other source — a gift-wrap publish or subscription cannot quietly redirect to relays the recipient has not authorised without leaking metadata about who the caller is talking to. A null result means "the caller cannot honour this DM; do not act."

The other branches never return null; they may return an empty array if their inputs are all empty (e.g. user has no outbox and the caller supplied no relays), which signals caller misconfiguration rather than a routing refusal:

`relays` valueMeaning`null`Branch is `Dm` / `DmInbox` and no DM-relay route exists. By design. Do not act.`[]`Branch is non-DM and the inputs produced nothing. Caller misconfiguration.non-emptyUse these relays.### Unknown kinds

[](#unknown-kinds)

Any kind not specifically named in the policy routes as `PublishBranch::General` with no recipient fanout and no indexer relays — i.e. to the user's outbox only. This is deliberate: the spec-derived default for an unrecognised kind is "publish to the author's own outbox, nothing more."

Two paths to change that:

- **Adapter layer (preferred for app-specific behaviour).** If your client has its own routing intuition for a new or experimental kind, handle it in the adapter that wraps this library. Inspect `$event->getKind()`, branch as you wish, and feed your selected relays directly into your pool. The library returns its General-branch answer; your adapter overlays your policy on top. This keeps the spec library small and stable.
- **Library (only when the rule belongs in the spec).** If a NIP defines routing for a new kind and the lib should encode that NIP-derived rule for everyone, add a case to `EventKind` and an arm to the relevant helper or branch. This adds a corpus vector and a binding decision across every port. Reserve it for behaviour that's a property of the protocol, not of your app.

### Blocklist application

[](#blocklist-application)

`blockedRelays` is subtracted from every routing output uniformly. The list is the caller's responsibility to pre-extract from kind 10006 events (use `RelayListExtractor::blocked($event->getTags())`). The library never connects to relays, so "blocked" here means "filtered out of routing outputs"; the engine layer above the library should also refuse to open connections to these URLs.

### Search relays

[](#search-relays)

`searchRelays` is the caller's pre-extracted list from their kind 10007 event. It is unioned (alongside `callerRelays`) into the `Search` branch only. It is not consulted for `General` or `DmInbox` reads.

Architecture
------------

[](#architecture)

```
src/Domain/
  Entity/
    Event.php                    Minimal event carrier (kind, pubkey, created_at, tags)
    Filter.php                   Minimal filter carrier (kinds, #p, search)
  Enum/
    EventKind.php                Backed enum of well-known kinds + static isInboxFanout / isDraft / isIndexed
    Route/
      PublishBranch.php          (General | Dm | Draft)
      ReadBranch.php             (Search | DmInbox | General)
  Service/                       Pure stateless services
    RelayListExtractor.php
    RelaySetBuilder.php
    EventSelector.php
    RoutePublishService.php
    RouteReadService.php
    RouteAuthorReadsService.php
    SelectAuthorRelaysService.php
    SelectZapRequestRelaysService.php
    SelectRelayHintService.php
    FindFilterPatternService.php
    MissingRelayListPubkeysService.php
  ValueObject/
    Identity/PublicKey.php
    Protocol/RelayUrl.php                    (with isOnion/isLoopback/isLocalAddr/isInsecure)
    Tag.php                                  Single-tag wrapper (value-indexed string access + fromRaw factory)
    Context/                                 Typed inputs (PublishContext, ReadContext, etc.)
    Route/                                   Typed outputs (PublishRoute, ReadRoute, AuthorReadRoute, enums)

```

There is no Application layer because no infrastructure ports are needed. There is no Infrastructure layer because there are no adapters. Routing is pure protocol logic.

### Relationship to `innis/nostr-core`

[](#relationship-to-innisnostr-core)

`innis/nostr-relay-selection` deliberately **does not depend** on `innis/nostr-core`. The two libraries are independent and can be used together or separately.

`PublicKey`, `RelayUrl`, `Event`, and `Filter` are re-declared here in minimal form because re-declaring four small value objects is preferable to forcing every consumer of relay selection to also pull in nostr-core's full cryptographic stack. The duplication is marked in a header comment on each duplicated file. If you are already using nostr-core, convert at the boundary (`PublicKey::fromHex($core->toHex())`).

What this library does NOT do
-----------------------------

[](#what-this-library-does-not-do)

Deliberate omissions. These belong in the engine layer wrapping the lib, not in the lib itself.

- **No empirical scoring.** No `received_events` counters, no time-decay, no popularity sort. The lib treats all spec-derived relays as equally valid; ordering follows the NIP rules and input order only.
- **No fetch tracking / no learning.** The lib does not know what relays you've connected to, succeeded with, or failed against. State is the caller's responsibility.
- **No caching.** Every call re-reads the events you pass in. Cache them yourself if you need to.
- **No randomness, no tie-breaks.** Two calls with the same inputs return the same outputs in the same order. Always.
- **No hardcoded fallback URLs.** If your routing returns empty, the caller decides what to do. The lib will never silently inject `wss://relay.damus.io` or any other default.
- **No pool-state awareness.** Whether a relay is currently connected is invisible to the lib.
- **No transport, no crypto, no I/O.** The lib has no `connect()`, no `publish()`, no `sign()`, no `decrypt()`. Pure data in, pure data out.

Testing
-------

[](#testing)

```
composer test          # Unit + Compliance + PHPStan (ship gate)
composer test-unit     # Unit suite only
composer analyse       # PHPStan level 9
composer fix-style     # php-cs-fixer
```

The compliance suite loads JSON test vectors under `tests/corpus/`. The corpus is the spec — any divergence between implementations is a test failure.

The corpus includes signed events imported verbatim from [`rust-nostr/nostr`](https://github.com/rust-nostr/nostr)'s gossip test suite under `tests/corpus/external-fixtures/rust-nostr/`. Each fixture carries a `source` field naming the upstream test it came from. The imported events are unmodified; our policy outputs are independently derived from the NIPs and may differ from rust-nostr's. Sharing fixture inputs across implementations makes any such divergence inspectable.

Anti-patterns
-------------

[](#anti-patterns)

- **Calling routing services directly from many places in your app.** Wrap them in a single adapter so policy lives in one place. If app-specific behaviour (defaults, fallbacks, pool state, home-relay ordering) leaks into the routing call sites, you'll end up with N divergent policy stacks instead of one.
- **Adding app-specific logic to this lib.** If your change needs to know about pool state, default relays, or the home relay, it belongs in the adapter, not here. The lib must remain pure and portable.
- **Adding a routing service without a corresponding test vector.** The corpus is the spec. Add a vector to `tests/corpus/*.json` and the harness picks it up automatically.
- **Putting a new kind into a relay set at the call site.** Add a case to `EventKind` and extend the appropriate `isInboxFanout` / `isDraft` / `isIndexed` predicate here, so every caller's routing changes consistently.

License
-------

[](#license)

MIT License. See LICENSE file for details.

###  Health Score

33

—

LowBetter than 72% of packages

Maintenance86

Actively maintained with recent releases

Popularity0

Limited adoption so far

Community2

Small or concentrated contributor base

Maturity38

Early-stage or recently created project

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Unknown

Total

1

Last Release

66d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/d2ca81e761ec3b2d4ce98ac59cd4394bb4d70ebf3d1e620ff154ffcfc7a34bfc?d=identicon)[innis](/maintainers/innis)

---

Tags

routingdomainRelaynostr

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Code StylePHP CS Fixer

Type Coverage Yes

### Embed Badge

![Health badge](/badges/innis-nostr-relay-selection/health.svg)

```
[![Health](https://phpackages.com/badges/innis-nostr-relay-selection/health.svg)](https://phpackages.com/packages/innis-nostr-relay-selection)
```

###  Alternatives

[lord/laroute

Access Laravels URL/Route helper functions, from JavaScript.

8022.0M8](/packages/lord-laroute)[bramus/router

A lightweight and simple object oriented PHP Router

1.1k490.5k58](/packages/bramus-router)[watson/active

Laravel helper for recognising the current route, controller and action

3363.8M16](/packages/watson-active)[helgesverre/domain-availability

A PHP library for checking if a domain name is registered or not

26519.5k1](/packages/helgesverre-domain-availability)[aura/payload

A Domain Payload implementation.

55381.8k9](/packages/aura-payload)[wazum/sluggi

TYPO3 extension for URL slug management with inline editing, auto-sync, locking, access control, and redirects

40529.5k](/packages/wazum-sluggi)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
