A production-oriented tool for porting a PHP codebase to TypeScript (targeting Next.js App Router first). It works on a real PHP AST — no regex transpilation — preserves comments and formatting, and treats correctness and fidelity as the bar: unsupported constructs surface as located diagnostics rather than silently wrong code.
Use php2js when you have an existing PHP application and want to:
- Assess a migration before committing to it — discover which frameworks are in use per concern (http / orm / templating / …) and how much raw PHP surface you're dealing with.
- Produce a confirmed, re-runnable migration plan — resolve target choices and ambiguities through a guided interview, persisted as an artifact.
- Mechanically transpile PHP to TypeScript — convert language constructs (procedural
code, OOP, closures,
match, exceptions, cross-file modules) to neutral TypeScript, with everything not yet supported clearly flagged.
It is not a runtime PHP emulator and not a one-click "PHP app → running Next.js app" button yet — see Status for exactly what works today.
php2js is a human-in-the-loop pipeline. Each stage is independently useful:
Stage 1: PROFILE Stage 2: PLAN (gate) Stage 3: TRANSFORM
scan + detect ──▶ recommend target stack ──▶ PHP AST → TS AST → code
→ Source Profile + interview → confirmed (engine + runtime +
report migration-plan.yaml Next.js adapter: http)
npm install
# Stage 1 — profile a codebase
npx tsx src/cli.ts profile <path-to-php-project> # Markdown report
npx tsx src/cli.ts profile <path-to-php-project> --json # machine-readable JSON
# Stage 2 — derive/confirm a migration plan (the gate)
npx tsx src/cli.ts plan <path-to-php-project> # writes ./migration-plan.yaml + status
npx tsx src/cli.ts plan <path> --file plan.yaml --interactive # guided Q&A
npx tsx src/cli.ts plan <path> --json # resolution status as JSONStage 3 (transform) is available as a full CLI command — use php2js transform <path> --plan <plan.yaml> --adapter nextjs for adapter-driven output, or without --plan for Phase-1 language-only transpilation (see below).
The engine API transpiles one parsed PHP file to a neutral TypeScript module:
import { parsePhp } from "./src/parser";
import { transpileFile } from "./src/engine/transpileFile";
const { ast } = parsePhp(phpSource, "input.php");
const { code, diagnostics } = await transpileFile({ file: "input.php", ast });Supported now: scalars, variables, operators (hybrid native/runtime), control flow, functions,
arrays (PhpArray + idiomatic JS arrays when provably list-safe), and basic classes. Unsupported
constructs produce located diagnostics and php.unsupported(...) placeholders rather than failing.
For a multi-file project, transpileProject resolves namespaces/use/cross-file references and emits
TypeScript modules mirroring the source tree, wired together with imports:
import { parsePhp } from "./src/parser";
import { transpileProject } from "./src/engine/project/transpileProject";
const files = sources.map((s) => ({ file: s.relPath, ast: parsePhp(s.code, s.relPath).ast }));
const { modules } = await transpileProject({ root: ".", files });
// modules: { path: "App/Main.ts", code, diagnostics }[]References to project-internal classes/functions become relative imports; common PHP built-ins
(Exception → Error) are mapped; unresolved vendor/framework symbols are emitted bare with a
deferred diagnostic for the target adapter to wire.
php2js learns new frameworks from data files in the migration project — no code changes:
.php2js/frameworks/<name>.json— a framework signature: weighted detection signals (composer prefix, sentinel file, namespace prefix, static-call class)..php2js/mappings/<source>.<concern>.json— a source→target mapping: rationale plus concept-level rows (supported/partial/unmapped) that drive recommendations, the plan interview, and migration reports. Project-local files override built-ins with the same key.
php2js profile writes any knowledge gaps to .php2js/gaps/ with pre-filled skeletons. The
bundled author-mapping Claude Code skill reads those reports, researches the framework, fills
the skeletons, and validates with php2js mappings validate / re-running php2js profile.
Targets without a code adapter (e.g. prisma) are recommended and planned but reported as
missing-adapter — adapter plug-ins are a planned follow-on.
Pipeline: discover → parse (once, error-tolerant) → detect (concern-scoped,
two-tier signals) → inventory → recommend → report. Framework knowledge lives in
pluggable SourceProfile plugins (src/detect/profiles). See
docs/superpowers/specs/2026-06-16-php-to-js-transpiler-profiling-pass-design.md.
Works today:
-
Stage 1 — Profiling: full CLI, Markdown + JSON output.
-
Stage 2 — Migration Plan + interview gate: full CLI, persisted
migration-plan.yaml, including asession.storedecision (defer / iron-session / redis, default defer) when raw$_SESSIONusage is detected. -
Stage 3 — Core transpiler engine (programmatic API): increment 1 (procedural + basic OOP), increment 2 (cross-file / module resolution), and increment 3 (everyday language features — closures/arrow functions,
match, exceptions, type casts, bitwise operators, null coalescing,instanceof,unset,clone,@silence operator,exit/die,declare, magic constants, and static includes). Inline HTML/template segments,isset/emptyconditionals, type casts(int)/(float)/(string)/(bool)/(array)/(object)(matching PHP conversion semantics for typical inputs), bitwise operators& | ^ << >> ~, null-coalescing??,instanceof,unset(variables/array keys/object properties),clone(shallow copy with__clonesupport),@(error suppression),exit/die(viaphp.exit()),declare(parsed but no-op), magic constants (__FILE__,__DIR__,__LINE__,__CLASS__,__FUNCTION__,__METHOD__,__NAMESPACE__,__TRAIT__;__FILE__/__DIR__resolve to project-relative module paths), and staticinclude/require(including__DIR__-prefixed paths resolve to module imports; dynamic includes remain diagnosed) now transpile. Bitwise ops coerce operands to integers and compute in 64-bit (losing precision beyond 2^53), and string operands are numerically coerced (unlike PHP's byte-wise string bitwise — a documented divergence). This enables PHP template files and language constructs to reproduce their rendered output. Note:globalandstaticvariable declarations are deferred — they emit a clear diagnostic rather than being silently dropped, pending the cross-file scope model needed to faithfully implement module-level bindings.Increment 4 adds advanced OOP: interfaces (with constant companions), traits (transpile-time inlining with
insteadof/asconflict resolution; same-file declarations only — cross-file trait use is a deferred diagnostic), enums (pure + backed, withcases()/from()/tryFrom()),abstract/finalmodifiers, late static binding (static::/new static), and magic methods (__get/__set/__isset/__unset/__call/__invokevia a per-classProxy, plus__toString). TheProxywrap is applied per declaring class and is not inherited by a subclass that does not itself declare a magic method (a JS limitation: a derived constructor discards the object itssuper()returned).__destructand__callStaticare deferred diagnostics (no faithful JS equivalent / static-side Proxying out of scope).Increment 5 adds generators, references, and variable-variables: generator functions and methods emit
return php.generator((function* () { … }).call(this))and yield[key, value]tuples consumed by aPhpGeneratorwrapper that mirrors the full PHP Generator API (current/key/next/valid/send/getReturn); keyed-yield (yield $k => $v) andyield from(viaphp.iterPairs) are supported. By-reference variables (by-ref params,$b = &$a,foreach ($a as &$v), andfunction() use (&$x)) are implemented via a single-slot box ({ v }) — aliased variables read and write through.v; call sites that pass a boxed variable to a by-value callee transparently unwrap it. Same-file by-ref signatures are resolved; for a dynamic callee or a cross-file function whose signature isn't visible, a boxed argument is passed by value and by-reference aliasing across that call boundary is silently not applied (cross-file by-ref propagation is deferred to a later increment). Variable-variables ($$x) open a per-scopeconst __vars = new Map()and route all local reads, writes, compound-assigns, and increments/decrements through it; complex-expression names (${"a"."b"}) emit avar-vardeferred diagnostic rather than failing silently.
Stage 3 — full pipeline (profile → plan → transform) is now complete for the supported subset.
php2js transform <path> --plan <plan.yaml> --adapter nextjs produces a verified, runnable Next.js App
Router project: engine language modules under lib/, adapter route handlers under app/, and a full
scaffold (package.json, tsconfig.json, next.config.ts, app/layout.tsx). The transform command
emits a VerificationReport containing a coverage figure (clean modules / emitted) and all flagged
items. The emitted project type-checks with tsc --noEmit.
To enter adapter-driven mode you must pass --plan <plan.yaml> explicitly. Without it the command
runs the Phase-1 language-only path (mirrored .ts tree under <outDir>/src/) — useful for assessing
how much raw PHP surface transpiles before committing to a framework target.
The Next.js App Router adapter (src/adapters/nextjs/) implements the http concern:
- File-per-endpoint routing — each PHP entrypoint becomes
app/<route>/route.ts; index.php maps to the root route. - Superglobal → Request binding —
$_GET,$_POST,$_COOKIE,$_SERVER,$_FILES,$_REQUESTare mapped to amakeRequestContext(req)support module (lib/php2js-support.ts). - Output sink — runtime
echois captured into a string accumulator and returned as aNextResponsebody; the prior sink is restored in afinallyblock. - Env mapping —
getenv()/$_ENVkeys rewrite toprocess.env.KEY; a.env.exampleis emitted. - Scaffold —
package.json,tsconfig.json(moduleResolution: "bundler"),next.config.ts,app/layout.tsx. - Verification report — coverage figure + all engine diagnostics and adapter flags surfaced in a structured
VerificationReport.
Deferred / flagged in the adapter:
- Sessions (
$_SESSION) — reads are routed throughlib/php2js-session.tsand flaggedneeds-input. The store choice (defer / iron-session / redis) is a plan-interview decision (session.store); non-defer choices emit a named integration point that throws until implemented. Framework session services are detected via signature-declaredsessionAccesspatterns (function/static calls, e.g. CI4session()/Services::session(), Laravelsession()/Session::); instance-shaped access ($this->session) is not yet detected. - Path parameters / dynamic routing — an entrypoint that routes on the request URI
(
$_SERVER['REQUEST_URI']/PATH_INFO) is flaggeddynamic-routing; non-supportedconcept rows of the chosen mapping surface asconcept-gapflags. For frameworks without a route extractor, emitted routing remains static file-per-endpoint and the flags are the signal that manual dispatch reconstruction is owed; with an extractor (CI4, below), path parameters become real dynamic segments instead. - Framework routing tables — CodeIgniter 4 routing is now parsed: explicit
app/Config/Routes.phprows (verbs, groups,(:segment)/(:any)placeholders → Next.js dynamic segments,$nargs) plus auto-routing conventions are extracted into a route table that drives the plan'sroutes:section and Phase-2's per-route class-method handlers (same-path multi-verb rows merge into one module; closures and unresolvable rows are flaggedroute-closure/route-unresolvedand skipped). Laravelroutes/web.phpand Symfony#[Route]are still open — projects without an extractor keep the file-per-entrypoint behavior. - Whole-array superglobal reads —
$_GETwithout a key is diagnosed; only static keyed access ($_GET['foo']) is lowered. - Cross-file by-reference — by-ref aliasing across file boundaries is a deferred diagnostic (Increment 5 limitation).
The engine guarantees that all transpiled output is parseable TypeScript (validated against @babel/parser). This is achieved through:
- Reserved-word-safe identifiers: Variables, parameters, function, and class names that collide with JavaScript/TypeScript reserved words are transparently renamed (e.g.,
$return→$return_) at all binding and reference sites for cross-file consistency. - Crash mitigation: Language constructs that would otherwise throw (e.g., unresolved variable substitution
$$xwithout a declared base) degrade to diagnostic warnings and emit placeholder expressions rather than stopping transpilation. - Per-file isolation in
transpileProject: Each file is transpiled independently, preventing errors in one module from cascading to the entire project (cross-file resolution happens after transpilation and respects module boundaries).
The closure capture-snapshot analysis is a deliberate over-approximation: any closure inside a loop snapshots all declared-by-value candidates, and any variable assigned two or more times anywhere in the enclosing scope is always snapshotted. One residual edge case is that a closure nested directly inside an arrow-function body does not get its own independent snapshot set (in practice the enclosing arrow's snapshot covers the common loop case, verified to output correctly).
- Closure-internal reference aliasing and variable-variables inside a closure body are not analyzed by the reference/boxing pass. A
$b = &$aassignment inside a closure body, or$$xusage inside a closure's own body, produces a locateddeferreddiagnostic rather than silent wrong output. - A function that uses both variable-variables (
$$x) and by-reference parameters is undecidable at translate time. It produces avar-var-byrefdeferred diagnostic; the emitted code is parseable but should not be relied upon for correctness. - Compound assignment on a side-effecting array-subscript LHS (
$arr[f()] += 1) evaluates the subscript expression twice in the emitted TypeScript (once to read, once to write). This matches array-keyed compound-assign behavior in JS but may double-invoke a side-effecting index expression.