Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message
laravelui5-odata-modeling
laravelui5-odata-modeling
laravelui5/chiron
claude-code
odata
eloquent
custom-entity-sets
read-models
performance
filtering

Model a read surface on laravelui5/odata — the OData v4 engine for Laravel. Use this when adding or reshaping anything a client reads: a list, a keyed detail, a related sub-list, a count, a filterable master, an export. The core is a five-step decision tree — native column → real Eloquent relation (free $expand) → scope ($filter) → relation plus shaping → virtual expand (only for the genuinely computed) — gated by a performance rule that overrides it: small-N detail uses discoverModel(), large-N lists stay raw-SQL custom entity sets, because the engine is fast by streaming rows rather than hydrating models. Triggers: "add an entity set", "expose this model over OData", "OData read", "master/detail", "$expand", "$filter", "$metadata", "discoverModel", "custom entity set", "AbstractEntitySet", "virtual expand", "the OData list is slow", "how do I filter/sort/page this", "expose an enum over OData", "odata:cache", "annotation", "ODataAnnotate", "computed property", "ODataComputed", "currency/unit decimals in UI5", "code list", "hide a column from OData", "$hidden", "$visible", "rename a property".

Install
php artisan boost:add-skill laravelui5/chiron

Save this content to: .claude/skills/laravelui5-odata-modeling/SKILL.md

# Modeling a read surface on laravelui5/odata

## When to use this skill

Any time data has to leave the application for a client to read — a UI5 table, Excel, Power BI,
react-admin, a `fetch` call. Do **not** use it for writes: the engine is read-only, and writes stay
in ordinary Laravel controllers, form requests and jobs.

## The rule that frames everything

**One Eloquent model per table. Relations become `$expand`s, scopes become `$filter`s, and the
exotic tools are for the genuinely computed.**

A list and a detail are views of the *same* table, so they are one discovered set: the list reads
it with `$select`, the detail reads `Set(id)?$expand=…`. Most "aspects" of a subject turn out to be
relationships nobody has written yet. Reach for a custom entity set or a virtual expand **last**.

## The decision tree — take the FIRST that fits

1. **A column on the table** → native property. Free with `discoverModel()`.
2. **A real Eloquent relation** → free `$expand`. Write the relation, discover **both** models.
3. **A filter over the list** → an Eloquent scope, surfaced through `$filter`.
4. **A relation that needs reshaping** (union of two directions, dedup, a catalog join) → the
   relation `$expand` plus light shaping in the client.
5. **Genuinely computed, with no relation to reflect** (a cross-table sum, a catalog×override
   left join) → a virtual expand. **Only this case earns one.**

If you are writing a custom entity set and the honest answer to *"is this just a relation or a
scope?"* is yes — stop and go back to the model.

## Two surfaces, nothing in between (odata 4.0)

**A discovered model is described by its table and its class.** What goes beyond the table is
declared on the model's class; what depends on the runtime comes from a resolver bound in the
container. Every declaration adds to discovery, none overrides it, and the wire carries exactly what
`$metadata` declares.

So a set is either a discovered model or a custom entity set. Renaming a column, giving it another
type or serving a cut of the table is a custom entity set's job, never an attribute on the model.

## The performance gate — row count overrides the tree

The engine is fast because it **streams optimized SQL rows and does not hydrate Eloquent models**.
Hydration costs roughly **30–75× per row** (measured, see `references/custom-entity-sets.md`).

- **Small, bounded N** — a keyed detail with a few `$expand`s, a related sub-list → `discoverModel()`.
  Use the application's own model, casts and all; at one row the overhead is noise.
- **Large or unbounded N** — a master list, an export, an aggregate → a **raw-SQL custom entity
  set**, registered with `discoverCustomEntitySet()`.

**A custom entity set on a big list is not legacy debt.** Never convert one to `discoverModel()`
for consistency — that trades away the reason the engine exists. `$expand` is eager-loaded, so N+1
is not the risk; per-row hydration is.

## The shape of a service

```php
use LaravelUi5\OData\ODataService;
use LaravelUi5\OData\Service\Contracts\EdmBuilderInterface;

class SalesService extends ODataService
{
    public function serviceUri(): string { return ''; }
    public function namespace(): string  { return 'App.Sales'; }

    protected function configure(EdmBuilderInterface $builder): EdmBuilderInterface
    {
        $this->discoverModel(Order::class);      // small-N detail, relations for free
        $this->discoverModel(Customer::class);   // discover the RELATED model too
        $this->discoverCustomEntitySet(OrderList::class); // large-N list, raw SQL

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

The detail reads `Orders(42)?$expand=customer`; the list reads `OrderList?$filter=…`. Never list the
discovered `Orders` *collection* on a large table — that is what `OrderList` is for.

## Which reference to read before implementing

- **`references/discovery.md`** — what `discoverModel()` derives, `$hidden`/`$visible`, the class
  attributes (`#[ODataEntity]`, `#[ODataAnnotate]`, `#[ODataComputed]`), casts, relations as
  navigation properties, and virtual expands (contract and scope).
- **`references/custom-entity-sets.md`** — when a custom set is justified, the measured numbers,
  the `columns()` contract, backed enums as `Edm.EnumType`, streaming.
- **`references/annotations.md`** — vocabulary terms, `Path` values, container annotations, and the
  code lists UI5 needs to format currencies and units per row.
- **`references/consuming.md`** — the URL surface and how the engine answers it (`$filter`, keys,
  paging, `IEEE754Compatible`), `$batch`, read authorization behaviour, caching, and what the
  common clients expect.

## Translated texts arrive translated

A localized vocabulary — a Customizing catalog, an ability's or a setting's title — reads in the
user's language by itself; so does master data. On a model, declare the translatable fields and use
the trait:

```php
#[Translatable(['title', 'description'])]
final class Item extends Model
{
    use HasTranslations;
}
```

Every query of the model — `discoverModel()` included — then delivers those columns in the request's
language: the further-language text from `sdk_translations`, else the column's base value, never
English. `$filter`, `$orderby` and `$search` run on that text. **Do not add a `title_text` column** or
resolve texts in a custom set. An editor binds the `translations` navigation (`AbstractTranslationsExpand`,
every language including the base), not the field; the owning action writes with
`$item->setTranslations(['fr' => ['title' => 'Vis']])` in its own transaction — it refuses the base
language and any language that is not one of the tenant's further ones (`LaravelUi5.getLanguages()`).

## Four things not to do

- **Do not write a controller, API resource or `?include=` convention** for data an OData service
  already serves. Declaring the service is the endpoint.
- **Do not hand-write or edit CSDL.** `$metadata` is generated from the model and the annotations.
- **Do not add `#[ODataEntity]` for its own sake.** It names the type and the set; discovery derives
  both from the class.
- **Do not put anything OData on a PHP property of a model** — no plain `public $column;`, no hooked
  property. A declared property shadows Eloquent's attribute bag, and since odata 4.0 nothing reads
  one. A column's terms go in `#[ODataAnnotate]` on the class, a value without a column in
  `#[ODataComputed]`, a hidden column in `$hidden`.

Modeling a read surface on laravelui5/odata

When to use this skill

Any time data has to leave the application for a client to read — a UI5 table, Excel, Power BI, react-admin, a fetch call. Do not use it for writes: the engine is read-only, and writes stay in ordinary Laravel controllers, form requests and jobs.

The rule that frames everything

One Eloquent model per table. Relations become $expands, scopes become $filters, and the exotic tools are for the genuinely computed.

A list and a detail are views of the same table, so they are one discovered set: the list reads it with $select, the detail reads Set(id)?$expand=…. Most "aspects" of a subject turn out to be relationships nobody has written yet. Reach for a custom entity set or a virtual expand last.

The decision tree — take the FIRST that fits

  1. A column on the table → native property. Free with discoverModel().
  2. A real Eloquent relation → free $expand. Write the relation, discover both models.
  3. A filter over the list → an Eloquent scope, surfaced through $filter.
  4. A relation that needs reshaping (union of two directions, dedup, a catalog join) → the relation $expand plus light shaping in the client.
  5. Genuinely computed, with no relation to reflect (a cross-table sum, a catalog×override left join) → a virtual expand. Only this case earns one.

If you are writing a custom entity set and the honest answer to "is this just a relation or a scope?" is yes — stop and go back to the model.

Two surfaces, nothing in between (odata 4.0)

A discovered model is described by its table and its class. What goes beyond the table is declared on the model's class; what depends on the runtime comes from a resolver bound in the container. Every declaration adds to discovery, none overrides it, and the wire carries exactly what $metadata declares.

So a set is either a discovered model or a custom entity set. Renaming a column, giving it another type or serving a cut of the table is a custom entity set's job, never an attribute on the model.

The performance gate — row count overrides the tree

The engine is fast because it streams optimized SQL rows and does not hydrate Eloquent models. Hydration costs roughly 30–75× per row (measured, see references/custom-entity-sets.md).

  • Small, bounded N — a keyed detail with a few $expands, a related sub-list → discoverModel(). Use the application's own model, casts and all; at one row the overhead is noise.
  • Large or unbounded N — a master list, an export, an aggregate → a raw-SQL custom entity set, registered with discoverCustomEntitySet().

A custom entity set on a big list is not legacy debt. Never convert one to discoverModel() for consistency — that trades away the reason the engine exists. $expand is eager-loaded, so N+1 is not the risk; per-row hydration is.

The shape of a service

use LaravelUi5\OData\ODataService;
use LaravelUi5\OData\Service\Contracts\EdmBuilderInterface;

class SalesService extends ODataService
{
    public function serviceUri(): string { return ''; }
    public function namespace(): string  { return 'App.Sales'; }

    protected function configure(EdmBuilderInterface $builder): EdmBuilderInterface
    {
        $this->discoverModel(Order::class);      // small-N detail, relations for free
        $this->discoverModel(Customer::class);   // discover the RELATED model too
        $this->discoverCustomEntitySet(OrderList::class); // large-N list, raw SQL

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

The detail reads Orders(42)?$expand=customer; the list reads OrderList?$filter=…. Never list the discovered Orders collection on a large table — that is what OrderList is for.

Which reference to read before implementing

  • references/discovery.md — what discoverModel() derives, $hidden/$visible, the class attributes (#[ODataEntity], #[ODataAnnotate], #[ODataComputed]), casts, relations as navigation properties, and virtual expands (contract and scope).
  • references/custom-entity-sets.md — when a custom set is justified, the measured numbers, the columns() contract, backed enums as Edm.EnumType, streaming.
  • references/annotations.md — vocabulary terms, Path values, container annotations, and the code lists UI5 needs to format currencies and units per row.
  • references/consuming.md — the URL surface and how the engine answers it ($filter, keys, paging, IEEE754Compatible), $batch, read authorization behaviour, caching, and what the common clients expect.

Translated texts arrive translated

A localized vocabulary — a Customizing catalog, an ability's or a setting's title — reads in the user's language by itself; so does master data. On a model, declare the translatable fields and use the trait:

#[Translatable(['title', 'description'])]
final class Item extends Model
{
    use HasTranslations;
}

Every query of the model — discoverModel() included — then delivers those columns in the request's language: the further-language text from sdk_translations, else the column's base value, never English. $filter, $orderby and $search run on that text. Do not add a title_text column or resolve texts in a custom set. An editor binds the translations navigation (AbstractTranslationsExpand, every language including the base), not the field; the owning action writes with $item->setTranslations(['fr' => ['title' => 'Vis']]) in its own transaction — it refuses the base language and any language that is not one of the tenant's further ones (LaravelUi5.getLanguages()).

Four things not to do

  • Do not write a controller, API resource or ?include= convention for data an OData service already serves. Declaring the service is the endpoint.
  • Do not hand-write or edit CSDL. $metadata is generated from the model and the annotations.
  • Do not add #[ODataEntity] for its own sake. It names the type and the set; discovery derives both from the class.
  • Do not put anything OData on a PHP property of a model — no plain public $column;, no hooked property. A declared property shadows Eloquent's attribute bag, and since odata 4.0 nothing reads one. A column's terms go in #[ODataAnnotate] on the class, a value without a column in #[ODataComputed], a hidden column in $hidden.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log
gregsphotography/site-builder-components
io-digital/clickatell
snowsoft/laravel-admin
oxygen/auth
relicsoftware/barakah-payment-gateway
tobya/work-with-composer
equipc/laravel-email-validator
equipc/laravel-email-database-log
adedsec/nova-translated-labels
wazza/laravel-db-encryption
hokny/laravel-generator
vonsogt/backup
jengo/queues
jeremykenedy/laravel-observability
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums