Add PHP 7.2.6 runtime (#28)


diff --git a/README.md b/README.md
index 5d66dcc..1833a8e 100644
--- a/README.md
+++ b/README.md
@@ -22,33 +22,54 @@
 [![Build Status](https://travis-ci.org/apache/incubator-openwhisk-runtime-php.svg?branch=master)](https://travis-ci.org/apache/incubator-openwhisk-runtime-php)
 
 
+## PHP versions
+
+This runtime provides PHP 7.2 and 7.1.
+
 ### Give it a try today
 To use as a docker action
+
+PHP 7.2:
 ```
-wsk action update myAction myAction.php --docker openwhisk/action-php-v7.1:1.0.0
+wsk action update myAction myAction.php --docker openwhisk/action-php-v7.2:latest
 ```
+
+PHP 7.1:
+```
+wsk action update myAction myAction.php --docker openwhisk/action-php-v7.1:latest
+```
+
 This works on any deployment of Apache OpenWhisk
 
 ### To use on deployment that contains the rutime as a kind
 To use as a kind action
+
+PHP 7.2:
+```
+wsk action update myAction myAction.php --kind php:7.2
+```
+
+PHP 7.1:
 ```
 wsk action update myAction myAction.php --kind php:7.1
 ```
 
 ### Local development
 ```
+./gradlew core:php7.2Action:distDocker
 ./gradlew core:php7.1Action:distDocker
 ```
-This will produce the image `whisk/action-php-v7.1`
+This will produce the images `whisk/action-php-v7.2` & `whisk/action-php-v7.1`
 
 Build and Push image
 ```
 docker login
+./gradlew core:php7.2Action:distDocker -PdockerImagePrefix=$prefix-user -PdockerRegistry=docker.io
 ./gradlew core:php7.1Action:distDocker -PdockerImagePrefix=$prefix-user -PdockerRegistry=docker.io
 ```
 
-Deploy OpenWhisk using ansible environment that contains the kind `php:7.1`
-Assuming you have OpenWhisk already deploy localy and `OPENWHISK_HOME` pointing to root directory of OpenWhisk core repository.
+Deploy OpenWhisk using ansible environment that contains the kinds `php:7.2` & `php:7.1`
+Assuming you have OpenWhisk already deploy locally and `OPENWHISK_HOME` pointing to root directory of OpenWhisk core repository.
 
 Set `ROOTDIR` to the root directory of this repository.
 
@@ -71,12 +92,16 @@
 
 To use as docker action push to your own dockerhub account
 ```
+docker tag whisk/php7.2Action $user_prefix/action-php-v7.2
+docker push $user_prefix/action-php-v7.2
+```
+```
 docker tag whisk/php7.1Action $user_prefix/action-php-v7.1
 docker push $user_prefix/action-php-v7.1
 ```
 Then create the action using your the image from dockerhub
 ```
-wsk action update myAction myAction.php --docker $user_prefix/action-php-v7.1
+wsk action update myAction myAction.php --docker $user_prefix/action-php-v7.2
 ```
 The `$user_prefix` is usually your dockerhub user id.
 
diff --git a/ansible/environments/local/group_vars/all b/ansible/environments/local/group_vars/all
index 1b3fe54..936316f 100755
--- a/ansible/environments/local/group_vars/all
+++ b/ansible/environments/local/group_vars/all
@@ -40,9 +40,15 @@
       deprecated: false
     php:
     - kind: "php:7.1"
-      default: true
+      default: false
       image:
         name: "action-php-v7.1"
       deprecated: false
+    php:
+    - kind: "php:7.2"
+      default: true
+      image:
+        name: "action-php-v7.2"
+      deprecated: false
   blackboxes:
     - name: "dockerskeleton"
diff --git a/core/php7.2Action/CHANGELOG.md b/core/php7.2Action/CHANGELOG.md
new file mode 100644
index 0000000..702b708
--- /dev/null
+++ b/core/php7.2Action/CHANGELOG.md
@@ -0,0 +1,38 @@
+<!--
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+-->
+
+## 1.0.0
+Initial release
+
+- Added: PHP: 7.2.6
+- Added: PHP extensions in addition to the standard ones:
+    - bcmath
+    - curl
+    - gd
+    - intl
+    - mbstring
+    - mysqli
+    - pdo_mysql
+    - pdo_pgsql
+    - pdo_sqlite
+    - soap
+    - zip
+- Added: Composer packages:
+    - [guzzlehttp/guzzle](https://packagist.org/packages/guzzlehttp/guzzle): 6.3.3
+    - [ramsey/uuid](https://packagist.org/packages/ramsey/uuid): 3.7.3
diff --git a/core/php7.2Action/Dockerfile b/core/php7.2Action/Dockerfile
new file mode 100644
index 0000000..fb08b24
--- /dev/null
+++ b/core/php7.2Action/Dockerfile
@@ -0,0 +1,63 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+FROM php:7.2.6-alpine
+
+RUN \
+    apk update && apk upgrade && \
+    # install dependencies
+   apk add \
+       postgresql-dev \
+       icu \
+       icu-libs \
+       icu-dev \
+       freetype-dev \
+       libjpeg-turbo-dev \
+       libpng-dev \
+       libxml2-dev \
+   && \
+   # install useful PHP extensions
+   docker-php-ext-install \
+       opcache \
+       mysqli \
+       pdo_mysql \
+       pdo_pgsql \
+       intl \
+       bcmath \
+       zip \
+       gd \
+       soap
+
+# install composer
+RUN curl -s -f -L -o /tmp/installer.php https://getcomposer.org/installer \
+    && php /tmp/installer.php --no-ansi --install-dir=/usr/bin --filename=composer \
+    && composer --ansi --version --no-interaction
+
+# create src directory to store action files
+RUN mkdir -p /action/src
+
+# install Composer dependencies
+COPY composer.json /action
+RUN cd /action && /usr/bin/composer install --no-plugins --no-scripts --prefer-dist --no-dev -o && rm composer.lock
+
+# copy required files
+COPY router.php /action
+COPY php.ini /usr/local/etc/php
+
+# Run webserver on port 8080
+CMD [ "php", "-S", "0.0.0.0:8080", "/action/router.php" ]
+
diff --git a/core/php7.2Action/build.gradle b/core/php7.2Action/build.gradle
new file mode 100644
index 0000000..c168aa2
--- /dev/null
+++ b/core/php7.2Action/build.gradle
@@ -0,0 +1,19 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+ext.dockerImageName = 'action-php-v7.2'
+apply from: '../../gradle/docker.gradle'
diff --git a/core/php7.2Action/composer.json b/core/php7.2Action/composer.json
new file mode 100644
index 0000000..5819627
--- /dev/null
+++ b/core/php7.2Action/composer.json
@@ -0,0 +1,11 @@
+{
+    "config": {
+        "platform": {
+            "php": "7.2"
+        }
+    },
+    "require": {
+        "guzzlehttp/guzzle": "6.3.3",
+        "ramsey/uuid": "3.7.3"
+    }
+}
diff --git a/core/php7.2Action/php.ini b/core/php7.2Action/php.ini
new file mode 100644
index 0000000..bee173d
--- /dev/null
+++ b/core/php7.2Action/php.ini
@@ -0,0 +1,37 @@
+; Licensed to the Apache Software Foundation (ASF) under one or more
+; contributor license agreements.  See the NOTICE file distributed with
+; this work for additional information regarding copyright ownership.
+; The ASF licenses this file to You under the Apache License, Version 2.0
+; (the "License"); you may not use this file except in compliance with
+; the License.  You may obtain a copy of the License at
+;
+;     http://www.apache.org/licenses/LICENSE-2.0
+;
+; Unless required by applicable law or agreed to in writing, software
+; distributed under the License is distributed on an "AS IS" BASIS,
+; WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+; See the License for the specific language governing permissions and
+; limitations under the License.
+
+[PHP]
+short_open_tag = Off
+output_buffering = Off
+expose_php = Off
+max_execution_time = 0
+memory_limit = -1
+error_reporting = E_ALL
+display_errors = Off
+log_errors = On
+log_errors_max_len = 0
+html_errors = Off
+variables_order = "EGPCS"
+request_order = "GP"
+post_max_size = 0
+enable_dl = Off
+zend.assertions = -1
+
+[opcache]
+opcache.enable=1
+opcache.enable_cli=1
+opcache.max_accelerated_files=7963
+opcache.validate_timestamps=0
diff --git a/core/php7.2Action/router.php b/core/php7.2Action/router.php
new file mode 100644
index 0000000..6a2c570
--- /dev/null
+++ b/core/php7.2Action/router.php
@@ -0,0 +1,342 @@
+<?php
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * router.php
+ *
+ * This file is the API client for the action. The controller POSTs /init to set up the action and
+ * then POSTs to /run to invoke it.
+ */
+
+namespace OpenWhiskPhpRuntime;
+
+use RuntimeException;
+use Throwable;
+use ZipArchive;
+
+// set up an output buffer to redirect any script output to stdout, rather than the default
+// php://output, so that it goes to the logs, not the HTTP client.
+ob_start(function ($data) {
+    file_put_contents("php://stdout", $data);
+    return '';
+}, 1, PHP_OUTPUT_HANDLER_CLEANABLE | PHP_OUTPUT_HANDLER_FLUSHABLE | PHP_OUTPUT_HANDLER_REMOVABLE);
+
+// Register a shutdown function so that we can fail gracefully when a fatal error occurs
+register_shutdown_function(function () {
+    $error = error_get_last();
+    if ($error && in_array($error["type"], [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR])) {
+        $result = ['error' => 'An error occurred running the action.'];
+        $body = json_encode((object)$result);
+        header('HTTP/1.0 502 Bad Gateway');
+        header('Content-Type: application/json');
+        header("Content-Length: " . mb_strlen($body));
+
+        // write out sentinels as we've finished all log output
+        file_put_contents("php://stdout", "XXX_THE_END_OF_A_WHISK_ACTIVATION_XXX\n");
+        file_put_contents("php://stderr", "XXX_THE_END_OF_A_WHISK_ACTIVATION_XXX\n");
+
+        ob_end_clean();
+        echo $body;
+        exit;
+    }
+});
+
+const ACTION_SRC_FILENAME = 'index.php';
+const SRC_DIR  = __DIR__ . '/src';
+const ACTION_CONFIG_FILE = __DIR__. '/config.json';
+const ACTION_SRC_FILE = SRC_DIR . '/' . ACTION_SRC_FILENAME;
+const TMP_ZIP_FILE = '/action.zip';
+
+// execute the revelant endpoint
+$result = route($_SERVER['REQUEST_URI']);
+sendResponse($result);
+exit;
+
+/**
+ * executes the relevant method for a given URL and return an array of data to send to the client
+ */
+function route(string $uri) : array
+{
+    try {
+        switch ($uri) {
+            case '/init':
+                return init();
+
+            case '/run':
+                $result = run();
+                writeSentinels();
+                return $result;
+
+            default:
+                throw new RuntimeException('Unexpected call to ' . $_SERVER["REQUEST_URI"], 500);
+        }
+    } catch (Throwable $e) {
+        $code = $e->getCode() < 400 ? 500 : $e->getCode();
+
+        if ($code != 502) {
+            writeTo("php://stdout", 'Error: ' . $e->getMessage());
+        }
+        writeSentinels();
+
+        http_response_code($code);
+        return ['error' => $e->getMessage()];
+    }
+}
+
+/**
+ * Send the response back
+ */
+function sendResponse(array $result) : void
+{
+    $body = json_encode((object)$result);
+    header('Content-Type: application/json');
+    header("Content-Length: " . mb_strlen($body));
+    ob_end_clean();
+    echo $body;
+}
+
+/**
+ * Handle the /init endpoint
+ *
+ * This end point is called once per container creation. It gives us the code we need
+ * to run and the name of the function within that code that's the entry point. As PHP
+ * has a setup/teardown model, we store the function name to a config file for retrieval
+ * in the /run end point.
+ *
+ * @return array Data to return to the client
+ */
+function init() : array
+{
+    // data is POSTed to us as a JSON string
+    $post = file_get_contents('php://input');
+    $data = json_decode($post, true)['value'] ?? [];
+
+    $name = $data['name'] ?? '';         // action name
+    $main = $data['main'] ?? 'main';     // function to call (default: main)
+    $code = trim($data['code'] ?? '');   // source code to run
+    $binary = $data['binary'] ?? false;  // code is binary?
+
+    if (!$code) {
+        throw new RuntimeException("No code to execute");
+    }
+
+    if ($binary) {
+        // binary code is a zip file that's been base64 encoded, so unzip it
+        unzipString($code, SRC_DIR);
+
+        // if the zip file didn't contain a vendor directory, move our vendor into the src folder
+        if (! file_exists(SRC_DIR . '/vendor/autoload.php')) {
+            exec('mv ' . escapeshellarg(__DIR__ . '/vendor') . ' ' . escapeshellarg(SRC_DIR . '/vendor'));
+        }
+
+        // check that we have the expected action source file
+        if (! file_exists(ACTION_SRC_FILE)) {
+            throw new RuntimeException('Zipped actions must contain ' . ACTION_SRC_FILENAME . ' at the root.', 500);
+        }
+    } else {
+        // non-binary code is a text string, so save to disk
+        file_put_contents(ACTION_SRC_FILE, $code);
+
+        // move vendor folder into the src folder
+        exec('mv ' . escapeshellarg(__DIR__ . '/vendor') . ' ' . escapeshellarg(SRC_DIR . '/vendor'));
+    }
+
+    // is action file valid PHP? run `php -l` to find out
+    list($returnCode, $stdout, $stderr) = runPHP(['-l', '-f', ACTION_SRC_FILE]);
+    if ($returnCode != 0) {
+        writeTo("php://stderr", $stderr);
+        writeTo("php://stdout", $stdout);
+
+        $message = 'PHP syntax error in ' . ($binary ? ACTION_SRC_FILENAME : 'action.');
+        throw new RuntimeException($message, 500);
+    }
+
+    // does the action have the expected function name?
+    $testCode = 'require "' . ACTION_SRC_FILE . '"; exit((int)(! function_exists("' . $main .'")));';
+    list($returnCode, $stdout, $stderr) = runPHP(['-r', $testCode]);
+    if ($returnCode != 0) {
+        writeTo("php://stderr", $stderr);
+        writeTo("php://stdout", $stdout);
+        throw new RuntimeException("The function $main is missing.");
+    }
+
+    // write config file for use by /run
+    $config = [
+        'file' => ACTION_SRC_FILE,
+        'function' => $main,
+        'name' => $name,
+    ];
+    file_put_contents(ACTION_CONFIG_FILE, '<?php return ' . var_export($config, true) . ';');
+
+    // reset OPcache
+    opcache_reset();
+
+    return ["OK" => true];
+}
+
+/**
+ * Handle the /run endpoint
+ *
+ * This end point is called once per action invocation. We load the function name from
+ * the config file and then invoke it. Note that as PHP writes to php://output, we
+ * capture in an output buffer and write the buffer to stdout for the OpenWhisk logs.
+ *
+ * @return array Data to return to the client
+ */
+function run() : array
+{
+    $config = require ACTION_CONFIG_FILE;
+    $_actionFile = $config['file'];
+    $_functionName = $config['function'];
+
+    // Extract the posted data
+    $post = json_decode(file_get_contents('php://input'), true);
+    if (!is_array($post)) {
+        $post = [];
+    }
+
+    // assign environment variables from the posted data
+    foreach (['api_key', 'namespace', 'action_name', 'activation_id', 'deadline'] as $param) {
+        if (array_key_exists($param, $post)) {
+            $_ENV['__OW_' . strtoupper($param)] = $post[$param];
+        }
+    }
+
+    // extract the function arguments from the posted data's "value" field
+    $args = [];
+    if (array_key_exists('value', $post) && is_array($post['value'])) {
+        $args = $post['value'];
+    }
+    $_ENV['WHISK_INPUT'] = json_encode($args);
+
+    // run the action
+    require __DIR__ . '/src/vendor/autoload.php';
+    require $_actionFile;
+    $result = $_functionName($args);
+    if (is_array($result)) {
+        return $result;
+    } elseif (is_scalar($result)) {
+        file_put_contents("php://stderr", 'Result must be an array but has type "'
+            . gettype($result) . '": ' . (string)$result . "\n");
+        file_put_contents("php://stdout", 'The action did not return a dictionary.');
+        throw new RuntimeException('The action did not return a dictionary.', 502);
+    } elseif (is_object($result)) {
+        if (method_exists($result, 'getArrayCopy')) {
+            return $result->getArrayCopy();
+        } elseif ($result instanceof \stdClass) {
+            return (array)$result;
+        }
+    }
+
+    return [];
+}
+
+/**
+ * Unzip a base64 encoded string to a directory
+ */
+function unzipString(string $b64Data, $dir): void
+{
+    file_put_contents(TMP_ZIP_FILE, base64_decode($b64Data));
+
+    $zip = new ZipArchive();
+    $res = $zip->open(TMP_ZIP_FILE);
+    if ($res !== true) {
+        $reasons = [
+            ZipArchive::ER_EXISTS => "File already exists.",
+            ZipArchive::ER_INCONS => "Zip archive inconsistent.",
+            ZipArchive::ER_INVAL => "Invalid argument.",
+            ZipArchive::ER_MEMORY => "Malloc failure.",
+            ZipArchive::ER_NOENT => "No such file.",
+            ZipArchive::ER_NOZIP => "Not a zip archive.",
+            ZipArchive::ER_OPEN => "Can't open file.",
+            ZipArchive::ER_READ => "Read error.",
+            ZipArchive::ER_SEEK => "Seek error.",
+        ];
+        $reason = $reasons[$res] ?? "Unknown error: $res.";
+        throw new RuntimeException("Failed to open zip file: $reason", 500);
+    }
+
+    $res = $zip->extractTo($dir . '/');
+    $zip->close();
+}
+
+/**
+ * Write the OpenWhisk sentinels to stdout and stderr so that it knows that we've finished
+ * writing data to them.
+ *
+ * @return void
+ */
+function writeSentinels() : void
+{
+    // write out sentinels as we've finished all log output
+    writeTo("php://stderr", "XXX_THE_END_OF_A_WHISK_ACTIVATION_XXX");
+    writeTo("php://stdout", "XXX_THE_END_OF_A_WHISK_ACTIVATION_XXX");
+}
+
+/**
+ * Run the PHP command in a separate process
+ *
+ * This ensures that if the action causes a fatal error, we can handle it.
+ *
+ * @param  array  $args  arguments to the PHP executable
+ * @param  string $stdin stdin to pass to the process
+ * @return array         array containing [int return code, string stdout string stderr]
+ */
+function runPHP(array $args, string $stdin = '') : array
+{
+    $cmd = '/usr/local/bin/php ' . implode(' ', array_map('escapeshellarg', $args));
+
+    $process = proc_open(
+        $cmd,
+        [
+            0 => ['pipe', 'r'],
+            1 => ['pipe', 'w'],
+            2 => ['pipe', 'w'],
+        ],
+        $pipes,
+        SRC_DIR
+    );
+
+    // write to the process' stdin
+    $bytes = fwrite($pipes[0], $stdin);
+    fclose($pipes[0]);
+
+    // read the process' stdout
+    $stdout = stream_get_contents($pipes[1]);
+    fclose($pipes[1]);
+
+    // read the process' stderr
+    $stderr = stream_get_contents($pipes[2]);
+    fclose($pipes[2]);
+
+    // close process & get return code
+    $returnCode = proc_close($process);
+
+    // tidy up paths in any PHP stack traces
+    $stderr = str_replace(__DIR__ . '/', '', trim($stderr));
+    $stdout = str_replace(__DIR__ . '/', '', trim($stdout));
+
+    return [$returnCode, $stdout, $stderr];
+}
+
+function writeTo($pipe, $text)
+{
+    if ($text) {
+        file_put_contents($pipe, $text . PHP_EOL);
+    }
+}
diff --git a/settings.gradle b/settings.gradle
index 3802e56..39f191d 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -18,6 +18,7 @@
 include 'tests'
 
 include 'core:php7.1Action'
+include 'core:php7.2Action'
 
 rootProject.name = 'runtime-php'
 
diff --git a/tests/src/test/scala/runtime/actionContainers/Php71ActionContainerTests.scala b/tests/src/test/scala/runtime/actionContainers/Php71ActionContainerTests.scala
index d215725..03f42f3 100644
--- a/tests/src/test/scala/runtime/actionContainers/Php71ActionContainerTests.scala
+++ b/tests/src/test/scala/runtime/actionContainers/Php71ActionContainerTests.scala
@@ -19,475 +19,9 @@
 
 import org.junit.runner.RunWith
 import org.scalatest.junit.JUnitRunner
-import common.WskActorSystem
-import actionContainers.{ActionContainer, BasicActionRunnerTests}
-import actionContainers.ActionContainer.withContainer
-import actionContainers.ResourceHelpers.ZipBuilder
-import spray.json._
 
 @RunWith(classOf[JUnitRunner])
-class Php71ActionContainerTests extends BasicActionRunnerTests with WskActorSystem {
-  // note: "out" will not be empty as the PHP web server outputs a message when
-  // it starts up
-  val enforceEmptyOutputStream = false
+class Php71ActionContainerTests extends Php7ActionContainerTests {
 
-  lazy val php71ContainerImageName = "action-php-v7.1"
-
-  override def withActionContainer(env: Map[String, String] = Map.empty)(code: ActionContainer => Unit) = {
-    withContainer(php71ContainerImageName, env)(code)
-  }
-
-  def withPhp71Container(code: ActionContainer => Unit) = withActionContainer()(code)
-
-  behavior of php71ContainerImageName
-
-  testEcho(Seq {
-    (
-      "PHP",
-      """
-          |<?php
-          |function main(array $args) : array {
-          |    echo 'hello stdout';
-          |    error_log('hello stderr');
-          |    return $args;
-          |}
-          """.stripMargin)
-  })
-
-  testNotReturningJson("""
-        |<?php
-        |function main(array $args) {
-        |    return "not a json object";
-        |}
-        """.stripMargin)
-
-  testUnicode(Seq {
-    (
-      "PHP",
-      """
-         |<?php
-         |function main(array $args) : array {
-         |    $str = $args['delimiter'] . " ☃ " . $args['delimiter'];
-         |    echo $str . "\n";
-         |    return  ["winter" => $str];
-         |}
-         """.stripMargin.trim)
-  })
-
-  testEnv(
-    Seq {
-      (
-        "PHP",
-        """
-         |<?php
-         |function main(array $args) : array {
-         |    return [
-         |       "env" => $_ENV,
-         |       "api_host" => $_ENV['__OW_API_HOST'],
-         |       "api_key" => $_ENV['__OW_API_KEY'],
-         |       "namespace" => $_ENV['__OW_NAMESPACE'],
-         |       "action_name" => $_ENV['__OW_ACTION_NAME'],
-         |       "activation_id" => $_ENV['__OW_ACTIVATION_ID'],
-         |       "deadline" => $_ENV['__OW_DEADLINE'],
-         |    ];
-         |}
-         """.stripMargin.trim)
-    },
-    enforceEmptyOutputStream)
-
-  it should "fail to initialize with bad code" in {
-    val (out, err) = withPhp71Container { c =>
-      val code = """
-                |<?php
-                | 10 PRINT "Hello world!"
-                | 20 GOTO 10
-            """.stripMargin
-
-      val (initCode, error) = c.init(initPayload(code))
-      initCode should not be (200)
-      error shouldBe a[Some[_]]
-      error.get shouldBe a[JsObject]
-      error.get.fields("error").toString should include("PHP syntax error")
-    }
-
-    // Somewhere, the logs should mention an error occurred.
-    checkStreams(out, err, {
-      case (o, e) =>
-        (o + e).toLowerCase should include("error")
-        (o + e).toLowerCase should include("syntax")
-    })
-  }
-
-  it should "fail to initialize with no code" in {
-    val (out, err) = withPhp71Container { c =>
-      val code = ""
-
-      val (initCode, error) = c.init(initPayload(code))
-
-      initCode should not be (200)
-      error shouldBe a[Some[_]]
-      error.get shouldBe a[JsObject]
-      error.get.fields("error").toString should include("No code to execute")
-    }
-  }
-
-  it should "return some error on action error" in {
-    val (out, err) = withPhp71Container { c =>
-      val code = """
-                |<?php
-                | function main(array $args) : array {
-                |     throw new Exception ("nooooo");
-                | }
-            """.stripMargin
-
-      val (initCode, _) = c.init(initPayload(code))
-      initCode should be(200)
-
-      val (runCode, runRes) = c.run(runPayload(JsObject()))
-      runCode should not be (200)
-
-      runRes shouldBe defined
-      runRes.get.fields.get("error") shouldBe defined
-    // runRes.get.fields("error").toString.toLowerCase should include("nooooo")
-    }
-
-    // Somewhere, the logs should be the error text
-    checkStreams(out, err, {
-      case (o, e) =>
-        (o + e).toLowerCase should include("nooooo")
-    })
-
-  }
-
-  it should "support application errors" in {
-    withPhp71Container { c =>
-      val code = """
-                |<?php
-                | function main(array $args) : array {
-                |     return [ "error" => "sorry" ];
-                | }
-            """.stripMargin;
-
-      val (initCode, error) = c.init(initPayload(code))
-      initCode should be(200)
-
-      val (runCode, runRes) = c.run(runPayload(JsObject()))
-      runCode should be(200) // action writer returning an error is OK
-
-      runRes shouldBe defined
-      runRes.get.fields.get("error") shouldBe defined
-      runRes.get.fields("error").toString.toLowerCase should include("sorry")
-    }
-  }
-
-  it should "fail gracefully when an action has a fatal error" in {
-    val (out, err) = withPhp71Container { c =>
-      val code = """
-                | <?php
-                | function main(array $args) : array {
-                |     eval("class Error {};");
-                |     return [ "hello" => "world" ];
-                | }
-            """.stripMargin;
-
-      val (initCode, _) = c.init(initPayload(code))
-      initCode should be(200)
-
-      val (runCode, runRes) = c.run(runPayload(JsObject()))
-      runCode should be(502)
-
-      runRes shouldBe defined
-      runRes.get.fields.get("error") shouldBe defined
-      runRes.get.fields("error").toString should include("An error occurred running the action.")
-    }
-
-    // Somewhere, the logs should be the error text
-    checkStreams(out, err, {
-      case (o, e) =>
-        (o + e).toLowerCase should include("fatal error")
-    })
-  }
-
-  it should "suport returning a stdClass" in {
-    val (out, err) = withPhp71Container { c =>
-      val code = """
-                | <?php
-                | function main($params) {
-                |     $obj = new stdClass();
-                |     $obj->hello = 'world';
-                |     return $obj;
-                | }
-            """.stripMargin
-
-      val (initCode, _) = c.init(initPayload(code))
-      initCode should be(200)
-
-      val (runCode, runRes) = c.run(runPayload(JsObject()))
-      runCode should be(200) // action writer returning an error is OK
-
-      runRes shouldBe defined
-      runRes.get.fields.get("hello") shouldBe defined
-      runRes.get.fields("hello").toString.toLowerCase should include("world")
-    }
-  }
-
-  it should "support returning an object with a getArrayCopy() method" in {
-    val (out, err) = withPhp71Container { c =>
-      val code = """
-                | <?php
-                | function main($params) {
-                |     $obj = new ArrayObject();
-                |     $obj['hello'] = 'world';
-                |     return $obj;
-                | }
-            """.stripMargin
-
-      val (initCode, _) = c.init(initPayload(code))
-      initCode should be(200)
-
-      val (runCode, runRes) = c.run(runPayload(JsObject()))
-      runCode should be(200) // action writer returning an error is OK
-
-      runRes shouldBe defined
-      runRes.get.fields.get("hello") shouldBe defined
-      runRes.get.fields.get("hello") shouldBe Some(JsString("world"))
-    }
-  }
-
-  it should "support the documentation examples (1)" in {
-    val (out, err) = withPhp71Container { c =>
-      val code = """
-                | <?php
-                | function main($params) {
-                |     if ($params['payload'] == 0) {
-                |         return;
-                |     } else if ($params['payload'] == 1) {
-                |         return ['payload' => 'Hello, World!'] ;        // indicates normal completion
-                |     } else if ($params['payload'] == 2) {
-                |         return ['error' => 'payload must be 0 or 1'];  // indicates abnormal completion
-                |     }
-                | }
-            """.stripMargin
-
-      c.init(initPayload(code))._1 should be(200)
-
-      val (c1, r1) = c.run(runPayload(JsObject("payload" -> JsNumber(0))))
-      val (c2, r2) = c.run(runPayload(JsObject("payload" -> JsNumber(1))))
-      val (c3, r3) = c.run(runPayload(JsObject("payload" -> JsNumber(2))))
-
-      c1 should be(200)
-      r1 should be(Some(JsObject()))
-
-      c2 should be(200)
-      r2 should be(Some(JsObject("payload" -> JsString("Hello, World!"))))
-
-      c3 should be(200) // application error, not container or system
-      r3.get.fields.get("error") shouldBe Some(JsString("payload must be 0 or 1"))
-    }
-  }
-
-  it should "have Guzzle and Uuid packages available" in {
-    // GIVEN that it should "error when requiring a non-existent package" (see test above for this)
-    val (out, err) = withPhp71Container { c =>
-      val code = """
-                | <?php
-                | use Ramsey\Uuid\Uuid;
-                | use GuzzleHttp\Client;
-                | function main(array $args) {
-                |     Uuid::uuid4();
-                |     new Client();
-                | }
-            """.stripMargin
-
-      val (initCode, _) = c.init(initPayload(code))
-
-      initCode should be(200)
-
-      // WHEN I run an action that calls a Guzzle & a Uuid method
-      val (runCode, out) = c.run(runPayload(JsObject()))
-
-      // THEN it should pass only when these packages are available
-      runCode should be(200)
-    }
-  }
-
-  it should "support large-ish actions" in {
-    val thought = " I took the one less traveled by, and that has made all the difference."
-    val assignment = "    $x = \"" + thought + "\";\n"
-
-    val code = """
-            | <?php
-            | function main(array $args) {
-            |     $x = "hello";
-            """.stripMargin + (assignment * 7000) + """
-            |     $x = "world";
-            |     return [ "message" => $x ];
-            | }
-            """.stripMargin
-
-    // Lest someone should make it too easy.
-    code.length should be >= 500000
-
-    val (out, err) = withPhp71Container { c =>
-      c.init(initPayload(code))._1 should be(200)
-
-      val (runCode, runRes) = c.run(runPayload(JsObject()))
-
-      runCode should be(200)
-      runRes.get.fields.get("message") shouldBe defined
-      runRes.get.fields.get("message") shouldBe Some(JsString("world"))
-    }
-  }
-
-  val exampleOutputDotPhp: String = """
-        | <?php
-        | function output($data) {
-        |     return ['result' => $data];
-        | }
-    """.stripMargin
-
-  it should "support zip-encoded packages" in {
-    val srcs = Seq(
-      Seq("output.php") -> exampleOutputDotPhp,
-      Seq("index.php") -> """
-                | <?php
-                | require __DIR__ . '/output.php';
-                | function main(array $args) {
-                |     $name = $args['name'] ?? 'stranger';
-                |     return output($name);
-                | }
-            """.stripMargin)
-
-    val code = ZipBuilder.mkBase64Zip(srcs)
-
-    val (out, err) = withPhp71Container { c =>
-      c.init(initPayload(code))._1 should be(200)
-
-      val (runCode, runRes) = c.run(runPayload(JsObject()))
-
-      runCode should be(200)
-      runRes.get.fields.get("result") shouldBe defined
-      runRes.get.fields.get("result") shouldBe Some(JsString("stranger"))
-    }
-  }
-
-  it should "support replacing vendor in zip-encoded packages " in {
-    val srcs = Seq(
-      Seq("vendor/autoload.php") -> exampleOutputDotPhp,
-      Seq("index.php") -> """
-                | <?php
-                | function main(array $args) {
-                |     $name = $args['name'] ?? 'stranger';
-                |     return output($name);
-                | }
-            """.stripMargin)
-
-    val code = ZipBuilder.mkBase64Zip(srcs)
-
-    val (out, err) = withPhp71Container { c =>
-      c.init(initPayload(code))._1 should be(200)
-
-      val (runCode, runRes) = c.run(runPayload(JsObject()))
-
-      runCode should be(200)
-      runRes.get.fields.get("result") shouldBe defined
-      runRes.get.fields.get("result") shouldBe Some(JsString("stranger"))
-    }
-  }
-
-  it should "fail gracefully on invalid zip files" in {
-    // Some text-file encoded to base64.
-    val code = "Q2VjaSBuJ2VzdCBwYXMgdW4gemlwLgo="
-
-    val (out, err) = withPhp71Container { c =>
-      val (initCode, error) = c.init(initPayload(code))
-      initCode should not be (200)
-      error shouldBe a[Some[_]]
-      error.get shouldBe a[JsObject]
-      error.get.fields("error").toString should include("Failed to open zip file")
-    }
-
-    // Somewhere, the logs should mention the failure
-    checkStreams(out, err, {
-      case (o, e) =>
-        (o + e).toLowerCase should include("error")
-        (o + e).toLowerCase should include("failed to open zip file")
-    })
-  }
-
-  it should "fail gracefully on valid zip files that are not actions" in {
-    val srcs = Seq(Seq("hello") -> """
-                | Hello world!
-            """.stripMargin)
-
-    val code = ZipBuilder.mkBase64Zip(srcs)
-
-    val (out, err) = withPhp71Container { c =>
-      c.init(initPayload(code))._1 should not be (200)
-    }
-
-    checkStreams(out, err, {
-      case (o, e) =>
-        (o + e).toLowerCase should include("error")
-        (o + e).toLowerCase should include("zipped actions must contain index.php at the root.")
-    })
-  }
-
-  it should "fail gracefully on valid zip files with invalid code in index.php" in {
-    val (out, err) = withPhp71Container { c =>
-      val srcs = Seq(Seq("index.php") -> """
-                    | <?php
-                    | 10 PRINT "Hello world!"
-                    | 20 GOTO 10
-                """.stripMargin)
-
-      val code = ZipBuilder.mkBase64Zip(srcs)
-
-      val (initCode, error) = c.init(initPayload(code))
-      initCode should not be (200)
-      error shouldBe a[Some[_]]
-      error.get shouldBe a[JsObject]
-      error.get.fields("error").toString should include("PHP syntax error in index.php")
-    }
-
-    // Somewhere, the logs should mention an error occurred.
-    checkStreams(out, err, {
-      case (o, e) =>
-        (o + e).toLowerCase should include("error")
-        (o + e).toLowerCase should include("syntax")
-    })
-  }
-
-  it should "support actions using non-default entry point" in {
-    val (out, err) = withPhp71Container { c =>
-      val code = """
-            | <?php
-            | function niam(array $args) {
-            |     return [result => "it works"];
-            | }
-            """.stripMargin
-
-      c.init(initPayload(code, main = "niam"))._1 should be(200)
-      val (runCode, runRes) = c.run(runPayload(JsObject()))
-      runRes.get.fields.get("result") shouldBe Some(JsString("it works"))
-    }
-  }
-
-  it should "support zipped actions using non-default entry point" in {
-    val srcs = Seq(Seq("index.php") -> """
-                | <?php
-                | function niam(array $args) {
-                |     return [result => "it works"];
-                | }
-            """.stripMargin)
-
-    val code = ZipBuilder.mkBase64Zip(srcs)
-
-    withPhp71Container { c =>
-      c.init(initPayload(code, main = "niam"))._1 should be(200)
-
-      val (runCode, runRes) = c.run(runPayload(JsObject()))
-      runRes.get.fields.get("result") shouldBe Some(JsString("it works"))
-    }
-  }
+  override lazy val phpContainerImageName = "action-php-v7.1"
 }
diff --git a/tests/src/test/scala/runtime/actionContainers/Php72ActionContainerTests.scala b/tests/src/test/scala/runtime/actionContainers/Php72ActionContainerTests.scala
new file mode 100644
index 0000000..7faed5e
--- /dev/null
+++ b/tests/src/test/scala/runtime/actionContainers/Php72ActionContainerTests.scala
@@ -0,0 +1,27 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package runtime.actionContainers
+
+import org.junit.runner.RunWith
+import org.scalatest.junit.JUnitRunner
+
+@RunWith(classOf[JUnitRunner])
+class Php72ActionContainerTests extends Php7ActionContainerTests {
+
+  override lazy val phpContainerImageName = "action-php-v7.2"
+}
diff --git a/tests/src/test/scala/runtime/actionContainers/Php7ActionContainerTests.scala b/tests/src/test/scala/runtime/actionContainers/Php7ActionContainerTests.scala
new file mode 100644
index 0000000..8ac62c7
--- /dev/null
+++ b/tests/src/test/scala/runtime/actionContainers/Php7ActionContainerTests.scala
@@ -0,0 +1,493 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package runtime.actionContainers
+
+import org.junit.runner.RunWith
+import org.scalatest.junit.JUnitRunner
+import common.WskActorSystem
+import actionContainers.{ActionContainer, BasicActionRunnerTests}
+import actionContainers.ActionContainer.withContainer
+import actionContainers.ResourceHelpers.ZipBuilder
+import spray.json._
+
+@RunWith(classOf[JUnitRunner])
+abstract class Php7ActionContainerTests extends BasicActionRunnerTests with WskActorSystem {
+  // note: "out" will not be empty as the PHP web server outputs a message when
+  // it starts up
+  val enforceEmptyOutputStream = false
+
+  lazy val phpContainerImageName = "action-php-v7.x"
+
+  override def withActionContainer(env: Map[String, String] = Map.empty)(code: ActionContainer => Unit) = {
+    withContainer(phpContainerImageName, env)(code)
+  }
+
+  def withPhp7Container(code: ActionContainer => Unit) = withActionContainer()(code)
+
+  behavior of phpContainerImageName
+
+  testEcho(Seq {
+    (
+      "PHP",
+      """
+          |<?php
+          |function main(array $args) : array {
+          |    echo 'hello stdout';
+          |    error_log('hello stderr');
+          |    return $args;
+          |}
+          """.stripMargin)
+  })
+
+  testNotReturningJson("""
+        |<?php
+        |function main(array $args) {
+        |    return "not a json object";
+        |}
+        """.stripMargin)
+
+  testUnicode(Seq {
+    (
+      "PHP",
+      """
+         |<?php
+         |function main(array $args) : array {
+         |    $str = $args['delimiter'] . " ☃ " . $args['delimiter'];
+         |    echo $str . "\n";
+         |    return  ["winter" => $str];
+         |}
+         """.stripMargin.trim)
+  })
+
+  testEnv(
+    Seq {
+      (
+        "PHP",
+        """
+         |<?php
+         |function main(array $args) : array {
+         |    return [
+         |       "env" => $_ENV,
+         |       "api_host" => $_ENV['__OW_API_HOST'],
+         |       "api_key" => $_ENV['__OW_API_KEY'],
+         |       "namespace" => $_ENV['__OW_NAMESPACE'],
+         |       "action_name" => $_ENV['__OW_ACTION_NAME'],
+         |       "activation_id" => $_ENV['__OW_ACTIVATION_ID'],
+         |       "deadline" => $_ENV['__OW_DEADLINE'],
+         |    ];
+         |}
+         """.stripMargin.trim)
+    },
+    enforceEmptyOutputStream)
+
+  it should "fail to initialize with bad code" in {
+    val (out, err) = withPhp7Container { c =>
+      val code = """
+                |<?php
+                | 10 PRINT "Hello world!"
+                | 20 GOTO 10
+            """.stripMargin
+
+      val (initCode, error) = c.init(initPayload(code))
+      initCode should not be (200)
+      error shouldBe a[Some[_]]
+      error.get shouldBe a[JsObject]
+      error.get.fields("error").toString should include("PHP syntax error")
+    }
+
+    // Somewhere, the logs should mention an error occurred.
+    checkStreams(out, err, {
+      case (o, e) =>
+        (o + e).toLowerCase should include("error")
+        (o + e).toLowerCase should include("syntax")
+    })
+  }
+
+  it should "fail to initialize with no code" in {
+    val (out, err) = withPhp7Container { c =>
+      val code = ""
+
+      val (initCode, error) = c.init(initPayload(code))
+
+      initCode should not be (200)
+      error shouldBe a[Some[_]]
+      error.get shouldBe a[JsObject]
+      error.get.fields("error").toString should include("No code to execute")
+    }
+  }
+
+  it should "return some error on action error" in {
+    val (out, err) = withPhp7Container { c =>
+      val code = """
+                |<?php
+                | function main(array $args) : array {
+                |     throw new Exception ("nooooo");
+                | }
+            """.stripMargin
+
+      val (initCode, _) = c.init(initPayload(code))
+      initCode should be(200)
+
+      val (runCode, runRes) = c.run(runPayload(JsObject()))
+      runCode should not be (200)
+
+      runRes shouldBe defined
+      runRes.get.fields.get("error") shouldBe defined
+    // runRes.get.fields("error").toString.toLowerCase should include("nooooo")
+    }
+
+    // Somewhere, the logs should be the error text
+    checkStreams(out, err, {
+      case (o, e) =>
+        (o + e).toLowerCase should include("nooooo")
+    })
+
+  }
+
+  it should "support application errors" in {
+    withPhp7Container { c =>
+      val code = """
+                |<?php
+                | function main(array $args) : array {
+                |     return [ "error" => "sorry" ];
+                | }
+            """.stripMargin;
+
+      val (initCode, error) = c.init(initPayload(code))
+      initCode should be(200)
+
+      val (runCode, runRes) = c.run(runPayload(JsObject()))
+      runCode should be(200) // action writer returning an error is OK
+
+      runRes shouldBe defined
+      runRes.get.fields.get("error") shouldBe defined
+      runRes.get.fields("error").toString.toLowerCase should include("sorry")
+    }
+  }
+
+  it should "fail gracefully when an action has a fatal error" in {
+    val (out, err) = withPhp7Container { c =>
+      val code = """
+                | <?php
+                | function main(array $args) : array {
+                |     eval("class Error {};");
+                |     return [ "hello" => "world" ];
+                | }
+            """.stripMargin;
+
+      val (initCode, _) = c.init(initPayload(code))
+      initCode should be(200)
+
+      val (runCode, runRes) = c.run(runPayload(JsObject()))
+      runCode should be(502)
+
+      runRes shouldBe defined
+      runRes.get.fields.get("error") shouldBe defined
+      runRes.get.fields("error").toString should include("An error occurred running the action.")
+    }
+
+    // Somewhere, the logs should be the error text
+    checkStreams(out, err, {
+      case (o, e) =>
+        (o + e).toLowerCase should include("fatal error")
+    })
+  }
+
+  it should "suport returning a stdClass" in {
+    val (out, err) = withPhp7Container { c =>
+      val code = """
+                | <?php
+                | function main($params) {
+                |     $obj = new stdClass();
+                |     $obj->hello = 'world';
+                |     return $obj;
+                | }
+            """.stripMargin
+
+      val (initCode, _) = c.init(initPayload(code))
+      initCode should be(200)
+
+      val (runCode, runRes) = c.run(runPayload(JsObject()))
+      runCode should be(200) // action writer returning an error is OK
+
+      runRes shouldBe defined
+      runRes.get.fields.get("hello") shouldBe defined
+      runRes.get.fields("hello").toString.toLowerCase should include("world")
+    }
+  }
+
+  it should "support returning an object with a getArrayCopy() method" in {
+    val (out, err) = withPhp7Container { c =>
+      val code = """
+                | <?php
+                | function main($params) {
+                |     $obj = new ArrayObject();
+                |     $obj['hello'] = 'world';
+                |     return $obj;
+                | }
+            """.stripMargin
+
+      val (initCode, _) = c.init(initPayload(code))
+      initCode should be(200)
+
+      val (runCode, runRes) = c.run(runPayload(JsObject()))
+      runCode should be(200) // action writer returning an error is OK
+
+      runRes shouldBe defined
+      runRes.get.fields.get("hello") shouldBe defined
+      runRes.get.fields.get("hello") shouldBe Some(JsString("world"))
+    }
+  }
+
+  it should "support the documentation examples (1)" in {
+    val (out, err) = withPhp7Container { c =>
+      val code = """
+                | <?php
+                | function main($params) {
+                |     if ($params['payload'] == 0) {
+                |         return;
+                |     } else if ($params['payload'] == 1) {
+                |         return ['payload' => 'Hello, World!'] ;        // indicates normal completion
+                |     } else if ($params['payload'] == 2) {
+                |         return ['error' => 'payload must be 0 or 1'];  // indicates abnormal completion
+                |     }
+                | }
+            """.stripMargin
+
+      c.init(initPayload(code))._1 should be(200)
+
+      val (c1, r1) = c.run(runPayload(JsObject("payload" -> JsNumber(0))))
+      val (c2, r2) = c.run(runPayload(JsObject("payload" -> JsNumber(1))))
+      val (c3, r3) = c.run(runPayload(JsObject("payload" -> JsNumber(2))))
+
+      c1 should be(200)
+      r1 should be(Some(JsObject()))
+
+      c2 should be(200)
+      r2 should be(Some(JsObject("payload" -> JsString("Hello, World!"))))
+
+      c3 should be(200) // application error, not container or system
+      r3.get.fields.get("error") shouldBe Some(JsString("payload must be 0 or 1"))
+    }
+  }
+
+  it should "have Guzzle and Uuid packages available" in {
+    // GIVEN that it should "error when requiring a non-existent package" (see test above for this)
+    val (out, err) = withPhp7Container { c =>
+      val code = """
+                | <?php
+                | use Ramsey\Uuid\Uuid;
+                | use GuzzleHttp\Client;
+                | function main(array $args) {
+                |     Uuid::uuid4();
+                |     new Client();
+                | }
+            """.stripMargin
+
+      val (initCode, _) = c.init(initPayload(code))
+
+      initCode should be(200)
+
+      // WHEN I run an action that calls a Guzzle & a Uuid method
+      val (runCode, out) = c.run(runPayload(JsObject()))
+
+      // THEN it should pass only when these packages are available
+      runCode should be(200)
+    }
+  }
+
+  it should "support large-ish actions" in {
+    val thought = " I took the one less traveled by, and that has made all the difference."
+    val assignment = "    $x = \"" + thought + "\";\n"
+
+    val code = """
+            | <?php
+            | function main(array $args) {
+            |     $x = "hello";
+            """.stripMargin + (assignment * 7000) + """
+            |     $x = "world";
+            |     return [ "message" => $x ];
+            | }
+            """.stripMargin
+
+    // Lest someone should make it too easy.
+    code.length should be >= 500000
+
+    val (out, err) = withPhp7Container { c =>
+      c.init(initPayload(code))._1 should be(200)
+
+      val (runCode, runRes) = c.run(runPayload(JsObject()))
+
+      runCode should be(200)
+      runRes.get.fields.get("message") shouldBe defined
+      runRes.get.fields.get("message") shouldBe Some(JsString("world"))
+    }
+  }
+
+  val exampleOutputDotPhp: String = """
+        | <?php
+        | function output($data) {
+        |     return ['result' => $data];
+        | }
+    """.stripMargin
+
+  it should "support zip-encoded packages" in {
+    val srcs = Seq(
+      Seq("output.php") -> exampleOutputDotPhp,
+      Seq("index.php") -> """
+                | <?php
+                | require __DIR__ . '/output.php';
+                | function main(array $args) {
+                |     $name = $args['name'] ?? 'stranger';
+                |     return output($name);
+                | }
+            """.stripMargin)
+
+    val code = ZipBuilder.mkBase64Zip(srcs)
+
+    val (out, err) = withPhp7Container { c =>
+      c.init(initPayload(code))._1 should be(200)
+
+      val (runCode, runRes) = c.run(runPayload(JsObject()))
+
+      runCode should be(200)
+      runRes.get.fields.get("result") shouldBe defined
+      runRes.get.fields.get("result") shouldBe Some(JsString("stranger"))
+    }
+  }
+
+  it should "support replacing vendor in zip-encoded packages " in {
+    val srcs = Seq(
+      Seq("vendor/autoload.php") -> exampleOutputDotPhp,
+      Seq("index.php") -> """
+                | <?php
+                | function main(array $args) {
+                |     $name = $args['name'] ?? 'stranger';
+                |     return output($name);
+                | }
+            """.stripMargin)
+
+    val code = ZipBuilder.mkBase64Zip(srcs)
+
+    val (out, err) = withPhp7Container { c =>
+      c.init(initPayload(code))._1 should be(200)
+
+      val (runCode, runRes) = c.run(runPayload(JsObject()))
+
+      runCode should be(200)
+      runRes.get.fields.get("result") shouldBe defined
+      runRes.get.fields.get("result") shouldBe Some(JsString("stranger"))
+    }
+  }
+
+  it should "fail gracefully on invalid zip files" in {
+    // Some text-file encoded to base64.
+    val code = "Q2VjaSBuJ2VzdCBwYXMgdW4gemlwLgo="
+
+    val (out, err) = withPhp7Container { c =>
+      val (initCode, error) = c.init(initPayload(code))
+      initCode should not be (200)
+      error shouldBe a[Some[_]]
+      error.get shouldBe a[JsObject]
+      error.get.fields("error").toString should include("Failed to open zip file")
+    }
+
+    // Somewhere, the logs should mention the failure
+    checkStreams(out, err, {
+      case (o, e) =>
+        (o + e).toLowerCase should include("error")
+        (o + e).toLowerCase should include("failed to open zip file")
+    })
+  }
+
+  it should "fail gracefully on valid zip files that are not actions" in {
+    val srcs = Seq(Seq("hello") -> """
+                | Hello world!
+            """.stripMargin)
+
+    val code = ZipBuilder.mkBase64Zip(srcs)
+
+    val (out, err) = withPhp7Container { c =>
+      c.init(initPayload(code))._1 should not be (200)
+    }
+
+    checkStreams(out, err, {
+      case (o, e) =>
+        (o + e).toLowerCase should include("error")
+        (o + e).toLowerCase should include("zipped actions must contain index.php at the root.")
+    })
+  }
+
+  it should "fail gracefully on valid zip files with invalid code in index.php" in {
+    val (out, err) = withPhp7Container { c =>
+      val srcs = Seq(Seq("index.php") -> """
+                    | <?php
+                    | 10 PRINT "Hello world!"
+                    | 20 GOTO 10
+                """.stripMargin)
+
+      val code = ZipBuilder.mkBase64Zip(srcs)
+
+      val (initCode, error) = c.init(initPayload(code))
+      initCode should not be (200)
+      error shouldBe a[Some[_]]
+      error.get shouldBe a[JsObject]
+      error.get.fields("error").toString should include("PHP syntax error in index.php")
+    }
+
+    // Somewhere, the logs should mention an error occurred.
+    checkStreams(out, err, {
+      case (o, e) =>
+        (o + e).toLowerCase should include("error")
+        (o + e).toLowerCase should include("syntax")
+    })
+  }
+
+  it should "support actions using non-default entry point" in {
+    val (out, err) = withPhp7Container { c =>
+      val code = """
+            | <?php
+            | function niam(array $args) {
+            |     return [result => "it works"];
+            | }
+            """.stripMargin
+
+      c.init(initPayload(code, main = "niam"))._1 should be(200)
+      val (runCode, runRes) = c.run(runPayload(JsObject()))
+      runRes.get.fields.get("result") shouldBe Some(JsString("it works"))
+    }
+  }
+
+  it should "support zipped actions using non-default entry point" in {
+    val srcs = Seq(Seq("index.php") -> """
+                | <?php
+                | function niam(array $args) {
+                |     return [result => "it works"];
+                | }
+            """.stripMargin)
+
+    val code = ZipBuilder.mkBase64Zip(srcs)
+
+    withPhp7Container { c =>
+      c.init(initPayload(code, main = "niam"))._1 should be(200)
+
+      val (runCode, runRes) = c.run(runPayload(JsObject()))
+      runRes.get.fields.get("result") shouldBe Some(JsString("it works"))
+    }
+  }
+}
diff --git a/tools/travis/test.sh b/tools/travis/test.sh
index 28da4fb..05d080e 100755
--- a/tools/travis/test.sh
+++ b/tools/travis/test.sh
@@ -28,7 +28,7 @@
 
 cd ${ROOTDIR}
 TERM=dumb ./gradlew :tests:checkScalafmtAll
-TERM=dumb ./gradlew :tests:test --tests *Php71*Tests
+TERM=dumb ./gradlew :tests:test