submtd/laravel-request-scope
Laravel Request Scope adds request-scoped service container bindings, so services are resolved once per request and reset automatically between requests. Useful for request-specific state, tenant context, and long-running workers such as Laravel Octane.
Install and confirm the API surface first. The package is essentially undocumented in the wild (no stars, no dependents), so before writing any code:
composer require submtd/laravel-request-scope
composer show submtd/laravel-request-scope # confirm version + Laravel constraint
ls vendor/submtd/laravel-request-scope/src # confirm namespaces/traits before copying snippets below
Publish any config (if the package registers one):
php artisan vendor:publish --tag=request-scope-config
Treat the published file as the contract: it usually holds the allow-lists for sortable/filterable/includable attributes and the default per_page.
Attach the trait to one model you already expose over an API — not all of them. Typical shape:
use Submtd\RequestScope\Concerns\HasRequestScope;
class Post extends Model
{
use HasRequestScope;
protected array $sortable = ['created_at', 'title', 'views'];
protected array $filterable = ['status', 'author_id'];
protected array $includable = ['author', 'comments'];
}
First use case: a list endpoint where the frontend controls ordering without new endpoints.
// routes/api.php
Route::get('/posts', [PostController::class, 'index']);
// PostController
public function index()
{
return PostResource::collection(
Post::requestScope()->paginate()
);
}
GET /api/posts?sort=-created_at&filter[status]=published&include=author&per_page=25
Where to look first: the model trait and the query-parameter parser. Everything else (config, service provider) is thin. Read those two files end-to-end before trusting the package with public traffic — you need to know exactly how it validates attribute names.
Controller-thin, model/scope-declared. Keep the allow-lists on the model and leave controllers as one-liners. This is the pattern the package is designed for, and it keeps the "what can the client ask for" question in one place.
public function index(Request $request)
{
$posts = Post::query()
->requestScope() // apply sort/filter/include/fields
->paginate(); // or ->simplePaginate()
return PostResource::collection($posts);
}
Compose with authorization, not instead of it. requestScope() widens a query; it must never widen visibility. Apply tenant/ownership constraints first:
$posts = Post::query()
->where('team_id', $request->user()->team_id) // hard constraint
->requestScope() // client-controlled narrowing
->paginate();
If the package internally calls select(), with(), or orderBy(), ordering of calls matters — constraints added before requestScope() can be clobbered by an aggressive select. Verify with ->toSql().
Allow-list aggressively; the package's real job is parsing. The value is ?filter[status]=draft,published → whereIn, ?sort=-views → orderByDesc, ?include=author.comments → nested with. Your job is to cap the surface:
protected array $sortable = ['created_at', 'title', 'views']; // never expose 'email' or 'password_hash'
protected array $filterable = ['status', 'author_id'];
protected array $includable = ['author', 'comments'];
Default eager loads plus client includes. Declare protected $with = ['author'] on the model and let include= add more. That way a client that sends nothing still avoids N+1 on the common case.
Custom filters for anything with an operator. Equality filters get you 60% of the way; date ranges, LIKE, and whereHas need an escape hatch. Expose named filters rather than raw columns:
// ?filter[search]=laravel -> where('title','like','%laravel%')
// ?filter[created_after]=2024-01-01 -> where('created_at','>=',...)
Implement these either as closures/classes registered in the config (if supported) or as a local scope applied before requestScope().
Validate the query string explicitly. Treat it as input, because it is:
$request->validate([
'sort' => ['nullable', 'string', 'regex:/^-?[a-z_]+$/'],
'filter' => ['nullable', 'array'],
'filter.*' => ['nullable'],
'include' => ['nullable', 'string'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
]);
Cache keys must include the full query string. Once list endpoints become filterable, a naive Cache::remember("posts.{$page}") returns the wrong page to the wrong user.
$key = 'posts.'.md5($request->getQueryString().'|'.$request->user()->team_id);
Test the parameter matrix, not just the happy path. A handful of feature tests covers more than reading the source:
$this->getJson('/api/posts?sort=-views')->assertOk()->assertJsonPath('data.0.id', 3);
$this->getJson('/api/posts?sort=password')->assertStatus(422); // or 400, whichever you decide
$this->getJson('/api/posts?filter[status]=published')->assertJsonCount(2, 'data');
$this->getJson('/api/posts?include=author')->assertJsonStructure(['data' => [['author']]]);
Attribute injection is the main risk. If sort=, filter[...], or fields[...] are passed to orderBy/select without an allow-list, a client can sort by an unindexed or sensitive column, or trigger a 500 on a non-existent one. Always declare allow-lists; never fall back to $request->input('sort') directly.
Return 4xx, not 500, for rejected parameters. Out of the box, most of these packages throw a generic exception. Wrap the call or validate up front so unknown sort/filter keys produce a clean 422 with the offending key in the message — frontend teams will thank you.
per_page is unbounded unless you bound it. ?per_page=100000 will happily try to serialize your whole table. Cap it in validation or in config.
Boolean and empty values get stringy. ?filter[active]=false arrives as the string "false", which is truthy in MySQL. Normalize booleans and drop empty filter arrays before they hit the query.
Sorting on joined/aliased columns breaks. sort=author_name won't work if author_name comes from a join; you need a select alias or a custom sort mapping. Decide per-endpoint.
Watch for select() side effects. If the package narrows columns for fields[...], later where()/orderBy() on excluded columns silently behave differently, and $post->author may be null because author_id wasn't selected. Always keep the primary key and FK in any field selection.
Global scopes and soft deletes still apply — good — but withoutGlobalScopes() in a controller plus requestScope() is an easy way to accidentally expose archived rows. Grep for it.
Debugging: the fastest loop is ->toSql() + ->getBindings(), or ->ddRawSql() in a scratch route:
dd(Post::requestScope()->toRawSql());
Also Log::info($request->fullUrl()) in the controller for a day — you'll see exactly which parameter shapes your clients actually send, which is usually different from what you documented.
Extension point: wrap it. Put requestScope() behind your own repository method or a ListPosts action so the package is one use statement away from being replaced. Given zero dependents and no release history, assume you may need to swap to spatie/laravel-query-builder or a 60-line hand-rolled parser.
Pin the version and check the Laravel constraint in composer.json. An unmaintained package that hard-requires illuminate/support ^10 will block your next major upgrade, and the fix is a fork, not a composer update.
Where it's a good fit: internal admin panels and early-stage APIs where you want filtering/sorting without writing ten controller methods. Where it isn't: public, security-sensitive endpoints, or anywhere you'd need documented, stable behavior for external consumers — build the allow-list + parser yourself in that case; it's a small amount of code and you own the failure modes.
How can I help you explore Laravel packages today?