Cloud Functions and PHP

April 25, 2021

GCP has recently added support for PHP 7.4 in Cloud Functions. This post will give you a quick introduction to how it works, and a few pasisng thoughts.

Prerequisites

If you haven't setup a GCP project before you'll need to go through the sign up process and configure a billing account.

Additionally you'll need to install the gcloud CLI: https://cloud.google.com/sdk/docs/install

Once the gcloud cli is installed you'll be able to authenticate with the following command:

gcloud auth login

Your first function

Let's start by creating a file named index.php with the following:

<?php
use Psr\Http\Message\ServerRequestInterface;

function helloHttp(ServerRequestInterface $request): string
{
    $queryString = $request->getQueryParams();
    $name = $queryString['name'] ?? $name;

    return sprintf('Hello, %s!', $name);
}

Hit save. Then open up a terminal in the same directory. To deploy this function we'll run the following:

gcloud functions deploy http-php-function \
  --runtime php74 \
  --trigger-http \
  --entry-point helloHttp

Feel free to replace http-php-function with a suitable name for your test function. It needs to be unique within your GCP project.

As this is a HTTP invoked function the first time you deploy a function it will ask you if you want to allow unauthenticated invocations of the functions. Say yes here for this example.

The command above will output various details, including the trigger url. This will look a little like this:

https://us-central1-[your-gcp-project-id].cloudfunctions.net/http-php-function

And there it is. Your first PHP function has been deployed on GCP!

If you want to view the logs after invoking the function you can do so with this command:

gcloud functions logs read http-php-function
Note: It might take a minute for logs to appear, as there is a slight delay.

Headers and response codes

By default, if your application code throws an exception then a 500 status code will be returned, otherwise it'll be treated as a 200.

To send different status codes, and headers we'll need our function to return an instance compatible with Psr\Http\Message\ResponseInterface. We can use Guzzle PSR7 response object for this.

composer require guzzlehttp/guzzle:^7.0

Then our updated function will look like this:

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use GuzzleHttp\Psr7\Response;

function helloHttp(ServerRequestInterface $request): ResponseInterface {
    $queryString = $request->getQueryParams();
    $name = $queryString['name'] ?? 'world';

    // Sending a 418 I'm a teapot response:
    return new Response(
      418,
      ['content-type' => 'application/json'],
      json_encode(["message" => sprintf('Hello, %s!', $name) ])
    );
}

Third party packages

When your function is deployed if it contains a composer.json file then the dependencies will be installed within Cloud Build. If you use a private package registry, then credentials will need to be provided to that context using build environment variables. https://cloud.google.com/functions/docs/env-var#using_build_environment_variables.

For this you'll want to use the COMPOSER_AUTH environment variable. https://getcomposer.org/doc/articles/authentication-for-private-packages.md#authentication-using-the-composer-auth-environment-variable

If the package is listed on packagist then you don't need to do anything else.

Next up I'll touch on local development using GCP's Functions Framework, other ways to invoke functions, and handling application logging.

Local development

The best way right now to get going locally is to run GCP's Functions Framework. Let's get started by installing the composer dependency into our existing project:

composer init -q --name ashsmith/cloudfunctions-php
composer require google/cloud-functions-framework

To run your function locally you can just run this:

export FUNCTION_TARGET=helloHttp
php -S localhost:8080 vendor/bin/router.php

Note that the FUNCTION_TARGET environment variable needs to be set, and this needs to match the function you wish to invoke.

You can find out more about the Functions Framework from GitHub: https://github.com/GoogleCloudPlatform/functions-framework-php

Application Logging

When you're running serverless stacks you do not have access to the runtime, so it's best to instrument your code with logging to give you plenty of visibility.

GCP has a logging tool called Cloud Logging. In PHP we can write to stderr and the logs will appear in Cloud Logging. You can also use the Cloud Logging SDK.

Writing logs to stderr

<?php

$log = fopen('php://stderr', 'wb');

// Simple log entry.
fwrite($log, "Hello world\n");

Using the Cloud Logging SDK

There are a few drawbacks to using the SDK, namely you need to instrument the invocation id of the function yourself and if you want support with Cloud Trace then you'll also need to include the trace id, as well as specifying the function name and region. Writing to stderr will include all of these attributes automatically. Without them, you'll need to write custom queries to retrieve them from Cloud Logging and they won't appear when viewing logs for your function (it'll need the resource type, function name, and region at the very least).

First up you'll need to install the SDK with composer like so:

composer require google/cloud-logging

Then here's a basic example with the bare minimum to get logs appearing in the right place:

use Google\Cloud\Logging\LoggingClient;
$logging = new LoggingClient();
$logger = $logging->psrLogger('app', [
  'resource' => [
    'type' => 'cloud_function',
    'labels' => [
      'function_name' => 'your-function-name',
      'region' => 'some-region',
    ],
  ],
]);

$logger->error("hello world");

You can grab the function name using the K_SERVICE environment variable, but the region you'd need to configure yourself as GCP Cloud Functions do not expose an environment variable for this. You could add a custom environment variable to grab that value (instead of hard coding, as you might want to deploy to multiple regions).

More info on the logging SDK can be found here: https://github.com/googleapis/google-cloud-php-logging

If you want to see a complete example which shows you how to include the function invocation ID, and Cloud Trace ID then here's a gist for you: https://gist.github.com/ashsmith/69541dc0e406d767e3fa3de978d66677

Invoke functions using different events

At the time of writing Cloud Functions PHP 7.4 supports HTTP and CloudEvents. What are CloudEvents? In esence they allow us to respond to events trigger within GCP. For example Pub/Sub, Cloud Storage or Firestore. CloudEvents use the cloudevents.io standard to describe events in a consistent manner.

Let's create a function that responds to messages from Pub/Sub:

First up, create your pubsub topic:

gcloud pubsub topics create ash-test

Then add to the following function to your existing index.php file:

use Google\CloudFunctions\CloudEvent;
function helloEvent(CloudEvent $event): void {
  $log = fopen('php://stderr', 'wb');

  $cloudEventData = $event->getData();
  $pubSubData = base64_decode($cloudEventData['message']['data']);

  $name = 'World';
  if ($pubSubData) {
      $name = htmlspecialchars($pubSubData);
  }

  $result = 'Hello, ' . $name . '!';
  fwrite($log, $result . PHP_EOL);
}

Now we can deploy the function with the following:

gcloud functions deploy ash-test-event \
  --runtime php74 \
  --trigger-topic ash-test \
  --entry-point helloEvent

Once thats deployed we can publish a message:

gcloud pubsub topics publish ash-test --message="ash"

And we can check the logs of our function:

gcloud functions logs read ash-test-event

My thoughts...

Having first-class support for PHP is fantastic. So far you have only been able to run PHP in serverless functions on AWS and Azure, so support coming to GCP is a good thing!

It seems odd to me that they have added PHP 7.4 support and not PHP 8.0 considering thats the latest release. But this has happened with other runtimes in the past. My guess is we'll have support before 7.4 is deprecated..

The logging leaves a lot to be desired right now too. Documentation suggests that structured logs are possible, but I've not been able to get this to work. I think this is due PHP adding additional text to the output:

[25-Apr-2021 17:03:09] WARNING: [pool app] child 19 said into stderr: "{"message":"Structured log with error severity","severity":"ERROR"}" 

The json string should have been parsed by Cloud Logging, but it still treated it as a plain string. Without being able to at least control the severity in this way makes logging and log-based metrics pretty much useless in a production application.

The alternative is to use to logging SDK I mentioned earlier, but this has drawbacks as I mentioned, and would require significant work to get it where you need it to be useful.

I recongise that PHP 7.4 support is in preview, so hopefully these problems will be resolved in the future.

If you want to use PHP and serverless infrastructure I recommend bref.sh, it's for AWS Lambda. It uses the serverless framework and a custom Lambda Layer to provide the PHP runtime.