PHP Session Handling on Heroku
Last updated December 02, 2024
HTTP is a stateless protocol, but for most applications, it’s necessary to preserve certain information (such as login state or the contents of a shopping cart) across requests.
Sessions are the solution, and the information can either be stored in encrypted HTTP cookies on the client side, or in some sort of storage on the server side (with an HTTP cookie holding only the session ID so the server can identify the client).
Both approaches have their advantages and disadvantages, but for server side sessions, some configuration is necessary. This article shows how to reliably handle sessions in PHP applications on Heroku.
The need for sessions
Whenever an application runs on more than one server (or dyno, as is the case at Heroku), with dynos receiving traffic randomly from a load balancer, PHP’s session support needs to be configured to not use standard file-based sessions.
If sessions are stored on each dyno’s file system, the next request from a user (who in this example is assumed to have previously been logged in, so the request would contain a session cookie) could end up on a different dyno, where the session information does not exist.
A common solution in the past has been the concept of “sticky sessions” where the load balancer makes sure to always send users to the same backend server. This approach is problematic for various reasons as it negatively affects scalability and durability e.g. in the event of a backend server outage.
Luckily, horizontal scalability (i.e. scaling out by using additional servers instead of scaling vertically by using bigger servers) has always been at the heart of PHP, so numerous options for distributed sessions are available natively in PHP and in all of the popular frameworks.
Session storage options
Sessions are typically stored either in suitably distributed or scalable data stores such as Memcache or Redis, or relational databases like PostgreSQL or MySQL.
Popular PHP extensions such as memcached
or redis
provide native session handlers that are easy to use, and userland libraries like the predis Redis library frequently provide session handlers (and often examples) as well.
In the following example, we’ll be using a Heroku add-on to store sessions in Memcached.
Storing sessions in Memcached
Application setup
If you haven’t created a Git repository and Heroku application yet, do so first:
$ mkdir heroku-session-example
$ cd heroku-session-example
$ git init -b main
$ heroku create
Enabling the memcached
extension
First up, you will need to list the memcached
PHP extension as a requirement for your application so Heroku knows to activate it upon push.
The easiest way (if you have the extension installed locally for development and testing) is to run composer require ext-memcached:*
to declare a dependency on the extension.
Alternatively, you can add the dependency to your composer.json
by hand. Either way, your composer.json
should then look like the following (assuming you have no other dependencies in your project):
{
"require": {
"ext-memcached": "*"
}
}
Next, update composer.lock
and commit this change to your Git repository:
$ composer update
$ git add composer.json composer.lock
$ git commit -m "require memcached ext"
Provisioning a Memcache add-on
Next up, add a Memcached add-on from the Heroku add-ons marketplace. This example uses MemCachier; the steps are similar for Memcached Cloud (except of course for the different environment variable names):
$ heroku addons:create memcachier
Configuring PHP to use memcached for sessions
To configure PHP to use the memcached
session handler, you can simply define custom PHP settings with a .user.ini
file in the root of your application (or, if you’re using a custom document root, into that directory) with the following contents:
session.save_handler=memcached
session.save_path="${MEMCACHIER_SERVERS}"
memcached.sess_binary=1 # for ext-memcached 2 / PHP 5
memcached.sess_binary_protocol=1 # for ext-memcached 3 / PHP 7
memcached.sess_sasl_username="${MEMCACHIER_USERNAME}"
memcached.sess_sasl_password="${MEMCACHIER_PASSWORD}"
These settings configure the servers to use, switches on the binary protocol for the connection (so we can use authentication), and sets the SASL authentication details.
This approach is taking advantage of a php.ini
feature that allows referencing environment variables. All Heroku config vars are exposed to an application’s environment, and when you provisioned the Memcachier add-on, several config vars, including the three MEMCACHIER_...
variables we’re referencing above, were set automatically for you.
If you’re using the Memcached Cloud instead of the MemCachier add-on, use the corresponding MEMCACHEDCLOUD_...
environment variable names instead.
As SASL authentication incurs some overhead on connect, it is a good idea to make the session connection persistent, which you can do using a prefix for the session save path if you’re using PHP 5 (and thus ext-memcached
2.x):
session.save_path="PERSISTENT=myapp_session ${MEMCACHIER_SERVERS}"
For PHP 7 and ext-memcached
version 3, you instead enable the protocol through an explicit setting:
memcached.sess_persistent=On
Instead of a .user.ini
, you can use a couple of ini_set()
calls in your code instead:
ini_set('session.save_handler', 'memcached');
ini_set('session.save_path', getenv('MEMCACHIER_SERVERS'));
if(version_compare(phpversion('memcached'), '3', '>=')) {
ini_set('memcached.sess_persistent', 1);
ini_set('memcached.sess_binary_protocol', 1);
} else {
ini_set('session.save_path', 'PERSISTENT=myapp_session ' . ini_get('session.save_path'));
ini_set('memcached.sess_binary', 1);
}
ini_set('memcached.sess_sasl_username', getenv('MEMCACHIER_USERNAME'));
ini_set('memcached.sess_sasl_password', getenv('MEMCACHIER_PASSWORD'));
Once you’re ready, add and push your changes:
$ git add .user.ini # or the PHP file you added the ini_set() calls to
$ git commit -m "session settings for memcached"
Testing sessions
In your code, you can now easily test your sessions. For instance, in a quick index.php
for testing (assuming you’re using the .user.ini
approach above; if not, insert the ini_set()
calls`):
<?php
// any ini_set() for session configuration goes here when not using .user.ini
session_start();
if (!isset($_SESSION['count'])) {
$_SESSION['count'] = 0;
}
$_SESSION['count']++;
echo "Hello #" . $_SESSION['count'];
You are now ready to add, commit and push to Heroku:
$ git add index.php
$ git commit -m "session test";
$ git push heroku main
Run heroku open
or manually point your browser to your application to see a greeting like “Hello #1”, and observe how as you refresh the page, the count keeps increasing. When using a different browser or clearing your cache, the count resets back to 1.
The eco
and basic
dyno types only support a maximum of one dyno running per process type. To scale, you will need to use the professional dyno types.
You can now scale up your number of dynos to make sure that even as your requests hit different dynos, the session data is shared across them:
$ heroku ps:scale web=5
Wait a few seconds for the new dynos to boot, then refresh the page a few times and observe how the count does not drop back to 1. If you’re curious, run heroku logs
(or heroku logs --tail
; use Ctrl-C
to exit again) to see how your requests are handled by different dyno instances (web.1
through web.5
).
You probably don’t need five web dynos right now, so scale back to 1.
$ heroku ps:scale web=1