firevel/includes

A query-string include parser for Laravel APIs (JSON:API-style relationship loading).

Maintainers

Package info

github.com/firevel/includes

pkg:composer/firevel/includes

Statistics

Installs: 23

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

0.0.1 2026-05-25 22:37 UTC

This package is auto-updated.

Last update: 2026-05-26 19:31:55 UTC


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 to includeConstraints() (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 its in operator, so a comma value is mapped to ['in' => $value] above — filterable then explodes the comma into a whereIn. (A bare scalar with a comma would be an equality match against the literal string.)
  • sortable consumes a comma-separated string directly (-field = descending), so sort:-created_at,name needs 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 limit to 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:

  1. 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).
  2. Parameter pairs split on the first colon only, so a value may contain colons (sort:created:desc{sort: "created:desc"}).
  3. 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 to firevel/sortable (sort:-created_at,name → two fields) and feed firevel/filterable's in operator (which explodes them into a whereIn); see Composing with filter, sort and visibility.
  4. A bare token with no colon is a boolean flag (comments(featured){featured: true}).
  5. Parameter values are returned as trimmed strings (no numeric/bool coercion, aside from the bare-flag case above). The caller casts as needed.
  6. Whitespace is trimmed around paths, segments, parameter keys and values.
  7. Empty segments are dropped: author,,commentsauthor,comments; comments..repliescomments.replies; comments()comments with no parameters; a trailing comma is ignored.
  8. Paths are deduped and their parameters merged, with the later occurrence winning on a key conflict.
  9. Relationship names pass through verbatim — they are not camelCased. The with() key matches what the client sent, so include=comment_replies yields the key comment_replies. If your relation methods are camelCase and you accept snake_case input, normalize it before parsing or in your closure.
  10. 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:

  1. Max depth — always enforced.
  2. Allowlist — if one was set with setAllowedIncludes().
  3. Relationship existence — otherwise, if a model was set with setModel().
  4. 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 of subject.anything survives).
  • 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 — raise Firevel\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.