novabytes / laravel-odata
Apply OData 4 query options to Eloquent models. Supports $filter, $select, $expand, $orderby, $top, $skip, and $count.
Requires
- php: ^8.2
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/http: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
- novabytes/odata-query-parser: ^0.3.0 || ^0.2.0 || ^0.1.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.94
- orchestra/testbench: ^9.0|^10.0|^11.0
- phpunit/phpunit: ^11.0
This package is auto-updated.
Last update: 2026-04-25 07:38:00 UTC
README
OData 4 for Laravel. Query (filter, select, expand, sort, paginate) and CRUD (create, read, update, delete) your Eloquent models via OData-compliant endpoints.
Built on top of novabytes-labs/odata-query-parser.
Table of Contents
- Installation
- Quick Start
- CRUD Endpoints
- Query Options
- PascalCase Conversion
- Security
- Entity Sets & Metadata
- Configuration
- Advanced Usage
- Requirements
- License
Installation
composer require novabytes/laravel-odata
Publish the config file:
php artisan vendor:publish --tag=odata-config
Quick Start
use NovaBytes\OData\Laravel\ODataQueryBuilder; class ProductController extends Controller { public function index(Request $request) { return ODataQueryBuilder::for(Product::class, $request) ->allowedFilters('name', 'price', 'is_active', 'category_id') ->allowedSorts('name', 'price', 'created_at') ->allowedExpands('category', 'reviews') ->allowedSelects('id', 'name', 'price', 'description', 'category_id') ->get(); } }
Your API now accepts OData queries:
GET /products?$filter=Price gt 100&$select=Name,Price&$expand=Category&$orderby=Price desc&$top=50&$skip=10&$count=true
CRUD Endpoints
Enable auto-registered CRUD routes for your entity sets:
// config/odata.php 'entity_sets' => [ \App\Models\Product::class => [ 'entitySet' => 'Products', 'operations' => ['read', 'create', 'update', 'delete'], 'allowedFilters' => ['name', 'price'], 'allowedSorts' => ['name', 'price'], 'allowedExpands' => ['category'], 'allowedSelects' => ['id', 'name', 'price', 'description'], 'allowedCreates' => ['name', 'price', 'description', 'category_id'], 'allowedUpdates' => ['name', 'price', 'description'], ], ], 'crud' => [ 'enabled' => true, 'route_prefix' => 'api', 'middleware' => ['api'], 'default_operations' => ['read'], ],
This registers the following routes:
| Method | Route | Description |
|---|---|---|
GET |
/api/Products |
List with OData query options |
POST |
/api/Products |
Create entity |
GET |
/api/Products/{key} |
Get single entity |
PUT |
/api/Products/{key} |
Full replace |
PATCH |
/api/Products/{key} |
Partial update |
DELETE |
/api/Products/{key} |
Delete entity |
Request bodies accept both PascalCase and snake_case field names. Only fields listed in allowedCreates/allowedUpdates are accepted — others return 400.
The CSDL and OpenAPI metadata endpoints automatically reflect CRUD capabilities.
Query Options
| Option | Example | Description |
|---|---|---|
$filter |
Price gt 100, contains(Name,'Widget'), Reviews/any(r:r/Rating gt 4) |
Filter results using comparison operators, functions, and lambda expressions |
$select |
Name,Price |
Choose which properties to return (primary key always included) |
$expand |
Category, Reviews($filter=Rating gt 4;$top=5) |
Eager-load relationships with optional nested query options |
$orderby |
Price desc, Name asc,Price desc |
Sort results by one or more properties |
$top |
10 |
Limit the number of results |
$skip |
20 |
Skip a number of results (for pagination) |
$count |
true |
Include total count in the response |
All OData 4 comparison operators (eq, ne, gt, ge, lt, le), logical operators (and, or, not), and 30+ built-in functions are supported. See the parser README for the full list.
PascalCase Conversion
OData uses PascalCase property names. This package automatically converts them to Eloquent's snake_case:
| OData | Eloquent |
|---|---|
Price |
price |
CategoryId |
category_id |
IsActive |
is_active |
Category (expand) |
category (relationship) |
Define your allowlists in snake_case — the conversion is handled for you.
Security
Every filterable, sortable, expandable, and selectable field must be explicitly whitelisted. Any request for a non-whitelisted field throws a 400 Bad Request by default.
If no allowlist is set for a given operation, that operation is unrestricted.
Entity Sets & Metadata
Register your models and their allowlists centrally in config/odata.php to enable:
- Auto-generated
$metadata(CSDL XML) endpoint for OData clients - Auto-generated OpenAPI 3.0 spec for human-readable documentation
- Shared allowlists — no need to repeat
allowedFilters()etc. in every controller
// config/odata.php 'entity_sets' => [ \App\Models\Product::class => [ 'entitySet' => 'Products', // optional, auto-generated from table name 'operations' => ['read', 'create', 'update', 'delete'], 'allowedFilters' => ['name', 'price', 'is_active'], 'allowedSorts' => ['name', 'price', 'created_at'], 'allowedExpands' => ['category', 'reviews'], 'allowedSelects' => ['id', 'name', 'price', 'description'], 'allowedCreates' => ['name', 'price', 'description', 'category_id'], 'allowedUpdates' => ['name', 'price', 'description'], ], \App\Models\Category::class => [ 'operations' => ['read'], 'allowedFilters' => ['name'], 'allowedExpands' => ['products'], ], ], 'namespace' => 'Default', 'metadata' => [ 'enabled' => true, 'route_prefix' => 'odata', 'openapi' => [ 'title' => 'My OData API', 'version' => '1.0.0', 'description' => '', ], ],
When entity_sets are configured, controllers can omit explicit allowlists:
// Allowlists are loaded from config automatically ODataQueryBuilder::for(Product::class, $request)->get(); // Explicit calls still override config when you need to restrict further ODataQueryBuilder::for(Product::class, $request) ->allowedFilters('name') // overrides config for this endpoint ->get();
Metadata Endpoints
When metadata.enabled is true, two routes are registered automatically:
| Endpoint | Content-Type | Description |
|---|---|---|
GET {prefix}/$metadata |
application/xml |
OData v4 CSDL document — used by Power BI, Excel, and OData client libraries |
GET {prefix}/openapi.json |
application/json |
OpenAPI 3.0 spec — use with Swagger UI, Redoc, or any API docs tool |
Both are auto-generated from your Eloquent models and the entity_sets config. Zero annotations needed — columns, types, and nullability are discovered from the database schema; relationships are discovered via reflection.
Configuration
// config/odata.php return [ 'response_format' => 'laravel', // 'laravel' or 'odata' 'max_expand_depth' => 3, // Max $expand nesting depth 'max_top' => 1000, // Max $top value (null = unlimited) 'default_top' => null, // Default $top when not specified (null = no limit) 'throw_on_invalid' => true, // true = 400 on invalid ops, false = silently ignore 'entity_sets' => [], // Model registrations (see above) 'namespace' => 'Default', // CSDL schema namespace 'metadata' => [ // Metadata endpoint config 'enabled' => false, 'route_prefix' => 'odata', 'openapi' => ['title' => 'OData API', 'version' => '1.0.0', 'description' => ''], ], 'crud' => [ // CRUD endpoint config 'enabled' => false, 'route_prefix' => 'api', 'middleware' => ['api'], 'default_operations' => ['read'], ], ];
Advanced Usage
Using toBuilder()
$builder = ODataQueryBuilder::for(Product::class, $request) ->allowedFilters('price') ->toBuilder(); $results = $builder->where('is_active', true)->get();
Existing query as starting point
$query = Product::where('is_active', true); $results = ODataQueryBuilder::for($query, $request) ->allowedFilters('name', 'price') ->get();
Requirements
- PHP >= 8.2
- Laravel 11, 12, or 13
License
MIT