PHPackages                             firevel/includes - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Database &amp; ORM](/categories/database)
4. /
5. firevel/includes

ActiveLibrary[Database &amp; ORM](/categories/database)

firevel/includes
================

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

0.0.1(2w ago)021↓100%MITPHPPHP ^8.1CI passing

Since May 25Pushed 2w agoCompare

[ Source](https://github.com/firevel/includes)[ Packagist](https://packagist.org/packages/firevel/includes)[ RSS](/packages/firevel-includes/feed)WikiDiscussions main Synced 1w ago

READMEChangelog (1)Dependencies (5)Versions (2)Used By (0)

Laravel Includes
================

[](#laravel-includes)

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
------------

[](#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
-----------

[](#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](#using-it-without-the-container)).

Model trait (recommended)
-------------------------

[](#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](#the-contract) is always available.

Composing with filter, sort and visibility
------------------------------------------

[](#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
------------

[](#the-contract)

`IncludesParser` exposes four public methods:

MethodReturnsPurpose`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()` arrayBuild 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
---------------------------

[](#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

[](#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

[](#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)

[](#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

[](#morphto)

The closure also receives polymorphic relations, so per-type constraints go through Eloquent's own [`MorphTo::constrain()`](https://laravel.com/docs/eloquent-relationships#constraining-eager-loads-with-morph-to-relationships):

```
->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
-------

[](#grammar)

The `include` parameter is a comma-separated list of relationship paths.

### Comma-separated list

[](#comma-separated-list)

```
include=author,comments

```

### Dot notation for nesting

[](#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

[](#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

[](#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](#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,,comments` → `author,comments`; `comments..replies` → `comments.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 &amp; limits
----------------------

[](#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

[](#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

[](#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

[](#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

[](#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
-------------

[](#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
------------------------------

[](#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
-------

[](#testing)

```
composer test   # phpunit
composer lint   # phpstan
```

License
-------

[](#license)

MIT. See [LICENSE](LICENSE).

###  Health Score

37

—

LowBetter than 81% of packages

Maintenance97

Actively maintained with recent releases

Popularity9

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity32

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Unknown

Total

1

Last Release

15d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/2696038?v=4)[Michael Slowik](/maintainers/sl0wik)[@sl0wik](https://github.com/sl0wik)

---

Top Contributors

[![sl0wik](https://avatars.githubusercontent.com/u/2696038?v=4)](https://github.com/sl0wik "sl0wik (2 commits)")

---

Tags

laraveleloquentJSON-APIincludeRelationshipsfirevelwith

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Type Coverage Yes

### Embed Badge

![Health badge](/badges/firevel-includes/health.svg)

```
[![Health](https://phpackages.com/badges/firevel-includes/health.svg)](https://phpackages.com/packages/firevel-includes)
```

###  Alternatives

[kirschbaum-development/eloquent-power-joins

The Laravel magic applied to joins.

1.6k29.9M42](/packages/kirschbaum-development-eloquent-power-joins)[mongodb/laravel-mongodb

A MongoDB based Eloquent model and Query builder for Laravel

7.1k8.0M84](/packages/mongodb-laravel-mongodb)[spatie/laravel-sluggable

Generate slugs when saving Eloquent models

1.5k12.4M291](/packages/spatie-laravel-sluggable)[psalm/plugin-laravel

Psalm plugin for Laravel

3325.1M337](/packages/psalm-plugin-laravel)[watson/validating

Eloquent model validating trait.

9743.4M53](/packages/watson-validating)[yajra/laravel-oci8

Oracle DB driver for Laravel via OCI8

8733.1M23](/packages/yajra-laravel-oci8)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
