Serializers describe your entities to the rest of the library. Each serializer declares:
- which fields the entity exposes, and how the library resolves them
- which relationships the entity has
- who can read each member
- how the library applies incoming writes
- Create a serializer
- Define fields
- Define relationships
- Write with deserialization
- Build a query
- Full example
Every serializer extends DoctrineSerializer and implements two methods, getFields() and getRelationships(). The descriptor classes are in Gearsite\Api\Serializer\Descriptor.
use App\Repository\ArticleRepository;
use Gearsite\Api\Serializer\Descriptor\Attribute;
use Gearsite\Api\Serializer\Descriptor\Relationship;
use Gearsite\Api\Serializer\DoctrineSerializer;
use Gearsite\Api\Serializer\SerializerRegistry;
use Symfony\Bundle\SecurityBundle\Security;
class ArticleSerializer extends DoctrineSerializer {
public function __construct(
ArticleRepository $repository,
private readonly SerializerRegistry $registry,
?Security $security = null,
) {
parent::__construct(type: 'article', repository: $repository, security: $security);
}
public function getFields(): array { /* ... */ }
public function getRelationships(): array { /* ... */ }
}Register the serializer as a Symfony service. The container then injects the dependencies for you.
Get related serializers from SerializerRegistry instead of injecting them directly. Serializers reference each other, and sometimes themselves. Constructor injection therefore creates circular dependencies. The registry defers resolution until the library walks a relationship.
?Security $security— this argument powers the write-side role checks. Pass it when any field or relationship is secured. The read side gets its ownSecurityinstance fromDocumentFactoryandGenerator.?LoggerInterface $logger— with a logger,deserialize()warns when a client tries to write a secured member without the roles for it. Without a logger, these attempts stay invisible.array $operationRoles— the roles for each operation code, used by atomic operations. An operation code with no declared roles is denied.
getFields() returns an array. Each key is the field name as it appears in the response.
A native field maps to an entity property. Use Attribute::native() when the field name and the property name are the same:
public function getFields(): array {
return [
'title' => Attribute::native(),
'body' => Attribute::native(),
];
}If the names differ, pass the property name. The library reads and writes that property with reflection, not the database column:
'created-at' => Attribute::native('createdAt'),A computed field comes from the hydrated entity instead of a property. Pass a closure that receives the entity and returns the value:
'full-name' => Attribute::computed(
resolver: fn($entity) => $entity->getFirstName() . ' ' . $entity->getLastName(),
),If the closure reads through a relationship, declare that relationship in needs. The query layer then adds a LEFT JOIN for it, even when the client did not request it. The resolver therefore never touches an uninitialized proxy:
'category-name' => Attribute::computed(
resolver: fn($entity) => $entity->getCategory()->getName(),
needs: ['category'],
),Computed fields are read-only. A write skips them, because they have no property to assign to.
Call ->secured() on a field to restrict it to the given roles. A user needs one of those roles. A user without them cannot read the field, and cannot write it:
'internal-notes' => Attribute::native()->secured(['ROLE_ADMIN']),
'salary' => Attribute::computed(
resolver: fn($entity) => $entity->getSalary(),
)->secured(['ROLE_HR', 'ROLE_ADMIN']),Write-side enforcement needs a Security instance on the serializer. Without one, a secured field fails closed, and the library treats it as inaccessible.
->mutator() is the write-side mirror of computed(). The closure receives the raw value from the request body and the target entity. The library assigns the returned value to the property:
'email' => Attribute::native()->mutator(fn(string $v) => strtolower(trim($v))),
'password' => Attribute::native('passwordHash')->mutator(fn(string $v) => password_hash($v, PASSWORD_DEFAULT)),CAUTION: A mutator must be a pure transform, not a validation gate. The library does not catch what the closure throws. The exception leaves deserialize() untranslated, and never becomes a JSON API errors document. A client then gets a framework 500 instead of a valid error response.
Validate the payload before you call deserialize(). If validation fails, answer with DocumentFactory::error():
$body = json_decode($request->getContent(), true);
if (!filter_var($body['data']['attributes']['email'] ?? null, FILTER_VALIDATE_EMAIL)) {
return $this->documentFactory->error(400, new Error(
status: '400',
title: 'Invalid attribute',
detail: 'email must be a valid email address.',
source: new ErrorSource(pointer: '/data/attributes/email'),
));
}
$this->serializer->deserialize($request->getContent(), $entity);A mutator runs before the automatic date and time coercion. It can therefore return a coercible string or a DateTimeInterface object. Mutators apply only on write, and only to native attributes. They compose with secured() in either order.
getRelationships() returns an array. Each key is the relationship name as it appears in the response.
Each serializer declares only its direct relationships. The chain handles the nesting. ArticleSerializer points to CommentSerializer, which points to AuthorSerializer. You do not declare comments.author on ArticleSerializer.
public function getRelationships(): array {
return [
'category' => Relationship::toOne($this->registry->get(CategorySerializer::class)),
'comments' => Relationship::toMany($this->registry->get(CommentSerializer::class)),
];
}If the entity property has a different name, pass it as the second argument:
'comments' => Relationship::toMany($this->registry->get(CommentSerializer::class), 'articleComments'),Call ->secured() to restrict a relationship by role. This gates both reads and writes:
'comments' => Relationship::toMany($this->registry->get(CommentSerializer::class))->secured(['ROLE_ADMIN']),Some relationships have no Doctrine association behind them. An example is "the most recent odometer reading for an asset". For these, declare a read-only computed relationship with a closure. The resolver receives the hydrated entity. computedToOne returns the related entity or null. computedToMany returns an iterable:
use Doctrine\Common\Collections\Criteria;
use Doctrine\Common\Collections\Order;
'latest-reading' => Relationship::computedToOne(
serializer: $this->registry->get(ReadingSerializer::class),
resolver: fn(Asset $asset) => $asset->getReadings()->matching(
Criteria::create()->orderBy(['recordedAt' => Order::Descending])->setMaxResults(1)
)->first() ?: null,
),On the read side, a computed relationship behaves like any other one. It renders identifier linkage. ?include=latest-reading registers the resolved resource into included. Nested includes through it work, and ->secured() composes normally.
Write paths reject it everywhere:
deserialize()warns and skips it. In strict mode it throwsReadOnlyRelationshipException.- An atomic operation that targets it fails with
400. This applies toref.relationshipand todata.relationships.
On an uninitialized one-to-many collection, matching() does not load the collection. Doctrine gives the criteria and an owner constraint to the entity persister. The persister then emits one statement:
SELECT ... WHERE asset_id = ? ORDER BY recorded_at DESC LIMIT 1That is one row and one index seek, whatever the size of the collection. Any method that iterates the collection loads every row first. This includes ->last(), ->toArray(), and foreach. Read Filtering Collections in the Doctrine manual.
Two limits apply. Both are visible in PersistentCollection::matching():
- Many-to-many collections do not get this optimization.
ManyToManyPersister::loadCriteria()applieswhereexpressions only. It ignoresorderByandsetMaxResultsat the SQL level. For an ordered "latest" of a many-to-many, reduce the collection inside the resolver. You can also restructure the association as one-to-many. - A dirty collection loads first. An unflushed
add()orremove()on that collection in the same request forces a full load beforematching()runs. A plain GET request is not affected.
needs works as it does for a computed attribute. The library JOINs and fetch-selects the listed relationships before the resolver runs. Leave needs empty when the resolver queries for itself, as above. In that example, needs: ['readings'] hydrates every reading first and defeats the purpose. Use needs only when the resolver wants a dependency fully loaded, such as sorting a small collection in PHP.
CAUTION: A resolver runs once for each rendered resource. One resource costs one extra query. A page of 25 resources costs 25. This is acceptable for LIMIT-1 index seeks. Be deliberate about computed relationships on large list endpoints.
A computed attribute can derive its value from a relationship through needs. The library JOINs that relationship whatever its own security status, because the JOIN happens before hydration and the resolver depends on it:
'category-name' => Attribute::computed(
resolver: fn($entity) => $entity->getCategory()->getName(),
needs: ['category'],
),
'category' => Relationship::toOne($this->registry->get(CategorySerializer::class))->secured(['ROLE_ADMIN']),The JOIN alone has no effect on the output. Gearsite\Api\Model\Resource still removes a secured relationship from the response. The computed value is a different matter. The library does not secure it automatically. If the derived value also needs a restriction, secure the computed attribute too, so that the intent is explicit:
'category-name' => Attribute::computed(
resolver: fn($entity) => $entity->getCategory()->getName(),
needs: ['category'],
)->secured(['ROLE_ADMIN']),When the current user lacks the roles for a relationship in needs, the library drops that dependency from the JOIN and skips the computed field. Everything fails closed together.
DoctrineSerializer::deserialize(string $body, object $entity) applies a JSON API request body onto an entity. Pass the raw request content. The method reads the top-level data member itself:
$this->serializer->deserialize($request->getContent(), $article);
$this->em->flush();Behavior:
- PATCH semantics — the library writes only the fields in the body. It leaves absent keys untouched.
- Malformed input is rejected — invalid JSON, or a body without a
dataobject, throws\InvalidArgumentException. - Declared members only — the payload can write only the attributes and relationships that the serializer declares. It is not a mass-assignment surface.
- Secured members are skipped when the caller lacks the roles. This needs a
Securityinstance on the serializer. With a logger configured, a real write attempt emits a warning. An absent member stays silent. - Mutators transform native attribute values on the way in.
- Strict mode —
deserialize($body, $entity, strict: true)throws instead of skipping. A denied secured member throwsSecuredWriteException. An unresolvable relationship id throwsUnresolvedIdentifierException. The atomic processor uses strict mode. Plain controllers usually stay lenient.
The array-accepting deserializeData() method takes an already-decoded data member and behaves the same way.
When a body sets a relationship, the library resolves the ids through resolveRelated(). The default resolves by id in one query and makes no authorization check. Any caller can therefore attach any existing row of the related type by id.
If rows of a type have per-row visibility rules, such as multi-tenant ownership, override resolveRelated() to scope the lookup. Keep it to one query. Rows outside the scope are not returned, the caller cannot attach them, and the write fails closed:
protected function resolveRelated(EntityRepository $repo, array $ids): array {
if (empty($ids)) return [];
// Rows outside the current tenant are never returned. The id predicate still hits an index.
return $repo->findBy(['id' => $ids, 'organization' => $this->tenant->current()]);
}The library drops any requested id that this method does not return. A to-many id is skipped. A to-one id becomes null.
Serializers describe entities. They do not run queries. A Generator subclass runs them. Generator is abstract, so extend it once and implement buildPagination() and buildFilter(). The specification leaves both strategies open:
use Gearsite\Api\Query\Generator;
class QueryGenerator extends Generator {
protected function buildPagination(): void {
$page = $this->request->getPageParameters();
$number = max(1, intval($page['number'] ?? 1));
$size = max(1, intval($page['size'] ?? 25));
$this->qb->setFirstResult(($number - 1) * $size)->setMaxResults($size);
}
protected function buildFilter(): void {
// Read $this->request->getFilterParameters() and apply them to $this->qb.
}
}CAUTION: Bind every filter value as a query parameter. Never concatenate request input into DQL.
Create the generator through QueryFactory, which injects the stopwatch and the logger. Then call build() and execute():
$results = $this->queryFactory
->create(QueryGenerator::class, serializer: $this->serializer, request: $request, security: $this->security)
->build()
->execute();build() returns the generator, not a QueryBuilder. To add your own constraints before the query runs, get the builder with getQueryBuilder():
$generator = $this->queryFactory->create(QueryGenerator::class, serializer: $this->serializer, request: $request)->build();
$generator->getQueryBuilder()->andWhere('e.published = true');
$results = $generator->execute();For the full controller wiring, read quick-start.md.
- It LEFT JOINs the dependencies of computed fields (
needs) onto the root query, even when the client did not request them. Resolvers therefore never hit an uninitialized proxy. This is the only thing that the library JOINs onto the root query. - It batch-loads the requested
includerelationships as separate queries after the root query runs. Each relationship level costs oneINquery. This is what keeps includes from becoming an N+1 problem. - It skips secured dependencies that the roles of the current user do not cover. This applies to
needsJOINs and to included relationships. - It applies the sort from
?sort=. Sorting works on native fields only. An unknown field is skipped with a warning. A computed field, or a secured field that the user cannot read, is skipped silently. - It calls your
buildPagination()andbuildFilter().
Role filtering of individual attributes and relationships happens later, in the Resource and Document layer. It does not happen in the query.
// CategorySerializer.php
class CategorySerializer extends DoctrineSerializer {
public function __construct(CategoryRepository $repository, ?Security $security = null) {
parent::__construct(type: 'category', repository: $repository, security: $security);
}
public function getFields(): array {
return [
'name' => Attribute::native(),
'slug' => Attribute::native(),
];
}
public function getRelationships(): array {
return [];
}
}
// CommentSerializer.php
class CommentSerializer extends DoctrineSerializer {
public function __construct(
CommentRepository $repository,
private readonly SerializerRegistry $registry,
?Security $security = null,
) {
parent::__construct(type: 'comment', repository: $repository, security: $security);
}
public function getFields(): array {
return [
'body' => Attribute::native(),
'created-at' => Attribute::native('createdAt'),
];
}
public function getRelationships(): array {
return [
'author' => Relationship::toOne($this->registry->get(AuthorSerializer::class)),
];
}
}
// ArticleSerializer.php
class ArticleSerializer extends DoctrineSerializer {
public function __construct(
ArticleRepository $repository,
private readonly SerializerRegistry $registry,
?Security $security = null,
) {
parent::__construct(type: 'article', repository: $repository, security: $security);
}
public function getFields(): array {
return [
'title' => Attribute::native(),
'body' => Attribute::native(),
'category-name' => Attribute::computed(
resolver: fn($entity) => $entity->getCategory()->getName(),
needs: ['category'],
),
'internal-notes' => Attribute::native()->secured(['ROLE_ADMIN']),
];
}
public function getRelationships(): array {
return [
'category' => Relationship::toOne($this->registry->get(CategorySerializer::class)),
'comments' => Relationship::toMany($this->registry->get(CommentSerializer::class)),
];
}
}A request to /articles?include=comments.author&fields[article]=title,body resolves in four stages:
- The root query selects the
articleentity. It LEFT JOINscategory, becausecategory-namedeclares that relationship inneeds. It does not joincommentsorauthor. - After the root query runs, one
INquery batch-loadscommentsfor every matched article. - A second
INquery batch-loadsauthorfor every one of those comments. fields[article]=title,bodylimits which attributes of the article the library renders.