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

Laravel Odata Adaptable Laravel Package

khalyomede/laravel-odata-adaptable

Laravel OData Adaptable brings OData-style query options to Laravel, letting clients filter, sort, select, and paginate Eloquent resources with familiar syntax. A lightweight way to expose flexible, standards-inspired APIs with minimal boilerplate.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and confirm what you actually got. The package is small and low-traffic (3 stars, 0 dependents), so treat the README as a hypothesis and the src/ directory as the source of truth.

composer require khalyomede/laravel-odata-adaptable

Laravel's package auto-discovery should register the service provider. Immediately after installing, do two things before writing any application code:

  1. Read vendor/khalyomede/laravel-odata-adaptable/src/ — there are likely only a handful of classes. Identify (a) the model-facing trait or scope, (b) the query-string parser, and (c) any config file. This tells you the real API surface faster than the README.
  2. Check the config. If a config file exists, publish it (php artisan vendor:publish --provider="Khalyomede\...") and read every key. With a package this small, most behavior is either hardcoded or governed by one or two config values.

Where to look first in your own app: put the trait on a single, read-only, non-critical model — a reporting or lookup table — and hit it through tinker or a throwaway route. Do not start by attaching it to User or anything with sensitive columns.

First use case. The natural fit is an index endpoint consumed by a client that already speaks OData: Kendo UI / DevExtreme grids, Power BI or Excel Power Query, SAPUI5/Fiori, or any generated OData client. Those clients send $filter, $select, $orderby, $top, $skip, and $count on their own — the package's job is to translate those into Eloquent builder calls so you don't hand-write a parser.

Minimal shape:

// app/Models/Invoice.php
use Khalyomede\ODataAdaptable\...; // trait name from src/

class Invoice extends Model
{
    use AdaptsToOData; // verify exact trait name
}

// routes/api.php
Route::get('/invoices', fn () => Invoice::query()->adaptToOData()->get());

Confirm the method name (adaptToOData, odata, scopeOData, etc.) from the trait before copying this — guessing here is the most common way to burn an hour.

Implementation Patterns

Apply OData late in the builder chain, but scope first. Authentication and tenancy constraints must go on the builder before the OData adaptation, so $filter can only narrow what the user is already allowed to see:

Invoice::query()
    ->where('tenant_id', auth()->user()->tenant_id)
    ->adaptToOData()
    ->get();

Separate "shape" from "transport." Wrap the adapted query in an invokable controller or a dedicated ODataController rather than sprinkling trait calls across CRUD controllers. OData endpoints are semantically different from your REST endpoints — they leak schema by design, and you want that surface area isolated and easy to audit or disable.

Whitelist columns at the model layer, not the controller. $select and $orderby accept arbitrary identifiers. Configure an allowlist (via config or a model property) of filterable/sortable/selectable fields, and treat anything outside it as a 400 rather than a silent ignore. This is the single most important pattern in this assessment.

Pair with Laravel's pagination deliberately. OData clients expect $top/$skip and @odata.count. Laravel expects page and returns data/meta. Pick one contract:

  • OData-native: let the package handle $top/$skip, and emit {"@odata.context": ..., "value": [...], "@odata.count": n} yourself.
  • Laravel-native: translate $top/$skip into perPage/page and return a standard ResourceCollection.

Do not do both — double-paginating ($top=25 plus ->paginate(15)) produces confusing truncation that is hard to diagnose from the client side.

Use $expand as an eager-load hint, with depth limits. Map $expand=customer,lines to with(['customer', 'lines']) and cap the nesting depth and the number of expansions per request. Unguarded $expand on a model with a deep relationship graph will happily generate N+1s or a very large join.

Validate OData params with a FormRequest. Cheap, and it gives you a single place to reject malformed filter syntax before it reaches the SQL compiler:

class ODataRequest extends FormRequest {
    public function rules(): array {
        return [
            '$filter'  => ['nullable', 'string', 'max:2000'],
            '$top'     => ['nullable', 'integer', 'min:1', 'max:500'],
            '$skip'    => ['nullable', 'integer', 'min:0'],
            '$orderby' => ['nullable', 'string', 'max:255'],
            '$select'  => ['nullable', 'string', 'max:1000'],
            '$expand'  => ['nullable', 'string', 'max:255'],
        ];
    }
}

Test with raw query strings, not arrays. OData literals use single quotes for strings and bare tokens for dates/guids ($filter=status eq 'open' and due lt 2024-06-01T00:00:00Z). Laravel's test helpers make this easy but you must build the URL as a string, since route(..., ['$filter' => ...]) will URL-encode the $ inconsistently across client libraries. Assert on assertJsonPath('@odata.count', ...) or your chosen shape.

Keep a feature flag around it. A config toggle (or middleware gate) that 404s the OData routes outside of development/known integrations is worth the ten lines. This package is unmaintained enough that you want a one-line kill switch in a deploy.

Gotchas and Tips

Filter injection is the real risk, not SQL injection. Eloquent parameterizes values, but the identifiers ($filter=some_column eq 1, $orderby=some_column) end up as column names in SQL. A parser that doesn't validate identifiers lets an anonymous caller enumerate your schema — and on some table types, sort by columns you never intended to expose. Audit the parser's identifier handling in src/ yourself; don't assume.

Unknown columns produce 500s, not 400s. SQLSTATE[42S22]: Column not found is the expected failure mode when a client guesses a field name. Catch the query exception around the OData endpoint (or pre-validate against the allowlist) and return a proper 400 with a message the client can act on. Kendo and Power Query both retry aggressively on 500s.

OData literal typing is stricter than it looks. $filter=active eq true and $filter=active eq 'true' are different requests. Dates are typically unquoted ISO-8601; strings are single-quoted with '' as the escape. If a filter silently returns everything or nothing, log the parsed result and compare it to what you expected — this is the fastest debugging path.

Case sensitivity. OData operators are case-sensitive in the spec (eq, and, or, contains) but clients vary. Check whether the parser lowercases; if not, normalize the query string in middleware before it reaches the package. Same for column names — MySQL is case-insensitive on most identifiers, PostgreSQL is not, so a filter that works locally will fail in production on Postgres.

Cap $top hard. No default limit means $top=1000000 is a free memory-exhaustion primitive against a busy endpoint. Enforce a max in validation and a default in config.

Watch interaction with global scopes. Soft deletes, scopeActive, and pivot constraints all still apply, which is usually what you want — but if a client's grid shows 500 rows and your API returns 480, this is the first place to look.

$count=true vs. Laravel's total(). A count() on a query with $select or joins can be expensive or wrong. If the package issues a separate count query, verify it doesn't inherit $top/$skip (a classic off-by-a-lot bug: total equals the page size).

Debugging: log the SQL. Wrap the endpoint in DB::listen(fn ($q) => logger($q->sql, $q->bindings)) during development. With an unfamiliar, thin package, reading the generated SQL is faster than reading the parser.

Extension points are limited — plan for a fork. With 3 stars and 0 dependents there is effectively no upstream support, no security-response process, and no guarantee it works with your Laravel version (small packages like this are often frozen at the Laravel 8/9 era; check composer.json constraints against your app before installing). If you need custom operators, nested property paths, or a different response envelope, budget for copying the parser into app/Support/OData/ and owning it. Pin the exact version in composer.json, and add a note in your README about why the dependency exists so the next developer doesn't rip it out blind.

When not to use it: if you only need ?sort=-created_at&filter[status]=open, a 20-line query scope is safer and more predictable. Reserve this package for genuine OData clients where the grammar — and the schema exposure that comes with it — is a required part of the integration contract.

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.
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox