- Book: PHP to TypeScript — A Bridge for Modern PHP 8+ Developers
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
A PHP developer opens a TypeScript codebase, scrolls to the ORM layer, and gets stuck on a familiar question: how does $user->name work in TypeScript when the property does not exist on the class?
In PHP, the answer is one of the magic methods: __get, __set, __call, __isset, __unset. The trapdoor under property access. The framework defines them once on the model base class, and the rest of the app reads $user->name, $user->save(), $user->orders without anyone declaring a property or method.
TypeScript has the same trapdoor. It is called Proxy, has been in the language since ES2015, and most TypeScript devs ignore it because the type system fights you on the way out. The shape maps almost one-to-one to the PHP magic methods you already use.
The mapping, briefly
A Proxy wraps a target object and intercepts operations through a set of traps. The relevant ones for PHP devs:
| PHP magic method | TypeScript Proxy trap | Triggers on |
|---|---|---|
__get($name) |
get(target, prop, receiver) |
reading obj.prop
|
__set($name, $value) |
set(target, prop, value, receiver) |
writing obj.prop = v
|
__call($name, $args) |
get returning a function |
calling obj.method(...)
|
__isset($name) |
has(target, prop) |
prop in obj |
__unset($name) |
deleteProperty(target, prop) |
delete obj.prop |
PHP runs the magic method only when the property or method does not exist on the class. Proxy runs the trap on every operation, then defers to the target by default. That asymmetry is the first thing to internalise.
1. Lazy property loading
The classic PHP pattern: a model has expensive related data (a remote API call, a database join, a file read). You do not want to pay for it in the constructor. You pay only if the caller reads the property.
class Report
{
private ?array $rows = null;
public function __get(string $name): mixed
{
if ($name === 'rows') {
return $this->rows ??= $this->loadFromDb();
}
throw new Error("Unknown property: $name");
}
private function loadFromDb(): array { /* ... */ }
}
$r = new Report();
$r->rows; // first access loads
$r->rows; // cached
Same shape in TypeScript via a get trap:
type Report = { rows: Row[] };
function lazyReport(): Report {
let cached: Row[] | null = null;
return new Proxy({} as Report, {
get(_target, prop) {
if (prop === 'rows') {
return cached ??= loadFromDb();
}
throw new Error(`Unknown property: ${String(prop)}`);
},
});
}
const r = lazyReport();
r.rows; // first access loads
r.rows; // cached
The get trap fires on every property read. The closure over cached does the memoisation. The cast {} as Report is the part that itches. TypeScript cannot infer that the proxy will satisfy Report because the trap is dynamic, so you assert the shape at the boundary and the rest of the file types cleanly.
PHP 8.4 added a built-in ReflectionClass::newLazyProxy that does this without the magic method dance. If you are on 8.4+, prefer it. The proxy idiom above is for code that wants a one-off without a framework.
2. Method auto-routing
PHP's __call is the engine behind every Active Record save(), every Eloquent dynamic finder, every find_by_email_and_status-style RPC. You declare zero methods on the class and the dispatcher decides at runtime.
class ApiClient
{
public function __call(string $name, array $args): mixed
{
return $this->request('POST', "/$name", $args[0] ?? []);
}
private function request(
string $method,
string $path,
array $body,
): array { /* ... */ }
}
$api = new ApiClient();
$api->createUser(['email' => 'a@b.com']);
$api->cancelOrder(['id' => 42]);
In TypeScript, Proxy does not have a separate call trap. You return a function from the get trap, and the call site does the rest:
type Api = {
createUser(body: { email: string }): Promise<User>;
cancelOrder(body: { id: number }): Promise<Order>;
};
function apiClient(): Api {
return new Proxy({} as Api, {
get(_target, prop) {
return (body: unknown) =>
request('POST', `/${String(prop)}`, body);
},
});
}
const api = apiClient();
await api.createUser({ email: 'a@b.com' });
await api.cancelOrder({ id: 42 });
The static type Api is doing all the work for the IDE. The Proxy does not know which methods exist; the type does. Every PHP dev underestimates that gap on the way in. In PHP, the runtime is the source of truth and the IDE plugins guess. In TypeScript, you write the type and the runtime is asked to honour it. Get the type wrong and a typo ships straight to the server.
3. ORM-style attribute access
Eloquent and Doctrine both use the magic-method pair to map class properties to database columns. $user->name reads a column; $user->name = 'Ada' writes it; $user->orders loads a relation.
$user->email = 'ada@example.com';
$user->save();
echo $user->name;
The TypeScript version that reads the closest:
type Row = Record<string, unknown>;
function record<T extends Row>(table: string, row: T): T {
return new Proxy(row, {
get(target, prop) {
if (prop === 'save') {
return () => db.update(table, target);
}
return target[prop as string];
},
set(target, prop, value) {
target[prop as string] = value;
return true;
},
});
}
type UserRow = { id: number; email: string; name: string };
const user = record<UserRow>('users', {
id: 1, email: '', name: 'Ada',
});
user.email = 'ada@example.com';
(user as any).save();
console.log(user.name);
Two trade-offs hit at once. The (user as any).save() is the price of mixing a method into a record type without declaring it on the type. Widen the type to include save: () => Promise<void> and you lie about every other row. The other trade-off is that Record<string, unknown> loses precise column types unless you push them through generics carefully. Per their schema docs, Drizzle and Kysely model the table as a schema value rather than a Proxy-backed record, which moves the contract from runtime reflection to compile-time shapes. Worth knowing before you write your own.
4. Immutable record proxies
A pattern every TypeScript codebase ends up needing: freeze a record so any write throws. Object.freeze does this shallowly with a generic TypeError. A Proxy gives you a clearer error message and lets you reject delete with a custom payload too. Both stay shallow unless you also wrap returned objects in the get trap.
function frozen<T extends object>(obj: T): T {
return new Proxy(obj, {
set(_target, prop) {
throw new TypeError(
`Cannot assign to '${String(prop)}': record is frozen`,
);
},
deleteProperty(_target, prop) {
throw new TypeError(
`Cannot delete '${String(prop)}': record is frozen`,
);
},
});
}
const config = frozen({ host: 'localhost', port: 5432 });
config.port = 6543; // throws: Cannot assign to 'port': record is frozen
PHP's equivalent is __set and __unset on a class:
class Frozen
{
public function __construct(private array $data) {}
public function __get(string $name): mixed
{
return $this->data[$name] ?? null;
}
public function __set(string $name, mixed $value): void
{
throw new Error("Cannot assign to '$name'");
}
public function __unset(string $name): void
{
throw new Error("Cannot unset '$name'");
}
}
TypeScript has a more precise alternative: readonly and as const give you compile-time immutability with no runtime cost. The proxy version is for cases where the freeze must hold against code you do not control: a third-party module that mutates configs, a plugin system, a caller without your types.
5. Audit-trail wrappers
Logging every read and write is the cleanest demo of why proxies exist. PHP does it with __get/__set on a debug subclass. TypeScript does it with a generic wrapper:
function audited<T extends object>(label: string, obj: T): T {
return new Proxy(obj, {
get(target, prop, receiver) {
const value = Reflect.get(target, prop, receiver);
console.log(`[${label}] read ${String(prop)} -> ${value}`);
return value;
},
set(target, prop, value, receiver) {
const prev = Reflect.get(target, prop, receiver);
console.log(
`[${label}] write ${String(prop)}: ${prev} -> ${value}`,
);
return Reflect.set(target, prop, value, receiver);
},
});
}
const session = audited('session', { userId: 0, token: '' });
session.userId = 42;
session.token;
Reflect is the companion API. Each trap has a Reflect counterpart that performs the default operation. Use it inside traps to forward correctly. Skipping Reflect.get and writing target[prop] works for the easy cases and breaks on getters that depend on the receiver. A class with a get fullName() accessor that reads this.firstName will see this bound to the raw target instead of the proxy, so any other trap-aware behaviour silently disappears.
What it costs you
Inference loses signal at the boundary. new Proxy(target, handler) returns the static type of target. Fabricated properties are invisible to the type system unless you cast. The casts pile up and silence real errors. This is the biggest reason most TypeScript codebases avoid Proxy outside library internals.
Runtime cost is real. Every property read on a proxy goes through the engine's slow path. V8 and JavaScriptCore both have fast paths for plain objects with monomorphic shapes; a proxy is not on that path. For an ORM model loaded once per request, no one cares. For a hot path inside a render loop, you will.
Debuggability drops. A stack trace goes through the trap, then Reflect, then the target. "Go to definition" lands on the trap rather than the original definition. Every PHP dev learns to read magic-method stack traces; expect the same curve here.
When not to reach for Proxy
Default away from Proxy. It earns the complexity when:
- You are building a library where the call surface is dynamic by spec (an RPC client, a logging shim, an ORM model layer). Users see a clean type; the proxy lives in a single file.
- You need a runtime invariant the type system cannot enforce: an immutable record handed to untrusted code, an audit trail, a deprecated-property warning.
- You are translating a PHP framework that already uses magic methods. A bespoke API client fits this; Eloquent to Drizzle does not (Drizzle moves the contract into types).
It does not earn it when:
- You want fewer characters at the call site. Use
as const, mapped types, or a builder. - You want to type-check method names dynamically. That is what mapped types are for. Compute method names in the type, declare them statically, skip the runtime trap.
- You want to add fields to a data class. Just add the fields. The type system rewards boring records.
Reach for Proxy when the static type can name the surface and the dynamic side is what the runtime owns. That is the same line you walked in PHP between declared properties and magic methods.
If this was useful
PHP to TypeScript in The TypeScript Library walks the full bridge: magic methods to proxies, traits to mixins, attributes to decorators, generators to async iterators, and the parts of generics PHP did not prepare you for. It is one of five books in the collection:
- TypeScript Essentials — entry point. Types, narrowing, modules, async, daily tooling.
- The TypeScript Type System — deep dive. Generics, mapped/conditional types, infer, template literals, branded types.
- Kotlin and Java to TypeScript — bridge for JVM developers.
- PHP to TypeScript — bridge for PHP 8+ developers. The book this post came from.
- TypeScript in Production — production layer. tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR.
Books 1 and 2 are the core path. Book 4 substitutes for 1 if you speak PHP. Book 5 is for anyone shipping TypeScript at work.
All five books ship in ebook, paperback, and hardcover.
This article was originally published by DEV Community and written by Gabriel Anhaia.
Read original article on DEV Community