REST API v2 response shaping
Every Endpoint handler returns an EndpointResult — a (modelClass, data, meta) triple. The framework's normalizer instantiates the Model with the data, calls toArray(), wraps it in {data: ..., meta: {...}, rels: {...}}, and returns JSON. The Model class is where you decide which entity fields appear in the response and what JSON keys they go under.
Endpoint handler
└── returns new EndpointResourceResult(WidgetModel::class, $widgetEntity, ?$meta)
└── framework: $model = new WidgetModel($widgetEntity)
└── $model->toArray() ← ModelTrait does the heavy lifting
└── walks `filter` array, builds `attributeNames`-keyed output
└── Response::formatData()
└── {"data": {...model output...}, "meta": {...}, "rels": {...}}
This skill covers everything from "what to return from the handler" through "how the JSON envelope is built." For the dispatch flow into the handler, see rest-endpoints. For the @OA\Schema annotation that documents the Model, see rest-openapi.
The Normalizable contract
A Model is any class implementing OrangeHRM\Core\Api\V2\Serializer\Normalizable:
interface Normalizable
{
public function toArray(): array;
}
You almost never implement this directly. The standard pattern is to use ModelTrait and wire an entity in the constructor.
ModelTrait — the standard pattern
ModelTrait gives a Normalizable class three setters and an implementation of toArray():
$this->setEntity($entity); // the Doctrine entity (or any object with getters)
$this->setFilters([...]); // which fields to include, as a getter chain
$this->setAttributeNames([...]); // the JSON output key for each filter entry
toArray() walks the filter array; for each entry, it derives a getter (string → get<UcfirstAttr>()), invokes it on the entity, and stores the result under the matching attributeNames[$index] key (or under filter[$index] itself if no override).
The basic shape
namespace OrangeHRM\X\Api\Model;
use OrangeHRM\Core\Api\V2\Serializer\ModelTrait;
use OrangeHRM\Core\Api\V2\Serializer\Normalizable;
use OrangeHRM\Entity\Widget;
class WidgetModel implements Normalizable
{
use ModelTrait;
public function __construct(Widget $widget)
{
$this->setEntity($widget);
$this->setFilters(['id', 'name', 'description']);
// Attribute names default to filter strings when omitted — these would be {id, name, description}
}
}
The framework calls getId(), getName(), getDescription() on the entity (camel-cased + get prefix). Output:
{ "id": 1, "name": "Acme", "description": "…" }
Renaming output keys
If JSON key shouldn't match the getter name (e.g. you want lastName in JSON but the getter is getLast()), provide attributeNames:
$this->setFilters(['id', 'last', 'first', 'middle']);
$this->setAttributeNames(['id', 'lastName', 'firstName', 'middleName']);
The two arrays are positional — index 0 of attributeNames matches index 0 of filter. Mismatch in length silently produces shifted output, so keep them aligned.
Nested getters — descending into related entities
When the value you want lives on a related entity, pass a sub-array of getter names instead of a string. ModelTrait walks the chain, calling each method on the previous result.
$this->setFilters([
'empNumber',
'lastName',
'firstName',
'middleName',
'employeeId',
['getEmployeeTerminationRecord', 'getId'], // $employee->getEmployeeTerminationRecord()->getId()
]);
$this->setAttributeNames([
'empNumber',
'lastName',
'firstName',
'middleName',
'employeeId',
'terminationId', // the JSON key for the nested chain
]);
Output:
{
"empNumber": 1,
"lastName": "…",
...,
"terminationId": 42 // null if no termination record
}
Null-safety is automatic at each link. ModelTrait::toArray() checks is_null($value) before each step — if getEmployeeTerminationRecord() returns null, the chain short-circuits and the output value is null (without calling getId() on null). This is convenient but masks errors: if you typo a getter name, the chain silently produces null instead of throwing.
Producing nested JSON objects
If you want a nested JSON object instead of a flat key, provide a sub-array under attributeNames for that index:
$this->setFilters([
'id',
['getJobTitle', 'getId'],
['getJobTitle', 'getJobTitleName'],
]);
$this->setAttributeNames([
'id',
['jobTitle', 'id'], // nested: { "jobTitle": { "id": … } }
['jobTitle', 'name'], // merged: { "jobTitle": { "id": …, "name": … } }
]);
ModelTrait::makeNestedArray() builds the nested structure; array_merge_recursive combines the two entries into one jobTitle object. Use this when the response should surface a sub-resource rather than flat scalars.
Collections within a model
If a step in the getter chain returns an iterable (e.g. getSkills() returns Collection<EmployeeSkill>), the trait switches to collection mode: the remaining getter names are applied to each element, and the result is an array under the model's output key.
$this->setFilters([
'id',
['getSkills', ['getName', 'getYears']],
]);
$this->setAttributeNames([
'id',
['skills', ['name', 'years']],
]);
Output:
{ "id": 1, "skills": [{"name": "PHP", "years": 5}, {"name": "Vue", "years": 3}] }
The trait detects the iterable at any point in the chain and switches mode automatically.
Picking the result class
| Result | When |
|---|---|
EndpointResourceResult($modelClass, $entity, ?$meta, ?$rels) |
Single resource (getOne, create, update). $entity is one object — the framework instantiates the Model with it. |
EndpointCollectionResult($modelClass, $entities, ?$meta, ?$rels) |
Collection (getAll). $entities is an array of objects — the framework instantiates the Model once per element. |
EndpointResourceResult is also used for delete responses (which echo back the deleted IDs) and MyInfo-style endpoints. The interface is the same shape; the result type is the only difference between one-vs-many output.
// Single
return new EndpointResourceResult(WidgetModel::class, $widget);
// Collection with meta total
return new EndpointCollectionResult(
WidgetModel::class,
$widgets,
new ParameterBag([CommonParams::PARAMETER_TOTAL => $count])
);
The constructor signature is (string $modelClass, $data, ?ParameterBag $meta = null, ?ParameterBag $rels = null). $rels is rarely used in this codebase — it would surface as a rels key in the envelope.
ParameterBag for meta
OrangeHRM\Core\Api\V2\ParameterBag (extends Symfony's ParameterBag) holds the response's meta object. The framework always emits a meta key — pass an empty ParameterBag or null for { "meta": {} }.
The single project-wide convention: list endpoints include total:
new ParameterBag([CommonParams::PARAMETER_TOTAL => $count])
// → {"meta": {"total": 137}}
Some endpoints add more keys (e.g. employee-list reports add employeeIds or filter-summary data). Always use CommonParams::PARAMETER_* constants when the key is a framework-standard one.
The response envelope
Response::formatData() always produces:
{
"data": { ... model output ... }, // or [...] for collection
"meta": { ... },
"rels": { ... }
}
meta and rels default to {} when not set. Errors are a separate shape produced by Response::formatError() — { "error": { "status": "...", "message": "..." } }.
Generic models (non-entity payloads)
When your response isn't backed by a Doctrine entity, use the generic models instead of inventing an entity:
ArrayModel($data)
Single response. Pass an associative array; that array becomes the data key verbatim.
return new EndpointResourceResult(ArrayModel::class, ['valid' => true]);
// → {"data": {"valid": true}, "meta": {}, ...}
Common uses: validation-only endpoints ({valid: true}), delete responses (echoing back the IDs), single-field reads.
ArrayCollectionModel($data)
Collection. Pass a plain array; becomes data as a JSON array.
return new EndpointCollectionResult(ArrayCollectionModel::class, [1, 2, 3]);
// → {"data": [1, 2, 3], "meta": {}, ...}
Used when "the list" doesn't map to entities — version constants, computed aggregates, etc.
The MODEL_MAP pattern — ?model=default|detailed
For endpoints where consumers want either a slim or a rich version of the same resource, declare a MODEL_MAP constant and switch on a ?model= query param. EmployeeAPI is the canonical example:
class EmployeeAPI extends Endpoint implements CrudEndpoint
{
public const FILTER_MODEL = 'model';
public const MODEL_DEFAULT = 'default';
public const MODEL_DETAILED = 'detailed';
public const MODEL_MAP = [
self::MODEL_DEFAULT => EmployeeModel::class,
self::MODEL_DETAILED => EmployeeDetailedModel::class,
];
protected function getModelClass(): string
{
$model = $this->getRequestParams()->getString(
RequestParams::PARAM_TYPE_QUERY,
self::FILTER_MODEL,
self::MODEL_DEFAULT,
);
return self::MODEL_MAP[$model];
}
protected function getModelParamRule(): ParamRule
{
return $this->getValidationDecorator()->notRequiredParamRule(
new ParamRule(self::FILTER_MODEL,
new Rule(Rules::IN, [array_keys(self::MODEL_MAP)])
)
);
}
public function getOne(): EndpointResourceResult
{
// ...
return new EndpointResourceResult($this->getModelClass(), $employee);
}
public function getValidationRuleForGetOne(): ParamRuleCollection
{
return new ParamRuleCollection(
new ParamRule(CommonParams::PARAMETER_EMP_NUMBER, new Rule(Rules::IN_ACCESSIBLE_EMP_NUMBERS)),
$this->getModelParamRule(),
);
}
}
The EmployeeDetailedModel adds nested chains for job title, employment status, supervisor, etc. — same entity, expanded filter and attributeNames arrays. The handler doesn't know or care which one was picked.
Use this pattern when:
- The "rich" version triggers extra DB joins you don't want by default
- Different consumers (mobile app vs. admin UI) want different field sets
- You'd otherwise be tempted to add
?include=jobTitle,supervisorstyle toggles
Avoid when:
- The difference is one or two fields — just include them by default
- Variants would proliferate (
detailed,verbose,minimal,internal) — at that point, design a field-selection mechanism instead
Recipes
Recipe 1 — A basic Model for a Doctrine entity
namespace OrangeHRM\X\Api\Model;
use OrangeHRM\Core\Api\V2\Serializer\ModelTrait;
use OrangeHRM\Core\Api\V2\Serializer\Normalizable;
use OrangeHRM\Entity\Widget;
class WidgetModel implements Normalizable
{
use ModelTrait;
public function __construct(Widget $widget)
{
$this->setEntity($widget);
$this->setFilters([
'id',
'name',
'description',
['getOwner', 'getEmpNumber'],
['getOwner', 'getLastName'],
]);
$this->setAttributeNames([
'id',
'name',
'description',
['owner', 'empNumber'], // nested {owner: {empNumber, lastName}}
['owner', 'lastName'],
]);
}
}
Output:
{ "id": 1, "name": "Acme", "description": "…",
"owner": { "empNumber": 7, "lastName": "Smith" } }
Recipe 2 — Default vs detailed variants
Already shown above (the MODEL_MAP pattern). The detailed model has the same constructor signature but bigger filter/attributeNames arrays:
class WidgetDetailedModel implements Normalizable
{
use ModelTrait;
public function __construct(Widget $widget)
{
$this->setEntity($widget);
$this->setFilters([
'id', 'name', 'description', 'createdAt', 'updatedAt',
['getOwner', 'getEmpNumber'],
['getOwner', 'getLastName'],
['getOwner', 'getFirstName'],
['getCategory', 'getId'],
['getCategory', 'getName'],
['getTags', ['getId', 'getName']], // collection inside the model
]);
$this->setAttributeNames([
'id', 'name', 'description', 'createdAt', 'updatedAt',
['owner', 'empNumber'],
['owner', 'lastName'],
['owner', 'firstName'],
['category', 'id'],
['category', 'name'],
['tags', ['id', 'name']],
]);
}
}
Recipe 3 — Validation-only / single-field response (no entity)
public function getOne(): EndpointResourceResult
{
$isUnique = $this->getService()->isUniqueEmail($email);
return new EndpointResourceResult(ArrayModel::class, ['valid' => $isUnique]);
}
Recipe 4 — Delete-response echo
The delete handler echoes the deleted IDs back to the client. Use ArrayModel:
public function delete(): EndpointResourceResult
{
$ids = $this->getRequestParams()->getArray(
RequestParams::PARAM_TYPE_BODY, CommonParams::PARAMETER_IDS
);
$this->getWidgetService()->deleteWidgets($ids);
return new EndpointResourceResult(ArrayModel::class, $ids);
}
This is the convention across the codebase. Look at any plugin's delete() to confirm — they all return ArrayModel with the IDs.
Recipe 5 — Collection with total + extra meta
return new EndpointCollectionResult(
EmployeeModel::class,
$employees,
new ParameterBag([
CommonParams::PARAMETER_TOTAL => $count,
'filterSummary' => ['activeCount' => $activeCount, 'terminatedCount' => $terminatedCount],
])
);
Output:
{
"data": [{...}, {...}],
"meta": { "total": 137, "filterSummary": { "activeCount": 120, "terminatedCount": 17 } },
"rels": {}
}
Checklists
Writing a new Model class
- File location:
src/plugins/orangehrm{X}Plugin/Api/Model/<Name>Model.php -
implements Normalizable; use ModelTrait; - Constructor takes the entity (typed); calls
setEntity,setFilters,setAttributeNames -
filterandattributeNamesarrays are the same length and aligned by index - Nested chains (
[getX, getY]) for related entities; output key is the matchingattributeNamesentry - For nested output objects, use
['outerKey', 'innerKey']inattributeNames - Add an
@OA\Schemablock — see therest-openapiskill - If detailed variant needed: register a
MODEL_MAPin the Endpoint and switch on?model=
Picking the result class
- Single resource →
EndpointResourceResult - Collection →
EndpointCollectionResult - Non-entity object payload →
ArrayModel+EndpointResourceResult - Non-entity array payload →
ArrayCollectionModel+EndpointCollectionResult - Delete echo →
ArrayModel+EndpointResourceResult - Include
totalinmetafor list endpoints — convention:new ParameterBag([CommonParams::PARAMETER_TOTAL => $count])
Debug "field missing / null in JSON"
- Field absent from response → not in
filterarray - Field appears under wrong JSON key →
filterandattributeNamesindices misaligned - Nested field shows up as null → an intermediate getter returned null;
ModelTraitshort-circuits the chain silently. Verify by calling the getter manually - Nested field shows up as null but the related entity exists → typo in the getter name in the
filterchain; the silent null-safety hides this - Detailed model returns default fields → handler is using
EmployeeModel::classinstead of$this->getModelClass() - Collection items have inconsistent shape → the entity for some items has lazy-loaded relations not yet materialized; force-fetch in the service before normalization
Things that bite
ModelTraitchain null-safety is silent. A typo in a nested getter (getEmployeeTerminationRecordvsgetEmpTerminationRecord) producesnull, not an error. Verify chains against the entity class's real getter list when adding nested fields.filterandattributeNamesare positional, not associative. Off-by-one in one but not the other shifts every key from that point on.EndpointResourceResultvsEndpointCollectionResultuse the same constructor signature but producedata: {...}vsdata: [...]. Picking the wrong one produces JSON that won't parse the way the client expects.- Generic models (
ArrayModel,ArrayCollectionModel) bypass entity getters. Whatever you pass becomes thedataverbatim — null-safety, type coercion, andattributeNamesmapping do not apply. MODEL_MAP?model= validation must useRules::IN, not a custom rule —?model=garbageshould be a 422, not a 500 fromMODEL_MAP[$invalid].- Meta is a
ParameterBag, not an array — passnew ParameterBag([...]), not the array directly. Construct errors here become "Argument must be of type ParameterBag, array given" at runtime.