Skip to content

Add a PHP example - #5

Merged
evsinev merged 4 commits into
mainfrom
feat/php-example
Sep 11, 2026
Merged

Add a PHP example#5
evsinev merged 4 commits into
mainfrom
feat/php-example

Conversation

@evsinev

@evsinev evsinev commented Sep 11, 2026

Copy link
Copy Markdown

A fourth example: the same payment, on PHP. php-js/ implements exactly the seven routes of the
server contract, with the same status codes, the same OAuth base string and the same sha1
callback checksum as go-js/.

No Composer, no framework, no autoloader — PHP 8.4 with the bundled curl, openssl and json.
Six files, four of them the integration:

settings.php  environment variables, validation and the key
oauth.php     OAuth 1.0a RSA-SHA256 signing
control.php   the 3DS callback checksum
paynet.php    the three gateway calls
index.php     routes under BASE_PATH, and the generated config.js
router.php    the `php -S` entry point, unused behind nginx

Port 3003, prefix /hosted-fields-examples-php. php -S 127.0.0.1:3003 router.php for a look,
PHP-FPM behind nginx in deploy/ for anything else.

What adding a language actually cost

  • scripts/sync-shared.sh: one line, and the six shared frontend files are copied and
    committed like everyone else's;
  • e2e-tests/src/apps.ts: one registry entry. No spec changes, no playwright.config.ts
    changes — projects and servers are both derived from APPS, and the specs resolve the app from
    the project name;
  • nothing in go-js/, nodejs-express-js/ or nextjs/.

Three things PHP does differently, and why

  • router.php never return falses. The app root is the php -S document root, so a
    delegated request would serve .env or execute paynet.php. index.php answers every path and
    serves public/ from an allowlist — which is also why the nginx block has no root and no
    try_files. Verified: /.env, /router.php, /paynet.php, /views/checkout.html and
    /tests/run.php all answer 404.
  • Settings are validated per request, because PHP has no startup to refuse at. A missing name
    is a 500 with the detail in the log, and it is the same property that lets CI lint and test
    with no credentials. Recorded in the root README beside Next's lazy check.
  • Forms are parsed by form_params(), not $_POST/$_GET, which rewrite . and a space in
    a parameter name and keep the last of a repeated one where Go and Node keep the first. The 3DS
    return is verified and then forwarded, and those have to be the same value.

Two smaller ones, both in php-js/CLAUDE.md: the signature encoder is rawurlencode and never
urlencode, and every sort is ksort(…, SORT_STRING) because the default reads numeric-looking
keys as numbers.

Checks

  • php -l over every file and php tests/run.php — 13 tests, on the same OAuth base-string and
    callback-checksum vectors go-js and nodejs-express-js pin, so a drifting example fails
    rather than agreeing with itself. Added as a CI job.
  • ./scripts/sync-shared.sh && git diff --exit-code clean.
  • cd e2e-tests && npm test40 passed, ten per app: happy path, the SDK's field-state
    classes, pre-tokenize validation, no ticket, a refused Sale, a decline, the 3DS hop out and back
    through the signed callback, and the forged-signature 403s. The emulator verifies the OAuth
    signature against the generated public key and rejects a Sale whose amount is not the server's,
    so the PHP signing and the server-owned amount are checked on the wire and not just rendered.
  • The three existing apps still build and test untouched.

No key, no MERCHANT_CONTROL and no real ENDPOINT_ID anywhere: .env.example and the FPM pool
template carry placeholders only.

@evsinev
evsinev merged commit 5a9f175 into main Sep 11, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant