Guides
Scaling Laravel with Memcached: A Caching Guide
1. Intro
A simple CRUD application scales well right up until it doesn't. Even for basic applications, each page view will likely mean a full round trip to the database, even for data that barely changes between requests. Additionally, if a page view even needs to update some data, that otherwise read-only path may face lock contentions, serializing requests that would otherwise run independently. Neither cost is dramatic on its own, but both scale directly with traffic, making the database itself a bottleneck and potentially increasing cloud costs due to higher resource usage.
To illustrate this, we'll use a simple artist catalog web application with data from MusicBrainz.org. For this example, we'll keep the model deliberately simple, consisting of just:
-
An
Artistmodel, holding an artist's own details (name,type(e.g. "Person" or "Group"),gender,begin_date/end_date,area, and a free-textgenresfield), as well as aview_countcolumn we'll come back to later. -
An
ArtistUrlmodel, holding atypestring (for example "youtube" to denote that a URL points to that artist's YouTube channel), the actualurland a relation pointing back to the artist the URL is associated with.
This catalog application will have just two kinds of pages:
- The homepage, showing an ordered list of artists (either the result of a search, or a default list of artists if no search term was provided); and
- Artist pages, showing details for a single artist such as its associated area or links to other websites and social networks.
To make listings useful, it's worth showing some of those additional details for each artist too, such as their country or their birth/foundation year, or their genres. And, following from that, a user browsing a listing may expect to first see artists whose type is known (i.e. "Person", "Group", etc.) and/or whose area is set, which requires additional computation in the database for sorting records (something we'll come back to shortly).
Regarding artist pages themselves, they show the same information
already present in listings (artist name, type and area, genres and
years active), as well as a list of links from the
ArtistUrl relation. Additionally, we keep a
counter of the number of times an artist has been accessed,
to show as a rough measure of its popularity.
In this guide, we'll see how to add caching to that application in three steps: first, by showing how to set up a connection to Memcached from Laravel, then showing how to cache the data required for both single artists' pages and listings and, finally, by leveraging Memcached for logging each page view, preventing each single page load from issuing a separate database update, which may increase costs and be a performance bottleneck.
2. The problem, concretely
The initial version of our application would consist on a single
controller with two methods (index and
show), where the first one (shown below) either
triggers a database lookup by term (via Artist::search)
or lists the artists in the database (via
Artist::defaultOrder).
// app/Http/Controllers/ArtistController.php
public function index(Request $request)
{
$q = $request->query('q');
$artists = $q
? Artist::search($q)->paginate(5)->withQueryString()
: Artist::defaultOrder()->paginate(5);
return view('artists.index', [
'artists' => $artists,
'q' => $q,
]);
}
Previously, we briefly discussed how to sort artists in listings, and decided that those with a known type or area should come first. However, that should be combined with the most-common sorting criteria (sorting by name). But that comes with a catch.
When sorting database results, a naive ORDER BY would
follow the standard ASCII ordering, making the list first show
artists with names starting with symbols (such as ^_^ or ¯\_(ツ)_/¯), or obscure
artists whose names start with a number (such as 800 Cherries). In order to avoid that, we need an additional
ordering clause for pulling names starting with letters from A to Z
first. We can do so by adding a scopeDefaultOrder
method to the model:
// app/Models/Artist.php
public function scopeDefaultOrder(Builder $query): Builder
{
return $query
->orderByRaw('(type IS NOT NULL AND
begin_date IS NOT NULL AND
area IS NOT NULL) DESC')
->orderByRaw("name REGEXP '^[A-Za-z]' DESC")
->orderBy('name');
}
Note how this requires three ordering clauses, where one of them consists of three nested clauses by itself. As we keep adding conditions, you can see how queries can quickly grow in complexity, which in turn makes it harder for the database to generate result listings fast enough (especially if the database holds hundreds of thousands or even millions of rows).
Similarly, when ordering search results themselves (that is, when there's a user-provided term), we may use a similar technique: show first results whose name exactly match search terms, then apply the same additional ordering clauses from the default ordering as tiebreakers:
// app/Models/Artist.php
public function scopeSearch(Builder $query, string $term): Builder
{
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $term);
return $query
->where('name', 'like', '%' . $escaped . '%')
->orderByRaw('name = ? DESC', [$term])
->orderByRaw('(type IS NOT NULL
AND begin_date IS NOT NULL
AND area IS NOT NULL) DESC')
->orderByRaw("name REGEXP '^[A-Za-z]' DESC")
->orderBy('name');
}
Regarding individual artists' pages, the catalog application will
just show its basic information (directly taken from the
Artist model) as well as any URL related to it (from
the ArtistUrl model). However, we also want to track
how many times each artist page has been accessed in order to show
it as a rough measure of its popularity. To do so, when serving an
artist page, we need to both update the counter in the database and
increase the in-memory view counter (so that the page shows the
correct amount) while also .
// app/Http/Controllers/ArtistController.php
public function show(Artist $artist)
{
$artist->load('urls');
// Update the DB
DB::table('artists')
->where('id', $artist->id)
->increment('view_count');
// Update the in-memory counter before passing the model over to
// the view instead of re-reading it from the DB with the new value
// (this will miss views recorded by concurrent page requests, but
// is enough to get an approximate value).
$artist->view_count++;
return view('artists.show', [
'artist' => $artist,
]);
}
While the approach discussed so far may work in some low-traffic scenarios, it may present problems as the application scales:
- Although most data in the database barely changes, every page view does a full round trip to the database.
-
For artist pages, every page view also issues a write on
a read path. This, although safe to execute
concurrently (a brief lock would normally be held for
UPDATE artists SET view_count = view_count + 1 WHERE id = ?, so two simultaneous views of the same artist serialize their increments rather than losing one), still means that every page view needs to wait for a write to complete, including I/O overhead, even though the artist data itself rarely changes.
In order to solve these two issues, we need to make use of two different techniques, namely read caching and write batching, as we'll see next.
3. Connecting Laravel to Memcached
The next step is to enable caching by pointing Laravel to your Memcached instance. To do so, if you are using Velstash Cache, go to your cache instance page, where you will find the host and port to connect to at the top of the page.
If you don't have the credentials to connect to your cache instance at hand (or if you want to create dedicated credentials for this application), go to the credentials section and create a new username & password pair by providing a name for your new application.
First, you would need to edit your .env file and add
the following contents (replace the host and port with those shown
in the dashboard for plain connections, and the username and
password with the credentials you previously generated for your
application).
CACHE_STORE=memcached
MEMCACHED_HOST=mc1.public.gra2.ovh.velstash.io
MEMCACHED_PORT=11211
MEMCACHED_USERNAME=your-username
MEMCACHED_PASSWORD=your-password
MEMCACHED_PERSISTENT_ID=velstash-cache
Then, ensure your config/cache.php file has the
following content:
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
// Velstash doesn't use SASL for authentication; instead, auth happens by
// issuing a SET carrying the username and password upon connecting, so
// username/password are handled by the 'memcached' driver override in
// AppServiceProvider instead of Laravel's built-in 'sasl' option.
'username' => env('MEMCACHED_USERNAME'),
'password' => env('MEMCACHED_PASSWORD'),
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
// Memcached::append()/prepend() (which will be later used
// for the artist view counters update batching) refuse to
// run at all on compressed items. ext-memcached compresses
// by default, so it has to be turned off store-wide here.
Memcached::OPT_COMPRESSION => false,
],
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
And finally, edit app/Providers/AppServiceProvider.php
and add the following code within the boot function:
public function boot(): void
{
Paginator::useBootstrapFive();
// Velstash doesn't implement SASL but uses the standard authentication
// mechanism built-in in the Memcached protocol consisting on issuing a SET
// with the credentials to any key (using `auth` here) right after
// connecting instead of using Laravel's built-in mechanism (which only
// works for servers using the 'sasl' mechanism).
Cache::extend('memcached', function ($app, array $config) {
$memcached = $app['memcached.connector']->connect(
$config['servers'],
$config['persistent_id'] ?? null,
$config['options'] ?? [],
);
// Only send the _auth SET on a fresh connection.
if (! empty($config['username']) && $memcached->isPristine()) {
$memcached->set(
'_auth',
"{$config['username']} {$config['password']}"
);
}
return $this->repository(
new MemcachedStore($memcached, $this->getPrefix($config))
);
});
}
Note the check for isPristine() here mimics how
Laravel's own MemcachedConnector already checks for a
reused connection before deciding whether to re-add servers; here,
we use it to know when a previous connection is not being reused and
send the authentication credentials in that case.
To check whether the connection is working, we can first check by
using artisan. From a shell session, run php
artisan tinker, then run the following commands:
$ php artisan tinker
Psy Shell v0.12.24 (PHP 8.5.4 — cli) by Justin Hileman
> config('cache.default')
= "memcached"
> Cache::put('ping', 'pong', 600);
= true
> Cache::get('ping')
= "pong"
If you are using Velstash Cache, you may confirm that the round trip to the server worked and that the item reached the cache by browsing to your cache instance page, which should now account the item (note the UI may take some minutes to reflect the new write).
Connecting using SSL
For using Velstash with SSL, given that Laravel does not support the
standard authentication mechanism defined by the Memcached protocol
when not using the binary protocol, you will need to define
a new cache store for your cache instance, and associate it
with a custom implementation. This custom
implementation is needed since PHP's
ext-memcached manages its own sockets
internally (it does not allow you to pass a tls://
socket as a parameter, and it neither supports TLS by itself).
For this custom implementation, we may start by adding the
connection parameters as a new entry under
config/cache.php like this:
'velstash' => [
'driver' => 'velstash',
'host' => env('VELSTASH_HOST', 'mc1.public.gra2.ovh.velstash.io'),
'port' => env('VELSTASH_PORT', 11211),
'tls' => env('VELSTASH_TLS', false),
'tls_servername' => env(
'VELSTASH_TLS_SERVERNAME',
env('VELSTASH_HOST', 'mc1.public.gra2.ovh.velstash.io')
),
'username' => env('VELSTASH_USERNAME'),
'password' => env('VELSTASH_PASSWORD'),
],
After that, update the values in your .env file to
match those for SSL connections (like we did for plain connection,
replace the host and port below with those shown in the dashboard
for SSL connections, and the username and password with the
credentials you previously generated for your application).
CACHE_STORE=velstash
VELSTASH_HOST=mc1.public.gra2.ovh.velstash.io
VELSTASH_PORT=11212
VELSTASH_TLS=true
VELSTASH_USERNAME=your-username
VELSTASH_PASSWORD=your-password
Before this works, we will need to add a custom store for handling TLS connections. It requires three things:
-
VelstashTlsConnection: A low-level client for handling TLS sockets and authentication. -
VelstashMemcachedStore: A custom cache store on top of that connection. -
Register a new driver: Updating the
AppServiceProviderso that thevelstashcache driver works.
For the first one, create a new file
app/Cache/VelstashTlsConnection.php and paste the
following contents:
<?php
namespace App\Cache;
use RuntimeException;
/**
* Minimal client for the plain-text memcached protocol, optionally over TLS
* (ext-memcached/libmemcached has no TLS support at all, hence this class).
*/
class VelstashTlsConnection
{
/** @var resource|null */
protected $socket;
/**
* Local ip:port of the socket last authenticated on, keyed by
* host:port. Needed instead of a bare "have we authenticated?" flag
* because PHP's persistent-stream pool can silently redial behind
* connect() (see reauthenticateIfNeeded()): a plain flag would miss
* that and keep sending commands over a fresh, unauthenticated
* connection.
*
* @var array<string, string>
*/
protected static array $authenticatedConnections = [];
public function __construct(
protected string $host,
protected int $port,
protected ?string $tlsServerName,
protected ?string $username,
protected ?string $password,
protected bool $tls = true,
protected float $timeout = 1.0,
) {
}
public function get(string $key): ?string
{
return $this->getMulti([$key])[$key] ?? null;
}
/**
* Fetch several keys in a single round trip via the protocol's
* multi-key "get k1 k2 k3" command. Missing keys are simply absent
* from the returned array.
*
* @param array<int, string> $keys
* @return array<string, string>
*/
public function getMulti(array $keys): array
{
if ($keys === []) {
return [];
}
$results = [];
$socket = $this->socket();
$this->write($socket, 'get '.implode(' ', $keys)."\r\n");
while (true) {
$header = rtrim((string) $this->readLine($socket), "\r\n");
if ($header === 'END' || $header === '') {
break;
}
// "VALUE <key> <flags> <bytes>"
[, $key, , $bytes] = explode(' ', $header);
$bytes = (int) $bytes;
$results[$key] = substr(
$this->readExact($socket, $bytes + 2),
0,
$bytes
);
}
return $results;
}
public function set(
string $key,
string $data,
int $exptime = 0,
int $flags = 0
): bool {
return $this->rawSet($this->socket(), $key, $data, $exptime, $flags);
}
/**
* Real atomic protocol "add": stores only if the key doesn't already
* exist. Returns false (NOT_STORED) otherwise — it never overwrites.
*/
public function add(
string $key,
string $data,
int $exptime = 0,
int $flags = 0
): bool {
return $this->rawStore(
'add',
$this->socket(),
$key,
$data,
$exptime,
$flags
);
}
public function append(string $key, string $data): bool
{
$socket = $this->socket();
// flags/exptime are ignored by the server for append (the item
// keeps whatever it already had) but the wire format still
// requires placeholders for them.
$this->write(
$socket,
sprintf("append %s 0 0 %d\r\n%s\r\n", $key, strlen($data), $data)
);
$result = rtrim((string) $this->readLine($socket), "\r\n");
// NOT_STORED means the key doesn't exist yet — append() never
// creates one, unlike set().
return ($result === 'STORED');
}
public function increment(string $key, int $value): int|false
{
return $this->incrementOrDecrement('incr', $key, $value);
}
public function decrement(string $key, int $value): int|false
{
return $this->incrementOrDecrement('decr', $key, $value);
}
protected function incrementOrDecrement(
string $command,
string $key,
int $value
): int|false {
$socket = $this->socket();
$this->write($socket, "{$command} {$key} {$value}\r\n");
$response = rtrim((string) $this->readLine($socket), "\r\n");
// Failure responses are "NOT_FOUND" or "CLIENT_ERROR ...", never a
// plain digit string, so this also doubles as the success check.
return ctype_digit($response) ? (int) $response : false;
}
public function delete(string $key): bool
{
$socket = $this->socket();
$this->write($socket, "delete {$key}\r\n");
$result = rtrim((string) $this->readLine($socket), "\r\n");
return ($result === 'DELETED');
}
public function flushAll(): bool
{
$socket = $this->socket();
$this->write($socket, "flush_all\r\n");
$result = rtrim((string) $this->readLine($socket), "\r\n");
return ($result === 'OK');
}
/**
* @return resource
*/
protected function socket()
{
if (is_resource($this->socket) && ! feof($this->socket)) {
return $this->socket;
}
$socket = $this->connect();
// stream_socket_client() with STREAM_CLIENT_PERSISTENT does not check
// liveness when it hands back a pooled connection: a connection killed
// by the peer or an intermediate server while idle between requests
// comes back looking like an ordinary successful connect. feof() is
// what actually catches that.
if (feof($socket)) {
fclose($socket);
$socket = $this->connect();
}
$this->reauthenticateIfNeeded($socket);
return $this->socket = $socket;
}
/**
* Opens (or, via STREAM_CLIENT_PERSISTENT, reattaches to) the TCP/TLS
* connection for this worker process, keyed by address: the TLS
* handshake is paid once per worker, not once per request.
*
* @return resource
*/
protected function connect()
{
$scheme = $this->tls ? 'tls' : 'tcp';
$socket = stream_socket_client(
"{$scheme}://{$this->host}:{$this->port}",
$errno,
$errstr,
$this->timeout,
STREAM_CLIENT_CONNECT | STREAM_CLIENT_PERSISTENT,
stream_context_create(
$this->tls ? [
'ssl' => [
'peer_name' => $this->tlsServerName ?? $this->host,
'verify_peer' => true,
'verify_peer_name' => true,
],
] : []
),
);
if ($socket === false) {
throw new RuntimeException(
sprintf(
'Unable to connect to Velstash Cache at %s:%d: %s (%d)',
$this->host,
$this->port,
$errstr,
$errno
)
);
}
$seconds = (int) $this->timeout;
$microseconds = (int) round(($this->timeout - $seconds) * 1_000_000);
stream_set_timeout($socket, $seconds, $microseconds);
return $socket;
}
/**
* Sends `_auth` only when this specific TCP connection hasn't already
* been authenticated: not merely "some connection to this host was,
* at some point". Those aren't the same thing: PHP's persistent-stream
* pool can silently redial inside connect() above (e.g. after the far
* end closed an idle connection), handing back a brand-new,
* unauthenticated connection that looks like an ordinary successful
* connect. A bare per-worker "authenticated" boolean would miss that
* and start sending commands over the new connection unauthenticated.
*
* The socket's local ip:port stands in for the actual OS-level
* connection identity. A PHP resource id would not: resource ids get
* reassigned on the next stream_socket_client() fetch as soon as the
* previous request's connection object (and its local resource
* handle) is garbage collected, which happens on every request in a
* worker process, even when the underlying TCP connection hasn't
* changed at all.
*
* @param resource $socket
*/
protected function reauthenticateIfNeeded($socket): void
{
if ($this->username === null && $this->password === null) {
return;
}
$addrKey = "{$this->host}:{$this->port}";
$localName = stream_socket_get_name($socket, false);
if ((self::$authenticatedConnections[$addrKey] ?? null) === $localName) {
return;
}
$auth = "{$this->username} {$this->password}";
if (! $this->rawSet($socket, '_auth', $auth, 0, 0)) {
throw new RuntimeException('Velstash Cache authentication failed.');
}
self::$authenticatedConnections[$addrKey] = $localName;
}
/**
* @param resource $socket
*/
protected function rawSet(
$socket,
string $key,
string $data,
int $exptime,
int $flags
): bool {
return $this->rawStore('set', $socket, $key, $data, $exptime, $flags);
}
/**
* Shared by set() ("set", always stores) and add() ("add", only
* stores if the key doesn't already exist — the wire-level primitive
* `Cache::add()` needs to actually be atomic, rather than Laravel's
* get()-then-put() fallback for stores without their own add()).
*
* @param resource $socket
*/
protected function rawStore(
string $command,
$socket,
string $key,
string $data,
int $exptime,
int $flags
): bool {
$this->write(
$socket,
sprintf(
"%s %s %d %d %d\r\n%s\r\n",
$command, $key, $flags, $exptime, strlen($data), $data
)
);
$result = rtrim((string) $this->readLine($socket), "\r\n");
return ($result === 'STORED');
}
/**
* @param resource $socket
*/
protected function write($socket, string $data): void
{
$written = 0;
$length = strlen($data);
while($written < $length) {
$n = fwrite($socket, substr($data, $written));
if (!$n) {
throw new RuntimeException(
'Failed writing to the Velstash Cache socket.'
);
}
$written += $n;
}
}
/**
* @param resource $socket
*/
protected function readLine($socket): ?string
{
$line = fgets($socket);
return ($line === false ? null : $line);
}
/**
* @param resource $socket
*/
protected function readExact($socket, int $length): string
{
$data = '';
while (strlen($data) < $length) {
$chunk = fread($socket, $length - strlen($data));
if ($chunk === false || $chunk === '') {
throw new RuntimeException(
'Unexpected EOF from the Velstash Cache socket.'
);
}
$data .= $chunk;
}
return $data;
}
}
Next we need a cache store that uses the TLS client we just added.
Create a new file app/Cache/VelstashMemcachedStore.php
and paste the following contents:
<?php
namespace App\Cache;
use Illuminate\Contracts\Cache\Store;
use Illuminate\Support\InteractsWithTime;
class VelstashMemcachedStore implements Store
{
use InteractsWithTime;
protected string $prefix;
public function __construct(
protected VelstashTlsConnection $connection,
string $prefix = '',
) {
$this->setPrefix($prefix);
}
public function get($key)
{
$data = $this->connection->get($this->prefix . $key);
return $this->decode($data);
}
public function many(array $keys)
{
$prefixedKeys = array_map(
fn ($key) => $this->prefix.$key,
$keys
);
$data = $this->connection->getMulti($prefixedKeys);
$results = [];
foreach ($keys as $i => $key) {
$results[$key] = $this->decode(
$data[$prefixedKeys[$i]] ?? null
);
}
return $results;
}
public function put($key, $value, $seconds)
{
return $this->connection->set(
$this->prefix.$key,
$this->encode($value),
$this->calculateExpiration($seconds)
);
}
public function putMany(array $values, $seconds)
{
$result = true;
foreach ($values as $key => $value) {
$result = $result &&
$this->put($key, $value, $seconds);
}
return $result;
}
/**
* Not part of Illuminate\Contracts\Cache\Store, but Laravel's Cache::add()
* facade helper looks for a method with this exact name and (only when a
* TTL argument is given, see Repository::add()) calls it directly instead
* of its own non-atomic is_null(get())-then-put() fallback. Defining this
* is what makes Cache::add($key, $value, $ttl) genuinely atomic against
* this store.
*/
public function add($key, $value, $seconds)
{
return $this->connection->add(
$this->prefix.$key,
$this->encode($value),
$this->calculateExpiration($seconds)
);
}
/**
* A raw counterpart to add() above, for keys meant to hold plain,
* non-serialized text. encode() would serialize() a non-int value,
* corrupting the raw append()-built content that goes on top of it
* afterwards.
*/
public function addRaw(string $key, string $data = ''): bool
{
return $this->connection->add($this->prefix.$key, $data);
}
/**
* Unlike ArrayStore/FileStore/RedisStore, this does not auto-create a
* missing key at 0. It uses the real protocol "incr" command, which (like
* ext-memcached's own MemcachedStore) returns false if the key doesn't
* already exist. Seed the key first with Cache::put()/add().
*/
public function increment($key, $value = 1)
{
return $this->connection->increment($this->prefix.$key, $value);
}
/**
* Same no-auto-create caveat as increment() above (uses the real protocol
* "decr" command, so a missing key returns false).
*/
public function decrement($key, $value = 1)
{
return $this->connection->decrement($this->prefix.$key, $value);
}
public function forever($key, $value)
{
return $this->put($key, $value, 0);
}
public function touch($key, $seconds)
{
$value = $this->get($key);
if ($value === null) {
return false;
}
return $this->put($key, $value, $seconds);
}
/**
* Not part of Illuminate\Contracts\Cache\Store. Implements an atomic
* protocol "append" operation, used later on for batching writes without
* hitting the DB on every request (see section 5). Fails (returns false) if
* the key doesn't already exist; append() never creates one.
*/
public function append(string $key, string $data): bool
{
return $this->connection->append($this->prefix.$key, $data);
}
/**
* Read a key's raw bytes without decode()'s int/serialize() handling.
* Needed for keys built via append() above, which hold plain
* concatenated text, not an encode()'d PHP value.
*/
public function getRaw(string $key): ?string
{
return $this->connection->get($this->prefix.$key);
}
public function forget($key)
{
return $this->connection->delete($this->prefix.$key);
}
public function flush()
{
return $this->connection->flushAll();
}
public function getPrefix()
{
return $this->prefix;
}
public function setPrefix($prefix)
{
$this->prefix = ! empty($prefix) ? $prefix.':' : '';
}
protected function calculateExpiration($seconds)
{
return $seconds > 0 ? $this->availableAt($seconds) : 0;
}
/**
* Plain ints are stored as bare decimal strings (never serialized) so the
* protocol's real incr/decr commands can operate on them atomically.
* Everything else goes through PHP's serialize(), whose output always
* starts with a type tag + colon (e.g. "s:4:", "i:", "b:", "d:", "a:",
* "O:", "N;") and so can never collide with a bare "^-?\d+$" integer string
* on the way back out.
*/
protected function encode($value): string
{
return is_int($value) ? (string) $value : serialize($value);
}
protected function decode(?string $raw)
{
if ($raw === null) {
return null;
}
$isInteger = preg_match('/^-?\d+$/', $raw);
return ($isInteger === 1 ? (int) $raw : unserialize($raw));
}
}
Finally, we register the new driver by updating
app/Providers/AppServiceProvider to have a
velstash driver for connecting to Memcached using TLS:
<?php
namespace App\Providers;
use App\Cache\VelstashMemcachedStore;
use App\Cache\VelstashTlsConnection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
// ...
public function boot(): void
{
//...
Cache::extend('velstash', function ($app, array $config) {
$connection = new VelstashTlsConnection(
host: $config['host'],
port: (int) $config['port'],
tlsServerName: $config['tls_servername'] ?? $config['host'],
username: $config['username'] ?? null,
password: $config['password'] ?? null,
tls: (bool) ($config['tls'] ?? true),
);
return $this->repository(
new VelstashMemcachedStore(
$connection,
$config['prefix'] ?? $app['config']['cache.prefix']
)
);
});
}
}
As we did for non-TLS connections, you may now use php artisan
tinker to check whether the connection is working:
$ php artisan tinker
Psy Shell v0.12.24 (PHP 8.5.4 — cli) by Justin Hileman
> config('cache.default')
= "velstash"
> Cache::put('ping', 'pong', 600);
= true
> Cache::get('ping')
= "pong"
4. Caching database reads: find() by ID and by name
Now that we have a Cache driver set up to use a Memcached
connection, we can wire it up to save data into Memcached whenever
we fetch information from the database. To do so, we will add calls
to Laravel's Cache class in all places where our
application reads information from the database; that is, where we
fetch an artist by ID, as well as where we retrieve artist lists
(both when browsing the general list and when listing artists by
query).
4a. Caching single artist reads by ID
If you check the implementation for the controller that shows an
artist, you will immediately see that it does not contain any calls
to the model (such as Artist::findOrFail()) in order to
retrieve the artist itself. Instead, its signature
ArtistController::show(Artist $artist) makes Laravel
use route-model binding to automatically resolve the
{artist} URL segment into a model instance
before the controller method body itself has a chance to
run: there's no Artist::findOrFail($id) call in the
controller to wrap in Cache::remember() since the DB
lookup happens one layer up, inside Eloquent's own
binding-resolution code.
That means we need to add caching one layer up, where
route-model binding resolves the artist ID into an actual model
instance, which by default runs a plain, uncached query. To
do so, we will override Model::resolveRouteBinding() in
the Artist model in order to pass the call to the
parent method as a callback for Laravel's
Cache::remember() method, which will cache database
results and prevent the DB from being hit again when the same
Artist ID is requested again. This is done by adding a
method override in Artist.php like this:
use Illuminate\Support\Facades\Cache;
class Artist extends Model {
// ...
public function resolveRouteBinding($value, $field = null)
{
return Cache::remember(
"artist:id:{$value}",
now()->addHour(),
fn () => parent::resolveRouteBinding($value, $field)?->load('urls')
);
}
}
Cache::remember() takes three arguments, and it's worth
being explicit about what each one does, since we'll be using this
same method throughout the rest of this guide:
-
The key (
"artist:id:{$value}") is what identifies this particular cached value among everything else Memcached is holding, for this or any other application. It has to uniquely encode everything the cached result depends on (here, it just carries the artist's ID). -
The timestamp (
now()->addHour()) is how long that value is allowed to stick around before Memcached will discard it (requiring future requests to recompute it).Cache::remember()accepts anything that resolves to a point in time (aDateTimeInterface, like theCarboninstancenow()->addHour()returns, aDateInterval, or a plain integer number of seconds), which will then be converted by Laravel to a UNIX timestamp representing the instant a cached item should expire, as expected by Memcached's own protocol. -
The callback (
fn () => parent::resolveRouteBinding(...)?->load('urls')) only ever runs on a cache miss onceCache::remember()checks whether the key already holds a value. If it does, that value is returned immediately and the callback call is skipped altogether, so its cost (here, the DB query) is avoided on a cache hit. On a miss, the callback runs, its return value gets stored under that key for the given TTL, and is then returned as the result from theCache::remember()call, so callers don't need to care whether they got a cached value or a freshly computed one.
After the call is wrapped within Cache::remember(), if
you refresh the page a couple of times, you should see that loading
the page the second time is faster, since most of the time taken by
that page was previously spent waiting for the database query to
return. Note, however, that the time saved for this simple page may
not be that much given that we are updating the
view_count in the database inside
ArtistController::show(Artist $artist), which still
requires Laravel to connect to the database and to issue a query,
which may minimize the savings obtained from caching the
Artist instance.
Note also that for this simple page you may see the opposite effect (an increase in the time taken) if you are testing locally and pointing your cache to an external server (such as Velstash Cache), due to the time taken to connect to the cache over the Internet. However, the time should drop dramatically once the application is deployed to the same datacenter your cache is deployed to.
In our case, commenting out the code that updates the
view_count in the DB and pointing the application to a
cache instance running in the same datacenter reduced the load time
for the artist page by half.
One subtlety worth calling out: a lookup for an ID that doesn't
exist resolves to null, and
Cache::remember() only persists a non-null
result; that is, it still calls the closure again on the next
request for that same bad ID, rather than caching the miss. That's a
deliberate trade-off, not a bug: route-model binding needs a genuine
null back from resolveRouteBinding() to
trigger Laravel's own 404 handling, and caching 404s here would mean
a typo in the URL (or a freshly deleted artist) keeps returning a
stale 404 after an actual model is persisted for that ID, rather
than re-checking the DB each time.
There's another issue worth flagging here: the cached
Artist instance can drift from the database between
requests; specifically, the view_count increment
ArtistController@show still performs writes straight to
the DB, so a cached page can display a view count that's already
behind reality until the one-hour TTL expires. We'll come back to
invalidation more generally in 4d.
4b. Caching artist lookups by name
Contrary to what we do for artist pages, the implementation for
artist listings served by ArtistController::index()
does call the model directly via
Artist::search($q)->paginate(5), so there's no
route-model-binding to handle in this case. As a refresher, without
caching, the controller for this case was implemented like this:
// app/Http/Controllers/ArtistController.php
public function index(Request $request)
{
$q = $request->query('q');
$artists = $q
? Artist::search($q)->paginate(5)->withQueryString()
: Artist::defaultOrder()->paginate(5);
return view('artists.index', [
'artists' => $artists,
'q' => $q,
]);
}
As the call to the model is done directly in this case, we can just
wrap the model calls as callbacks to Cache::remember()
right in the controller. Here we will start with the calls done for
search pages.
Two things make the cache key for this call less obvious than the single-artist one, though:
- First, a single search term paginates across multiple pages and each page is a different result set, so the page number needs to be included in the cache key; otherwise every page of a given search would collide on (and overwrite) the same cache entry.
- And second, the search term itself is free-form user input, so it should be normalized somehow before using it as part of a cache key. In this case, we decided to normalize it by putting it in lowercase and then hashing it, which serves both to fold together searches that only differ by case, and to keep arbitrarily long or unusual query strings from turning into an oversized or malformed cache key (as Memcached won't allow keys with whitespaces or control characters, nor keys longer than 250 bytes).
Also, given that Artists' view counts are not used in result listings and that they are subject to change frequently, we won't save them in the cache for listings.
Taking all into account, the controller for artist listings can be rewritten as follows:
// app/Http/Controllers/ArtistController.php
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\Cache;
class ArtistController extends Controller
{
protected const LISTING_COLUMNS = [
'id', 'name', 'type', 'gender',
'begin_date', 'end_date', 'area', 'genres',
];
// ...
public function index(Request $request)
{
$q = $request->query('q');
$page = Paginator::resolveCurrentPage();
$artists = $q
? Cache::remember(
'artists:search:'.md5(strtolower($q)).":page:{$page}",
now()->addMinutes(5),
fn () => Artist::search($q)
->paginate(5, self::LISTING_COLUMNS)
->withQueryString()
)
: Artist::defaultOrder()->paginate(5); // uncached, see 4c below
return view('artists.index', [
'artists' => $artists,
'q' => $q,
]);
}
}
Here we pass the same three Cache::remember() arguments
as before (see 4a for what each one does in general). In this case,
the cache key needs a second component to hold a page number, since
page 1 and page 2 of the same search are different result sets that
would otherwise collide on (and overwrite) the same cache entry.
Note also that, when adding caching above, we deliberately gave search results a much shorter TTL than the one used for each artist page in the prior section (5 minutes here, one-hour TTL for each artist page). That's because search terms, being user input, are far more varied than artist IDs, and each distinct term/page combination is its own cache entry: even though the list of results for an individual page is cheap to cache and keep around (and prevents an expensive text lookup against the DB), many will never be requested a second time, and maintaining each search term entered in the search box will become expensive in terms of size, causing frequent evictions for items from a comparatively small, frequently-revisited set of individual artist pages looked up by artist ID. A short TTL keeps the cache from filling up with one-off searches nobody repeats, while still absorbing bursts of the same search term if queried over and over again (a term trending on social media, a shared link, a slow crawler hammering the same query, etc.).
Finally, note also we pass a self::LISTING_COLUMNS
argument to paginate() in order to restrict the query
(and therefore what gets cached) to just the columns we actually
require in the view. Specifically, we are leaving the
view_count column out of the data returned for artist
lists (and, therefore, from cached listings), saving a bit of space
by dropping a field that would be difficult to keep updated in every
result listing that gets cached anyway.
4c. Caching the artist list when no search is performed
The default listing (when browsing the result listing normally) follows the same shape as previously, but this time we don't need to include a search term into the cache key since only the page number varies. Also, given that this time the results in each page never vary due to different search terms (every user should see the same items on every page in the listing if not performing a search), we may choose to keep them in cache for longer, since we expect a higher hit rate for them (here we are using 30 minutes).
// app/Http/Controllers/ArtistController.php
public function index(Request $request)
{
$q = $request->query('q');
$page = Paginator::resolveCurrentPage();
$artists = $q
? Cache::remember(
'artists:search:'.md5(strtolower($q)).":page:{$page}",
now()->addMinutes(5),
fn () => Artist::search($q)
->paginate(5, self::LISTING_COLUMNS)
->withQueryString()
)
: Cache::remember(
"artists:list:page:{$page}",
now()->addMinutes(30),
fn () => Artist::defaultOrder()
->paginate(5, self::LISTING_COLUMNS)
);
return view('artists.index', [
'artists' => $artists,
'q' => $q,
]);
}
At this point, you may repeat the check previously done for individual artist pages and measure the time taken to load a page from the listing (either with or without search terms): this time the time saved should be even more dramatic when visiting the same page a second time (both while browsing the listing normally and while browsing search results). In our tests, pages in the list of artists go from 5 to 10 seconds down to around 10 milliseconds for requests that end up hitting the cache.
4d. Keeping the cache correct when data changes
So far we've only cached reads, but nothing yet stops the
single-artist cache implemented in
Artist::resolveRouteBinding() back on chapter 4a going
stale the moment someone edits an artist through, say, a future
admin screen: the cached copy would keep serving the old data for up
to an hour, regardless of what's now in the database.
In order to fix that for the single-artist cache specifically, we
need to hook into Eloquent's model events via booted()
in order to drop any cached data from the cache once an
Artist is saved or deleted:
// app/Models/Artist.php
protected static function booted(): void
{
static::saved(
fn (Artist $artist) => Cache::forget("artist:id:{$artist->id}")
);
static::deleted(
fn (Artist $artist) => Cache::forget("artist:id:{$artist->id}")
);
}
saved fires after both creates and updates, so this
listener covers "an artist's details changed" in general;
deleted covers removal. Either way, the very next
request for that artist's page re-populates the cache from a fresh
DB read, instead of waiting out the TTL.
Note we're deliberately not extending this to the
search/listing caches from 4b and 4c. Those are keyed by search term
and page number, not by artist ID, so there's no single, predictable
key to Cache::forget() when one artist's row changes,
since that artist could in principle appear on any number of listing
pages or search results.
Doing this precisely would require a cache being able to fetch items with queries such as "list every item referencing ID 123", and that is something Laravel's cache tagging does not solve (it provides namespacing, not per-item tagging). Additionally, Memcached itself has no concept of tags or secondary indexes at all, and works purely as a key-value store, with no way to query "which keys reference X".
In order to solve this, we'd need a real reverse index, which would
have to be built by hand; for example, maintaining
artist:{id}:pages => [page cache keys] entries and
keeping them updated whenever a new search results' page is added to
the cache. And still, that would still produce inaccurate results:
since Memcached may evict any cached key under memory pressure, the
list of cached listing pages referencing a given artist ID could get
dropped from the cache at any time, which would prevent the system
from removing the cached data for listings that referenced that
artist (making the whole approach unreliable).
5. Counting views without making writes on every page load
Up to this point we've seen the typical usage of a cache that stores data both for listings and individual items. However, when we described pages for individual artists, we wanted to track the number of times each is accessed in a counter within the artist table itself.
In a high-traffic application, keeping that counter updated in realtime may introduce some performance problems:
- Incrementing the counter implies a write against the database per page view.
- Even if data is cached, updating the counter on each access means the cached data is immediately invalid right after the first time it is used (since the counter is now stale).
In a high-traffic application, incrementing a database counter may introduce contention: since each request for a given ID would update the same row, it would require others to wait until the write completes (that is, requests accessing the same artist would end up being served sequentially; and, unlike a normal update, incrementing a value requires locking the row to prevent other threads from writing to it, since next increments would require to know what value was settled by the transaction that first requested the increment).
And, even if we ignore the fact a page view now requires a database access (which is exactly what a cache was expected to avoid), ensuring that only the most up-to-date counter value ends up in the cache would require additional mechanisms (such as distributed locks) to prevent two requests (potentially served by different physical servers or server processes) to update the cache for the same artist at the same time.
In order to solve this issue, we may leverage Memcached append operations in order to maintain an append-only list, which would serve as a log of what IDs require their counters to be updated. Then a background process could add all page views that happened recently back to the database in a single update, which avoids the performance problems associated with individual requests updating counters concurrently in real time.
The implementation for this approach would live in a new
ArtistViewBatcher service exposing two public methods:
one used by controllers to signal a new page view, and another one
used by a scheduled command to update the DB with the new views.
// app/Support/ArtistViewBatcher.php
class ArtistViewBatcher
{
public function recordView(string $artistId): void
{
$store = Cache::getStore();
if ($store instanceof MemcachedStore) {
$this->appendToList($store->getMemcached(), $artistId);
} elseif ($store instanceof VelstashMemcachedStore) {
$this->appendToList($store, $artistId);
}
}
/**
* @return int the number of distinct artists whose view_count was updated
*/
public function flush(): int
{
$store = Cache::getStore();
if ($store instanceof MemcachedStore) {
return $this->flushList($store->getMemcached());
} elseif ($store instanceof VelstashMemcachedStore) {
return $this->flushList($store);
} else {
return 0;
}
}
//...
}
Once the new service is defined, the code in the controller required to track the new page view is reduced to just calling the new class:
// app/Http/Controllers/ArtistController.php
public function show(Artist $artist, ArtistViewBatcher $viewBatcher)
{
$artist->loadMissing('urls');
// Batch writes instead of hitting the DB on every page view: record this
// view in the cache, and let the scheduled artists:flush-views command
// periodically apply the accumulated totals to the DB.
$viewBatcher->recordView($artist->id);
// The DB row won't reflect this view until the next scheduled flush, so
// bump the in-memory value before it reaches the view. Note this doesn't
// touch the DB, it only makes the number shown here account for the view
// we just recorded above.
$artist->view_count++;
return view('artists.show', [
'artist' => $artist,
]);
}
As we've seen in the implementation for the public methods in the new service, recording a view is turned into an operation to append an item into a list, while recording them back to the database is conceptually modeled as flushing that list back to the database.
However, a simple approach that just keeps updating items to a list, and then gets that list cleared out once all views have been recorded may cause a race condition if a page view is being recorded while the list is being written back to the database. In order to avoid incoming requests from updating the list of pending page views while the list itself is being written back to the database, we would actually need several lists of IDs, and make sure the web server does not update a list once the background process starts flushing it to the database.
Specifically, we need an item in the cache telling which list we are currently appending to, and use a predictable pattern for naming the items in Memcached holding individual lists. Then the operation to append an item to the list may be implemented as two high level operations:
- Get the cache key for the current list based on an index key, initializing it to zero if missing.
- Actually append the ID of the entity for which we are recording a view for.
The implementation for this method is included below. Note it
distinguishes between the raw Memcached instance and the
VelstashMemcachedStore we introduced earlier: since the
latter is implemented to honor the expectations of Laravel's
Cache class that requires add to store
values encoded with serialize(), directly using that
with an empty string (used to make sure the item exists before
trying to append to it, which is needed for append to
work) would make the list end up like s:0:"";id1 id2
instead of holding just the list of IDs.
// app/Support/ArtistViewBatcher.php
class ArtistViewBatcher
{
protected const INDEX_KEY = 'artist-counter-pending-flushes-index';
protected const LIST_KEY_PREFIX = 'artist-counter-pending-flushes-';
protected function appendToList($store, string $artistId): void
{
$isRawMemcached = $store instanceof Memcached;
if ($isRawMemcached) {
$store->add(self::INDEX_KEY, 0);
} else {
$store->addRaw(self::INDEX_KEY, '0');
}
$index = $store->get(self::INDEX_KEY);
$listKey = self::LIST_KEY_PREFIX.$index;
if ($isRawMemcached) {
$store->add($listKey, '');
} else {
$store->addRaw($listKey, '');
}
$store->append($listKey, $artistId.' ');
}
}
Once we have a method for appending the IDs, we need another one for actually collecting which IDs need their counters updated and by which amount. The mechanism this time is a bit more complex:
- Initialize the key used to track what is the current index name, in case it was not previously set.
- Increment the index so that new writes would now go into a different cache key from that point onwards. Given that Memcached returns the value resulting from an increment operation, this also serves to know what was the previously-used index.
- Fetch the list of pending IDs we are about to flush, which is stored under the key associated with the index we fetched on the previous step. Once fetched, delete it so we avoid double-counting on a next run if the list index even wraps.
- Count how many times each ID is included in the list, additionally filtering out empty values in case there is any.
- For each ID, issue an update against the database and drop any cached data for the associated artist, which will now reflect the updated count once a new page view fetches the artist.
Note that the code below uses increment for updating
the counter, which bypasses Eloquent and therefore does not trigger
the listeners set in Artist::booted(), which would
otherwise drop the cached value for the updated artists per the
implementation we previously described in chapter 4d.
// app/Support/ArtistViewBatcher.php
class ArtistViewBatcher
{
// ...
protected function flushList($store): int
{
$isRawMemcached = $store instanceof Memcached;
if ($isRawMemcached) {
$store->add(self::INDEX_KEY, 0);
} else {
$store->addRaw(self::INDEX_KEY, '0');
}
$newIndex = $store->increment(self::INDEX_KEY);
$oldIndex = $newIndex - 1;
$listKey = self::LIST_KEY_PREFIX.$oldIndex;
if ($isRawMemcached) {
$raw = $store->get($listKey);
$store->delete($listKey);
} else {
$raw = $store->getRaw($listKey);
$store->forget($listKey);
}
if (!$raw) {
return 0;
}
$counts = array_count_values(
array_filter(explode(' ', trim($raw)))
);
foreach ($counts as $artistId => $count) {
DB::table('artists')->where('id', $artistId)
->increment('view_count', $count);
Cache::forget("artist:id:{$artistId}");
}
return count($counts);
}
}
The flush job itself is now a thin wrapper for the logic in
ArtistViewBatcher::flush():
// app/Console/Commands/FlushArtistViews.php
class FlushArtistViews extends Command
{
protected $signature = 'artists:flush-views';
protected $description =
'Apply batched, cached artist view counts to the artists table.';
public function handle(ArtistViewBatcher $viewBatcher): int
{
$flushed = $viewBatcher->flush();
$this->info("Flushed pending views for {$flushed} artist(s).");
return self::SUCCESS;
}
}
Wiring the flush to actually run on a schedule is just one line, in
routes/console.php:
use Illuminate\Support\Facades\Schedule;
Schedule::command('artists:flush-views')->everyMinute();
While developing locally, you can verify if this works by manually
running php artisan schedule:work to run Laravel's
scheduler in the foreground, checking every minute for any due
commands (enough to watch artists:flush-views fire and
the DB catch up shortly after browsing a few artist pages).
6. Wrap-up
In this tutorial, we have explored how to configure Laravel to use Memcached as its backing storage for cache and then how to leverage that cache for improving application performance on different scenarios of increasing complexity: caching single-model reads by ID, caching lookups by name and caching paginated results when no query to search for is provided.
Then, we explored how to keep the cache updated when data is updated in most common scenarios by evicting cached data by ID when a model is saved or deleted, and surfaced common problems to keep cached listings updated when individual items are updated.
Finally, we introduced a more complex scenario where we detected a write-constrained scenario on a supposedly write-only path and leveraged Memcached's speed for turning that into a deferred write. Specifically, we prevented several requests from updating the same database table concurrently under the hot path by adopting an in-memory write log, so writes could be batched and then grouped based on the row they refer to under a single query instead.