Generates typed TypeScript interfaces and proxy configs from PHP RPC contract interfaces annotated with #[RpcStream], #[RpcSubscribe], and #[RpcPublish].
npm install --save-dev @php-websocket-rpc/codegennpx php-rpc-codegen --input src/Contracts/ --output src/generated/rpc-types.tsThen use the generated types in your app:
import { RpcClient } from '@php-websocket-rpc/client';
import { ChatServiceProxy, ChatServiceConfig } from './generated/rpc-types';
const client = await RpcClient.connect('ws://127.0.0.1:9502/rpc');
const chat = client.createProxy<ChatServiceProxy>(ChatServiceConfig);
chat.onMessage((msg) => console.log(msg));
chat.send('Hello!');Usage: php-rpc-codegen [options]
Options:
-V, --version output the version number
-i, --input <paths...> Input PHP file(s) or directory/glob patterns (required)
-o, --output <path> Output TypeScript file (required)
-w, --watch Watch mode — re-generate on file changes
--no-banner Omit the auto-generated header comment
-h, --help display help for command
{
"scripts": {
"gen:rpc": "php-rpc-codegen --input src/Contracts/ --output src/generated/rpc-types.ts",
"build": "npm run gen:rpc && tsc"
}
}Given this PHP file:
use PhpWebsocketRpc\Rpc\Contract\Attribute\RpcPublish;
use PhpWebsocketRpc\Rpc\Contract\Attribute\RpcStream;
use PhpWebsocketRpc\Rpc\Contract\Attribute\RpcSubscribe;
interface MathService
{
public function add(int $a, int $b): int;
public function log(string $message): void;
}
interface NumberStreamService
{
#[RpcStream]
public function count(int $limit): \Iterator;
}
interface EventService
{
#[RpcSubscribe(channel: 'events', type: 'string')]
public function onEvent(callable $callback): void;
}
interface ChatService
{
#[RpcSubscribe('chat')]
public function onMessage(callable $callback): void;
#[RpcPublish('chat')]
public function send(string $message): void;
}Produces:
import type { ProxyOptions } from '@php-websocket-rpc/client';
export interface MathServiceProxy {
add(a: number, b: number): Promise<number>;
log(message: string): void;
}
export interface NumberStreamServiceProxy {
count(limit: number): AsyncIterable<number>;
}
export interface EventServiceProxy {
onEvent(callback: (value: string) => void): void;
}
export interface ChatServiceProxy {
onMessage(callback: (value: unknown) => void): void;
send(message: string): void;
}
export const MathServiceConfig = {
service: 'MathService',
notify: ['log'],
} satisfies ProxyOptions;
export const NumberStreamServiceConfig = {
service: 'NumberStreamService',
stream: ['count'],
} satisfies ProxyOptions;
export const EventServiceConfig = {
service: 'EventService',
subscribe: ['onEvent'],
channel: 'events',
} satisfies ProxyOptions;
export const ChatServiceConfig = {
service: 'ChatService',
subscribe: ['onMessage'],
publish: ['send'],
channel: 'chat',
} satisfies ProxyOptions;When the PHP server sends value objects over the wire, they are serialized as [FQCN, props] — the fully-qualified class name followed by its properties. The codegen generates a classMap that maps these FQCNs to their TypeScript interfaces so the client can deserialize them automatically.
import { RpcClient, createContractProxy } from '@php-websocket-rpc/client';
import { ChatServiceProxy, ChatServiceConfig, classMap } from './generated/rpc-types';
const client = await RpcClient.connect('ws://127.0.0.1:9502/rpc');
const chat = createContractProxy<ChatServiceProxy>(client, {
...ChatServiceConfig,
classMap, // ← enables automatic wire deserialization
});
// When the server returns a typed value object like [FQCN, props],
// the client uses classMap to reconstruct it as the correct TypeScript type
chat.onMessage((msg) => console.log(msg)); // msg is properly typedThe generated classMap looks like:
export const classMap: Record<string, (data: Record<string, unknown>) => unknown> = {
'App\\Contract\\ChatNotification': (data) => data as ChatNotification,
'App\\Contract\\MessageNotification': (data) => data as MessageNotification,
};Only class DTOs get entries. Enums are scalars on the wire (string or int) and don't need deserialization.
All PHP contracts, DTOs, and enums processed by the codegen must share the same namespace and be placed in the same folder (or a folder tree fed to --input). This ensures:
- Type names are unique within the generated file.
- The
classMapcan resolve all FQCNs without collisions. - Contract interface proxies can reference DTO and enum types correctly.
Recommended project structure:
src/
Contract/
ChatEventInterface.php
ChatNotification.php
MessageNotification.php
MessageSenderType.php
CLI invocation:
php-rpc-codegen --input src/Contract/ --output src/generated/rpc-types.ts| PHP | TypeScript |
|---|---|
int / float |
number |
string |
string |
bool |
boolean |
void |
void |
mixed / object |
unknown |
array |
unknown[] |
?Type |
Type | null |
callable |
(...args: unknown[]) => unknown |
| PHP enum (parsed) | enum type name (union of literals) |
| PHP class DTO (parsed) | class interface name |
| custom class (not parsed) | Record<string, unknown> |
| PHP Signature | Detected Pattern | TS Return Type |
|---|---|---|
function f(...): T (no attribute) |
call |
Promise<T> |
function f(...): void (no attribute) |
notify |
void |
#[RpcStream] function f(...): \Iterator |
stream |
AsyncIterable<T> |
#[RpcSubscribe] function f(callable): void |
subscribe |
void (callback-driven) |
#[RpcPublish] function f(...): void |
publish |
void |
| PHP | TypeScript |
|---|---|
int / float |
number |
string |
string |
bool |
boolean |
void |
void |
mixed / object |
unknown |
array |
unknown[] |
?Type |
Type | null |
callable |
(...args: unknown[]) => unknown |
| custom class | Record<string, unknown> |
The codegen uses php-parser to build a full AST from your PHP files, then walks the AST looking for interface declarations with methods. It reads PHP 8.5 attributes (#[RpcStream], #[RpcSubscribe], #[RpcPublish]) to detect the RPC pattern for each method, maps PHP types to TypeScript types, and emits ready-to-use interface definitions and config objects.
Only interface declarations are processed — class bodies, functions, and non-interface code is ignored.