Tuesday, 29 July 2025

Shared/distributed locks in PHP with Symfony locking

G'day:

This is another one of these issues that got into my mind at some point, and I never had a chance to look into it until now.

Previously I was working on a CFML app, which had code that could not be run at the same time as other code, so handled this with CFML's native <cflock> mechanism. This works fine on a single CFML server, but is no good when there's more than server running the application. We never had to solve this issue during my time working on that project, but the question stuck with me

I don't give a rat's arse about solving this with CFML; but I can foresee it being a "good to know" thing in the PHP space. Turns out it's actually very bloody easy.

Symfony proves a locking component: The Lock Component. And the docs are pretty straight forward. It's installed as one might predict:

composer require symfony/lock:7.3.*

It creates config/packages/lock.yaml:

framework:
    lock: '%env(LOCK_DSN)%'

And that had me looking for where LOCK_DSN was set, and what it needed to be. This lead me to the Available Stores bit of the docs I linked to above, which listed a bunch of underlying storage mechanisms for the locks. Each had features and limitations, which got me to think about what I am trying to test here. Basically two things:

  • The locks needed to be respected across different PHP containers.
  • I needed to be able to create blocking locks.

On that second point: the Locking Component's default behaviour is to try to acquire a lock, and respond immediately one way or the other (yep you got the lock; nope you didn't get that lock). This is cool a lot of the time; but sometimes I can see wanting to wait until [whatever] has finished with the lock, and then grab the lock and crack on with some other stuff.

The only option that supported both remote & blocking locks was a PostgreSql solution, but I'm fucked if I'm gonna install PostgreSql just to solve a locking challenge. I looked at some other solutions, and the "flock"-based solution would work for me. Despite it not being remote-capable, it stores its locking metadata on the file system; and I could easily use a mounted volume in my docker containers to have multiple PHP containers using the same directory. For my immediate purposes this is fine. If I need multiple app containers spread across multiple host machines, I'll look into other solutions.

So the answer for where LOCK_DSN is set is: in .env:

# stick stuff in here that all envs need

LOCK_DSN=flock
MESSENGER_TRANSPORT_DSN=amqp://guest:guest@host.docker.internal:5672/%2f/messages

And that's all the DSN needs to have as a value when using Flock.

The code for initialising a Flock lock is thus:

$store = new FlockStore('[some directory to use for lock metadata]');
$factory = new LockFactory($store);

So before going any further I need that shared directory set up:

# docker/php/envVars.public

APP_ENV=dev
APP_CACHE_DIR=/var/cache/symfony
APP_LOCK_DIR=/tmp/symfony/lock
APP_LOG_DIR=/var/log/symfony
COMPOSER_CACHE_DIR=/tmp/composer-cache
PHPUNIT_CACHE_RESULT_FILE=0
# docker/php/Dockerfile

# [...]

# need to use 777 as both php-fpm and php-cli will write to these directories
RUN mkdir -p /var/cache/symfony && chown www-data:www-data /var/cache/symfony && chmod 777 /var/cache/symfony
RUN mkdir -p /var/cache/symfony/dev && chown www-data:www-data /var/cache/symfony/dev && chmod 777 /var/cache/symfony/dev
RUN mkdir -p /var/log/symfony && chown www-data:www-data /var/log/symfony && chmod 777 /var/log/symfony
RUN mkdir -p /tmp/symfony/lock && chown www-data:www-data /tmp/symfony/lock && chmod 777 /tmp/symfony/lock

# [...]
# docker/docker-compose.yml

services:
  # [...]

  php-web:
    # [...]

    volumes:
      - ..:/var/www
      - /var/log/symfony:/var/log/symfony
      - /tmp/symfony/lock:/tmp/symfony/lock

    # [...]

  php-worker:
    # [...]

    volumes:
      - ..:/var/www
      - /var/log/symfony:/var/log/symfony
      - /tmp/symfony/lock:/tmp/symfony/lock

    # [...]

Now I can wire-up the services:

# config/services.yaml

parameters:

services:
  # [...]

  Symfony\Component\Lock\Store\FlockStore:
    arguments:
      - '%env(APP_LOCK_DIR)%'

  Symfony\Component\Lock\LockFactory:
    arguments:
      - '@Symfony\Component\Lock\Store\FlockStore'

# [...]

Oh and I need a logger for this too:

# config/packages/monolog.yaml

monolog:
  channels:
    # [...]
    - locking

  handlers:
    # [...]

    locking:
      type: stream
      path: '%kernel.logs_dir%/locking.log'
      level: debug
      channels: ['locking']

Now I'm gonna create a web endpoint that creates a lock around some long-running code, logging as I go:

# src/Controller/LockController.php

namespace App\Controller;

use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/lock', name: 'app_lock')]
class LockController extends AbstractController
{
    public function __construct(
        private readonly LockFactory $lockFactory,
        private readonly LoggerInterface $lockingLogger
    )
    {
    }

    #[Route('/long', name: 'app_lock_long')]
    public function longLock(): Response
    {
        $this->lockingLogger->info('web_lock_long: started');

        $lock = $this->lockFactory->createLock('long_lock', 30);
        $this->lockingLogger->info('web_lock_long: lock created');

        if ($lock->acquire(true)) {
            $this->lockingLogger->info('web_lock_long: lock acquired');
            sleep(20); // Simulate a long-running process
            $this->lockingLogger->info('web_lock_long: processing done, releasing lock');
            $lock->release();
        } else {
            $this->lockingLogger->warning('web_lock_long: could not acquire lock');
        }

        return new Response('Lock operation completed.');
    }
}

Most of that is boilerplate and logging. The short version is:

$lock = $this->lockFactory->createLock('long_lock', 30); // name, TTL

if ($lock->acquire(true)) { // true makes it a blocking lock
    // do stuff
    $lock->release();
} else {
    // didn't get the lock
}

I'll run this on the php-web container, eg: http://localhost:8080/lock/long. It'll get a lock, and then sit around doing nothing for 20sec. Logging all the way.

I have created an equivalent command for php-worker to run via the CLI. It's analogous to the controller method, logic-wise:

src/Command/LongLockCommand.php

namespace App\Command;

use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Lock\LockFactory;

#[AsCommand(
    name: 'app:long-lock',
    description: 'Acquires a long lock for testing lock contention.'
)]
class LongLockCommand extends Command
{
    public function __construct(
        private readonly LockFactory $lockFactory,
        private readonly LoggerInterface $lockingLogger
    ) {
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $this->lockingLogger->info('command_lock_long: started');

        $lock = $this->lockFactory->createLock('long_lock', 30);
        $this->lockingLogger->info('command_lock_long: lock created');

        if ($lock->acquire(true)) {
            $this->lockingLogger->info('command_lock_long: lock acquired');
            sleep(20); // Simulate a long-running process
            $this->lockingLogger->info('command_lock_long: processing done, releasing lock');
            $lock->release();
        } else {
            $this->lockingLogger->warning('command_lock_long: could not acquire lock');
        }

        $output->writeln('Lock operation completed.');
        return Command::SUCCESS;
    }
}

This is run thus:

docker exec php-worker bin/console app:long-lock

And that's everything. If I hit the URL in a browser, and then a few seconds later call the command, I see this sort of thing in the log file:

tail -f /var/log/symfony/locking.log

[2025-07-29T10:57:41.626263+01:00] locking.INFO: web_lock_long: started [] []
[2025-07-29T10:57:41.626556+01:00] locking.INFO: web_lock_long: lock created [] []
[2025-07-29T10:57:41.626671+01:00] locking.INFO: web_lock_long: lock acquired [] []
[2025-07-29T10:57:47.231988+01:00] locking.INFO: command_lock_long: started [] []
[2025-07-29T10:57:47.233814+01:00] locking.INFO: command_lock_long: lock created [] []
[2025-07-29T10:58:02.398162+01:00] locking.INFO: web_lock_long: processing done, releasing lock [] []
[2025-07-29T10:58:02.398440+01:00] locking.INFO: command_lock_long: lock acquired [] []
[2025-07-29T10:58:23.175676+01:00] locking.INFO: command_lock_long: processing done, releasing lock [] []

Success. We can see that the web request creates and acquires the lock, and the command comes along afterwards and cannot acquire its lock until after the web request releases it.

Job done. This was way easier than I expected, actually.

Righto.

--
Adam