This library implements the JSON API Atomic Operations extension. A client posts a batch of add, update, and remove operations in one request body. The server runs them all-or-nothing in one database transaction. It then answers with one result for each operation.
- Responsibility split
- Endpoint setup
- Walkthrough: create, link by lid, mutate a relationship
- Authorization
- Caveats
| The library owns | You own |
|---|---|
Parsing and validation of atomic:operations documents |
The route (one POST endpoint) |
Dispatch of each type to its serializer |
Media-type enforcement (optional, helper provided) |
Resolution of lid local identifiers |
Authorization policy |
| The transaction (begin, flush per operation, commit, roll back) | Validation beyond the structure of the specification |
atomic:results and error documents with pointers |
The EntityManager after a failed batch |
use Gearsite\Api\Atomic\OperationsProcessor;
use Gearsite\Api\Atomic\ResultsDocument;
class OperationsController extends AbstractController {
public function __construct(
private readonly OperationsProcessor $processor, // autowired by the bundle
private readonly DocumentFactory $documentFactory,
private readonly EntityManagerInterface $em,
) {}
#[Route('/operations', methods: ['POST'])]
public function operations(SymfonyRequest $request): Response {
// The library does not enforce the media type. This check is recommended.
if (!$request->hasAtomicExtension()) {
return $this->documentFactory->error(415, new Error(status: '415', title: 'Unsupported Media Type',
detail: 'Atomic operations require Content-Type ' . ResultsDocument::MEDIA_TYPE));
}
return $this->processor->process($request->getContent(), $this->em);
}
}If you enforce content negotiation, requests must carry this header:
Content-Type: application/vnd.api+json; ext="https://jsonapi.org/ext/atomic"
Responses use the same media type.
A successful batch answers 200 with one result for each operation, in order. An operation with nothing to report returns {}:
{ "atomic:results": [
{ "data": { "type": "author", "id": "17", "attributes": { ... }, "relationships": { ... } } },
{ "data": { "type": "article", "id": "42", ... } },
{}
] }When no operation produces content, the batch answers 204. An all-remove batch is the usual example.
Any error rolls the whole batch back. The response is then an errors document. Each source.pointer walks into the operation that failed, for example /atomic:operations/1/data/attributes/title. The members atomic:results and errors never appear together.
ref.relationship targets one relationship instead of a whole resource.
To-one relationships:
updatewith an identifier sets the relationship.updatewithdata: nullclears it.addandremoveare rejected with400.
To-many relationships:
addappends members. Members that are already present are ignored.updatereplaces the full membership.removeremoves the listed members. Members that are absent are ignored.
CAUTION: A relationship operation must target the owning side of a many-to-many association. Doctrine does not persist a change made on the inverse side.
The atomic endpoint collapses your per-route access control into one POST. The processor therefore asks the serializer of each operation for permission first:
authorizeOperation(string $op, ?object $entity): boolThis method is closed by default. A type that declares nothing cannot be written through the atomic endpoint at all.
This is the default implementation:
parent::__construct(type: 'transaction', repository: $repository, security: $security, operationRoles: [
'add' => ['ROLE_ACCOUNTING'],
'update' => ['ROLE_ACCOUNTING'],
'remove' => ['ROLE_ACCOUNTING_ADMIN'],
]);To keep the atomic endpoint and your controllers in agreement, override the hook and delegate to the same voter. The policy then lives in one place:
// The override in the serializer. A two-line adapter, with no policy in it.
public function authorizeOperation(string $op, ?object $entity): bool {
return $this->security?->isGranted(match ($op) {
'add' => ArticleVoter::CREATE,
'update' => ArticleVoter::EDIT,
'remove' => ArticleVoter::DELETE,
}, $entity ?? Article::class) ?? false;
}// The controller uses the same constants. One voter, two entry points.
#[Route('/articles/{id}', methods: ['DELETE'])]
#[IsGranted(ArticleVoter::DELETE, subject: 'article')]
public function delete(Article $article): Response { ... }The voter holds the real rules, such as ownership, roles, and entity state:
class ArticleVoter extends Voter {
public const CREATE = 'article.create';
public const EDIT = 'article.edit';
public const DELETE = 'article.delete';
protected function supports(string $attribute, mixed $subject): bool {
return in_array($attribute, [self::CREATE, self::EDIT, self::DELETE], true)
&& ($subject instanceof Article || $subject === Article::class);
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool {
return match ($attribute) {
self::CREATE => $this->security->isGranted('ROLE_AUTHOR'),
self::EDIT => $subject->getAuthor()?->getUser() === $token->getUser()
|| $this->security->isGranted('ROLE_EDITOR'),
self::DELETE => $this->security->isGranted('ROLE_ADMIN'),
};
}
}An atomic request means "apply exactly this, or nothing". A plain PATCH logs a warning and skips a secured member. A batch does not. An operation that contains a secured attribute or relationship fails the whole batch with 403. The error points at the member.
The same rule applies in two more cases:
- An operation that targets a secured relationship through
ref.relationship. - A relationship identifier that does not resolve. The batch fails with
404instead of dropping the identifier.
- The EntityManager closes after a failed flush. When
process()returns an error response, do not use that EntityManager again in the same request. If you must continue, callManagerRegistry::resetManager(). - Only the database rolls back. Mutators, hooks, and lifecycle listeners run for each operation inside the transaction. Work that they do outside the database is not undone. This includes mail, queues, and files. Keep external side effects out of every code path that a batch can reach.
- Gaps in auto-increment values are normal. The database does not return the ids of rolled-back inserts to the sequence.
hreftargeting and composite keys are not supported.
POST /operations { "atomic:operations": [ // 1. Create an author with a client-chosen local id. { "op": "add", "data": { "type": "author", "lid": "a1", "attributes": { "first-name": "Ada", "last-name": "Lovelace" } } }, // 2. Create an article that references the author above. No real id is needed. { "op": "add", "data": { "type": "article", "lid": "art1", "attributes": { "title": "Notes", "body": "..." }, "relationships": { "author": { "data": { "type": "author", "lid": "a1" } } } } }, // 3. Append a comment to the to-many relationship of the article. { "op": "add", "ref": { "type": "article", "lid": "art1", "relationship": "comments" }, "data": [ { "type": "comment", "id": "7" } ] } ] }