firevel / includes
A query-string include parser for Laravel APIs (JSON:API-style relationship loading).
Requires
- php: ^8.1
- illuminate/database: ^10.0 || ^11.0 || ^12.0 || ^13.0
- illuminate/support: ^10.0 || ^11.0 || ^12.0 || ^13.0
Requires (Dev)
- orchestra/testbench: ^10.0 || ^11.0
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0 || ^12.0
README
A standalone query-string include parser for Eloquent (JSON:API-style
relationship loading). It turns an include request parameter into an array
ready for Eloquent's with(), routing per-relationship parameters into a
constraint closure you supply.
It's just a parser — it has no opinion about authorization, filtering, or your models. Drop it into any Laravel app.
composer require firevel/includes
Requires PHP 8.1+ and Laravel 10–13 (illuminate/support + illuminate/database).
What it does
"comments(status:published).replies"
│
▼ parse
paths + parameters: comments {status: published}, comments.replies {}
│
▼ generateWith(your closure)
Eloquent with() array: ['comments' => fn($q) => …, 'comments.replies' => fn($q) => …]
The parser turns the string into relationship paths plus arbitrary key/value
parameters, then hands each path's parameters to your closure, which
returns the eager-load constraint for that relationship (or null to load it
open). Filtering is the typical use of the parameters, but they can drive
anything — sorting a relation, limiting it, selecting columns, etc. The package
never inspects them.
Quick start
use Firevel\Includes\IncludesParser; $with = app(IncludesParser::class) ->parseIncludes($request->input('include')) ->generateWith(function (array $parameters) { // Return a closure to constrain the relationship... return function ($relationship) use ($parameters) { if (isset($parameters['limit'])) { $relationship->limit((int) $parameters['limit']); } return $relationship; }; // ...or return null to load the relationship "open" (no constraint). }); $posts = Post::with($with)->paginate();
IncludesParser is bound in the container so app(IncludesParser::class) picks
up your config. It holds per-request state, so each resolution is a fresh
instance — never share one across requests. You can also just new it (see
Using it without the container).
Model trait (recommended)
For the common case, add the HasIncludes trait to a model and it collapses
into the query chain — no manual parser wiring, and the model is derived from
the query itself:
use Firevel\Includes\HasIncludes; class Post extends Model { use HasIncludes; // Optional allowlist. Omit it to accept any real relationship // (validated against the model). Mirrors Fractal's $availableIncludes. protected $availableIncludes = ['comments', 'comments.replies', 'author']; // Optional policy. Default: every relationship is loaded "open". // Override to apply your own per-relationship constraints. protected function includeConstraints(mixed ...$context): \Closure { return fn (array $parameters) => function ($relationship) use ($parameters) { $limit = $parameters['limit'] ?? null; $filters = \Illuminate\Support\Arr::except($parameters, ['sort', 'limit']); // Apply parameters however you like. To route them to // firevel/filterable + firevel/sortable + visibleBy, see // "Composing with filter, sort and visibility" below. if ($filters) { $relationship->where($filters); } if ($limit !== null) { $relationship->limit((int) $limit); // per-parent on Laravel 11+ } return $relationship; }; } }
The controller becomes a single chain:
$posts = Post::query() ->withIncludes($request->input('include'), $request->user()) // parse + gate + with() ->paginate(); return fractal($posts, $transformer) ->parseIncludes(Post::includeNames($request->input('include'))) // clean names for Fractal ->respond();
withIncludes($include, ...$context)— a query scope that derives the model from the query, gates the paths (allowlist or relationship existence), and applies->with(). Anything passed after the include string is forwarded toincludeConstraints()(above, the authenticated user).includeNames($include)— returns the gated, parameter-free dot-paths, safe to hand to a transformer such as Fractal (which only needs names; the raw parameter syntax would break its parser).includeConstraints()— override per model to set the policy; the default loads everything open. A model may also delegate per related model ($relationship->getRelated()) for a "chain of constraint types".
If you don't want the trait, the underlying parser API is always available.
Composing with filter, sort and visibility
firevel/includes knows nothing about these scopes — your includeConstraints()
(or generateWith() closure) wires them up. The closure receives the relation,
so it can call whatever the related model supports. A convention-A router:
protected function includeConstraints(mixed ...$context): \Closure { [$user] = $context + [null]; return fn (array $parameters) => function ($relationship) use ($parameters, $user) { $sort = $parameters['sort'] ?? null; $limit = $parameters['limit'] ?? null; // Everything else is a filter. A comma value means "any of these", // which firevel/filterable expresses through its `in` operator. $filters = []; foreach (\Illuminate\Support\Arr::except($parameters, ['sort', 'limit']) as $column => $value) { $filters[$column] = str_contains((string) $value, ',') ? ['in' => $value] : $value; } $relationship->visibleBy($user); // authorization if ($filters) $relationship->filter($filters); // firevel/filterable if ($sort) $relationship->sort($sort); // firevel/sortable (comma string) if ($limit !== null) $relationship->limit((int) $limit); // per-parent on Laravel 11+ return $relationship; }; }
Things to know:
- filterable applies
=for a scalar value; multiple values go through itsinoperator, so a comma value is mapped to['in' => $value]above — filterable then explodes the comma into awhereIn. (A bare scalar with a comma would be an equality match against the literal string.) - sortable consumes a comma-separated string directly (
-field= descending), sosort:-created_at,nameneeds no translation. - visibleBy takes the acting user, passed through as
withIncludes($include, $user). - These scopes must exist on the related model being loaded. For a relation
whose model isn't filterable/sortable/visible, give it a simpler policy — that's
the per-related-model "chain of constraint types" (
$relationship->getRelated()). - Clamp a client-supplied
limitto a sane maximum.
The contract
IncludesParser exposes four public methods:
| Method | Returns | Purpose |
|---|---|---|
parseIncludes(string|array $include): self |
$this |
Parse and store the include parameter. |
setAllowedIncludes(array $allowed): self |
$this |
Restrict accepted paths to an allowlist. |
setModel(Model|class-string $model): self |
$this |
Accept any path that is a real relationship on this model. |
generateWith(Closure $factory): array |
with() array |
Build the eager-load array. |
For each parsed include path the parser calls $factory($parameters), where
$parameters are the parameters parsed for that path (an empty array if
none). The factory returns either:
- a
Closure— used as the eager-load constraint for that path ([path => closure]); Eloquent invokes it with the relation/query; or null— the relationship is loaded open, added as a bare entry with no constraint applied.
So one factory can constrain some relationships and leave others open.
Constraint closure patterns
The closure is yours — these are just patterns. The package requires none of them and references nothing in your models.
Apply parameters
A value may carry multiple comma-separated values, so a whereIn is the natural
handling (a single value is just a one-element whereIn):
->generateWith(fn (array $parameters) => function ($relationship) use ($parameters) { foreach ($parameters as $column => $value) { $relationship->whereIn($column, explode(',', (string) $value)); } return $relationship; });
Load some relationships open
Return null to skip the constraint entirely for a given path:
->generateWith(fn (array $parameters) => $parameters === [] ? null : fn ($relationship) => $relationship->where($parameters) );
Per-related-model policy (chain of constraint types)
The closure receives the Relation, so it can pick a different policy for each
link of the chain based on the model being loaded there
($relationship->getRelated()). For ?include=property.rooms.facilities you
might constrain rooms but leave the public facilities open:
->generateWith(fn (array $parameters) => function ($relationship) use ($parameters) { $related = $relationship->getRelated(); if ($related instanceof \App\Models\Facility) { return $relationship; // open } return $relationship->where($parameters); // constrained });
If your models expose their own query scopes (e.g. a visibleBy() for
authorization or a filter() scope), call them here too — that logic stays in
your closure, never in this package:
return $relationship->visibleBy($user)->filter($parameters);
A model can even decide for itself: have the closure delegate to a method on the
related model ($related->constrainInclude($relationship, $parameters)), so each
model owns its own include policy without a separate class.
MorphTo
The closure also receives polymorphic relations, so per-type constraints go
through Eloquent's own MorphTo::constrain():
->generateWith(fn (array $parameters) => function ($relationship) use ($parameters) { if ($relationship instanceof \Illuminate\Database\Eloquent\Relations\MorphTo) { return $relationship->constrain([ \App\Models\Post::class => fn ($q) => $q->where($parameters), \App\Models\Video::class => fn ($q) => $q->where($parameters), ]); } return $relationship->where($parameters); });
Grammar
The include parameter is a comma-separated list of relationship paths.
Comma-separated list
include=author,comments
Dot notation for nesting
Nested paths are expanded into the multiple with() entries Eloquent needs, so
each level can carry its own parameters. Ancestors are added automatically:
include=comments.replies,comments.author
produces the keys comments, comments.replies, and comments.author.
Per-include parameters
Any segment may carry an optional, parenthesised group of pipe-separated
key:value pairs:
include=comments(status:published|limit:5)
A value may freely contain commas, which is the usual way to express
multiple values (the caller splits them, e.g. into a whereIn):
include=comments(status:active,pending|limit:5)
→ comments ⇒ {status: "active,pending", limit: "5"}.
Parameters attach to the relationship at their own level, so each level of a nested path carries its own:
include=comments(status:published).replies(limit:5)
→ comments ⇒ {status: "published"}, comments.replies ⇒ {limit: "5"}.
These parameters are passed through verbatim to your closure — the parser never interprets them. Treating them as filters is the common case, but they can mean anything you like.
Grammar rules and decisions
Where the grammar was ambiguous, the simplest defensible rule was chosen:
- Separators are positional. Commas separate paths at the top level, dots
separate segments, pipes separate parameters — and all are ignored inside the
value of a parameter. So a value may safely contain a
.or a,(e.g.after:2020.01.01,status:active,pending). - Parameter pairs split on the first colon only, so a value may contain
colons (
sort:created:desc→{sort: "created:desc"}). - Parameters are pipe-separated (
|) inside the parentheses. A value may therefore contain commas — the usual multi-value convention — but cannot contain a pipe. Commas pass straight tofirevel/sortable(sort:-created_at,name→ two fields) and feedfirevel/filterable'sinoperator (which explodes them into awhereIn); see Composing with filter, sort and visibility. - A bare token with no colon is a boolean flag (
comments(featured)→{featured: true}). - Parameter values are returned as trimmed strings (no numeric/bool coercion, aside from the bare-flag case above). The caller casts as needed.
- Whitespace is trimmed around paths, segments, parameter keys and values.
- Empty segments are dropped:
author,,comments→author,comments;comments..replies→comments.replies;comments()→commentswith no parameters; a trailing comma is ignored. - Paths are deduped and their parameters merged, with the later occurrence winning on a key conflict.
- Relationship names pass through verbatim — they are not camelCased. The
with()key matches what the client sent, soinclude=comment_repliesyields the keycomment_replies. If your relation methods are camelCase and you accept snake_case input, normalize it before parsing or in your closure. parseIncludes()also accepts an array of path strings; each element is parsed exactly as a comma-separated string would be.
Allowlist & limits
Requested paths are gated in this order of precedence:
- Max depth — always enforced.
- Allowlist — if one was set with
setAllowedIncludes(). - Relationship existence — otherwise, if a model was set with
setModel(). - With neither an allowlist nor a model, every (in-depth) path passes.
Allowlist
Gate input against a list of known paths:
$parser->setAllowedIncludes(['comments', 'comments.replies']);
The allowlist is matched against the full path the client requested (before
expansion). Ancestors created by expanding an allowed nested path are implicitly
permitted — allowing comments.replies also allows the comments key it expands
into.
Validate against a model
When you have not set an allowlist, you can instead let the parser accept any path that resolves to a real Eloquent relationship on a model:
$parser->setModel(Post::class); // instance or class string
Each segment of a requested path must be a real relation on the model at that
level of the chain — comments.replies requires Post::comments() to return a
relation and the related Comment to define replies(). Unknown segments cause
the whole path to be rejected (per on_disallowed).
Two caveats:
- MorphTo short-circuits. Once a chain reaches a polymorphic
MorphTo, the concrete related model is unknown, so any segments below it are accepted rather than rejected (e.g. all ofsubject.anythingsurvives). - Method-based relations only. Relations registered dynamically via
Model::resolveRelationUsing()have no method on the model and are not auto-detected — use an allowlist for those.
An allowlist takes precedence: if both are set, only the allowlist is consulted.
Max depth
The maximum nesting depth is enforced from config and counted in segments
(comments.replies is depth 2). Paths deeper than max_depth are rejected.
Behaviour on rejection
The allowlist, the depth limit, and the relationship-existence check are all
governed by on_disallowed:
ignore(default) — silently drop the offending path.throw— raiseFirevel\Includes\Exceptions\DisallowedIncludeException.
Configuration
Publish the config file:
php artisan vendor:publish --tag=includes-config
config/includes.php:
return [ // Maximum dot-notation nesting depth, counted in segments. 'max_depth' => 5, // 'ignore' to silently drop disallowed paths, 'throw' to raise an exception. 'on_disallowed' => 'ignore', ];
Both can also be set per-instance with setMaxDepth() and setOnDisallowed().
Using it without the container
The parser has no hard dependency on the container or config — construct it directly and parsing works the same:
use Firevel\Includes\IncludesParser; $with = (new IncludesParser(maxDepth: 3, onDisallowed: 'throw')) ->setAllowedIncludes(['comments']) ->parseIncludes($include) ->generateWith($factory);
The service provider only does two things: merge/publish the config and bind the parser with those config values applied.
Testing
composer test # phpunit composer lint # phpstan
License
MIT. See LICENSE.