ondewo / nlu-client-php
ONDEWO NLU (Natural Language Understanding) gRPC client for PHP, generated from the ondewo-nlu-api protocol buffer definitions
Requires
- php: >=8.1.0
- ext-grpc: *
- google/protobuf: 4.33.6
- grpc/grpc: 1.82.0
Requires (Dev)
None
Suggests
- psr/log: To receive ClientConfig's insecure-channel warning on a PSR-3 logger instead of error_log()
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-10 04:35:52 UTC
README
|
|
|
ONDEWO NLU Client PHP Library
This library is the PHP gRPC client for the ONDEWO NLU (Natural Language Understanding) server.
There is no hand-written transport layer in this repository. The entire client surface — messages, enums and
one <Service>Client stub per gRPC service — is generated from the protocol buffer definitions of the
ondewo-nlu-api repository by the
ONDEWO proto compiler, which is vendored here as a git
submodule and pinned to a release tag. The generated stubs are committed — the repository root is the
composer package, Packagist serves the tree of a git tag verbatim and composer has no build step, so what is
committed here is exactly what a consumer receives.
The only hand-written PHP is the bearer-token authentication surface in auth/, which turns a token into the
$opts array a generated stub is constructed with.
Requirements
- PHP >= 8.1
- The grpc PHP extension (
ext-grpc) — every generated<Service>Clientextends\Grpc\BaseStub - The bcmath PHP extension (
ext-bcmath) — only to parse integers out of the JSON wire format;google/protobufmerely suggests it, somergeFromJsonString()on a message with an int field fails without it - Composer 2.x
- Docker — only to work on this repository (regenerate the stubs, run the tests in the utils image, release), never to use the client
Installing the extension:
# Debian / Ubuntu sudo apt-get install -y php-grpc php-bcmath # or, from source sudo pecl install grpc
Installation
The client is published on Packagist as
ondewo/nlu-client-php and is installed like any other composer package:
composer require ondewo/nlu-client-php
The client version tracks the ONDEWO NLU API in major and minor version, so pin the minor series that matches the server you talk to:
composer require ondewo/nlu-client-php:^7.3
or, in composer.json:
{
"require": {
"ondewo/nlu-client-php": "^7.3"
}
}
Two things a consumer has to know:
ext-grpcis a hard requirement, not an optional extra — every generated<Service>Clientextends\Grpc\BaseStub, so composer refuses to install the package without it. Install it first (see Requirements above);composer require ondewo/nlu-client-php --ignore-platform-req=ext-grpcwill resolve, but the client will fatal at runtime.ext-bcmathis not declared —google/protobufonly suggests it — but its pure-PHP JSON parser range-checks integers withbccomp(), so install it too if you read or write the JSON wire format.- There is no build step. Packagist serves the tree of a git tag verbatim and the generated stubs are
committed, so the package a consumer downloads is byte-for-byte the tagged repository: after
composer require,vendor/ondewo/nlu-client-php/src/already holds every stub and the autoloader is generated by composer itself. Nothing needs docker, protoc or theondewo-nlu-apisubmodule.
Everything is then reachable through composer's autoloader:
require __DIR__ . '/vendor/autoload.php'; use Ondewo\Nlu\AgentsClient; // generated, from src/ use Ondewo\Nlu\Auth\BearerTokenAuthenticator; // hand-written, from auth/
To work on the client itself:
git clone --recurse-submodules git@github.com:ondewo/ondewo-nlu-client-php.git
cd ondewo-nlu-client-php
make setup_developer_environment_locally
make help lists every documented target; make makefile_chapters lists the Makefile's sections.
make setup_developer_environment_locally, the native make test / make ci and every other target that calls
php, composer or gh directly need those tools on your machine. The docker targets need only make, git,
docker, perl and curl: make build generates the stubs in the compiler image, and make test_via_docker runs
the test suite in the utils image (Dockerfile.utils: PHP 8.4 with ext-grpc, pcov and bcmath, Composer
and gh). The utils image mounts the repository and runs as your user, so vendor/ and build/ land in your
working tree.
Repository structure
.
├── ondewo-nlu-api <----- submodule: the .proto definitions (ondewo/ = the services, google/ = imports)
├── ondewo-proto-compiler <----- submodule: the compiler images, pinned to tags/5.15.2
├── auth <----- HAND-WRITTEN sources (bearer token authenticator, ClientConfig: TLS / mutual TLS)
├── src <----- GENERATED stubs, committed - compiler-owned, wiped on every generation run
│ ├── GPBMetadata <----- descriptor bootstrap, one class per .proto
│ └── Ondewo <----- messages, enums and the <Service>Client stubs
├── tests <----- PHPUnit suite (not part of the published classmap)
├── tools <----- dev-only composer project: PHPUnit + the coverage gate
├── vendor <----- composer dependencies (gitignored)
├── composer.json <----- the package manifest; MERGED with the compiler defaults on every run
├── phpunit.xml.dist <----- test suite + coverage scope (auth/ only)
└── Makefile <----- build, test and release automation
Three rules follow from that layout and matter more than anything else in this file:
- Never put hand-written PHP in
src/. It is deleted and rewritten on every generation run. Hand-written code belongs inauth/at the repository root, whichcomposer.jsonautoloads throughautoload.psr-4. - Never pin
google/protobuforgrpc/grpcincomposer.jsonto anything other than the versions the compiler image ships. The image resolves the merged manifest offline from a cache it pre-warmed at image-build time; a pin outside that cache fails the generation run. - Never add
require-devto the rootcomposer.json.composer update --no-devstill resolves dev requirements in order to write a lock file, so a single entry there makes that same offline resolution fail. Dev tooling lives in its own composer project undertools/— see tools/README.md.
Regenerating the stubs
make build
That is the whole flow, and it is: check out the pinned submodules → build the ondewo-php-proto-compiler:latest
image from the submodule → run it over the protos, as your user → write the client version into composer.json
and the README's install snippets.
The generation step on its own is a single container run:
docker run --rm \ --user "$(id -u):$(id -g)" \ -e HOME=/tmp/home \ -e TEMP_SRC_DIRECTORY=/tmp/compile-src \ -e COMPOSER_HOME=/tmp/home/.composer \ -e COMPOSER_CACHE_READ_ONLY=1 \ -v $(pwd):/input-volume \ -v $(pwd):/output-volume \ ondewo-php-proto-compiler:latest ondewo-nlu-api ondewo
- The two positional arguments are
<relative_protos_dir> <target_subdir>: the proto root inside the input volume (which becomes protoc's-Iroot), and the sub-directory to scope generation to.ondewokeeps the vendoredgoogle/tree out of the entry set while the image's dependency resolver still pulls in the google protos that are actually imported. - There is no
-it. It breaks every non-interactive caller withcannot attach stdin to a TTY-enabled container because stdin is not a terminal. - Input and output volume are both the repository root. The image copies the input volume into an internal
temporary directory and compiles there, so the mounted input is never mutated; it then writes
composer.json,composer.lock,src/andvendor/back here, wiping its ownsrc/andvendor/first so a renamed or deleted proto leaves no orphaned stub behind. - The image was built to run as root.
--usermakes the files it writes yours, and the-eoverrides redirect the paths a non-root user cannot write: its staging directory (TEMP_SRC_DIRECTORY),HOMEandCOMPOSER_HOME, and the pre-warmed composer cache, which is read-only (COMPOSER_CACHE_READ_ONLY). The run fails withoutTEMP_SRC_DIRECTORYorCOMPOSER_CACHE_READ_ONLY. Without--userit succeeds, but every file it writes is owned by root.
To poke around inside the image, and only there, -it is correct:
docker run -it --entrypoint /bin/bash \ -v $(pwd):/input-volume \ -v $(pwd):/output-volume \ ondewo-php-proto-compiler:latest
Usage
<?php require __DIR__ . '/vendor/autoload.php'; use Grpc\ChannelCredentials; use Ondewo\Nlu\AgentsClient; use Ondewo\Nlu\AgentView; use Ondewo\Nlu\Auth\BearerTokenAuthenticator; use Ondewo\Nlu\ListAgentsRequest; // The PHP namespace is protoc's UpperCamel form of the proto package: // `package ondewo.nlu;` becomes `Ondewo\Nlu`, and a service `Agents` becomes // `AgentsClient` (grpc_php_plugin's default class suffix). Browse src/Ondewo/Nlu for // the services and messages your pinned API version actually declares. // // BearerTokenAuthenticator is the hand-written half: it builds the `$opts` array and // stamps `authorization: Bearer <token>` onto the metadata of every call. $auth = new BearerTokenAuthenticator(getenv('ONDEWO_TOKEN')); $client = new AgentsClient( getenv('ONDEWO_NLU_HOST') ?: 'localhost:50055', $auth->channelOptions(ChannelCredentials::createSsl()) ); $request = new ListAgentsRequest(); $request->setAgentView(AgentView::AGENT_VIEW_SHALLOW); [$response, $status] = $client->ListAgents($request)->wait(); if ($status->code !== \Grpc\STATUS_OK) { throw new RuntimeException("gRPC call failed ({$status->code}): {$status->details}"); } echo $response->serializeToJsonString(), PHP_EOL;
For an insecure channel against a local server, call $auth->channelOptions() with no argument — null
credentials is exactly what ChannelCredentials::createInsecure() returns.
TLS, mutual TLS and certificates
gRPC encrypts with TLS. "SSL" in names such as \Grpc\ChannelCredentials::createSsl() or
grpc.ssl_target_name_override is legacy naming; no SSL protocol version is ever negotiated.
Ondewo\Nlu\Auth\ClientConfig (hand-written, in auth/) builds the target and the $opts array of every
generated <Service>Client, with the same TLS rules and channel defaults as the other ONDEWO SDKs:
| Mode | ClientConfig arguments |
|---|---|
| Plaintext (not for production) | useSecureChannel: false |
| TLS, system trust store | none (grpcCert empty) |
| TLS, custom CA | grpcCert = PEM of the CA that signed the server certificate |
| Mutual TLS | grpcCert plus grpcClientCert and grpcClientKey |
<?php use Ondewo\Nlu\Auth\BearerTokenAuthenticator; use Ondewo\Nlu\Auth\ClientConfig; use Ondewo\Nlu\AgentsClient; $config = new ClientConfig( host: '10.0.0.5', port: 50051, grpcCert: file_get_contents('certs/ca.pem'), grpcClientCert: file_get_contents('certs/client.pem'), // leave both out for server-authenticated TLS grpcClientKey: file_get_contents('certs/client.key'), ); $client = new AgentsClient($config->target(), $config->channelOptions([ 'update_metadata' => new BearerTokenAuthenticator(getenv('ONDEWO_TOKEN')), 'grpc.ssl_target_name_override' => 'nlu.example.internal', // only when connecting by IP ]));
Rules the code enforces:
- The three certificate fields hold PEM content, not file paths. Read the files yourself
(
file_get_contents()returnsfalsefor a missing file, whichdeclare(strict_types=1)code rejects with aTypeError). CRLF line endings are fine. grpcClientCertandgrpcClientKeygo together: setting only one throwsInvalidArgumentExceptionwhen the config is built, andchannelCredentials()repeats the check before anything reaches ext-grpc. This is not pedantry: grpc-core aborts the whole PHP process on a private key without its certificate and silently connects without a client identity on a certificate without its key. Empty strings on both mean server-authenticated TLS; an emptygrpcCertmeans the platform's default trust store.useSecureChannel: falsewith a client certificate throwsInvalidArgumentExceptioninstead of silently dropping the identity. A plaintext channel is allowed, but logs a warning naminghost:port: on the PSR-3 logger passed tochannelOptions(logger: ...)/channelCredentials($logger), otherwise througherror_log(). The SDK never configures your logging.channelOptions()refuses acredentialsoption of its own: TLS material goes throughClientConfig, where it is checked. Every other option (update_metadata, anygrpc.*channel argument) is passed through and wins over the defaults.- No exception message renders a PEM or the config: they name the field and
host:port. target()brackets a bare IPv6 literal (::1becomes[::1]:50051) and leaves[...]orscheme:hosts alone.- The server certificate is verified against
grpcCert, and the host you connect to must match one of the certificate's subject alternative names (SAN). When you connect by IP and the certificate has no IP SAN, tell gRPC which name to check with the channel optiongrpc.ssl_target_name_override.
Channel defaults (ClientConfig::DEFAULT_CHANNEL_OPTIONS, identical to ondewo-client-utils-python): message
size limit 2³¹-1 bytes both ways; grpc.keepalive_time_ms 30000 with grpc.keepalive_permit_without_calls 0 and
grpc.http2.max_pings_without_data 2 (pings only during calls; a server answers endless pings on a silent
stream with GOAWAY too_many_pings); grpc.keepalive_timeout_ms and grpc.http2.ping_timeout_ms 20000 (a dead
connection is detected in about 40 s instead of 85 s); grpc.max_reconnect_backoff_ms 5000 (gRPC's default is
120 s). Gap: the Python SDK also installs a per-method retry policy (idempotent reads only); this PHP SDK
installs none, so only gRPC's transparent retries (a request that provably never reached the server) apply.
A test PKI with openssl
A CA, a server certificate with SANs, and a client certificate with the clientAuth extended key usage. For
tests only: the keys are unencrypted.
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -days 365 \ -subj "/CN=Test CA" -keyout ca.key -out ca.pem printf 'subjectAltName=DNS:localhost,IP:127.0.0.1\nextendedKeyUsage=serverAuth\n' > server.ext openssl req -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \ -subj "/CN=localhost" -keyout server.key -out server.csr openssl x509 -req -in server.csr -CA ca.pem -CAkey ca.key -CAcreateserial -days 365 \ -extfile server.ext -out server.pem printf 'extendedKeyUsage=clientAuth\n' > client.ext openssl req -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \ -subj "/CN=my-client" -keyout client.key -out client.csr openssl x509 -req -in client.csr -CA ca.pem -CAkey ca.key -CAcreateserial -days 365 \ -extfile client.ext -out client.pem chmod 600 *.key openssl verify -CAfile ca.pem server.pem client.pem
The client then uses ca.pem / client.pem / client.key; a server that requires client certificates uses
server.pem / server.key and trusts ca.pem for its clients.
TLS security notes
(string) $config,var_dump()/print_r()(__debugInfo()) andjson_encode()(jsonSerialize()) show a non-emptygrpcClientKeyas***REDACTED***(an empty one stays empty). That JSON is for logs and cannot be read back; keep the key in a file or secret store and pass it to the constructor at startup.serialize(),var_export()and an(array)cast are not redacted: they carry the private key in clear text. Do not log them, and treat anything serialized from a config as a secret (file mode0600, never committed).- The key parameter is
#[\SensitiveParameter]: from PHP 8.2 on, stack traces show it as aSensitiveParameterValue. PHP 8.1 ignores the attribute; there, setzend.exception_ignore_args = On(thephp.ini-productiondefault) to keep arguments out of traces.
TLS troubleshooting
A failed handshake ends the call with $status->code === \Grpc\STATUS_UNAVAILABLE (14); the cause is in
$status->details:
CERTIFICATE_VERIFY_FAILED: unable to get local issuer certificate(inside "Ssl handshake failed"):grpcCertis not the CA that issued the server certificate (or is empty, so the system trust store is used), or the server does not send its intermediate certificates.Hostname Verification Check failed(older gRPC:Peer name <host> is not in peer certificate): the host you connect to is not in the server certificate's SAN. Connect by a name in the SAN, add the SAN, or setgrpc.ssl_target_name_override.Socket closedagainst a server that requires client certificates: no client certificate was presented, or one the server's CA did not issue. The server log names the reason (e.g.PEER_DID_NOT_RETURN_A_CERTIFICATE). SetgrpcClientCert/grpcClientKey.empty address list, withCould not load any root certificatein the gRPC log:grpcCertholds something that is not PEM, typically a file path. Passfile_get_contents(...)instead.
Testing
make ci # what GitHub Actions runs: no submodules, no docker make test # the same, plus the Packagist dry run and `make check_build` against the ondewo-nlu-api submodule make test_via_docker # `make test` in the utils image, dependencies included - no host PHP or Composer
make ci is composer validate → php -l over the hand-written sources → PHPUnit with coverage → the
coverage threshold gate. It runs on PHP 8.1 and 8.4 in GitHub Actions, against the committed stubs. That
workflow (.github/workflows/ci.yml) is only a test and lint gate: no docker image is built, no proto compiler
runs, nothing is packaged or published and no secret is used there. make test adds the credential-free
Packagist dry run and the stub inventory (make check_build); the
release runs it in the utils image.
What the suite actually asserts:
| Test | What it would catch |
|---|---|
tests/Generated/GeneratedCodeTest.php |
A stub that does not load, a GPBMetadata descriptor whose initOnce() chain has a missing transitive import, a service whose client class was never generated, an empty src/ |
tests/Generated/MessageSerializationTest.php |
A field that never reaches the wire, a proto3 optional field that drops its zero value, a moved enum zero constant, a broken JSON mapping (including the integer path, which needs ext-bcmath) |
tests/Generated/ServiceClientTest.php |
A stub that cannot be constructed, a missing or re-shaped RPC method, a streaming RPC generated as a unary one |
tests/Auth/BearerTokenAuthenticatorTest.php |
Any regression in the hand-written auth surface |
tests/Auth/ClientConfigTest.php |
Half a client identity reaching ext-grpc, an identity dropped on a plaintext channel, a PEM or key in a message or rendering, a wrong target or channel default |
tests/Tls/MutualTlsHandshakeTest.php |
A broken real handshake: TLS, mutual TLS, CRLF PEMs and [::1] must connect; a missing or foreign client certificate, a wrong CA and the system roots against the test CA must fail as UNAVAILABLE |
tests/ReleaseNotesTest.php |
A RELEASE.md heading or separator the release-notes slice would miss, i.e. a GitHub release with empty notes |
The handshake test generates its PKI with ext-openssl at test time and needs python3 with grpcio for its
server (tests/Tls/tls_test_server.py; PHP has no gRPC server): pip install grpcio, or point
ONDEWO_TLS_TEST_PYTHON at an interpreter that has it. CI installs it.
Coverage is reported for the hand-written code only — phpunit.xml.dist's <source> is auth/, and
make coverage fails below COVERAGE_MIN (100%). The generated stubs are machine output and are deliberately
outside the metric, but they are not outside the tests: every one of the committed classes is loaded and every
descriptor initialised by GeneratedCodeTest.
Versioning and releasing
ONDEWO_NLU_VERSION at the top of the Makefile is the single source of truth and must match
the ONDEWO NLU API in major and minor version. make update_composer_version propagates it into
composer.json and make update_readme_version into the install snippets above (together with the compiler pin
in the repository tree); never edit either by hand.
A release runs entirely on the releasing machine, from one make target. GitHub Actions builds nothing,
publishes nothing and holds no credential; the credentials exist only in the ondewo-devops-accounts
repository.
# bump ONDEWO_NLU_VERSION and ONDEWO_NLU_API_GIT_BRANCH, add a RELEASE.md entry, then:
make ondewo_release
The ondewo-nlu-api repository's make release_all_clients (or make release_php_client) does exactly this:
it clones this repository, rewrites the version and both submodule pins in the Makefile, adds the
RELEASE.md entry and runs make ondewo_release. In order, ondewo_release:
- writes the version into
composer.jsonand this README, and checks that the release branch and tag are still free (make spc); - clones
ondewo-devops-accountsand passes exactlyGITHUB_GH_TOKEN(account_github.env),PACKAGIST_USERNAMEandPACKAGIST_API_TOKEN(account_packagist.env) tomake release; - checks that the three credentials are set (
make check_release_credentials) and thatRELEASE.mdhas notes for the version, then, in the utils image, logsghin withGITHUB_GH_TOKENexactly as step 7 will and asks GitHub, read-only, whether that token may push to this repository (make validate_release_credentials_via_docker_image); - builds (pinned submodules, compiler image, stubs), then runs the test suite and the Packagist dry run in the utils image;
- commits the release, pushes
master, and creates and pushes therelease/<version>branch and the<version>tag; - tells Packagist to crawl the new tag (
make publish, see below); - creates the GitHub release, whose notes are sliced out of
RELEASE.mdby the version heading. It comes last, so an existing GitHub release means every step before it succeeded.
Everything that can fail for a build, test, validation or GitHub-credential reason runs before the first push.
The Packagist token is the exception: Packagist documents no read-only call that accepts it (its only
authenticated endpoints — update, create and edit a package — all change state), so it is checked for presence
only, and a wrong token fails the ping in step 6, after the tag. The release then stops before the GitHub
release. spc refuses to run the whole release again, so finish it with the two remaining steps, with the
corrected credentials in the environment:
make publish_via_docker_image RELEASE_TAG=<version> # needs PACKAGIST_USERNAME and PACKAGIST_API_TOKEN make release_to_github_via_docker_image # needs GITHUB_GH_TOKEN
The release host needs only make, git, docker, perl and curl. The stubs are generated in the compiler image. The
token check, the tests, the dry run, make publish and gh release create run in the utils image
(Dockerfile.utils, tagged ondewo-nlu-client-utils-php:<version>), which the release builds before the first
push. Its first build compiles ext-grpc from PECL and takes several minutes; later builds reuse docker's layer
cache. The credentials go into those containers as environment variables, by name, so they never appear on
docker's command line.
Publishing to Packagist
Packagist takes no upload. There is no twine upload and no npm publish equivalent: Packagist reads the
git tags of this repository off GitHub and serves each tagged tree as a version. So "publishing" is two things,
and only the second one is a command:
- the git tag —
make create_release_tagpushes it, and that tag is the artifact; - a ping that tells Packagist to crawl the repository now instead of at its next scheduled pass —
make publish.
make publish # validate the package, then POST to https://packagist.org/api/update-package make publish_via_docker_image # the same in the utils image (after `make build_utils_docker_image`)
make publish refuses to run without credentials, then runs the full dry run, then pings the API. It fails
unless the answer is HTTP 200 or 202 (Packagist queues the crawl and usually answers 202 Accepted) and carries
{"status":"success"}. It is wired into make release through make publish_via_docker_image, so a normal
make ondewo_release needs no extra step; run it directly only to finish a release that stopped after its tag
(see Versioning and releasing).
The package was submitted to Packagist once, by hand, before 7.1.0 — that claimed the ondewo/ vendor namespace
for the ONDEWO account. The update API answers 404 for a package that was never submitted, so this would
only have to be repeated for a new package name.
Credentials
| Name | What it is | Where it lives |
|---|---|---|
PACKAGIST_USERNAME |
the Packagist login name that owns the ondewo/ vendor namespace |
ondewo-devops-accounts → account_packagist.env |
PACKAGIST_API_TOKEN |
that account's API token, from packagist.org/profile → Show API token | ondewo-devops-accounts → account_packagist.env |
GITHUB_GH_TOKEN |
the token gh release create authenticates with; it needs push access to this repository and, as a classic token, the repo and read:org scopes gh auth login insists on |
ondewo-devops-accounts → account_github.env |
They live nowhere else — not in this repository and not in GitHub. All three default to an ENTER_…
placeholder in the Makefile and are only ever supplied at runtime: make ondewo_release reads them out of the
devops-accounts clone with anchored ^NAME= matches and deletes the clone after a successful release (a failed
one leaves it in the gitignored ondewo-devops-accounts/ directory until the next make clone_devops_accounts
replaces it). Every recipe that carries a credential is @-prefixed so it never reaches a build log, and
make TEST prints <set> / <unset> rather than the value.
What is verified without credentials
make packagist_dry_run is the credential-free half of make publish. It is part of make test, so it runs in
make test_via_docker and therefore in make release before anything is pushed.
make packagist_dry_run_via_docker runs it alone, in the utils image:
| Check | What it would catch |
|---|---|
composer validate |
a composer.json Packagist cannot parse |
composer_validate_strict |
any new strict-mode warning — the three deliberate ones (the version field and the two exact google/protobuf / grpc/grpc pins) are enumerated in the Makefile and allowed, everything else fails |
check_version_agreement |
ONDEWO_NLU_VERSION, composer.json's name and version, the RELEASE.md entry and — on a tag — the tag name drifting apart. A tag that disagrees with the version field makes Packagist publish the tree under a version nobody tagged |
check_packagist_payload |
the update request pointing at the wrong repository — the API identifies the package by its VCS url, not by its composer name |
See RELEASE.md for the release history and CONTRIBUTING.md for how to contribute.
License
Apache License 2.0 — see LICENSE.