christianjbrown / cloud-run-function-lib
A strongly-typed PHP 8.5+ framework for building Google Cloud Run functions HTTP endpoints that return a consistent JSON envelope with built-in header auth, CORS, and CDN cache-control headers.
Package info
github.com/christianjbrown/cloud-run-function-lib-php
pkg:composer/christianjbrown/cloud-run-function-lib
Requires
- php: ^8.5
- christianjbrown/user-friendly-exception: ^1.0
- guzzlehttp/guzzle: ^7.15
- psr/clock: ^1.0
- symfony/clock: ^8.0
Requires (Dev)
- christianjbrown/code-quality-scripts: ^1.0
- opis/json-schema: ^2.4
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^13.0
- zircote/swagger-php: ^6.4
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-01 13:35:40 UTC
README
A strongly-typed PHP framework for building Google Cloud Run function HTTP endpoints that return a consistent JSON envelope. You write the business logic; the library handles header-based authorization, CORS, CDN cache-control headers, and uniform success/error responses.
It is built around PSR-7: you hand it a ServerRequestInterface and it returns a ResponseInterface. Configuration is read straight from your Cloud Run environment variables.
- Uniform envelope — every response carries
success,timestamp_unix, andtimestamp_iso8601, plusdata,version(the Cloud Run revision), orerroras appropriate. - Header authorization — optionally require a header key/value before running your handler.
- CORS + caching —
Access-Control-*,Vary,Cache-Control, andSurrogate-Controlheaders derived from config. - Safe error handling — user-friendly exceptions surface their message; anything else returns a generic error unless
DEBUGis on, and is logged to stderr either way.
✔️ Prerequisites
💡 If you're on MacOS and have Homebrew, PHP and Composer will install with brew install composer.
🏗️ Installation
For your composer-enabled project:
composer require christianjbrown/cloud-run-function-lib
💻 Usage
Implement DataProviderInterface with your endpoint's logic — it receives the PSR-7 request and returns an array that becomes the response data:
use ChristianBrown\CloudRunFunction\DataProviderInterface; use Psr\Http\Message\ServerRequestInterface; final class MyDataProvider implements DataProviderInterface { /** * @return mixed[] */ public function getData(ServerRequestInterface $request): array { // Your business logic. Throw a UserFriendlyExceptionInterface to return // a specific message to the client; any other Throwable is hidden unless DEBUG is on. return ['hello' => 'world']; } }
Build the function with CloudRunFunctionFactory, which wires every default, and run the request:
use ChristianBrown\CloudRunFunction\CloudRunFunctionFactory; $cloudFunction = (new CloudRunFunctionFactory())->createFromEnvironment(new MyDataProvider(), $_ENV); $response = $cloudFunction->run($request); // ChristianBrown\CloudRunFunction\ResponseInterface (PSR-7)
If you need the config yourself (for example to wrap it in your own config object), build it with
the factory's transformer and pass it to create():
$factory = new CloudRunFunctionFactory(); $config = $factory->createConfigTransformer()->transform($_ENV); // FunctionConfigInterface $cloudFunction = $factory->create(new MyDataProvider(), $config);
Every collaborator (RequestAuthorizerInterface, JsonResponseFactoryInterface, the config appliers)
is an interface injected through a constructor, so any of them can be replaced by building
CloudRunFunction yourself. A new environment variable is a new FunctionConfigApplierInterface
class registered in the transformer's list.
$response is a PSR-7 response ready to emit (e.g. with guzzlehttp/psr7's HTTP factories or your Cloud Run function's runtime).
Environment variables
FunctionConfigTransformer::transform() (via the factory) reads these keys (only K_REVISION is required — Cloud Run sets it automatically):
| Variable | Purpose |
|---|---|
K_REVISION |
Required. The revision id, surfaced as version in the response. |
DEBUG |
"true" to return raw exception messages instead of a generic error. |
REQUIRED_HEADER_KEY / REQUIRED_HEADER_VALUE |
Require this header on the request, else 401. |
REQUIRED_ORIGIN |
Value for Access-Control-Allow-Origin (enables the Vary header). |
USE_CACHE_TTL |
s-maxage / max-age seconds for successful responses. |
USE_BROWSER_CACHE_TTL |
max-age seconds for browsers only, when that should differ from the CDN's. See below. |
USE_CACHE_BUT_REQUEST_TTL |
stale-while-revalidate seconds. |
USE_CACHE_IF_ERROR_TTL |
stale-if-error seconds. |
Letting a purge reach visitors
By default the browser and the CDN are given the same TTL, so USE_CACHE_TTL=3600
produces:
Cache-Control: s-maxage=3600, max-age=3600, stale-while-revalidate=..., stale-if-error=...
Surrogate-Control: max-age=3600, stale-while-revalidate=..., stale-if-error=...
That max-age is what makes a surrogate-key purge look like it did nothing: the
CDN drops its copy, but a visitor who loaded the page in the last hour keeps
theirs. Set USE_BROWSER_CACHE_TTL to split the two. With 0:
Cache-Control: s-maxage=3600, max-age=0, must-revalidate
Surrogate-Control: max-age=3600, stale-while-revalidate=..., stale-if-error=...
The browser now revalidates on every page load, which the CDN answers from its
own cache, so a purge is visible immediately. Fastly reads Surrogate-Control
in preference to Cache-Control and strips it before the response reaches the
client, so the CDN's own TTL and its stale-while-revalidate / stale-if-error
resilience are untouched.
Note what is not in that Cache-Control: the stale directives carry no s-
prefix, so leaving them there would let a browser serve a body days old of its
own accord and undo the point of revalidating. They are emitted on
Surrogate-Control only whenever USE_BROWSER_CACHE_TTL is set.
Leave the variable unset and the headers are exactly as they were before it existed.
Response shape
A successful response:
{
"data": { "hello": "world" },
"success": true,
"timestamp_iso8601": "2026-07-15T12:00:00+00:00",
"timestamp_unix": 1784030400,
"version": "my-service-00001-abc"
}
An error response omits data and adds error:
{
"error": "Not authorized",
"success": false,
"timestamp_iso8601": "2026-07-15T12:00:00+00:00",
"timestamp_unix": 1784030400,
"version": "my-service-00001-abc"
}
🚨 Error handling
Inside your DataProviderInterface::getData(), throwing an exception that implements christianjbrown/user-friendly-exception's UserFriendlyExceptionInterface returns its message to the client (HTTP 500). Any other Throwable returns a generic "An unhandled error occurred" message — unless DEBUG is enabled, in which case the raw message is returned to aid debugging. In both cases the Throwable is written to stderr with error_log(), so the cause is kept in Cloud Logging against the failing request rather than discarded with the response. A failed authorization check short-circuits with "Not authorized" (HTTP 401) before your handler runs.
⬆️ Upgrading to 2.0
CloudRunFunction no longer builds its own collaborators, FunctionConfig is immutable, and the
response classes are gone. Everything is built by CloudRunFunctionFactory.
Building the function:
// 1.x $config = (new FunctionConfigTransformer())->transform($_ENV); $cloudFunction = new CloudRunFunction($dataProvider, $config); // 2.0 $cloudFunction = (new CloudRunFunctionFactory())->createFromEnvironment($dataProvider, $_ENV); // 2.0, when you hold the config yourself $factory = new CloudRunFunctionFactory(); $config = $factory->createConfigTransformer()->transform($_ENV); $cloudFunction = $factory->create($dataProvider, $config);
Building a config by hand (tests, for example):
// 1.x $config = (new FunctionConfig('rev'))->setDebug(true)->setUseCacheTtl(60); // 2.0: with-ers return a new instance, assign the result $config = (new FunctionConfig('rev'))->withDebug(true)->withUseCacheTtl(60);
Responses:
// 1.x new JsonSuccessResponse($config, $data, 200, $origin); new JsonErrorResponse($config, 'message', 500, $origin); // 2.0 $responses = new JsonResponseFactory(new ResponseBodyBuilder(), new CorsHeaderBuilder(new AllowOriginResolver()), new CacheHeaderBuilder(), new NativeClock()); $responses->success($config, $data, 200, $origin); $responses->error($config, 'message', 500, $origin);
new FunctionConfigTransformer() with no arguments no longer works: it needs its list of appliers,
which CloudRunFunctionFactory::createConfigTransformer() supplies.
📝 Changelog
Notable changes in each release are listed in CHANGELOG.md.
📄 License
Released under the MIT License.