Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Data Grid Laravel Package

spiral/data-grid

Spiral Data Grid is a PHP package for building flexible, framework-agnostic data grids with sortable columns, filtering, pagination, and customizable rendering. It turns arrays or ORM results into interactive tables quickly while keeping configuration clean and extensible.

View on GitHub
Deep Wiki
Context7

Getting Started

spiral/data-grid is a specification builder, not a grid UI and not an ORM integration. It gives you a small object model — GridSchema, fields, sorters, filters, value casters — that you populate once, feed raw request input into, and then apply to a data source through a WriterInterface. Everything between "input" and "SQL" is your responsibility.

The first thing a Laravel developer must understand: this package is not Laravel-native and has no Eloquent bridge. It ships from the Spiral ecosystem and the only first-party writer (spiral/data-grid-bridge) targets Cycle ORM. There is no service provider, no facade, no config file, and no composer require that wires itself up. You will be writing an adapter. That is the whole assessment in one sentence — plan for roughly a day of integration work before the first grid renders.

Install:

composer require spiral/data-grid

The good news is that the main package already contains a generic Spiral\DataGrid\GridFactory that takes a WriterInterface you supply, so you do not need the Cycle bridge to use it outside Spiral. A minimal end-to-end flow looks like:

use Spiral\DataGrid\GridFactory;
use Spiral\DataGrid\GridSchema;

$schema = (new GridSchema())
    ->addField('id')
    ->addField('name')
    ->addField('email');

$factory = new GridFactory(new EloquentGridWriter());   // your adapter
$grid    = $factory->create(User::query(), $schema);
$grid    = $grid->withInput($request->query());

$users = iterator_to_array($grid);   // already filtered/sorted/limited

Where to look first, in order:

  1. Spiral\DataGrid\GridSchema — the object you will actually author day to day. Its addField, addSort, addFilter methods are the whole vocabulary.
  2. Spiral\DataGrid\SpecificationInterface and the Specification\Filter\*, Specification\Sorter\*, Specification\Value\* namespaces — these are the leaves your writer must pattern-match on.
  3. Spiral\DataGrid\WriterInterface — a two-method contract (write($source, SpecificationInterface $spec)). This is the seam you own.
  4. Spiral\DataGrid\GridInterface — withInput(), getIterator(), and the field metadata accessors you will expose to your frontend.
  5. Spiral\DataGrid\InputScope / GridInput — if you want query keys other than the defaults (sort, filter, pagination).

First use case to build: an admin index endpoint. One GridSchema per resource, returned from a UserGridSchema factory class, applied to a Builder, returning { data, meta }. Resist the urge to make it generic on day one; the package rewards narrow, explicit schemas.

Implementation Patterns

One schema class per resource, not one per controller. The schema is a value object describing a contract ("users can be sorted by name and filtered by status"). Put it behind a dedicated class so both the HTTP endpoint and a future export or CLI command reuse it:

final class UserGridSchema
{
    public function __invoke(): GridSchema
    {
        return (new GridSchema())
            ->addField('id')
            ->addField('name')
            ->addField('email')
            ->addField('created_at')
            ->addSort('id', new AscSorter(new IntValue()), new DescSorter(new IntValue()))
            ->addSort('name', new AscSorter(new StringValue()), new DescSorter(new StringValue()))
            ->addSort('created_at', new AscSorter(new DateTimeValue()), new DescSorter(new DateTimeValue()))
            ->addFilter('status', new Equals(new EnumValue(['active', 'banned'])))
            ->addFilter('name', new Like(new StringValue()))
            ->addFilter('id', new In(new IntValue()));
    }
}

The schema is the allow-list. Anything not registered here is unrepresentable, which is exactly the security property you want — you never touch raw input when building orderBy or where.

Write one writer per storage layer and keep it dumb. The writer's only job is translating spec objects into builder calls. Keep it a pure match (true) over the specification types, with no business logic:

final class EloquentGridWriter implements WriterInterface
{
    public function write($source, SpecificationInterface $spec): mixed
    {
        return match (true) {
            $spec instanceof SorterInterface => $this->sort($source, $spec),
            $spec instanceof FilterInterface => $this->filter($source, $spec),
            default => $source,
        };
    }

    private function sort(Builder $q, SorterInterface $s): Builder
    {
        foreach ($s->getExpressions() as $column => $direction) {
            $q->orderBy($column, $direction);
        }
        return $q->orderBy('id'); // deterministic tiebreaker
    }

    private function filter(Builder $q, FilterInterface $f): Builder
    {
        return match (true) {
            $f instanceof Equals  => $q->where($f->getField(), $f->getValue()),
            $f instanceof Like    => $q->where($f->getField(), 'like', '%' . $f->getValue() . '%'),
            $f instanceof In      => $q->whereIn($f->getField(), $f->getValue()),
            $f instanceof Between => $q->whereBetween($f->getField(), $f->getValue()),
            default               => $q,
        };
    }
}

Note the default branch: unknown specifications should be ignored, never fatal. Unknown spec == feature you haven't written yet, not a 500.

Let value casters do the parsing, not your controller. IntValue, BoolValue, EnumValue, ArrayValue, DateTimeValue exist so that ?filter[id]=1,2,3 becomes [1,2,3] before it ever reaches the query. The typical bug is passing the raw comma string into an In filter — cast first, then filter. If your value class throws on invalid input, catch it at the boundary and return a 422; the package deliberately does not decide that policy for you.

Expose schema metadata alongside the data. GridInterface::getFields() (plus your schema's registered sorts and filters) is the cheapest way to keep frontend and backend honest. Emit it once from an /api/grids/users endpoint — column names, sortable flags, filter types, enums — and the client stops hardcoding sort keys that silently no-op.

Paginate outside the package. The limiter caps rows; it does not know about page. In Laravel, read page from the request, compute offset, apply ->offset()->limit() after the grid has been written, and run a separate count() on a clone of the builder (before ordering — MySQL will hate ORDER BY in a count). Return { data, meta: { total, page, per_page } }. Trying to make data-grid own pagination is the single most common source of wasted effort with this package.

Compose filters instead of writing new ones. Before creating a custom FilterInterface, check whether the built-in composite/group specifications and Not-style wrappers cover you. A reusable pattern for "search box hits three columns":

->addFilter('q', new OrGroup(
    new Like('name', new StringValue()),
    new Like('email', new StringValue()),
))

Verify the exact group constructor names against src/Specification/Filter/ on your installed tag — this namespace has grown over releases and signatures are not stable across majors.

Test schemas as pure units. Both the schema and the writer are DB-free (the writer against an in-memory SQLite builder, the schema against a spy writer that records the spec tree). This is the package's best hidden strength: filter/sort policy is trivially unit-testable without HTTP or a database, so regression tests for "was status ever accidentally sortable" cost nothing.

Gotchas and Tips

No Laravel package discovery means no forgiveness. If you register GridFactory as a singleton bound to GridFactoryInterface, make sure you also bind the writer — otherwise you get a container resolution error three layers deep in a controller. A one-line DataGridServiceProvider with both bindings saves an afternoon.

The Cycle bridge is a trap for Laravel users. spiral/data-grid-bridge pulls in cycle/orm and doctrine/dbal. It works, and some teams genuinely use Doctrine alongside Eloquent, but pulling a second ORM into a Laravel app to get sorting is almost never worth it. Write the 60-line Eloquent writer instead.

Empty strings are not dropped. ?filter[name]= arrives as '', is accepted by the schema, and produces WHERE name LIKE '%%' — harmless but slow, and worse with Equals (matches nothing, silently emptying the table). Strip empty/null input before withInput(), or wrap filters in a when() at the writer boundary. InputScope::withDefault() helps for defaults but does not sanitise blanks.

Sort direction is encoded in the input, not the schema. Registering both an AscSorter and DescSorter under one key is the standard pattern; the descending variant is conventionally triggered by a - prefix or a :desc suffix on the value. Decide on one convention, document it in the metadata endpoint, and never accept a raw direction string — that is where ORDER BY injection sneaks back in.

Value type mismatches fail at the database, not at the spec. Passing a StringValue-cast "42" into a Postgres integer column raises a driver error that looks like a framework bug. Match the caster to the column type; for UUIDs and enums use the string/enum casters, for JSON columns you will need a custom value class because there is no built-in array-to-JSON caster.

Debugging is dump-the-spec-tree, not dump-the-SQL. Because your writer is the only place SQL is formed, instrument it: log get_class($spec) and getValue() per call, or enable DB::listen() and correlate. When a filter "does nothing," it is almost always (a) a spec the writer's match doesn't handle, or (b) a filter key mismatch between the frontend and addFilter(). Both are visible in one log line.

Limits are a ceiling, not a page size. GridSchema::setLimit() / input pagination sets the maximum, and the default may be surprisingly small or absent depending on version. Always enforce your own max (e.g. min($requested, 100)) before handing control to the grid, or a user can request pagination=1000000 and take the database with them.

The low star count is the real risk factor. 54 stars, zero dependents, a single maintainer ecosystem — treat this as a library you may have to fork. Pin the version in composer.json, keep the writer adapter thin and isolated in app/DataGrid/, and keep the schema classes dependency-free. If you find yourself spreading Spiral\DataGrid\Specification\* imports across fifty files, you have coupled your domain to a library with an uncertain bus factor.

Extension points worth knowing: custom ValueInterface implementations (for encrypted fields, JSON, money), custom FilterInterface implementations for domain-specific predicates like NearbyRadius, and a second post-processing writer passed to GridFactory that can rewrite the source after the main writer runs (useful for soft-delete scoping or tenant isolation that must apply last). The last one is underused and is the cleanest place to enforce multi-tenancy without repeating where('tenant_id', ...) in every schema.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin
skyraptor/chatter
edlugz/sasapay
abbasudo/artisan-route