ShapeRuntime

Switch your stack between PHP and .NET without rewriting your app.

PHP and .NET share the substrate, not just the brand. The same config/shape.json runs on either runtime. The same database schema, manifest, OpenAPI, MCP tools, and JWT tokens come out the other end. Switching is a deployment change, not a rewrite.

What stays the same when you switch

Eight surfaces are byte-compatible across the two runtimes. Verified against production deployments — the feedback files in the repo (SHAPE-FEEDBACK.md, TOCSBE-FEEDBACK.md) document each item with commit SHAs.

SurfaceWhy it just works
Database schema + auto-detect conventionsBoth runtimes read deleted_at, audit_log, users_lookup_column, created_at/by, updated_at/by the same way. Schema is the contract.
config/shape.json (masks, access map, DSN, OIDC presets, file uploads, auth)Verbatim — no translation step. The .NET ShapeApp.FromDatabase reads the same JSON the PHP ShapeApp::fromDatabase reads.
External .sql mask filesReferenced via "sqlFile": "queries/topAuthors.sql" in either runtime. :param placeholders are normalized identically.
?manifest JSON outputByte-compatible. AI agents trained against one work against the other unchanged. Entity list, mask params, default limits, audit columns, soft-delete flags — all identical shape.
?openapi spec outputSame paths, same tags, same x-tagGroups, same examples. SDK generators (openapi-generator) produce equivalent clients.
?mcp server + stdioSame JSON-RPC protocol, same tool definitions, same resource URIs (shape://manifest, shape://README.md). Claude Desktop / Cursor / Cline configs unchanged.
JWT tokens (HS256 + OIDC RS256)Same signing, same OIDC presets (entra/auth0/okta/google/cognito). Sessions survive the runtime switch if you keep the secret. OIDC tokens come from the IdP regardless.
REST / OData / generic-mask URLsGET /api.php/<entity>/<id> works identically. Same ?$top / ?$skip / ?$orderby semantics. Same /api.php/_/<mask> generic path.

Migration in four steps

A straightforward deployment exercise — no data migration, no code rewrite.

  1. Install the new runtime Stand up the .NET (or PHP) build alongside your existing one. Same machine works; separate hosts work; Docker container works. The two don't need to know about each other.
  2. Point at the same config and database Pass the same config/shape.json path. Use the same DSN, JWT secret, OIDC config. Both runtimes are read-only on those inputs.
  3. Diff the surfaces curl -s OLD/api.php?manifest | jq -S versus the new runtime's. Same for ?openapi and a sample ?op=query. Should be byte-equivalent except for the runtime-identity field. Frontend smoke-test passes unchanged.
  4. Switch traffic Flip the DNS record, the load balancer pool, or the reverse-proxy upstream directive. Reverse to roll back. The two runtimes can also coexist in production behind a weighted split for staged rollout.

The same JSON config, two runtimes

Below: identical config/shape.json consumed by the PHP entry point and the .NET entry point. The application layer is this small.

PHP — public/api.php

<?php
require __DIR__ . '/../vendor/autoload.php';

Shape\ShapeApp::run(__DIR__ . '/..');
// Discovers config/database.php (or config/shape.json),
// merges env.local.php, dispatches to ShapeRouter::run.

.NET — Program.cs

using Shape;

await ShapeApp.Run("config/shape.json", args);
// Reads the same JSON config (masks, access, DSN, OIDC),
// overlays DB_DSN / JWT_SECRET / CLOUD_SQL_INSTANCE env vars,
// dispatches to MapShape on a Kestrel host.

Both runtimes read the same config/shape.json shape. Masks support two authoring styles in the same block — an external sqlFile reference for queries you want under version control as standalone .sql assets, and inline sql with typed params for short queries you want to read alongside the config:

{
    "db":     { "dsn": "pgsql:host=...;dbname=...", "user": "...", "pass": "..." },
    "access": { "admin": ["customers", "invoices", "products"] },
    "auth": {
        "driver": "jwt",
        "oidc": {
            "preset":       "entra",
            "tenant_id":    "...",
            "audience":     "api://my-app",
            "role_mapping": { "Backend.Admin": "admin" }
        }
    },
    "masks": {
        "reports.topCustomers": {
            "sqlFile":     "queries/top-customers.sql",
            "roles":       ["admin"],
            "description": "Top 20 customers by revenue."
        },

        "orders.recentByRegion": {
            "sql": "SELECT id, customer_id, total, created_at FROM orders WHERE region = :region AND created_at >= :since ORDER BY created_at DESC LIMIT 50",
            "params": {
                "region": { "type": "string", "required": true },
                "since":  { "type": "string", "required": true }
            },
            "roles":       ["admin", "manager"],
            "description": "Last 50 orders for a region since a given date."
        }
    }
}

Both styles share the same :param placeholder syntax, the same role-gating, the same description-into-manifest passthrough, and the same OData query options at call time (?$top, ?$skip, ?$orderby). Pick whichever style fits the SQL — short hand-written queries inline, long reporting queries in their own file under queries/*.sql.

FAQ

What about hooks and commands written in PHP / C#?

Hooks and custom commands are language-specific. They're the small fraction of code that's actually your business logic — and they need to be ported. The framework primitives around them (mask config, audit log, validators, FK navigation) don't change. In practice this is <10% of a typical app's code, and it's the part you'd want to revisit anyway when changing language.

Are the manifests really byte-identical?

For the schema-derived portion, yes. Tags, entity definitions, mask params, audit columns, soft-delete flags, default limits, file uploads — all produced by the same conventions reading the same schema, so the JSON output is identical modulo whitespace. Free-text fields (e.g. server identity) differ. Tested by diffing the manifest output of the same database via PHP and .NET runtimes.

Do JWT tokens really work across the switch?

Yes for HS256 (shared-secret) tokens — both runtimes use the same algorithm and the same secret. For OIDC tokens (RS256), the runtime never issues them; it validates them against the IdP's JWKS endpoint, so the issuer (Entra ID / Auth0 / Okta / Google / Cognito) is the source regardless of which runtime is verifying.

What about database performance characteristics?

Both runtimes use prepared statements; both push ?$top / ?$skip into SQL; both cache schema introspection. .NET's connection pool is built into the BCL and doesn't need db.persistent opt-in (which PHP needs for PHP-FPM). Net effect: the two runtimes have similar per-request profiles on the same hardware.

Can the two runtimes run side-by-side in production?

Yes — that's the recommended migration path. Behind a load balancer with a weighted split, you can route 5% of traffic to the new runtime, watch metrics, ramp up gradually, and roll back instantly if needed. Both runtimes share the database (no replication), share the auth (same tokens), and share the config (same JSON file).

What's NOT compatible across runtimes?

Three things: (1) hooks and commands written in language-specific code; (2) the FromAssembly entity-first mode in .NET versus the entity-class form in PHP — the attribute / annotation surfaces differ at authoring time; (3) language-specific extension hooks (e.g. PHP autoload tricks, .NET DI registrations). The framework-shaped surface — masks, access, manifest, OpenAPI, MCP — is identical.

Read the underlying parity story in the PHP feedback file and the .NET port feedback file · Browse the full architecture page · See the 90-second pitch

Hosted on resolving…