jaspr / mapper
JSON API implementation, by annotations or schemas.
Requires
- php: ^8.2
- ext-json: *
- composer/class-map-generator: ^1.0
- fig/http-message-util: ^1.1
- jaspr/expression: ^1.1.3
- jaspr/oas-builder: ^1.2 || ^2.0
- psr/http-factory: ^1.0
- psr/http-server-middleware: ^1.0
- psr/log: ^1.0 || ^2.0 || ^3.0
- psr/simple-cache: ^1.0 || ^2.0 || ^3.0
- symfony/string: ^5.4 || ^6.0 || ^7.0 || ^8.0
- symfony/translation-contracts: ^2.1 || ^3.0
Requires (Dev)
- doctrine/collections: ^1.6.8
- phpstan/phpstan: ^2.0.2
- phpunit/phpunit: ^11.5
- rector/rector: 2.0.18
- slim/psr7: ^1.3
- squizlabs/php_codesniffer: ^3.13.6
- swaggest/json-schema: ^0.12.29
- symfony/cache: ^7.1 || ^8.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-26 14:58:33 UTC
README
A PHP implementation of the JSON:API 1.1 server side. You describe your classes with PHP attributes; the mapper does the rest of the protocol:
- a PSR-15 middleware that negotiates media types, parses and validates every request, and answers protocol errors with JSON:API error documents;
- an encoder that turns your objects into documents, with links, sparse fieldsets, inclusion and pagination;
- the Atomic Operations extension and profiles;
- an OpenAPI 3.0 document and a TypeScript contract for
@jaspr/client-js, both generated from the same metadata.
Your handlers load and store data; the mapper never talks to a database.
Upgrading from 5.x? Read UPGRADE.md.
Installation
composer require jaspr/mapper
PHP 8.2 or later. Report issues at gitlab.com/jaspr/mapper.
Quick start
1. Describe a resource.
use JSONAPI\Mapper\Annotation as API;
#[API\Resource('articles')]
class Article
{
#[API\Id]
public string $id;
#[API\Attribute]
public string $title;
#[API\Attribute]
public ?string $subtitle = null;
#[API\Attribute(readOnly: true)]
public DateTimeImmutable $createdAt;
#[API\Relationship(Person::class)]
public Person $author;
/** @var Comment[] */
#[API\Relationship(Comment::class)]
public array $comments = [];
}
2. Build the metadata once (cached through any PSR-16 cache):
$repository = MetadataFactory::create(['src/Resource'], $cache, new AnnotationDriver());
3. Add the middleware in front of your JSON:API routes:
$middleware = new PsrJsonApiMiddleware(
$responseFactory, // PSR-17
$streamFactory, // PSR-17
new DefaultParserFactory('https://api.example.com', $repository),
new RequestDependentEncoderFactory($repository)
);
4. Handle the request. The middleware hands every handler a parsed request and a document builder:
use JSONAPI\Mapper\Middleware\PsrJsonApiMiddleware;
public function handle(ServerRequestInterface $request): ResponseInterface
{
/** @var Builder $builder */
$builder = $request->getAttribute(PsrJsonApiMiddleware::BUILDER);
$query = $builder->getRequest(); // ParsedRequest: path, filter, sort, page, include, fields
$result = $this->articles->find($query->getFilter(), $query->getSort(), $query->getPagination());
$query->getPagination()?->setTotal($result->total); // optional; adds meta.total and the last link
$document = $builder->setData($result->items)->build();
return $this->responseFactory->createResponse(200)
->withBody($this->streamFactory->createStream(json_encode($document)));
}
The middleware sets the JSON:API Content-Type and Vary: Accept. Status codes of successful writes are
yours to choose: 201 with Location after a create, 204 after a delete.
Describing resources
Resources are described with PHP attributes, read by AnnotationDriver:
#[Resource(type, operations, clientId)]on the class.operationslimits the endpoints (Operation::LIST,CREATE,READ,UPDATE,DELETE);clientId: trueaccepts client-generated ids on create.#[Id]on the id property or getter.#[Attribute(name, type, of, nullable, required, readOnly)]on public properties or getters. Most arguments are inferred from the PHP type;ofnames the item type of an array.#[Relationship(target, name, isCollection, nullable, operations, withData, required)]. The target is a resource class.withData: falseleaves out the linkage until the relationship is included, which saves loading it.#[Pagination(getter)]on a to-many relationship returns its total (or a pagination object), so the relationship gets pagination links andmeta.total.#[Meta(getter)]on a resource or relationship adds itsmetamember.
Example with properties · example with getters
If you prefer plain PHP to attributes, implement JSONAPI\Mapper\Schema\Resource and return a
ResourceSchema from getSchema(), then use SchemaDriver instead of AnnotationDriver.
Required and read-only fields
A required attribute or relationship must be sent when a client creates the resource; leaving it out is
a 422. Updates never require anything. The generated TypeScript marks optional fields with ?, and
the OpenAPI POST body lists the required ones (it is inlined, leaves out read-only attributes, and offers
id only for resources with clientId: true).
A field is required unless the mapper can see it may be left out:
| Declaration | Required on create |
|---|---|
public string $code; | yes |
public ?string $note; (nullable, no default) | yes, null is a valid value |
public ?string $note = null; / public bool $active = true; | no, it has a default |
promoted public int $credits = 5 in the constructor | no, it has a default |
#[Attribute] public function getLabel(): string | yes, a getter has no default |
#[Attribute(readOnly: true)] | no, clients cannot send it |
a relationship without the UPDATE operation | no, clients cannot write it |
required: true or required: false on the annotation (or the schema factory) always wins. Declare it
where the business rule differs from the declaration, e.g. required: false for a field your create
handler fills in itself. readOnly is never inferred: mark computed getters readOnly: true, or they
become required and writable.
Polymorphic relationships (abstract targets)
A relationship target may be an abstract base class, letting a single relationship resolve to any of
its concrete subtypes. Annotate the abstract base as a resource and each concrete subtype as its own resource:
#[API\Resource(type: 'payment-method')]
abstract class PaymentMethod
{
#[API\Id] public string $id;
#[API\Attribute] public string $status; // common fields live on the base
}
#[API\Resource(type: 'card-payment')]
class CardPayment extends PaymentMethod { #[API\Attribute] public string $pan; }
#[API\Resource(type: 'wire-payment')]
class WirePayment extends PaymentMethod { #[API\Attribute] public string $iban; }
class Order
{
#[API\Id] public string $id;
// target is the abstract base; the related resource may be any concrete subtype
#[API\Relationship(PaymentMethod::class)] public PaymentMethod $method;
}
Requirements and behavior:
- The abstract base must declare a common
#[Id]; any#[Attribute]/#[Relationship]declared on it are the common fields shared by all subtypes. - Concrete subtypes are discovered by inheritance, so they must live under the paths scanned by
MetadataFactory. An abstract base with no registered concrete subtype logs a warning at build time. - Encoding/decoding is polymorphic: the concrete
typeis used. Writing a relationship whosetypeis not one of the allowed concrete subtypes is rejected with a validation error. - Filtering through a polymorphic relationship is restricted to the common fields declared on the abstract base (subtype-only fields are not addressable).
- OpenAPI: the relationship
dataidentifiertypeis an enum of the concrete subtypes, and related/include schemas expand to the concrete subtypes. No standalone CRUD path is generated for the abstract base — it is a relationship target only. - TypeScript: the relationship is emitted as a union of the concrete subtype interfaces
(e.g.
ToOneRelationship<CardPayment | WirePayment>); the abstract base is not emitted as an interface and its common fields are inlined into each subtype. - Route registration:
MetadataRepository::getAll()still returns the abstract base. Consumers registering CRUD routes should skip entries whereisAbstract()istrue(abstracts are not instantiable). UsegetSubTypes(string $type)/getConcreteByClass(string $className)to resolve the concrete subtypes.
Note: interfaces as targets are not supported yet — use an abstract class.
Shapes (structured values)
A shape is a typed JSON structure that is not a JSON:API resource: the value of an attribute
(a time range, a list of hours, an address) or the body of an endpoint outside JSON:API (GET /me,
a report, JSON-RPC params). You describe it once as a PHP value object, and the mapper encodes it,
decodes and validates it, and describes it in the TypeScript contract and the OpenAPI document.
In one sentence: a shape is a readonly class marked #[API\Shape], and its promoted
constructor parameters are its members.
use DateTimeImmutable;
use InvalidArgumentException;
use JSONAPI\Mapper\Annotation as API;
enum Kind: string
{
case LECTURE = 'lec';
case TUTORIAL = 'tut';
}
#[API\Shape]
final readonly class Hours
{
public function __construct(
public ?int $parallel, // required, may be null
public int $hours = 0, // optional: when omitted, 0
) {
if ($hours < 0) {
throw new InvalidArgumentException('Hours must not be negative.'); // becomes a 422
}
}
}
#[API\Shape]
final readonly class Slot
{
public function __construct(
public Kind $kind, // backed enum: its value in JSON
public DateTimeImmutable $at, // RFC 3339 string in JSON
/** @var string[] */
public array $rooms, // list of scalars: the docblock names the item type
#[API\Property(of: Hours::class)]
public array $hours = [], // list of shapes: name the item class
#[API\Property(name: 'room-note')]
public ?string $roomNote = null, // another JSON name
) {
}
}
#[API\Resource('course')]
class Course
{
#[API\Id]
public string $id;
#[API\Attribute]
public Slot $slot;
/** @var Hours[] */
#[API\Attribute(of: Hours::class)]
public array $parallels = [];
}
How it works
- Every promoted parameter is a member and must be public, named by the naming strategy (
#[API\Property(name: '...')]renames one). A member is optional on input when its parameter has a default, and nullable when its type allowsnull. - Member types:
string,int,float,bool, backed enums, dates, other shapes, and arrays of these. Scalar item types may come from a docblock; class item types need#[API\Property(of: X::class)]. - Everything else in the class is not data: properties set in the constructor body (derived values) and non-promoted parameters (they need a default) are never encoded, decoded or described.
- Decoding builds the shape through its constructor and reports every problem with a JSON Pointer into
the value:
{"slot": {"kind": "lab"}}gives a 422 at/data/attributes/slot/kind(and at.../at,.../roomsfor the missing members); an unknown member gives a 400; anInvalidArgumentExceptionfrom the constructor gives a 422 at the shape with its message. - An invalid shape — not readonly, a non-public member, a member of an unsupported type, a misplaced
#[API\Property]— fails when metadata is built, with a message naming the class and member.
What Slot becomes:
{"kind": "lec", "at": "2026-10-16T08:00:00+02:00", "rooms": ["T9:105"], "hours": [{"parallel": 1, "hours": 2}], "room-note": null}
export interface Slot {
kind: 'lec' | 'tut';
at: string;
rooms: string[];
hours?: Hours[];
'room-note'?: string | null;
}
and components/schemas/Slot in the OpenAPI document, with kind, at and rooms required and no
additional properties.
Shapes in your own endpoints. Every discovered shape is exported, also when no resource uses it. Serialise and validate with the same rules as attributes:
$codec = new ShapeCodec($metadataRepository);
$json = $codec->encode($me); // stdClass for json_encode
$params = $codec->decode(CopyParams::class, $body, '/params'); // throws InvalidShape
$hours = $codec->decodeList(Hours::class, $body);
InvalidShape is a DecoderException carrying the JSON:API errors. Reference a shape from your own
OpenAPI operations with $oas->getComponents()->createSchemaReference('Slot').
Why it is built this way
- Immutable by design. A
readonlyshape cannot change after it is built, and decoding always goes through the constructor, so the checks you write there always run. - PHP's own syntax for a data carrier. Promoted parameters hold the field list, types and defaults
in one place, and
new Slot(kind: Kind::LECTURE, at: $at, rooms: [])reads the same in your code. - No marker per field. A shape is nothing but data; promotion is the opt-in. A value you do not promote stays out of the contract.
- One description, three outputs. The JSON the mapper produces, the TypeScript contract and the OpenAPI schema come from the same class, so they cannot drift apart.
Shapes are found in the paths given to MetadataFactory::create(); with a custom naming strategy, pass
new ShapeDriver($strategy) as its last argument. The names Meta, Link, ErrorObject, Resource,
ToOne, ToMany, Index and ResourceIndex are reserved. ErrorObject is the OpenAPI schema of a
JSON:API error object, which your own paths can reference.
Naming
When #[Resource] names no type, the naming strategy derives one from the class name:
RecommendedNamingStrategy (the default) as a plural camelCase word (BlogPost → blogPosts),
KebabCaseURLNamingStrategy in kebab-case (blog-posts), which it also uses for relationship names in
URLs. Member names are camelCase in both. Pass the same strategy to the driver,
DefaultParserFactory, RequestDependentEncoderFactory, IndexDocument and
OpenAPISpecificationBuilder, so URLs are parsed the way links are written.
Reading requests
$builder->getRequest() returns the ParsedRequest:
| Method | Holds |
|---|---|
getPath() | resource type, id, relationship, and whether the URL is a collection or relationship |
getFilter() | the parsed filter |
getSort() | the sort fields, checked for syntax and duplicates |
getPagination() | page offset/limit or number/size; set the total on it |
getInclusion() | the include paths, checked against the metadata |
getFieldset() | the sparse fieldsets |
getBody() | the decoded and validated request document |
The body of a create or update is available as a JSON:API document:
use JSONAPI\Mapper\Components\Member\Document\Data;
$resource = $request->getParsedBody()->get(Data::getMemberName())->getValue(); // ResourceObject
$title = $resource->getAttribute('title')?->getValue();
$author = $resource->getRelationship('author')?->getData(); // identifier
By the time your handler runs, the body is valid: types match the URL, read-only attributes are absent, required fields are present (on create), values match their declared types, and shapes are decoded.
Filters
The default filter syntax is borrowed from OData:
?filter=title eq 'JSON:API' and contains(subtitle,'guide') and author.name in ('Ada','Grace')
getFilter()->getCondition() returns the expression tree (jaspr/expression) to translate into your
query. For simpler needs there is the QData syntax (?filter=title::contains::guide|year::gt::2020),
parsed by QuatrodotFilterParser; it has no escaping, so values cannot contain | or ::.
Pagination
Offset pagination (page[offset], page[limit], default limit 25) is the default. For page numbers
(page[number], page[size]), build your own parser factory:
final class PageNumberParserFactory implements ParserFactoryInterface
{
public function __construct(private string $baseUrl, private MetadataRepository $repository) {}
public function create(): Parser
{
$parser = new Parser($this->baseUrl);
$parser->addParser(new PathParser($this->repository, $this->baseUrl));
$parser->addParser(new PageStrategyParser(25));
$parser->addParser(new SortParser());
$parser->addParser(new InclusionParser($this->repository));
$parser->addParser(new FieldsetParser());
$parser->addParser(new ExpressionFilterParser($this->repository, new ExpressionBuilder()));
$parser->addParser(new BodyParser($this->repository));
return $parser;
}
}
The same approach swaps in QuatrodotFilterParser.
Your own query parameters
Unknown query parameters are rejected with 400, as JSON:API requires. Register implementation-specific
ones (they must contain a character outside a-z, e.g. withDrafts) and read them back:
new DefaultParserFactory('https://api.example.com', $repository, queryParameters: ['withDrafts']);
$withDrafts = $builder->getRequest()->getQueryParameters()['withDrafts'] ?? null;
They are kept in the self, pagination and related links.
Endpoints that do not support a query parameter
The parser accepts sort, filter and page on every collection, and the OpenAPI document describes them
there along with a 400 response. When an endpoint cannot honour one of them, reject it instead of ignoring
it — JSON:API requires a 400 Bad Request for an unsupported sort. Throw from your handler; the middleware
turns it into a JSON:API error naming the parameter:
use JSONAPI\Mapper\Exception\UnsupportedQueryParameter;
use JSONAPI\Mapper\Middleware\PsrJsonApiMiddleware;
use JSONAPI\Mapper\Request\RequestPart;
$parsedRequest = $request->getAttribute(PsrJsonApiMiddleware::BUILDER)->getRequest();
if ($parsedRequest->getSort() !== null) {
throw new UnsupportedQueryParameter(RequestPart::SORT_PART_KEY); // 400, source.parameter = "sort"
}
The middleware does the same for InvalidQueryParameter, FilterException and DecoderException
(including InvalidShape from ShapeCodec::decode()) thrown by a handler; any other exception propagates.
Errors
The middleware answers every protocol error itself: 400 for malformed requests, 403 for operations a
resource does not allow, 404 for unknown types or relationships, 406/415 for media types, 409 for type
or id conflicts and 422 for invalid values, each with source.pointer or source.parameter.
For your own errors, add Error objects to a document and return it with a matching status.
$builder->getEncoder()->error($exception) turns any other exception into a 500 error object; pass
true as the second argument to include the exception's class, message and code (in development only).
Extensions and profiles
The Atomic Operations extension is registered on the middleware:
$middleware->addExtension(new AtomicDefinition($repository));
A handler of the operations endpoint receives the validated operations, applies them, sets the
resulting object on those that have one (adding or updating a resource) and lets the builder write
atomic:results:
$operations = $request->getParsedBody()->get(Operations::getMemberName());
foreach ($operations as $operation) {
$result = $this->apply($operation);
if ($operation instanceof OperationWithResult) {
$operation->setResult($result); // the created or updated object
}
}
$document = $builder->setData($operations)->build();
Profiles the client asks for in Accept reach the handler as
$request->getAttribute(PsrJsonApiMiddleware::PROFILES). Apply them and declare them in the response
Content-Type; the middleware keeps that header.
Index document
new IndexDocument($repository, 'https://api.example.com') lists the collection endpoints:
{
"jsonapi": {"version": "1.1"},
"links": {"articles": "https://api.example.com/articles", "people": "https://api.example.com/people"},
"meta": {"title": "JSON:API Index Page", "baseUrl": "https://api.example.com/"}
}
@jaspr/client-js reads it in useJsonApiWithIndex.
OpenAPI
$builder = new OpenAPISpecificationBuilder($repository, 'https://api.example.com');
$oas = $builder->create(new Info('Library API', '1.0.0'), $securityRequirement);
$oas->getComponents()->addSecurityScheme('oauth', $securityScheme);
$json = json_encode($oas);
The document (OpenAPI 3.0.3, built with jaspr/oas-builder) has
a path for every supported operation, every error the mapper returns (with the ErrorObject schema),
the query parameters, shapes as component schemas, and POST bodies that list the fields required on
create. With security requirements, every operation documents a 401.
Add your own paths before encoding, reusing the components:
$oas->getPaths()->addPath('/me', (new PathItem())->setGet(
Operation::new()->setResponses((new Responses())
->addResponse('200', (new Response('Current user'))->addContent(
'application/json',
(new MediaType())->setSchema($oas->getComponents()->createSchemaReference('Me'))
))
->addResponse('401', $oas->getComponents()->createResponseReference('401')))
));
TypeScript contract
file_put_contents('index.ts', (new TypeScriptInterfaceBuilder($repository))->build());
writes one interface per resource and shape and a ResourceIndex for @jaspr/client-js: ? marks
fields that may be left out on create, readonly marks read-only attributes. The output is sorted, so it
is the same on every machine and diffs show only real changes.
Metadata cache
MetadataFactory::create() scans the given paths for resources and shapes and validates them; invalid
metadata fails at boot with a message naming the class and member. The result is stored in the PSR-16
cache you pass, so production requests skip the scan. Clear the cache when resources change.
License
MIT