flat3/lodata
Lodata is an OData v4 server for Laravel that exposes Eloquent models as standards-compliant APIs. It supports $filter, $select, $expand, $orderby, $top and $skip query options, metadata discovery, and easy model configuration for building rich, queryable endpoints.
Install the package and publish its config if you need to change the base path or schema namespace:
composer require flat3/lodata
php artisan vendor:publish --tag=lodata-config # optional, see install docs for the exact tag
Register a single OData endpoint with the route macro the package provides:
// routes/api.php
Route::odata('odata', \App\Http\Controllers\ODataController::class);
Then create the controller and list the Eloquent models you want exposed:
namespace App\Http\Controllers;
use App\Models\Airport;
use App\Models\Flight;
use Flat3\Lodata\Controller\OData as ODataController;
class OData extends ODataController
{
public $entitySets = [
Flight::class,
Airport::class,
];
// public $namespace = 'App'; // controls the CSDL namespace
}
That is enough to get a read/write OData surface over your models. Verify in this order:
GET /api/odata — service document listing entity sets.GET /api/odata/$metadata — the CSDL. This is the single most useful debugging endpoint; it tells you exactly what properties, types, keys and navigations the package inferred from your models.GET /api/odata/Flights?$top=5&$select=id,number,departure — first real query.If you have many models, php artisan lodata:discover can scan app/Models and scaffold the $entitySets array, which is faster than hand-listing them.
First use case: a read-only reference feed for Power BI / Excel / a JS OData client. Models exposed read-only need no extra work — Laravel handles GET, and you can lock down writes with middleware (->middleware(['auth:sanctum'])) before you broaden exposure.
Model = entity type. The package reflects the database schema for properties, $casts for EDM types, and belongsTo / hasMany / belongsToMany for navigation properties. You rarely define the EDM by hand; you shape it by shaping the model and migrations.
One controller per audience, not one giant controller. Mount separate endpoints — /odata/public and /odata/admin — with different $entitySets and different middleware. It is far easier to reason about exposure and versioning than a single controller with conditional logic. Treat the endpoint prefix as your API version.
Control the surface with $select conventions rather than hiding columns ad hoc. For list endpoints, clients should ask for $select=id,name; the package will only project those columns. For fixed integrations, document the required $select in the consumer.
Query options you will actually use: $filter, $select, $expand, $orderby, $top, $skip, $count, $search, $apply, $compute, $format. $filter is where most of the value is — clients can push predicate logic instead of you building bespoke query endpoints:
GET /api/odata/Flights?$filter=departure gt 2024-01-01T00:00:00Z and destination eq 'LHR'&$orderby=departure desc&$count=true
Relationships: navigation paths (/Flights(1)/Airport, /Airports(2)/Flights) come from the Eloquent relations. $expand=Airport fetches them inline. Use $expand selectively — see gotchas below.
Writes: POST / PATCH / PUT / DELETE map onto Eloquent create/save/delete, and @odata.bind lets you set foreign keys by navigation URL. Deep inserts (creating a parent and children in one POST) work via nested payloads. Gate these behind auth before you ship.
Row-level security lives in the model, not the controller. The package builds Eloquent queries, so global scopes apply. A Flight::addGlobalScope('tenant', fn ($q) => $q->where('tenant_id', auth()->id())) is usually cleaner than trying to inspect the OData path inside middleware. Route-level middleware handles who can reach the endpoint; model scopes handle what rows they see.
Extensions: computed properties, custom functions/actions, custom entity sets backed by non-Eloquent data, and custom primitive types are the supported escape hatches. Reach for a custom entity set when you need a read-only projection (e.g. an aggregated report) that doesn't map cleanly to a table.
Testing: plain HTTP tests work well.
$this->getJson('/api/odata/Flights?$filter=status eq \'active\'&$top=3')
->assertOk()
->assertJsonStructure(['@odata.context', 'value' => [['id', 'number']]]);
Assert on @odata.context and property names — those are your public contract, and they change when migrations change.
$metadata and immediately exposes the field on every query. Make a habit of diffing $metadata after every migration, and treat any diff as a potentially breaking API change.$hidden / $visible protect you. The OData surface is derived from the DB schema and casts, not from Eloquent serialization. A password or internal flag column will be exposed unless you exclude it through the package's property configuration or expose a dedicated read model. Check $metadata, not the model file.$metadata is generated per request. For large models it's a meaningful startup cost. If profiling shows it, cache the CSDL and clear caches (php artisan optimize:clear) after schema changes. Under Octane, don't stash the EDM in a static without resetting it per request.$top is unbounded unless you bound it. Set a max top / default page size in config so $top=1000000 doesn't become a support ticket.$count=true issues a second count query. Fine on small sets, expensive on wide filtered sets — measure before enabling it in a dashboard.$expand can N+1. Log the SQL (DB::listen) on an expanded request. If the package isn't eager-loading the relation, add with() via a global scope on the hot paths./Flights is not /flights. Keep entity set names stable and document them; they are part of your URL contract./{segment} route, or a generic route will shadow it and return 404s that look like package bugs.api middleware. If you mount on web, every POST/PATCH/DELETE from a non-browser client returns 419.OData-Version, OData-EntityId and Location in your CORS config; some clients fail cryptically on updates if Location isn't readable.$ in URLs. $filter values routinely contain $, ' and spaces. URL-encode from clients; be aware that some proxies/log pipelines mangle $ in query strings. Nginx access-log formats that interpolate $ are a common red herring.$filter string functions (contains, startswith, tolower) are translated to SQL. SQLite, MySQL and Postgres differ on LIKE case sensitivity and function availability — test on the production engine, not just the local one.decimal:2 casts come out as strings; floats lose money precision. Carbon casts become Edm.DateTimeOffset — make sure the timezone is what clients expect. Enum casts need checking against the EDM enum you intend.If-Match optimistic concurrency and last-write-wins. The default is the latter.$metadata first, then retry the query with $select restricted — it isolates filtering from projection problems.How can I help you explore Laravel packages today?