REST API v2 response shaping
SkillAI & modelsReference for shaping OrangeHRM REST API v2 responses — the `Normalizable` + `ModelTrait` contract, writing a Model class that maps a Doctrine entity to a JSON object, the `filter` array (getter chain — supports nested-getter sequences for related entities), the `attributeNames` array (output JSON keys), `EndpointResourceResult` vs `EndpointCollectionResult`, the `ParameterBag` meta envelope, generic non-entity models (`ArrayModel`, `ArrayCollectionModel`), and the `MODEL_MAP` pattern for `?model=default|detailed` variants. Use whenever the user is writing or editing a Model class, picking which `EndpointResult` to return, debugging "why is field X missing/null in the JSON response", adding a detailed-vs-default model variant, or returning an ad-hoc payload that doesn't come from an entity. Companion to `rest-endpoints` (the dispatch spine) and `rest-openapi` (Model classes carry `@OA\Schema`).
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the REST API v2 response shaping skill
What this skill tells your AI
The instructions your AI receives, as published by orangehrm/orangehrm in .agents/skills/rest-serialization/SKILL.md and read by ahel’s review.
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.
Signals
- GitHub stars
- 1k
- Forks
- 749
- Last commit
- Jun 2026
Advanced
- Catalog kind
- skill
- Gateway key
rest-serialization- Source
- github.com/orangehrm/orangehrm