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
strictly-add-query-filters
strictly-add-query-filters
mrsafalpiya/strictly-laravel
claude-code
query-data
filtering
enums
inertia
data-objects
route-query

Adds query-string filters to a GET page the Strictly Laravel way: a query class the action type-hints, sent back as a prop, and filter links built with the route's typed query option. Use when adding filters, search, sorting, tabs or any ?key=value parameter to a page.

Install
php artisan boost:add-skill mrsafalpiya/strictly-laravel

Save this content to: .claude/skills/strictly-add-query-filters/SKILL.md

# Add query filters

The worked example adds a search box and a sort order to the `projects/index` page (skill `strictly-add-page`). A query class types the query string on both sides: the action receives it parsed, the route function's `query` option only takes its keys and values, and the page reads it back as a prop.

## 1. Write the query class

A query class extends `StrictlyLaravel\Data\QueryData`. Every property has the default an absent key means: a nullable one defaults to `null`, an array to `[]`:

```php title="app/Data/ProjectFiltersData.php"
final class ProjectFiltersData extends QueryData
{
    public function __construct(
        public ?string $search = null,
        public ProjectSort $sort = ProjectSort::Name,
    ) {}
}
```

```php title="app/Enums/ProjectSort.php"
enum ProjectSort: string
{
    case Name = 'name';
    case Newest = 'newest';
}
```

Parsing is lenient: an invalid value falls back to its default instead of a 422, so an old bookmark still opens the page. There is no `authorize()` for a query class; authorize with the route's middleware or a policy.

## 2. Type-hint it in the GET action and send it back

```php title="app/Http/Controllers/ProjectController.php"
    public function index(ProjectFiltersData $filters): IndexPage
    {
        $search = $filters->search;

$projects = Project::query()
            ->when($search !== null, fn (Builder $query) => $query->whereLike('name', "%{$search}%"))
            ->orderBy($filters->sort === ProjectSort::Newest ? 'created_at' : 'name', $filters->sort === ProjectSort::Newest ? 'desc' : 'asc')
            ->get()
            ->map(fn (Project $project): ProjectData => ProjectData::fromModel($project))
            ->all();

        return new IndexPage(
            projects: $projects,
            filters: $filters,
        );
    }
```

```php title="app/Pages/Projects/IndexPage.php"
    /**
     * @param  list<ProjectData>  $projects
     */
    public function __construct(
        public array $projects,
        public ProjectFiltersData $filters,
    ) {}
```

## 3. Build filter URLs with the route's `query` option

`index({ query })` takes `Partial<ProjectFiltersData>`; enum values in case order come from `enumValues`:

```tsx title="resources/js/pages/projects/index.tsx"
import { Head, Link } from '@inertiajs/react';
import { visit } from '@mrsafalpiya/strictly-laravel/react';
import { enumValues } from '@/generated';
import type { PageProps } from '@/generated';
import { index } from '@/routes/projects';

type Props = PageProps<'projects/index'>;

export default function Index({ projects, filters }: Props) {
    const search = (value: string) => {
        const query = { ...filters, search: value === '' ? null : value };

        visit(index({ query }), { preserveState: true, replace: true });
    };

return (
        <>
            <Head title="Projects" />
            <input
                type="search"
                defaultValue={filters.search ?? ''}
                onChange={(event) => search(event.target.value)}
            />
            <nav>
                {enumValues.ProjectSort.map((sort) => (
                    <Link
                        key={sort}
                        href={index({ query: { ...filters, sort } })}
                    >
                        {sort}
                    </Link>
                ))}
            </nav>
            <ul>
                {projects.map((project) => (
                    <li key={project.id}>{project.name}</li>
                ))}
            </ul>
        </>
    );
}
```

`mergeQuery` keeps the current URL's other keys: `index({ mergeQuery: { sort } })`. A `<select>` of sort orders reads its value back as a `ProjectSort` with `selectedKey(enumValues.ProjectSort, value)` (`@mrsafalpiya/strictly-laravel/routes`), never a cast. Wayfinder leaves `null` and `[]` out of the URL, which the server reads back as the default.

A route whose action takes no query class takes no query string: its `query` and `mergeQuery` accept nothing, so a link can't send a key no query class declares. On the server, read keys only from the query class, never with `$request->query()` (`strictly.queryRead`) or `$request->input()` (`strictly.inputRead`), which skip its fallback to the default, and take it in a controller action: a closure route that takes a query class or reads the query string fails `strictly.closureRoute` or `strictly.queryRead`, since Wayfinder types a route function only from a controller action. Pagination needs no query class: `<Pagination>` follows the paginator's own links, and `<InfiniteScroll>` asks for the next page itself.

## 4. Request it with and without filters in feature tests

Send each nullable filter both absent and set, or `ci:check:coverage` reports the `filters` prop's gap:

```php title="tests/Feature/Projects/ProjectIndexTest.php"
beforeEach(function () {
    Project::query()->create(['name' => 'Launch', 'description' => 'The first release']);
    Project::query()->create(['name' => 'Backlog', 'description' => null]);
});

test('the projects page lists every project by name without filters', function () {
    $this->actingAs(User::factory()->create())
        ->get(route('projects.index'))
        ->assertInertia(fn (Assert $page) => $page
            ->where('filters', ['search' => null, 'sort' => 'name'])
            ->where('projects.0.name', 'Backlog'));
});

test('the projects page filters by the query string', function () {
    $this->actingAs(User::factory()->create())
        ->get(route('projects.index', ['search' => 'Lau', 'sort' => 'newest']))
        ->assertInertia(fn (Assert $page) => $page
            ->where('filters', ['search' => 'Lau', 'sort' => 'newest'])
            ->has('projects', 1));
});
```

Done when `composer ci:check` passes.

## When a check fails

- `ci:check:test` fails with `App\Data\ProjectFiltersData::$sort has no default, so a query string`: give the property the default an absent key means.
- `ci:check:test` fails with `App\Data\ProjectFiltersData::$statuses is a list, so it must default`: default a list to `[]` and a nullable property to `null`.
- `ci:check:tsc` fails with `error TS2561: Object literal may only specify known properties, but 'statuse' does not exist in type 'Partial<ProjectFiltersData>'.`: the key isn't a property of the query class.
- `ci:check:tsc` fails with `error TS2322: Type '"oldest"' is not assignable to type 'ProjectSort | undefined'.`: the value isn't one the property's type allows.
- `ci:check:tsc` fails with `error TS2353: Object literal may only specify known properties, and 'tab' does not exist in type 'NoQueryClass'.`: the route's action takes no query class; write one (step 1) and type-hint it on the action.
- `ci:check:test` fails with `The closure route GET projects takes App\Data\ProjectFiltersData`: move the route to a controller action that takes the query class.
- `ci:check:test` fails with `App\Http\Controllers\DashboardController::index() reads the query`: read the key from the action's query class instead of the request.
- `ci:check:test` fails with `Expected response status code [422] but received 200.`: an invalid query value falls back to its default; assert the default instead of a 422.

Add query filters

The worked example adds a search box and a sort order to the projects/index page (skill strictly-add-page). A query class types the query string on both sides: the action receives it parsed, the route function's query option only takes its keys and values, and the page reads it back as a prop.

1. Write the query class

A query class extends StrictlyLaravel\Data\QueryData. Every property has the default an absent key means: a nullable one defaults to null, an array to []:

final class ProjectFiltersData extends QueryData
{
    public function __construct(
        public ?string $search = null,
        public ProjectSort $sort = ProjectSort::Name,
    ) {}
}
enum ProjectSort: string
{
    case Name = 'name';
    case Newest = 'newest';
}

Parsing is lenient: an invalid value falls back to its default instead of a 422, so an old bookmark still opens the page. There is no authorize() for a query class; authorize with the route's middleware or a policy.

2. Type-hint it in the GET action and send it back

    public function index(ProjectFiltersData $filters): IndexPage
    {
        $search = $filters->search;

$projects = Project::query()
            ->when($search !== null, fn (Builder $query) => $query->whereLike('name', "%{$search}%"))
            ->orderBy($filters->sort === ProjectSort::Newest ? 'created_at' : 'name', $filters->sort === ProjectSort::Newest ? 'desc' : 'asc')
            ->get()
            ->map(fn (Project $project): ProjectData => ProjectData::fromModel($project))
            ->all();

        return new IndexPage(
            projects: $projects,
            filters: $filters,
        );
    }
    /**
     * @param  list<ProjectData>  $projects
     */
    public function __construct(
        public array $projects,
        public ProjectFiltersData $filters,
    ) {}

3. Build filter URLs with the route's query option

index({ query }) takes Partial<ProjectFiltersData>; enum values in case order come from enumValues:

import { Head, Link } from '@inertiajs/react';
import { visit } from '@mrsafalpiya/strictly-laravel/react';
import { enumValues } from '@/generated';
import type { PageProps } from '@/generated';
import { index } from '@/routes/projects';

type Props = PageProps<'projects/index'>;

export default function Index({ projects, filters }: Props) {
    const search = (value: string) => {
        const query = { ...filters, search: value === '' ? null : value };

        visit(index({ query }), { preserveState: true, replace: true });
    };

return (
        <>
            <Head title="Projects" />
            <input
                type="search"
                defaultValue={filters.search ?? ''}
                onChange={(event) => search(event.target.value)}
            />
            <nav>
                {enumValues.ProjectSort.map((sort) => (
                    <Link
                        key={sort}
                        href={index({ query: { ...filters, sort } })}
                    >
                        {sort}
                    </Link>
                ))}
            </nav>
            <ul>
                {projects.map((project) => (
                    <li key={project.id}>{project.name}</li>
                ))}
            </ul>
        </>
    );
}

mergeQuery keeps the current URL's other keys: index({ mergeQuery: { sort } }). A <select> of sort orders reads its value back as a ProjectSort with selectedKey(enumValues.ProjectSort, value) (@mrsafalpiya/strictly-laravel/routes), never a cast. Wayfinder leaves null and [] out of the URL, which the server reads back as the default.

A route whose action takes no query class takes no query string: its query and mergeQuery accept nothing, so a link can't send a key no query class declares. On the server, read keys only from the query class, never with $request->query() (strictly.queryRead) or $request->input() (strictly.inputRead), which skip its fallback to the default, and take it in a controller action: a closure route that takes a query class or reads the query string fails strictly.closureRoute or strictly.queryRead, since Wayfinder types a route function only from a controller action. Pagination needs no query class: <Pagination> follows the paginator's own links, and <InfiniteScroll> asks for the next page itself.

4. Request it with and without filters in feature tests

Send each nullable filter both absent and set, or ci:check:coverage reports the filters prop's gap:

beforeEach(function () {
    Project::query()->create(['name' => 'Launch', 'description' => 'The first release']);
    Project::query()->create(['name' => 'Backlog', 'description' => null]);
});

test('the projects page lists every project by name without filters', function () {
    $this->actingAs(User::factory()->create())
        ->get(route('projects.index'))
        ->assertInertia(fn (Assert $page) => $page
            ->where('filters', ['search' => null, 'sort' => 'name'])
            ->where('projects.0.name', 'Backlog'));
});

test('the projects page filters by the query string', function () {
    $this->actingAs(User::factory()->create())
        ->get(route('projects.index', ['search' => 'Lau', 'sort' => 'newest']))
        ->assertInertia(fn (Assert $page) => $page
            ->where('filters', ['search' => 'Lau', 'sort' => 'newest'])
            ->has('projects', 1));
});

Done when composer ci:check passes.

When a check fails

  • ci:check:test fails with App\Data\ProjectFiltersData::$sort has no default, so a query string: give the property the default an absent key means.
  • ci:check:test fails with App\Data\ProjectFiltersData::$statuses is a list, so it must default: default a list to [] and a nullable property to null.
  • ci:check:tsc fails with error TS2561: Object literal may only specify known properties, but 'statuse' does not exist in type 'Partial<ProjectFiltersData>'.: the key isn't a property of the query class.
  • ci:check:tsc fails with error TS2322: Type '"oldest"' is not assignable to type 'ProjectSort | undefined'.: the value isn't one the property's type allows.
  • ci:check:tsc fails with error TS2353: Object literal may only specify known properties, and 'tab' does not exist in type 'NoQueryClass'.: the route's action takes no query class; write one (step 1) and type-hint it on the action.
  • ci:check:test fails with The closure route GET projects takes App\Data\ProjectFiltersData: move the route to a controller action that takes the query class.
  • ci:check:test fails with App\Http\Controllers\DashboardController::index() reads the query: read the key from the action's query class instead of the request.
  • ci:check:test fails with Expected response status code [422] but received 200.: an invalid query value falls back to its default; assert the default instead of a 422.
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