Skip to content

Applying Annotations ​

Annotations can be applied programmatically when building the schema, or via PHP attributes that the AttributeReader picks up.

Programmatic annotations ​

Pass annotations to any Edm constructor via the annotations parameter:

php
use LaravelUi5\OData\Edm\Annotation\Annotation;
use LaravelUi5\OData\Edm\Annotation\ConstantAnnotationValue;
use LaravelUi5\OData\Edm\Property\Property;
use LaravelUi5\OData\Edm\Type\EntityType;
use LaravelUi5\OData\Edm\Type\PrimitiveType;
use LaravelUi5\OData\Edm\EdmPrimitiveType;

// Annotate a property
$nameProp = new Property(
    name: 'name',
    type: new PrimitiveType(EdmPrimitiveType::String),
    annotations: [
        new Annotation(
            term: 'Org.OData.Core.V1.Description',
            value: new ConstantAnnotationValue('String', 'The product name'),
        ),
    ],
);

// Annotate an entity type
$productType = new EntityType(
    namespace: 'App.Products',
    name: 'Product',
    key: [$idProp],
    declaredProperties: [$idProp, $nameProp],
    annotations: [
        new Annotation(
            term: 'Org.OData.Core.V1.Description',
            value: new ConstantAnnotationValue('String', 'A product in the catalog'),
        ),
    ],
);

Annotation value types ​

Constant value — one expression and its literal. The first argument is the CSDL expression name, and it is written into $metadata as given: String, Bool, Int, Decimal, Date, EnumMember, or a path expression such as Path or PropertyPath. The literal is always a string.

php
new ConstantAnnotationValue('String', 'Hello World')   // String="Hello World"
new ConstantAnnotationValue('Bool', 'true')            // Bool="true"
new ConstantAnnotationValue('Path', 'name')            // Path="name"

Record value — an optional record type, followed by its property values:

php
use LaravelUi5\OData\Edm\Annotation\PropertyValue;
use LaravelUi5\OData\Edm\Annotation\RecordAnnotationValue;

new RecordAnnotationValue(
    'com.sap.vocabularies.UI.v1.HeaderInfoType',
    new PropertyValue('TypeName', new ConstantAnnotationValue('String', 'Product')),
    new PropertyValue('Title', new RecordAnnotationValue(
        'com.sap.vocabularies.UI.v1.DataField',
        new PropertyValue('Value', new ConstantAnnotationValue('Path', 'name')),
    )),
)

Collection value — an ordered list, passed as separate arguments:

php
use LaravelUi5\OData\Edm\Annotation\CollectionAnnotationValue;

new CollectionAnnotationValue(
    new RecordAnnotationValue(
        'com.sap.vocabularies.UI.v1.DataField',
        new PropertyValue('Value', new ConstantAnnotationValue('Path', 'name')),
        new PropertyValue('Label', new ConstantAnnotationValue('String', 'Product Name')),
    ),
    new RecordAnnotationValue(
        'com.sap.vocabularies.UI.v1.DataField',
        new PropertyValue('Value', new ConstantAnnotationValue('Path', 'price')),
        new PropertyValue('Label', new ConstantAnnotationValue('String', 'Price')),
    ),
)

Attribute-based annotations ​

Vocabulary term classes can be used as PHP attributes. When models are registered via discoverModel(), ModelDiscovery automatically reads class-level and property-level vocabulary attributes and attaches them to the EntityType and Property objects. They then appear in the $metadata CSDL XML.

Class-level annotations ​

Class-level annotations (applied to the model class) work directly:

php
use LaravelUi5\OData\Vocabularies\Core\V1\Description;
use LaravelUi5\OData\Vocabularies\Ui\V1\LineItem;
use LaravelUi5\OData\Vocabularies\Ui\V1\SelectionFields;

#[Description('A product in the catalog')]
#[SelectionFields(['name', 'category'])]
#[LineItem(['name', 'category', 'price'])]
class Product extends Model
{
    // ...
}

These are placed on the <EntityType> element in $metadata.

Property-level annotations and PHP 8.4 property hooks ​

Property-level annotations require a declared PHP property to attach to. However, Eloquent models store their data in an internal $attributes array accessed via __get() magic — there are no PHP class properties for database columns by default.

Declaring a bare typed property breaks Eloquent. If you write public string $name;, PHP accesses the declared (uninitialized) property directly instead of falling through to __get(), causing a TypeError.

The solution is PHP 8.4 property hooks. They give you a real PHP property (visible to reflection, can carry attributes) while delegating access to Eloquent's attribute system:

php
use LaravelUi5\OData\Vocabularies\Common\V1\Label;
use LaravelUi5\OData\Vocabularies\Core\V1\Description;
use LaravelUi5\OData\Vocabularies\Ui\V1\Hidden;
use LaravelUi5\OData\Vocabularies\Ui\V1\SelectionFields;

#[Description('A product in the catalog')]
#[SelectionFields(['name', 'category'])]
class Product extends Model
{
    #[Label('Product Name')]
    #[Description('The display name of the product')]
    public ?string $name {
        get => $this->getAttribute('name');
        set(?string $value) { $this->setAttribute('name', $value); }
    }

    #[Label('Category')]
    public ?string $category {
        get => $this->getAttribute('category');
        set(?string $value) { $this->setAttribute('category', $value); }
    }

    #[Hidden]
    public ?int $internal_flags {
        get => $this->getAttribute('internal_flags');
        set(?int $value) { $this->setAttribute('internal_flags', $value); }
    }
}

This pattern:

  • Works with Eloquent: getAttribute()/setAttribute() preserves casts, mutators, and dirty tracking
  • Supports reflection: AttributeReader reads the vocabulary attributes from the declared property
  • Only needed for annotated properties: columns without annotations continue through __get() as usual

Write the set hook as a block, not with =>

set($value) => $this->setAttribute('name', $value) assigns the expression's result to the property. setAttribute() returns the model, so $product->name = 'x' fails with a TypeError, and the property stops being virtual. Use the block form: set(?string $value) { $this->setAttribute('name', $value); }. Mass assignment (create(), fill(), update()) bypasses the hook, so that form only breaks on direct assignment, which makes it easy to miss.

Type the property nullable. The get hook returns whatever the attribute bag holds, and that is null for a column a fresh model has not been given yet.

Leaving hidden columns out: useHidden ​

A model's $hidden keeps columns such as password out of every serialized row. It does not keep them out of $metadata, so a client can still ask about them: $filter=startswith(password,'$2y'). Set useHidden on the class to drop them from the entity type:

php
#[ODataEntity(useHidden: true)]
class User extends Model
{
    protected $hidden = ['password', 'remember_token'];
}

Hidden columns and hidden relations are then neither declared nor filterable. The key stays even if $hidden names it, because an entity type needs its key. useHidden is off by default, so an existing service keeps its schema. For a single column without $hidden, use #[ODataIgnore] on a hooked property (see Model discovery).

How discovery wires it ​

No extra registration needed. When you call discoverModel(), ModelDiscovery automatically:

  1. Reads class-level vocabulary attributes via AttributeReader::readClass()
  2. Reads property-level vocabulary attributes via AttributeReader::readProperty() for each column that has a declared PHP property on the model class
  3. Passes the annotations to EntityType and Property constructors
  4. The CsdlSerializer serializes them into the $metadata XML
php
protected function configure(EdmBuilderInterface $builder): EdmBuilderInterface
{
    $this->discoverModel(Product::class);

    return $builder
        ->namespace('App.Products')
        ->useVocabulary(Vocabulary::Core)
        ->useVocabulary(Vocabulary::UI)
        ->useVocabulary(Vocabulary::Common);
}

Reading attributes manually ​

For advanced use cases outside of model discovery, use AttributeReader directly:

php
use LaravelUi5\OData\Service\Discovery\AttributeReader;

$reader = new AttributeReader();

// Class-level annotations
$annotations = $reader->readClass(new ReflectionClass(Product::class));

// Property-level annotations
$annotations = $reader->readProperty(
    new ReflectionProperty(Product::class, 'name')
);

// Parameter-level annotations (for function parameters)
$annotations = $reader->readParameter($reflectionParameter);

The AttributeReader only picks up attributes that implement TypedAnnotationInterface. Other PHP attributes (routing, validation, ORM) are silently ignored.

Path values ​

Some terms do not carry a fixed value. Their value is read per entity from another property. The currency of an amount is the typical case: one row is in EUR, the next in JPY. A Path expresses this:

php
use LaravelUi5\OData\Edm\Annotation\Path;
use LaravelUi5\OData\Vocabularies\Measures\V1\ISOCurrency;

#[ISOCurrency(new Path('currency'))]
public ?string $amount {
    get => $this->getAttribute('amount');
    set(?string $value) { $this->setAttribute('amount', $value); }
}

This is written as <Annotation Term="Org.OData.Measures.V1.ISOCurrency" Path="currency"/>. In programmatic annotations the equivalent is new ConstantAnnotationValue('Path', 'currency'), or (new Path('currency'))->toAnnotationValue().

A generated term accepts a Path wherever its value is a single primitive. Since 3.1.0 this covers the Measures and CodeList vocabularies and Common.Text, Common.UnitSpecificScale and Common.UnitSpecificPrecision. The other vocabularies follow when they are next regenerated.

Annotating the entity container ​

Service-wide terms target the entity container. Call annotateContainer() in configure(). It takes generated terms or plain Annotations:

php
use LaravelUi5\OData\Vocabularies\CodeList\V1\CurrencyCodes;

protected function configure(EdmBuilderInterface $builder): EdmBuilderInterface
{
    $this->annotateContainer(
        new CurrencyCodes(url: '../[email protected]/$metadata', collectionPath: 'Currencies'),
    );

    return $builder->namespace($this->namespace());
}

The annotations are written inline in <EntityContainer>, and odata:cache keeps them.

Code lists for currencies and units ​

UI5's sap.ui.model.odata.type.Currency and Unit format each row with the decimals of its currency or unit. They look those decimals up in a code list that the service announces. Three pieces make it work. The decimals of a currency then come from the code list, not from a fixed Scale:

1. The business property points at its code. Use Measures.ISOCurrency for an amount and Measures.Unit for a quantity, each as a path to the property that holds the code.

2. The container points at the code list. CodeList.CurrencyCodes and CodeList.UnitsOfMeasure name the service (url) and the entity set (collectionPath). UI5 resolves a relative url against the service's own URL. The code list may live in the same service or in a separate one that every app shares. UI5 loads each list once per page, even when several models point at it.

3. The code-list set annotates its key. The set has exactly one key, the code. On that key go Common.UnitSpecificScale (the decimals), Common.Text (the display name) and, optionally, CodeList.StandardCode, each as a path:

php
use LaravelUi5\OData\Edm\Annotation\Path;
use LaravelUi5\OData\Edm\EdmPrimitiveType;
use LaravelUi5\OData\Vocabularies\CodeList\V1\StandardCode;
use LaravelUi5\OData\Vocabularies\Common\V1\Text;
use LaravelUi5\OData\Vocabularies\Common\V1\UnitSpecificScale;

#[ODataEntity(name: 'Currency', entitySet: 'Currencies')]
class Currency extends Model
{
    protected $primaryKey = 'code';
    protected $keyType = 'string';
    public $incrementing = false;

    #[UnitSpecificScale(EdmPrimitiveType::Int16, new Path('decimals'))]
    #[Text(new Path('name'))]
    #[StandardCode(new Path('iso'))]
    public ?string $code {
        get => $this->getAttribute('code');
        set(?string $value) { $this->setAttribute('code', $value); }
    }
}

Things to know:

  • UnitSpecificScale must never be null. UI5 ignores such an entry, treats the code as unknown, and renders the amount empty.
  • The set is delivered unpaged. UI5 requests it with $select and without $top or Prefer, and loads only the first page it gets. The engine pages only when a client sends Prefer: odata.maxpagesize, or when the host sets odata.pagination.default. That setting applies to every set, so a service that serves code lists must leave it unset.
  • The text follows Accept-Language. UI5 sends the UI language in this header. It sends no sap-language parameter unless the app puts one into its service URL.
  • Input is not checked against the code's decimals by default. Currency and Unit default to preserveDecimals: true, so too many decimals are accepted. To reject them, bind with formatOptions: {preserveDecimals: false}. A fixed Scale on a plain Edm.Decimal is enforced without that (see Type facets).

Vocabulary references ​

When annotations use terms from external vocabularies (Core, UI, Common, etc.), the $metadata document must include the corresponding <edmx:Reference> elements so that clients can resolve the term definitions.

For any of the 11 built-in vocabularies, use useVocabulary() with the Vocabulary enum:

php
use LaravelUi5\OData\Edm\Vocabularies\Vocabulary;

protected function configure(EdmBuilderInterface $builder): EdmBuilderInterface
{
    $builder
        ->useVocabulary(Vocabulary::Core)
        ->useVocabulary(Vocabulary::UI);

    // ... entity types, sets, etc.
    return $builder->namespace($this->namespace());
}

Available enum cases: Core, Validation, Measures, Aggregation, Authorization, Capabilities, Common, UI, Analytics, Communication, PersonalData.

For vocabularies outside the built-in set, use addReference() directly:

php
use LaravelUi5\OData\Edm\IncludedSchema;
use LaravelUi5\OData\Edm\Reference;

$builder->addReference(new Reference(
    uri: 'https://example.com/vocabularies/Custom.xml',
    includes: [new IncludedSchema(namespace: 'com.example.Custom.v1', alias: 'Custom')],
));

Each vocabulary whose terms you use needs its own reference. Without it the annotations will still appear in the XML, but clients may not be able to interpret them.

Where annotations appear ​

Annotations are serialized into the $metadata CSDL XML document:

xml
<EntityType Name="Product">
  <Annotation Term="Org.OData.Core.V1.Description" String="A product in the catalog"/>
  <Property Name="name" Type="Edm.String">
    <Annotation Term="com.sap.vocabularies.Common.v1.Label" String="Product Name"/>
  </Property>
</EntityType>

UI5 clients can read these annotations through the OData V4 metadata model for table columns, form fields, filter fields and value helps. SAP Fiori Elements (SAPUI5) configures them automatically.