ixarlie/mutex-bundle
Provides named mutex locks for PHP applications, letting you guard critical sections and prevent race conditions. A simple API to acquire, wait for, and release locks around shared resources, background jobs, or scheduled tasks so only one process runs at a time.
Install and register
composer require ixarlie/mutex-bundle:^3.0
Version note (3.0.0). The only published change in 3.0.0 is "Symfony Package Upgrade" — a major bump whose real content is a raised Symfony (and therefore likely PHP) floor. There are no new drivers, no new APIs, and no documented deprecations. Practically that means: read composer.json in the tag to see the new symfony/* and php constraints before upgrading a running app, and if you are pinned to an older Symfony line, stay on ^2.1 until you move the framework. Because it is a semver-major, assume the DI wiring and any listener/attribute signatures moved with the Symfony version it now targets.
Symfony Flex will normally register the bundle; if not, add it to config/bundles.php:
return [
// ...
Ixarlie\MutexBundle\IxarlieMutexBundle::class => ['all' => true],
];
Minimal configuration — the bundle is a thin Symfony integration over a PHP mutex driver (flock, Redis/Predis, Memcached, PDO/MySQL, SQLite, semaphore). You pick one driver and that decides where the lock lives:
# config/packages/ixarlie_mutex.yaml
ixarlie_mutex:
driver: flock # dev/single host
# driver: redis # or predis / memcached / pdo / mysql / sqlite
Where to look first
README.md, composer.json (new in 3.0.0 — this is where the Symfony/PHP floor is now stated) and DependencyInjection/Configuration.php — the latter is the only authoritative list of valid driver keys and their options. The README tends to be sparse, so the extension class is your real documentation.Mutex/Driver/* or similar) — see which drivers are actually implemented vs. listed.bin/console debug:container --tag=... / debug:container mutex to confirm the service id and whether it is autowirable as an interface or only as the concrete Mutex class. After the 3.0.0 upgrade, re-run this first — a Symfony major bump is exactly when a private service or a removed tag alias bites.First use case — guard the one thing that hurts when it runs twice: a webhook handler, a cron command, or a "generate report" endpoint.
use Ixarlie\MutexBundle\MutexInterface;
final class ReportController
{
public function __construct(private readonly MutexInterface $mutex) {}
public function __invoke(): Response
{
if (!$this->mutex->acquire('report:generate')) {
return new Response('Already running', 409);
}
try {
// heavy work here
return new Response('ok');
} finally {
$this->mutex->release('report:generate');
}
}
}
If the interface is not autowirable, alias it in services.yaml:
services:
Ixarlie\MutexBundle\MutexInterface: '@ixarlie_mutex.mutex'
Wrap it once, use it everywhere. Don't sprinkle acquire/release through the codebase. Write one small runner that guarantees release and gives you a boolean return:
final class LockRunner
{
public function __construct(private readonly MutexInterface $mutex) {}
public function run(string $key, callable $fn, bool $wait = false): mixed
{
if (!$this->mutex->acquire($key, $wait ? 30 : 0)) {
throw new \RuntimeException("Lock '$key' is held");
}
try {
return $fn();
} finally {
$this->mutex->release($key);
}
}
}
Everything else (controllers, commands, handlers) depends on LockRunner, not the mutex directly. This makes swapping the bundle later a one-file change — and it is your insurance policy for the next major bump, since only one class touches the package's API.
Attribute-driven locking on controllers. If the bundle ships a #[Mutex]-style attribute and a kernel/controller listener, use it for endpoints whose only job is "don't run twice":
#[Mutex(key: 'webhook:stripe', ttl: 120)]
public function webhook(Request $request): Response { /* ... */ }
Verify the attribute's real namespace and argument names in the source before relying on it — this is the part of the bundle most likely to differ from the README, and the part most likely to have changed under a Symfony major upgrade (event/listener signatures and kernel.controller argument handling move between Symfony versions).
Console commands. Cron overlap is the highest-value use case. Guard execute():
protected function execute(InputInterface $input, OutputInterface $output): int
{
try {
return $this->runner->run('cron:reindex', fn () => $this->doWork($output));
} catch (\RuntimeException) {
$output->writeln('Skipped: previous run still active');
return Command::SUCCESS;
}
}
Messenger handlers. Prevent duplicate processing when a message is redelivered. Key the lock on the aggregate id (order:42), not on the handler, so two different orders still run concurrently.
Key naming discipline. Namespace every lock: {app}:{env}:{tenant}:{resource}:{id}. A generic 'lock' key shared between dev and prod on the same Redis instance is a classic outage.
Driver per environment. flock locally and in CI (fast, no infra), Redis or PDO in production. Because the driver is configured, not hard-coded, this is just two YAML files:
# config/packages/test/ixarlie_mutex.yaml
ixarlie_mutex: { driver: flock }
Testing. Point the test environment at flock and use temp-dir file locks, or register an in-memory/fake implementation of MutexInterface in services_test.yaml so unit tests don't need infrastructure.
Integration with Symfony Lock semantics. If your app already uses symfony/lock, keep it as the source of truth for new code and treat this bundle as a narrower convenience layer — mixing two lock families means two independent notions of "held". This matters more after 3.0.0: the bundle now tracks a newer symfony/*, which may collide with or duplicate your existing symfony/lock install.
flock is per-host. It provides zero protection across multiple app servers or containers. This is the single most common mistake — it works in dev and silently does nothing useful in a load-balanced prod. Choose redis, pdo/mysql, or memcached the moment you have more than one host.
Always release in finally. An exception between acquire and release leaks the lock. With Redis/PDO you should also set a TTL (where supported) so a fatal error or SIGKILL doesn't leave a permanent lock; with flock the OS cleans up on process death, so don't over-engineer there.
Timeout semantics matter. acquire($key, 0) in most of these drivers means "try once, don't block" — not "wait forever". Read the driver's implementation; the difference between non-blocking and short-blocking is what turns a 409 into a 30-request pile-up.
Private services and autowiring. Bundles of this size sometimes ship a concrete Mutex service without an interface alias. If injection fails, run debug:container and alias explicitly — don't fall back to $container->get(). Re-check this immediately after any 3.0.x install; container compilation errors from a changed services.xml are the usual way a "dependency-only" major release shows up.
Upgrading to 3.0.0 in practice. There is no migration guide and the release body is a single line, so treat the upgrade as a black box: bump, run bin/console cache:clear and debug:container, then run your one-integration-test-per-driver suite before deploying. If you only have flock covered in tests, you have no signal on the Redis/PDO paths — which are the ones production depends on.
Long-running workers. In Messenger workers and RoadRunner/Swoole setups, a lock acquired in one message can survive into the next. Make the try/finally airtight and consider releasing defensively at the top of the loop.
Debugging stuck locks. flock: look for the lock file path in the driver config. Redis: KEYS 'app:*' / TTL <key>. PDO/MySQL: inspect the lock table. Add a small bin/console command that reports held locks in each environment — you will need it, and it doubles as your smoke test after a version bump.
Maturity check before betting on it. This package has 4 stars, no dependents, and a very low opportunity score. 3.0.0 does not change that picture — a major version driven solely by a dependency upgrade, with one line of release notes and no changelog detail, is a maintenance signal rather than a maturity signal. That's not disqualifying (the mutex concept is trivial and the code is thin), but treat it as "vendor this and read the driver you use". Pin the version in composer.json (^3.0 only after you've confirmed the Symfony/PHP constraints match your app; otherwise ^2.1), and add one integration test per driver against real infrastructure (a Redis container in CI) so a silent behavior change in a patch release gets caught.
Extension points. If you need a driver it doesn't ship (e.g. DynamoDB, Postgres advisory locks), check whether drivers are registered via a tagged service or a switch in the extension. If it's a switch, you can't extend cleanly — write your own MutexInterface implementation and alias it, rather than forking the bundle. Doing this is also the cheapest way to survive the next Symfony bump.
Consider symfony/lock first. For a new project, symfony/lock gives you the same primitives (Redis/PDO/Semaphore/Postgres stores, LockFactory, expiring locks) with real maintenance and test coverage — and it will already be compatible with whatever Symfony version you are on. Adopt this bundle when you're already on it or when its Symfony integration (attribute/listener) genuinely saves you code — not for the locking itself.
How can I help you explore Laravel packages today?