Running Laravel on Google App Engine

October 9, 2018 (updated October 26, 2019)

This article addresses some of the common challenges you're likely to encounter when configuring and deploying a Laravel application on Google App Engine.

What is Google App Engine?

Google App Engine (GAE) is a fully managed platform for running web and mobile apps in the cloud. Your GAE app runs in Docker containers and benefits from features such as automatic scaling, load balancing, health-checking and monitoring. In short, GAE lets you offload many of your infrastructure concerns.

You don't need to be familiar with Docker in order to use GAE. Google Cloud will build the images for you and orchestrate the necessary infrastructure according to a configuration file you provide. And if you ever decide that the default setup is lacking you are free to customize the runtime by supplying your own Docker image.

To know whether App Engine is suitable for your project check out the official website.

You should also note that GAE apps run in one of two environments: "standard" and "flexible". This article assumes you've chosen the flexible environment which is, as the name implies, more flexible. It's also a more expensive option. Please refer to the official documentation to learn about pricing, capabilities and limitations.

Prerequisites

This article is not a replacement for the official App Engine documentation. If you plan on deploying a production app to Google App Engine you should familiarize yourself with the platform by reading the documentation.

Make sure you've done the following before you continue:

  1. Create a Google Cloud account.
  2. Create a Google App Engine project.
  3. Install the Cloud SDK on your local machine. This gives you a command-line interface (gcloud) to the Google Cloud Platform which is used to deploy and manage your application.
  4. Create a Laravel project locally.

Configuring Your Laravel App

Your GAE application is configured using a file named app.yaml. This file serves as the basis for every deployment and should be placed in your project's root directory. While there are many available configuration options which you should eventually explore, the app.yaml for a Laravel project can be as simple as this:

runtime: php
env: flex

runtime_config:
    document_root: public

# Skip ".env", which is for local development
skip_files:
    - .env

env_variables:
    APP_ENV: 'production'
    APP_KEY: 'abc123'
    APP_LOG: errorlog
    APP_LOG_LEVEL: debug
    # ... more environment variables specific to your project

The env_variables section should contain all your environment variables, i.e. the variables you usually put in the .env file. Remember to respect the YAML format when copying variables from .env. to app.yaml: The syntax is <key>: <val>.

GAE will make sure all your Composer dependencies are installed (by running composer install) before your application is uploaded to the cloud. However, there are certain custom commands we want to execute immediately after our dependencies are installed. We can make use of Composer's post-install-cmd event which is triggered after composer install by placing the following in our composer.json file:

"scripts": {
    // ... existing scripts are removed for brevity ...
    "post-install-cmd": [
        "chmod -R 755 bootstrap\/cache",
        "php artisan config:cache",
        "php artisan route:cache"
    ]
}

The first command makes our cache directory writable. The second creates a cache file for faster configuration loading, while the third creates a route cache for faster route registration. More information about these commands can be found in the Laravel documentation or in this article.

The commands will run each time a new release is deployed.

Connecting to Google Cloud SQL

Google Cloud SQL is a managed database service which supports MySQL, PostgreSQL and SQL Server. If you don't want to install, configure and maintain your own database server (or cluster), Cloud SQL is for you.

To set up a MySQL instance, see the official documentation. Remember to enable the Cloud SQL API as described in the article. Otherwise, connections will fail.

When the instance is up and running, add the database variables to your appl.yaml. You also need a new section (beta_settings) as shown below:

env_variables:
    # ... other env variables omitted for brevity ...

    DB_CONNECTION: mysql
    DB_SOCKET: '/cloudsql/<connectionName>'
    DB_PORT: 3306
    DB_DATABASE: '<databaseName>'
    DB_USERNAME: '<username>'
    DB_PASSWORD: '<password>'

# New section:
beta_settings:
    cloud_sql_instances: "<connectionName>"

<connectionName> should consist of three values separated by a colon: <projectId>:<region>:<instanceName>.

<projectId> is the ID of your Google Cloud project. <instanceName> is the name of your MySQL instance. Example: /cloudsql/funkyproject:europe-west1:mysql-instance1.

You can also retrieve the whole string by running the following command:

gcloud sql instances describe <instanceName>

Look for the connectionName value.

Deployment

Deploying your app is as easy as running the following command:

gcloud app deploy

Behind the scenes GAE will build a Docker image containing your code, install the dependencies (which will trigger the post-install-cmnd commands) and upload the image to the Google App Engine servers. When the container (your app) starts to run, all traffic is directed to this specific release.

Queues and Workers

Laravel offers a unified API across a variety of different queue backends that allows you to defer time consuming tasks such as sending emails, talking to external APIs and generating reports.

While there are specialized tools that are better suited for queueing jobs (e.g. Beanstalk or Redis), the simplest way to get started is by storing jobs in the database you're already using -- MySQL or PostgreSQL. Using the database driver saves us from having to spin up another virtual machine or launching another App Engine service.

The Laravel documentation will walk you through the process of creating, dispatching and deferring jobs. In the end we want to run our worker by executing the artisan queue:work command.

Luckily Google App Engines lets us provide a Supervisor configuration for our app. Supervisor is a process monitor that will make sure our artisan queue:work process never stops running.

Create the file additional-supervisord.conf:

[program:queue-worker]
process_name=%(program_name)s_%(process_num)02d
command = php %(ENV_APP_DIR)s/artisan queue:work --sleep=3 --tries=3
stdout_logfile = /dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile = /dev/stderr
stderr_logfile_maxbytes=0
user = www-data
autostart = true
autorestart = true
priority = 5
stopwaitsecs = 20

Then (re)deploy your app with gcloud app deploy and Google App Engine will make sure Supervisor monitors your worker.

(Note: Running multiple service instances will lead to multiple workers running simultaneously. If your app generates a lot of traffick you should consider using another, more specialized, queue driver.)

Logging and Exception Handling With Stackdriver

Google Stackdriver is a monitoring service for cloud-powered applications. You can use it to log and collect metrics from any part of your infrastructure including Google App Engine. By integrating your Laravel app with Stackdriver you'll be able to view your application logs in the Cloud Console and receive notifications (email and push!) when errors are detected.

In order to log messages to Google Stackdriver you need to install the log client for Google Cloud:

composer require google/cloud-logging google/cloud-error-reporting

Set enable_stackdriver_integration to true in the runtime_config section of your app.yaml file:

runtime: php
env: flex

# Insert this:
runtime_config:
  enable_stackdriver_integration: true

# ... environment variables ...

Laravel is able to write logs to different channels. While several channels are provided out of the box we need to create our own custom channel that sends log entries to Stackdriver. Configure a new channel by adding a new entry to the channels array in config/logging.php:

'channels' => [
    'stackdriver' => [
        'driver' => 'custom',
        'via' => App\Logging\CreateStackdriverLogger::class,
    ],
],

The name of our channel is "stackdriver" and the via option points to a factory class which will be invoked to create a Monolog instance. Monolog is the underlying logging library used by Laravel, and we need to instantiate it in a specific way in order to make it write to Stackdriver.

Create the custom Monolog factory in a new file namedCreateStackdriverLogger.php and place it in app/Logging:

<?php

namespace App\Logging;

use Monolog\Logger;
use Google\Cloud\Logging\LoggingClient;
use Monolog\Handler\PsrHandler;

class CreateStackdriverLogger
{
    public function __invoke(array $config)
    {
        $logger = LoggingClient::psrBatchLogger('app');
        $handler = new PsrHandler($logger);

        return new Logger('stackdriver', [$handler]);
    }
}

To make sure our new channel is used in production add the LOG_CHANNEL key to the env_variables section of app.yaml:

env_variables:
    LOG_CHANNEL: 'stackdriver'

Laravel will use this environment variable to determine which log channel to use. If you haven't modified it, config/logging.php should should have the following line by default:

'default' => env('LOG_CHANNEL', 'stack'),

That takes care of logging.

Exceptions should also be reported to Stackdriver. In app/Exceptions/Handler.php add a conditional to check whether the app is running on GAE. If that's the case we want the Google Cloud library handle exceptions:

// Import this at the top of the file
use Google\Cloud\ErrorReporting\Bootstrap;

// ... code omitted ...

    // Change the `report` function
    public function report(Exception $exception)
    {
        if (isset($_SERVER['GAE_SERVICE'])) {
            if ($this->shouldReport($exception)) {
                Bootstrap::exceptionHandler($exception);
            }
        } else {
            // Standard behavior
            parent::report($exception);
        }
    }

// ...

The standard Laravel exception handler lets you exclude certain exceptions from being reported by adding them to the $dontReport array. This is useful if you don't want to litter your logs with 404 errors and other uninteresting events.

As you can see in the code snippet above, we retain this behavior by checking shouldReport() before passing the exception to Google's exception handler.

Stackdriver provides many features and configuration options that can help you monitor your app's health. You should check out the Cloud Console to see if there's any particular feature or option you want to explore further.

Retrieving the Client's IP Address

GAE doesn't populate the variable $_SERVER['REMOTE_ADDR] with the IP address of the client that sent the request. This means that the convenient helper method $request->getClientIp() won't return anything. However, you can find the client's IP in the X-Forwarded-For header which App Engine populates with a comma-delimited list of proxy IP addresses through which the request has been routed. The documentation states:

The first IP in this list is generally the IP of the client that created the request. The subsequent IPs provide information about proxy servers that also handled the request before it reached the application server.

With this in mind we can create a middleware that populates REMOTE_ADDR with the client's IP address by extracting the first item in X-Forwarded-For:

<?php

namespace App\Http\Middleware;

use Closure;

class GaeProxyIp
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     */
    public function handle($request, Closure $next)
    {
        // Google App Engine includes the client's IP as the first item in
        // X-Forwarded-For, but nowhere else; REMOTE_ADDR is empty.

        if (isset($_SERVER['GAE_SERVICE'])) {
            $forwardedFor = array_map('trim', explode(',', $request->header('X-Forwarded-For')));
            $request->server->set('REMOTE_ADDR', $_SERVER['REMOTE_ADDR'] = $forwardedFor[0]);
        }

        return $next($request);
    }
}

Remember to add this middleware in the middleware array in app/Http/Kernel.php for it to run on every request.

You can now call $request->getClientIp() to retrieve your visitor's IP address.

Redirecting HTTP to HTTPS

App Engine can automatically renew SSL/TLS certificates for you. Certificate management is configured in the Cloud Console.

There's no dedicated configuration option in App Engine to redirect HTTP traffic to HTTPS. This must be done at the application level, either using custom NGINX configuration files or a middleware in your Laravel application.

Here's an example middleware:

<?php

namespace App\Http\Middleware;

use Closure;

class GaeRedirectToHttps
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     */
    public function handle($request, Closure $next)
    {
        if (isset($_SERVER['GAE_SERVICE']) && !$request->secure()) {
            return redirect()->secure($request->getRequestUri());
        }

        return $next($request);
    }
}

You should also consider HTTPS Strict Transport Security (HSTS). This is a web policy that helps to protect against downgrade attacks by telling clients that the website should only be accessed through secure HTTPS connections. Websites communicate this policy by sending a HSTS response header:

Strict-Transport-Security: max-age=31536000

The header instructs conforming clients that subsequent communication with the website should only happen over HTTPS. Also, the header should only be sent over secure connections. You should read up on HSTS if you're not familiar with the policy.

Here's a middleware that sets the relevant header on every (secure) response:

<?php

namespace App\Http\Middleware;

use Closure;

class GaeSetHstsHeader
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     */
    public function handle($request, Closure $next)
    {
        $response = $next($request);

        if (isset($_SERVER['GAE_SERVICE']) && $request->secure()) {
            $response->header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
        }

        return $response;
    }
}

Where to go from here

Depending on your app there are certain additional steps you may want to take:

  • Uploading files to Google Cloud Storage
  • Running scheduled jobs
  • Connecting to Cloud SQL locally