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.
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.
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.
| Surface | Why it just works |
|---|---|
| Database schema + auto-detect conventions | Both 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 files | Referenced via "sqlFile": "queries/topAuthors.sql" in either runtime. :param placeholders are normalized identically. |
?manifest JSON output | Byte-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 output | Same paths, same tags, same x-tagGroups, same examples. SDK generators (openapi-generator) produce equivalent clients. |
?mcp server + stdio | Same 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 URLs | GET /api.php/<entity>/<id> works identically. Same ?$top / ?$skip / ?$orderby semantics. Same /api.php/_/<mask> generic path. |
A straightforward deployment exercise — no data migration, no code rewrite.
config/shape.json path. Use the same DSN, JWT secret, OIDC config. Both runtimes are read-only on those inputs.
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.
upstream directive. Reverse to roll back. The two runtimes can also coexist in production behind a weighted split for staged rollout.
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.
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.
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.
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.
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.
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).
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…