laravelui5/odata
Laravel UI5 OData adds OData service support to Laravel, letting you expose Eloquent models with query options such as $filter, $select, $expand, $orderby, and $count. It integrates with Laravel routing and is designed for SAPUI5/OpenUI5 frontends.
This release (v3.1.0) does not change the install story, but it does change what your service announces, so the first thing to do after upgrading is diff $metadata.
Minimal steps
composer require the new version, then republish/merge config if you have a published config/odata.php.EdmxWriter, which dropped facets, the collection flag and default values. Warm and cold $metadata now differ for anything cached before the upgrade:
php artisan odata:cache --clear # or delete the cache store entries
php artisan odata:cache
/$metadata and compare against your previous output. Expect new Nullable="false" on every NOT NULL column and MaxLength on length-bearing string columns.200 may now return 400/501.Where to look first
services/model-discovery — refreshed in this release, and now explicitly documents why polymorphic relations are skipped and what to do instead.query-options/pagination — the warning about @odata.nextLink losing your query options is gone because the link is fixed; read it to confirm your client assumptions.ColumnFacetResolverInterface and TypeFacets — the new extension seam.#[ODataProperty] attribute class — nullable:, precision: and scale: are now live parameters, not decoration.First use case
Expose one Eloquent model, build the schema, and confirm the facets land:
#[ODataEntitySet('Products')]
class Product extends Model
{
#[ODataProperty(key: true)]
public int $id;
#[ODataProperty]
public string $name; // varchar(120) -> MaxLength="120"
#[ODataProperty]
public string $price; // decimal(19,6) -> Precision="19" Scale="6"
}
Then GET /odata/Products?$select=name,price&$expand=lines($count=true) and check that price carries its scale and that [email protected] appears before lines.
Let the schema be the source of truth, then override deliberately. The order per column is schema → resolver → attribute, with the key always non-nullable. Reach for the narrowest override that solves the problem:
// One model, one property — literal facets
#[ODataProperty(precision: 18, scale: 2, nullable: false)]
public string $unitPrice;
Installation-wide facets via the resolver. When a facet is a business fact rather than a column fact (currency decimals, a site-wide price precision), bind your own resolver. There is a single binding, not a chain, so use bind() — ColumnFacetsAsDeclared is registered with bindIf, so any container binding of yours wins regardless of provider order:
public function register(): void
{
$this->app->bind(ColumnFacetResolverInterface::class, PriceFacetResolver::class);
}
final class PriceFacetResolver implements ColumnFacetResolverInterface
{
public function resolve(string $model, string $column, mixed $cast, TypeFacets $facets): TypeFacets
{
return $column === 'unit_price'
? $facets->withPrecision(18)->withScale(2)
: $facets;
}
}
Use withNullable(), withMaxLength(), withPrecision(), withScale() rather than constructing a fresh TypeFacets so you don't silently discard the schema-derived facets. isDefault() is handy when you want to detect "nothing was declared" and only then apply your own rule.
Treat the metadata cache as a deployment artifact. Because the resolver runs when the schema is built, a cached service freezes whatever the resolver returned at cache time. Wire odata:cache into your deploy pipeline after any change to resolver logic or installation configuration, and add a test that asserts warm and cold $metadata are byte-identical — the package does this internally, and it is worth mirroring for your own entity sets.
Use $count inside $expand for master–detail screens. $expand=lines($count=true) now emits [email protected] computed with a withCount sub-select, honouring the expand's own $filter but not its $top/$skip, and it survives $select. That is exactly the shape UI5 and Excel paging expect — you no longer need a second round trip to size the collection.
Stop hand-building pagination links. @odata.nextLink is now the request URL with only $skip swapped, carrying the query string byte for byte (including custom query options and $count). Any client-side helper that reconstructed links from $filter/$orderby/$select should be deleted.
Write filters that translate. tolower/toupper map to LOWER()/UPPER() in any comparison, so UI5's case-insensitive filters work; contains/startswith/endswith match literally with wildcards escaped the same way as $search; in and 3 lt id (mirrored) now work. Prefer these over client-side filtering.
Plan for the polymorphic gap. MorphTo, MorphToMany and MorphOneOrMany are skipped by discovery entirely — even where the target looks fixed — because a polymorphic join runs over a type column plus an id, which no ReferentialConstraint can express. Model these as explicit navigation properties or separate entity sets.
The metadata change is host-visible even though this is a minor release. Every NOT NULL column now announces Nullable="false" and every length-bearing string announces MaxLength. UI5's V4 types read both as constraints, so a two-way bound control will now validate against them. Fields that previously accepted over-length strings or nulls from the client will start rejecting them. Audit your forms before rolling out, and treat clients that parse $metadata as potentially breaking.
An #[ODataProperty(type:)] override kills the column facets. A facet is applied only when the final Edm type can carry it, so overriding decimal(19,6) to Edm.String drops Precision/Scale. If you override the type, declare the facets you still want explicitly.
Invalid facet/type combinations throw a LogicException at schema build time. Scale outside Edm.Decimal, Precision outside Edm.Decimal and the temporal types, MaxLength outside Edm.String/Edm.Binary, or Scale > Precision all fail loudly. Good, but it means a bad resolver can take down $metadata and odata:cache — and on an uncached service, the request. Cover your resolver with a test that builds the schema for every affected model.
The resolver is one binding, not a chain. You cannot register several resolvers and have them compose; delegate inside your implementation if you need combined behaviour. Also watch provider order only in the sense that bind() beats the package's bindIf() — you don't need to reorder providers.
Stale cache = stale facets. Changing your resolver, or the installation fact behind it (price decimals, currency), requires a cache rebuild. Symptom: cold requests behave correctly while the deployed service keeps serving the old constraints.
Filter errors are now honest, which will surface latent client bugs. Untranslatable functions, operators and lambdas answer 501 unsupported_filter naming the construct; a comparison without a property, null with anything but eq/ne, or a non-Boolean standalone expression answers 400 invalid_filter. Previously these returned 200 with the wrong rows — often nothing for arithmetic/function operands and everything for null comparisons and unsupported functions. If a dashboard "worked" before, it may have been showing the wrong data.
Behaviour changes worth explicit tests: $filter=false now returns nothing (it returned everything); in lists are translated correctly rather than yielding []; the parser accepts id in (1,3), nav/any(p: p/name eq 'x'), ( id eq 1 ) and contains( name , 'x' ).
Key literals are validated against the key type. Products(abc) answers 400 invalid_key instead of silently becoming Products(0) and returning the wrong row. Integers are range-checked, true/false for Boolean keys, and Guid, Date, DateTimeOffset, TimeOfDay and Duration are parsed. Decimal, temporal and Guid keys remain strings so they stay exact — don't cast them yourself before calling.
Errors now arrive before the first row. EntitySetHandler runs the query up to the first row before committing the response, so a refused filter or a SQL error keeps its 400/501 instead of arriving after a 200. Rows still stream. The trade-off: a slow first row delays response headers, so very expensive queries no longer fail fast on the wire — keep an eye on first-row latency for large sets.
$count inside $expand has two refusals. A single-valued navigation answers 400 invalid_expand; a virtual navigation answers 501 unsupported_expand, because its resolver decides which rows it returns and cannot be counted reliably. Don't put $count=true on expanded singletons or virtual sets.
SQLite reports an integer primary key as nullable — ignore it, the package does. Key properties are never nullable regardless of what the driver reports. Other drivers may also report surprising facets; if a MaxLength looks wrong, check Schema::getColumns() output on the deployed driver before filing a bug.
Debugging checklist for "my filter returns the wrong rows": capture the outgoing request ($filter is echoed in the 501/400 body for untranslatable constructs), confirm the property is mapped in $metadata with the expected type, and check whether a type override silently stripped the facets your client relies on.
How can I help you explore Laravel packages today?