raqmeyat / smart-orm
Request-driven smart field selection and smart filters for Eloquent, with data-class and filter generators. Laravel 13+, PHP 8.4+, SQLite / MySQL / PostgreSQL.
Package info
pkg:composer/raqmeyat/smart-orm
Requires
- php: ^8.4
- ext-json: *
- illuminate/console: ^13.0
- illuminate/contracts: ^13.0
- illuminate/database: ^13.0
- illuminate/http: ^13.0
- illuminate/support: ^13.0
- nesbot/carbon: ^3.8
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^11.0
- phpunit/phpunit: ^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Request-driven smart field selection, smart filters and typed data classes for Eloquent. Built for mobile-first JSON APIs: clients ask for exactly the columns and rows they need, the server only ever honours an explicit allow-list.
- Laravel 13+, PHP 8.4+
- SQLite, MySQL / MariaDB and PostgreSQL (schema introspection and
LIKEbehaviour are driver-aware) - Two generators:
make:data-model {model}andmake:filtter {model}
composer require raqmeyat/smart-orm
The service provider is auto-discovered. Optional publishing:
php artisan vendor:publish --tag=smart-orm-config # config/smart-orm.php php artisan vendor:publish --tag=smart-orm-stubs # stubs/smart-orm/*.stub (customise generated code)
1. Smart fetch: select only what you need
Add the trait (or HasSmartOrm, which bundles both traits):
use Illuminate\Database\Eloquent\Model; use Raqmeyat\SmartOrm\Concerns\HasSmartFetch; class Post extends Model { use HasSmartFetch; protected $fillable = ['author_id', 'title', 'body', 'views', 'status']; }
Post::query()->smartSelect()->paginate(); // reads ?fields=title,views Post::query()->smartSelect('title,views')->get(); // explicit Post::query()->smartSelect($request, ['author_id']) // force columns your eager loads need ->with('author')->get();
| Request | SQL columns |
|---|---|
?fields=title,views |
posts.title, posts.views, posts.id |
?fields=* |
posts.* (hidden attributes still never serialise) |
(no fields) |
posts.* by default, or the fillable list, see config |
?fields[posts]=title |
JSON:API style, also supported |
What is selectable: the model's $fillable, minus anything in $hidden, plus the primary key (and any
extra_selectable columns from config). Requested names are intersected with that list, so column names never
come from user input verbatim. Override per model:
public function smartFetchable(): array { return ['title', 'views', 'created_at', 'id']; }
Unknown fields are ignored by default (fields.strict = false). That is deliberate: an old mobile build that
still asks for a column you removed must not break during a rolling deploy. Turn strict on to get a 422.
smartSelect() keeps expression columns that are already on the query (withCount, selectSub), so order does
not matter.
2. Smart filters
Generate an allow-list from the table schema, then review it:
php artisan make:filtter Post # alias: make:filter
final class PostFilter extends Filter { public function filters(): array { return [ 'id' => FilterOperator::numeric(), 'title' => FilterOperator::text(), 'views' => FilterOperator::numeric(), 'status' => FilterOperator::choice(), 'published_at' => FilterOperator::temporal(), 'author.name' => FilterOperator::text(), // relation filter (whereHas) ]; } public function sorts(): array { return ['id', 'views', 'published_at']; } public function defaultSort(): ?string { return '-published_at'; } }
Post::query()->smartFilter(PostFilter::class)->smartSelect()->paginate(); Post::query()->smartFilter()->paginate(); // App\Filters\PostFilter, found by convention
Request grammar
| Request | Meaning |
|---|---|
?filter[title]=Laravel |
equals |
?filter[title][like]=lara |
contains (case-insensitive on every driver, wildcards escaped) |
?filter[title][starts]=PHP / [ends]=tips |
starts / ends with |
?filter[views][gte]=10&filter[views][lte]=99 |
several operators on one field (AND) |
?filter[views][between]=5,10 |
inclusive range |
?filter[id][in]=1,2,3 or ?filter[id][]=1&filter[id][]=2 |
in list (nin = not in) |
?filter[published_at][null]=1 |
IS NULL (0 = IS NOT NULL; notnull is the inverse) |
?filter[author.name]=Alice |
through a relation |
?filter[search]=hooks |
a custom filter (see below) |
?sort=-views,title |
descending views, then ascending title |
Operators: eq neq gt gte lt lte like starts ends in nin between null notnull.
Custom filters
public function custom(): array { return [ 'search' => fn (Builder $query, mixed $term) => $query->where( fn (Builder $q) => $q->where('title', 'like', "%{$term}%")->orWhere('body', 'like', "%{$term}%"), ), ]; }
Safety properties
- Allow-list only. Fields, operators and sort columns not declared in the Filter respond
422(filters.strict). Unlike fields, silently ignoring a filter would return more data than requested. - No raw input in SQL. Column names come from the Filter; values are bound parameters.
LIKEterms are escaped, so%and_match literally. PostgreSQL usesILIKEto match the case-insensitive default of SQLite/MySQL.- Values are cast from the model's
$casts(orFilter::casts()): booleans, integers, dates and enums bind identically on all three databases ("true"against a MySQLtinyintwould otherwise compare as0). - Limits:
max_filters,max_sorts,max_in_values. neqcompiles to<>, which, as in SQL, does not matchNULLrows. Combine withnullif you need them.
Errors render themselves as JSON:
{ "message": "Operator [like] is not allowed for filter [id].",
"errors": { "id": ["Operator \"like\" is not allowed."] },
"allowed": ["eq", "neq", "gt", "gte", "lt", "lte", "in", "nin", "between", "null", "notnull"] }
3. Data classes
php artisan make:data-model Post
reads the real table schema and your $casts and writes App\Data\PostData:
final readonly class PostData extends Data { public function __construct( public int $id, public int $author_id, public string $title, public ?string $body, public int $views, public PostStatus $status, // enum cast wins over the raw string column public bool $is_published, public ?CarbonImmutable $published_at, public ?array $meta, ) {} }
PostData::fromModel($post); // full typed object (all attributes must be loaded) PostData::collect($posts); // list<PostData> $data->toArray(); // scalars only: ISO-8601 UTC dates, enum values PostData::partial($post); // ONLY the attributes a smartSelect() loaded PostData::collectPartial($posts);
partial() is the mobile payload path: unselected columns are absent, not null, and every value is typed:
return PostData::collectPartial(Post::query()->smartFilter()->smartSelect()->paginate()->items());
fromModel() on a partially loaded model throws DataHydrationException naming the missing attributes, so a
half-loaded object can never masquerade as a full one.
Type mapping: integers → int, floats → float, decimals → string (a float would round money),
booleans (incl. tinyint(1)) → bool, dates → CarbonImmutable, JSON / array casts → array, backed-enum casts
→ the enum, everything else textual → string. Hidden attributes are skipped unless --with-hidden.
property_case = camel in config emits camelCase properties.
Generator options
| Command | Options |
|---|---|
make:data-model {model} |
--with-hidden, --force |
make:filtter {model} |
--only-fillable, --force |
{model} accepts User, Billing/Invoice or a fully qualified class. Output goes to App\Data / App\Filters
(configurable), mirroring the model's sub-namespace. Existing files are never overwritten without --force.
Generated filters never include hidden, JSON, long-text or unknown-typed columns, but review them: the
generator cannot know which non-hidden columns are sensitive.
Configuration
See config/smart-orm.php; every option is documented inline.
Testing
composer install vendor/bin/phpunit # SQLite in memory SMART_ORM_DB=mysql vendor/bin/phpunit # needs MySQL on 127.0.0.1 (DB_HOST, DB_USERNAME, ... override) SMART_ORM_DB=pgsql vendor/bin/phpunit # needs PostgreSQL
CI runs the suite on PHP 8.4 and 8.5, SQLite, MySQL 8.0 / 8.4 and PostgreSQL 16 / 17.
Publishing to Packagist
- Push this directory to GitHub as
raqmeyat/smart-orm(the package name israqmeyat/smart-orm). - Tag a release:
git tag v1.0.0 && git push origin v1.0.0. - Submit the repository URL at https://packagist.org/packages/submit, then enable the GitHub webhook / Packagist GitHub App so new tags are picked up automatically.
Do not add a version key to composer.json; Packagist derives versions from git tags.
License
MIT. See LICENSE. By Loai Kanou