Some controllers must never run twice at the same time: importing a catalog, charging an order, or selling the last tickets of a conference. The Lock component solves this, but to use it in a controller, you have to inject a lock factory, build the key, acquire the lock, handle the case where it's already taken and release it at the end. Symfony 8.2 replaces all that with a single attribute.

The #[Lock] Attribute

Adrian Brajković
Contributed by Adrian Brajković in #66515

With the Lock component installed, use the new #[Lock] attribute to allow only one request at a time to run a controller for a given key. By default, a competing request gets a 409 Conflict response:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\HttpKernel\Attribute\Lock;
use Symfony\Component\Security\Http\Attribute\CurrentUser;

class CatalogController extends AbstractController
{
    // lock the POST requests so only one catalog import runs at a time;
    // GET requests, which only display the import form, are not locked
    #[Lock('catalog', methods: 'POST')]
    public function import(): Response { /* ... */ }

    // wait for the lock to be released instead of rejecting the request
    #[Lock('catalog-export', blocking: true)]
    public function export(): Response { /* ... */ }

    // one lock per user, using a named lock factory and a TTL of 60 seconds
    #[Lock(new Expression('"report-" ~ args["user"].getUserIdentifier()'), factory: 'reports', ttl: 60)]
    public function report(#[CurrentUser] User $user): Response { /* ... */ }
}

Set read: true to acquire a shared lock. In this example, several users can download the catalog at the same time. While a download holds the lock, imports are rejected. While an import holds it, downloads are rejected:

1
2
#[Lock('catalog', read: true)]
public function download(): Response { /* ... */ }

Symfony releases the lock when the controller finishes. For a StreamedResponse, it keeps the lock until the content has been sent. The default TTL is 30 seconds, and Symfony doesn't refresh it automatically. If the lock expires before the controller finishes, another request can acquire it. Also, the blocking option has no timeout of its own, so a waiting request can wait as long as the lock is held.

Unless the key depends on controller arguments, Symfony acquires the lock before resolving them. If an argument is a Doctrine entity, a rejected request doesn't need to load it. A request that waits for the lock loads it after the wait, avoiding stale data.

For the same reason, the lock is acquired before #[IsGranted] is checked, whatever the order of the attributes (the same happens with #[RateLimit]). A user without access to a locked controller can get a 409 Conflict response instead of a 403 Forbidden one, or wait for the lock before being denied if the lock is blocking.

Building Lock Keys from Route Parameters

Nicolas Grekas
Contributed by Nicolas Grekas in #66784

Most locks are scoped to a single resource, so their key is built from a route parameter. Instead of writing an expression for that, you can use {name} placeholders in the key, with the same syntax as in route paths. Each placeholder is replaced by the request attribute of the same name:

1
2
3
#[Route('/conference/{conference}/tickets', methods: 'POST')]
#[Lock('ticket-sale-{conference}', blocking: true, ttl: 10)]
public function buyTicket(Conference $conference): Response { /* ... */ }

Placeholders now also work in the keys of the #[RateLimit] attribute:

1
2
3
#[Route('/api/users/{id}')]
#[RateLimit('api', key: 'user-{id}')]
public function show(int $id): JsonResponse { /* ... */ }

Injecting the Acquired Lock

Adrian Brajković
Contributed by Adrian Brajković in #66697

Sometimes a controller needs to work with its own lock: to refresh it when the work takes longer than the TTL, or to release it as soon as the critical part is done. Add a LockInterface or SharedLockInterface argument to the controller and Symfony injects the lock acquired by the #[Lock] attribute:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use Symfony\Component\HttpKernel\Attribute\Lock;
use Symfony\Component\Lock\LockInterface;

#[Lock('catalog', methods: 'POST')]
public function import(?LockInterface $lock): Response
{
    // ... update the catalog

    $lock?->release();

    // ... send notifications without holding the lock

    return new Response();
}

Only a lock acquired by the request itself is injected. Make the argument nullable to get null when there's none (for example, in a sub-request that reuses the lock of its main request, or when methods excludes the current HTTP method).

Published in #Living on the edge