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 returns the callback’s result after ownership is confirmed at release, and throws on contention or when work or lock coordination fails. Ownership lasts only for the configured lease duration; multi-stage work can renew it at explicit checkpoints.
Installation
Section titled “Installation”Choose a lock implementation
Section titled “Choose a lock implementation”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.
Prepare the application container
Section titled “Prepare the application container”The examples assume the application has a composition root that registers providers in order.
Configuration
Section titled “Configuration”Use the WordPress database
Section titled “Use the WordPress database”Install the database package:
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.
Use Redis
Section titled “Use Redis”Install the Redis implementation and the supported Predis client:
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:
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:
Register the connection provider, Redis lock provider, and your application provider in that order:
In src/App.php:
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.
Run a protected operation
Section titled “Run a protected operation”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:
run() makes one acquisition attempt. Contention during that acquisition throws LockContendedException without invoking its callback; it does not wait or retry. Callback exceptions also propagate unchanged, including contention from nested operations. The callback receives a LockLease, which this short operation can ignore. After confirmed release, run() returns the callback’s value unchanged, including objects, false, or null.
The TTL is in whole seconds and must be at least one. Choose one longer than the import’s bounded work.
Here, synchronize() preserves an application-level boolean API: it returns false only for contention before this invocation’s callback starts, and true only after the importer finishes and release confirms ownership. Setting $started before calling the importer distinguishes a skipped attempt from nested contention after partial work. A callback result of false in another service would be a normal result, not a contention signal.
Renew between bounded stages
Section titled “Renew between bounded stages”Keep the whole multi-stage operation inside one run() callback. Call the supplied lease’s renew(): void at checkpoints before starting the next bounded stage. Each renewal extends ownership from the backend’s current time by the original TTL; Foundation retains the latest token and owns cleanup. There is no automatic heartbeat.
In this example, the existing Catalog_Importer::import_batch(int $site_id, int $batch_id): int imports one bounded batch and returns its imported-record count. Each batch, including remote calls, must fit within the TTL.
In src/Catalog/Batch_Catalog_Synchronizer.php:
The caller receives the total imported count only after release confirms ownership. The lease supports only renew() for application use: do not construct it yourself, release it, replace its token, or reacquire through it. Use it only inside its callback; after the callback exits, including during cleanup, renew() throws LogicException.
run() attempts owner-safe release with the latest token when the callback exits. If the callback throws, that escaping exception takes precedence over an earlier caught renewal failure and any cleanup failure. If the callback returns after catching a renewal failure, the recorded renewal failure takes precedence over cleanup failures. Only an otherwise successful operation exposes a release failure directly.
Customization
Section titled “Customization”Select another default backend
Section titled “Select another default backend”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.
Use PhpRedis or a custom connection
Section titled “Use PhpRedis or a custom connection”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:
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:
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.
Test application behavior
Section titled “Test application behavior”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):
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.
Advanced usage
Section titled “Advanced usage”In-memory locks and direct contracts
Section titled “In-memory locks and direct contracts”Install only the shared contract and in-memory implementation for tests or work confined to one PHP process:
For integrations that own a different lifecycle, the low-level Lock contract exposes acquire(), owner-safe release(), refresh(), and advisory isAcquired(). acquire() returns a token or null on contention; refresh() returns a replacement token or null on lost ownership. Such integrations must retain the latest token, stop on ownership loss, and preserve operation failures during cleanup themselves. Use acquire() rather than a check-then-act sequence. Prefer LockOperation and its managed lease for ordinary application work.