ramadan/easy-model
ramadan/easy-model is a Laravel package that simplifies Eloquent model development with convenient helpers and utilities for common model tasks, reducing boilerplate and helping you build clean, maintainable applications faster.
Install via Composer and let Laravel's package discovery register the service provider:
composer require ramadan/easy-model
Before writing any code, do these three things in order:
tests/ will show you the exact call signatures for fromRequest(), the allowed*() allow-lists, and what the pagination and mass-write methods return. Trust the tests over the README where they disagree."ramadan/easy-model": "^2.1". If you were on 2.0.x, read the 2.0.0...2.1.0 compare: v2.1.0 adds a large surface (request-driven queries, soft deletes, mass writes) but the release notes don't flag removals — still, scan for method renames before assuming drop-in compatibility.allowedFilters(), allowedSorts(), allowedIncludes(), and allowedSearch() are configured per query at the call site, not via published config. That's the package's central design decision and it changes how you organise code (see below).First use case: take one controller action with a handful of conditional where() calls plus a search filter, and rewrite it as a declared, request-driven query:
$users = User::query()
->fromRequest($request)
->allowedFilters(['status', 'role'])
->allowedSorts(['created_at', 'name'])
->allowedIncludes(['team'])
->allowedSearch(['name', 'email'])
->paginate();
Compare ->toSql() and ->getBindings() before and after — the package exposes toSql() itself, so this costs nothing. If the SQL matches what your hand-written method produced, you've validated the abstraction on a low-risk query. Start with the most-repeated query in your codebase, not the most complex one.
Make the allow-list the contract. The allowed*() methods are the idiomatic entry point and they replace the hand-rolled filter-array pattern the package previously implied. Define the allowed keys once per resource, close to the controller or in a query object, and treat that list as your API surface:
// app/Queries/UsersIndexQuery.php
final class UsersIndexQuery
{
public function __construct(private Request $request) {}
public function build()
{
return User::query()
->fromRequest($this->request)
->allowedFilters(['status', 'role', 'created_at'])
->allowedSorts(['created_at', 'name'])
->allowedIncludes(['team', 'posts'])
->allowedSearch(['name', 'email', 'posts.title'])
->when($this->request->boolean('trashed'), fn ($q) => $q->onlyTrashed());
}
}
This keeps controllers thin and gives you one place to edit when the package's API shifts. The when()/unless() helpers added in v2.1.0 are the right tool for these opt-in modifiers.
Validate values, not just keys. allowedFilters(['status']) restricts which columns can be filtered; it does not restrict what values arrive. Keep a FormRequest in front of it ('status' => ['nullable', Rule::in(['active','pending','banned'])], 'sort' => ['nullable', 'regex:/^-?[a-z_]+$/']) so enum-like and free-text filters can't be used to probe or produce pathological queries. The allow-list is a blast-radius control, not input validation.
Keep the standard Laravel chain. Controller → FormRequest → Query object → API Resource still applies; the package replaces the query construction in the middle, not the edges. Pagination methods return paginators, so JsonResource::collection() continues to emit meta/links — verify the concrete return type (LengthAwarePaginator vs CursorPaginator) for whichever of paginate(), simplePaginate(), or cursorPaginate() you pick, because the JSON shape differs.
Separate read paths from write paths. v2.1.0 adds performInsert(), performUpsert(), restore(), forceDelete(), and onlyTrashed() — the package is no longer read-only. Resist using mass-write methods inside query objects; keep them in dedicated Action classes so an accidental forceDelete() on a wide filter is obvious in review and easy to wrap in a transaction.
Compose with scopes and macros. Eloquent local scopes and Builder::macro() still work alongside package methods. Use scopes for domain rules (scopePublished) and reserve package methods for generic filter/sort/include/paginate behaviour. That keeps a future removal mechanical rather than a rewrite.
Cache at the query-object level. Include the whole allow-listed filter/sort/include payload in the cache key. With fromRequest() in play, the request itself is the input — hash a normalised array of just the allowed keys, not $request->all(), or a stray tracking parameter will fragment your cache into uselessness.
usingModelEvents() is opt-in for a reason. performInsert() and performUpsert() are bulk operations; without usingModelEvents() they skip observers, model events, and anything your app relies on (audit logs, cache invalidation, UUID generation, saved hooks). If you have observers on the model, call usingModelEvents() or you will silently lose them. Test one insert with an observer assertion before trusting it.forceDelete() on a query wrapper is a loaded gun. It takes a builder, not a model, so a mis-scoped filter deletes more than intended. Wrap every restore()/forceDelete() call in a DB transaction, assert the affected count, and gate it behind a policy check at the controller — soft-delete scoping is not an authorisation layer.allowedSearch(['posts.title']) and the v2.1.0 relation-join aliasing in addOrderBy() mean the package is joining tables for you. Two consequences: (a) you can get duplicate parent rows when a parent has multiple matching children — check whether you need distinct (and whether the package exposes it) or whether whereHas would have been safer; (b) joins can silently conflict with your own ->join() calls in the same query. Inspect toSql() whenever search and sort touch relations simultaneously.toSql() is now first-class — use it as your debugging tool. Log ->toSql() and ->getBindings() after each package call in development. Binding-order bugs in filter and search mappers are the classic failure mode: they're invisible until a row exists where the wrong parameter happens to match.addWhereJsonContains(), addWhereDate(), and addWherePeriod() will emit JSON_CONTAINS on MySQL, @> on Postgres, and json_extract on SQLite. If CI runs SQLite and production runs Postgres, add at least one integration test against the production engine, or you'll ship a filter that works in CI and 500s in production.allowedIncludes() is an eager-loading allow-list — verify it holds. After the package methods run, inspect $query->getEagerLoads() and confirm your addWith()/addWithCount() calls and the allow-listed includes are all present. Custom builders that don't proxy everything through __call can drop them.paginate(), simplePaginate(), cursorPaginate(), and the mass-write methods) execute immediately and return results, not a builder. If you get a paginator back where you expected a builder, you can't chain further conditions — reorder so all when()/unless() modifiers are applied before the terminal call.Illuminate\Database\Eloquent\Builder (typically ->getQuery() or ->toBase()). With soft-delete and JSON filtering added, the chance of hitting a missing feature grows; no escape hatch means a fork.fromRequest() with no query string, and allowedFilters([]), are the classic "all rows returned" or "WHERE status = NULL" bugs. Assert that an unset filter means no constraint, not a null comparison.composer audit, grep the source for DB::raw() with interpolated input, and keep everything behind your own query-object layer so the package stays replaceable.php artisan config:clear and composer dump-autoload — package-discovered providers and facades are cached in bootstrap/cache/, and a stale cached provider after a feature-heavy minor bump produces confusing "method does not exist" errors.How can I help you explore Laravel packages today?