Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

359 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

php2js — PHP → TypeScript migration tool

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.

Who this is for / what it's for

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.

The workflow

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)

Usage

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 JSON

Stage 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).

Stage 3 — transpiling a file (engine, increment 1)

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.

Stage 3 — transpiling a project (engine, increment 2)

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 (ExceptionError) are mapped; unresolved vendor/framework symbols are emitted bare with a deferred diagnostic for the target adapter to wire.

Extending detection & mappings

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.

Architecture

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.

Status

Works today:

  • Stage 1 — Profiling: full CLI, Markdown + JSON output.

  • Stage 2 — Migration Plan + interview gate: full CLI, persisted migration-plan.yaml, including a session.store decision (defer / iron-session / redis, default defer) when raw $_SESSION usage 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/empty conditionals, 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 __clone support), @ (error suppression), exit/die (via php.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 static include/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: global and static variable 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/as conflict resolution; same-file declarations only — cross-file trait use is a deferred diagnostic), enums (pure + backed, with cases()/from()/ tryFrom()), abstract/final modifiers, late static binding (static::/new static), and magic methods (__get/__set/__isset/__unset/__call/__invoke via a per-class Proxy, plus __toString). The Proxy wrap 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 its super() returned). __destruct and __callStatic are 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 a PhpGenerator wrapper that mirrors the full PHP Generator API (current/key/next/valid/ send/getReturn); keyed-yield (yield $k => $v) and yield from (via php.iterPairs) are supported. By-reference variables (by-ref params, $b = &$a, foreach ($a as &$v), and function() 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-scope const __vars = new Map() and route all local reads, writes, compound-assigns, and increments/decrements through it; complex-expression names (${"a"."b"}) emit a var-var deferred 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, $_REQUEST are mapped to a makeRequestContext(req) support module (lib/php2js-support.ts).
  • Output sink — runtime echo is captured into a string accumulator and returned as a NextResponse body; the prior sink is restored in a finally block.
  • Env mappinggetenv()/$_ENV keys rewrite to process.env.KEY; a .env.example is emitted.
  • Scaffoldpackage.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 through lib/php2js-session.ts and flagged needs-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-declared sessionAccess patterns (function/static calls, e.g. CI4 session()/Services::session(), Laravel session()/ 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 flagged dynamic-routing; non-supported concept rows of the chosen mapping surface as concept-gap flags. 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.php rows (verbs, groups, (:segment)/(:any) placeholders → Next.js dynamic segments, $n args) plus auto-routing conventions are extracted into a route table that drives the plan's routes: section and Phase-2's per-route class-method handlers (same-path multi-verb rows merge into one module; closures and unresolvable rows are flagged route-closure/route-unresolved and skipped). Laravel routes/web.php and Symfony #[Route] are still open — projects without an extractor keep the file-per-entrypoint behavior.
  • Whole-array superglobal reads$_GET without 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).

Known limitations

Parseable output guarantee

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 $$x without 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).

Closure capture analysis

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).

References and variable-variables — known limitations (Increment 5)

  • Closure-internal reference aliasing and variable-variables inside a closure body are not analyzed by the reference/boxing pass. A $b = &$a assignment inside a closure body, or $$x usage inside a closure's own body, produces a located deferred diagnostic rather than silent wrong output.
  • A function that uses both variable-variables ($$x) and by-reference parameters is undecidable at translate time. It produces a var-var-byref deferred 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.

About

A comprehensive tool for transpiling from PHP to typescript, with framework retargeting.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages