Skip to content

Lock

Foundation Lock lets an application name work that must not overlap, select the shared backend that coordinates it, and run bounded work through LockOperation. It reports successful completion or contention, and throws when the operation or lock coordination fails. Ownership lasts only for the configured lease duration.

For WordPress applications where every participant can reach the same primary database, DatabaseLock is the simplest persistent option. Choose Redis when your application has a Redis service you want to use for lock coordination. Use InMemoryLock for tests or work confined to one PHP process.

Implementation Package Coordinates work across
DatabaseLock stellarwp/foundation-database Requests and workers sharing the primary WordPress database
RedisLock stellarwp/foundation-lock-redis Requests and workers sharing a Redis endpoint
InMemoryLock stellarwp/foundation-lock One PHP process

Install the package for your chosen implementation. The database and Redis packages include stellarwp/foundation-lock automatically. Once configured, either backend supports the same application usage.

The examples assume the application has a composition root that registers providers in order.

Install the database package:

composer require stellarwp/foundation-database

Follow the database lock guide to register its providers, initialize the lock table, and select DatabaseLock as your application’s Lock implementation. Then continue to Usage below.

Install the Redis implementation and the supported Predis client:

composer require stellarwp/foundation-lock-redis "predis/predis:>=3.0 <4.0"

Set a stable application prefix and configure one writable Redis endpoint for locks. Foundation supports TCP, TLS, and Unix-socket connections. The required lock.redis.parameters setting accepts a Predis URI or parameter array; lock.redis.options accepts optional Predis client settings. Missing parameters raise InvalidArgumentException during provider registration; add the setting shown below before registering the provider.

In config.php:

<?php declare(strict_types=1);

$config = [
	'foundation' => [ 'prefix' => $_ENV['FOUNDATION_PREFIX'] ?? 'your-plugin' ],
	'lock' => [ 'redis' => [
		'parameters' => [
			'host'     => $_ENV['FOUNDATION_LOCK_REDIS_HOST'] ?? '127.0.0.1',
			'port'     => (int) ( $_ENV['FOUNDATION_LOCK_REDIS_PORT'] ?? 6379 ),
			'database' => (int) ( $_ENV['FOUNDATION_LOCK_REDIS_DATABASE'] ?? 1 ),
		],
	] ],
];

if ( isset( $_ENV['FOUNDATION_LOCK_REDIS_PREFIX'] ) ) {
	$config['lock']['redis']['prefix'] = $_ENV['FOUNDATION_LOCK_REDIS_PREFIX'];
}

return $config;

Invalid Predis configuration raises ContainerException when the connection is resolved; correct the configuration before retrying.

Create src/Lock/Provider.php to select Redis as this application’s default lock implementation:

<?php declare(strict_types=1);

namespace Plugin\Lock;

use StellarWP\Foundation\Container\Contracts\Provider as Service_Provider;
use StellarWP\Foundation\Container\Contracts\Resolver as C;
use StellarWP\Foundation\Lock\Contracts\Lock;
use StellarWP\Foundation\LockRedis\RedisLock;

/**
 * Selects Redis for application lock consumers.
 */
final class Provider extends Service_Provider {

	/**
	 * Register the application's default lock implementation.
	 */
	public function register(): void {
		$this->container->singleton(
			Lock::class,
			static fn ( C $c ): RedisLock => $c->get( RedisLock::class )
		);
	}
}

Register the connection provider, Redis lock provider, and your application provider in that order:

In src/App.php:

use StellarWP\Foundation\Container\Contracts\Provider;
use StellarWP\Foundation\LockRedis\LockRedisProvider;
use StellarWP\Foundation\LockRedis\PredisConnectionProvider;
use Plugin\Lock;

/** @var list<class-string<Provider>> */
private const array PROVIDERS = [
	PredisConnectionProvider::class,
	LockRedisProvider::class,
	Lock\Provider::class,
];

The Redis key prefix defaults to foundation.prefix . ':lock:'. Without configuration, Foundation uses nx:lock:; this example defaults to your-plugin:lock:. A complete application that owns its shared composition root can use nx, but a distributable standalone plugin must set a stable unique foundation.prefix. The optional FOUNDATION_LOCK_REDIS_PREFIX environment variable sets lock.redis.prefix explicitly; Foundation preserves its nonempty value exactly. When that variable is absent, the provider derives the prefix from foundation.prefix.

Inject LockOperation into application services to acquire and release the selected lock around their work. The same service works with your chosen database or Redis implementation. In this example, Catalog_Importer is an existing application collaborator whose import(int $site_id): void method performs the import.

In src/Catalog/Catalog_Synchronizer.php:

<?php declare(strict_types=1);

namespace Plugin\Catalog;

use StellarWP\Foundation\Lock\LockOperation;
use Throwable;

/**
 * Prevents overlapping catalog imports for one site.
 */
final readonly class Catalog_Synchronizer {

	/**
	 * Use the configured lock lifecycle and catalog importer.
	 */
	public function __construct(
		private LockOperation $lock_operation,
		private Catalog_Importer $catalog_importer
	) {
	}

	/**
	 * Synchronize a site's catalog, or skip a duplicate attempt.
	 *
	 * @throws Throwable When importing or lock coordination fails.
	 */
	public function synchronize( int $site_id ): bool {
		return $this->lock_operation->run(
			name: sprintf( 'catalog:%d:sync', $site_id ),
			ttl: 300,
			operation: fn () => $this->catalog_importer->import( $site_id )
		);
	}
}

The TTL is in whole seconds and must be at least one. Choose one longer than the import’s bounded work. The callback receives no arguments and its return value does not affect the result.

synchronize() returns false only when another owner holds catalog:<site ID>:sync; the importer is not called. It returns true only after the importer finishes and release confirms ownership. Callback failures and lock backend failures throw. If the importer completes but release returns false, LockOperation throws LockOwnershipLostException; the completed import is not undone, so retry only when the import’s own idempotency policy permits it.

Configure the replacement backend and change the application’s Lock binding before resolving any consumer that receives LockOperation; Catalog_Synchronizer remains unchanged. During deployment, quiesce workers using the old backend or prefix before starting workers using the replacement, because different backends and prefixes do not coordinate with each other.

Predis is the supplied setup path. For PhpRedis, install and enable the extension, omit PredisConnectionProvider and the Predis dependency, and supply an application-owned connection dedicated to locks. Bind the Redis Connection contract before RedisLock resolves. An application can also replace a provider-supplied connection after provider registration and before resolution.

In src/Lock/PhpRedis_Connection_Provider.php:

<?php declare(strict_types=1);

namespace Plugin\Lock;

use StellarWP\Foundation\Container\Contracts\Provider as Service_Provider;
use StellarWP\Foundation\LockRedis\Connections\PhpRedisConnection;
use StellarWP\Foundation\LockRedis\Contracts\Connection;

/**
 * Adapts the application's configured PhpRedis client for Foundation locks.
 */
final class PhpRedis_Connection_Provider extends Service_Provider {

	/**
	 * Register the Redis lock connection adapter.
	 */
	public function register(): void {
		$this->container->singleton( Connection::class, PhpRedisConnection::class );
	}
}

The application must bind its dedicated, connected Redis instance before this adapter resolves. A custom adapter can implement Connection and use the same replacement point, provided its evaluate() and exists() methods preserve the lock contract’s atomicity and failure behavior.

Use Redis for one feature while another lock stays default

Section titled “Use Redis for one feature while another lock stays default”

An application can keep DatabaseLock as the global Lock while one feature uses Redis. Register both Redis Foundation providers, keep the database Lock binding, then configure that feature’s LockOperation construction with the Redis lock.

In src/Catalog/Provider.php:

<?php declare(strict_types=1);

namespace Plugin\Catalog;

use StellarWP\Foundation\Container\Contracts\Provider as Service_Provider;
use StellarWP\Foundation\Container\Contracts\Resolver as C;
use StellarWP\Foundation\Lock\LockOperation;
use StellarWP\Foundation\LockRedis\RedisLock;

/**
 * Configures catalog synchronization with its Redis lock lifecycle.
 */
final class Provider extends Service_Provider {

	/**
	 * Use Redis only for Catalog_Synchronizer's lock operation.
	 */
	public function register(): void {
		$this->container->when( Catalog_Synchronizer::class )
			->needs( LockOperation::class )
			->give(
				static fn ( C $c ): LockOperation => new LockOperation(
					$c->get( RedisLock::class )
				)
			);
	}
}

Register this feature provider in App after the Redis providers. Its contextual binding selects Redis for Catalog_Synchronizer; other services continue using the application’s default lock.

Use InMemoryLock to test a service’s lock behavior without Redis or WordPress. Inject new LockOperation( $lock ) alongside the service’s usual application test doubles. The following standalone test demonstrates successful work and contention with that same lock instance.

Inside a test method in tests/Unit/Lock/LockOperationTest.php (with the shown imports at file scope):

use StellarWP\Foundation\Lock\InMemoryLock;
use StellarWP\Foundation\Lock\LockOperation;
use StellarWP\Foundation\Lock\SystemClock;

$lock      = new InMemoryLock( new SystemClock() );
$operation = new LockOperation( $lock );
$imports   = [];
$import    = static function () use ( &$imports ): void {
	$imports[] = 42;
};

$this->assertTrue( $operation->run( 'catalog:42:sync', 300, $import ) );

$owner = $lock->acquire( 'catalog:42:sync', 300 );

$this->assertNotNull( $owner );
$this->assertFalse( $operation->run( 'catalog:42:sync', 300, $import ) );
$this->assertSame( [ 42 ], $imports );
$this->assertTrue( $lock->release( $owner ) );

Both attempts use the same InMemoryLock instance. Use this implementation for tests and work within one PHP process, and Redis or database locks for work shared across requests.

A feature with bounded stages can inject Lock directly and refresh its ownership token between stages.

Inside src/Catalog/Batch_Catalog_Synchronizer.php:

// File-scope imports; the operation below belongs in the service method.
use StellarWP\Foundation\Lock\Exceptions\LockOwnershipLostException;
use Throwable;

$token = $lock->acquire( 'catalog:42:sync', 120 );

if ( $token === null ) {
	return;
}

try {
	$this->catalog_importer->import_first_batch( 42 );

	$refreshed = $lock->refresh( $token, 120 );

	if ( $refreshed === null ) {
		// Ownership expired or changed. Stop before the next protected stage.
		return;
	}

	$token = $refreshed;
	$this->catalog_importer->import_remaining_batches( 42 );
} catch ( Throwable $failure ) {
	try {
		$lock->release( $token );
	} catch ( Throwable ) {
		// Preserve the import failure when cleanup also fails.
	}

	throw $failure;
}

if ( ! $lock->release( $token ) ) {
	throw new LockOwnershipLostException( 'Catalog synchronization lock ownership was lost.' );
}

For payment gateways and other remote side effects, use the remote API’s idempotency support alongside the lock. Reuse the same idempotency key when retrying the same business operation after a timeout.

Install only the shared contract and in-memory implementation for tests or work confined to one PHP process:

composer require stellarwp/foundation-lock

For direct token lifecycle, the Lock contract exposes acquire(), release(), refresh(), and advisory isAcquired(); use acquire() rather than a check-then-act sequence.