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-page
mrsafalpiya/strictly-laravel
claude-code
inertia
data-classes
page-props
type-generation
typescript
controllers

Adds an Inertia page the Strictly Laravel way: a Page class built from Data classes, a controller action returning it, a component typed by PageProps, and a feature test. Use when creating a page or screen, or adding or changing a page's props.

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

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

# Add a page

The worked example adds `projects/index`, listing the projects of a `Project` model (`name`, nullable `description`).

## 1. Create the Page class and its component

```bash
php artisan make:page projects/index
```

This writes `app/Pages/Projects/IndexPage.php` and `resources/js/pages/projects/index.tsx`, already in sync, and regenerates the types. The page component's path is the Page class's name: `App\Pages\Projects\IndexPage` renders `resources/js/pages/projects/index.tsx`.

## 2. Describe each value as a Data class

A Data class is `final`, lives in `App\Data`, and is built from its model by a named constructor, so only these fields reach the browser:

```php title="app/Data/ProjectData.php"
final class ProjectData extends Data
{
    public function __construct(
        public int $id,
        public string $name,
        public ?string $description,
    ) {}

    public static function fromModel(Project $project): self
    {
        return new self(
            id: $project->id,
            name: $project->name,
            description: $project->description,
        );
    }
}
```

## 3. Declare the props on the Page class

The constructor's promoted properties are the page's props. Declare a list as a `list<…>` of its item type, never `array<int, …>`, which PHP sends as a JSON object once a `filter()` leaves a gap in its keys:

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

Prop types: a Data class, a scalar, a backed enum, `?X` for a value that may be missing, `Lazy|XData` for a deferred prop (skill `strictly-add-deferred-props`).

## 4. Return the Page class from a controller action

```php title="app/Http/Controllers/ProjectController.php"
    public function index(): IndexPage
    {
        return new IndexPage(
            projects: Project::query()
                ->orderBy('name')
                ->get()
                ->map(fn (Project $project): ProjectData => ProjectData::fromModel($project))
                ->all(),
        );
    }
```

```php title="routes/web.php"
    Route::get('projects', [ProjectController::class, 'index'])->name('projects.index');
```

## 5. Type the component by the page's props

```tsx title="resources/js/pages/projects/index.tsx"
import { Head } from '@inertiajs/react';
import type { PageProps } from '@/generated';

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

export default function Index({ projects }: Props) {
    return (
        <>
            <Head title="Projects" />
            <ul>
                {projects.map((project) => (
                    <li key={project.id}>
                        <h2>{project.name}</h2>
                        {project.description !== null && (
                            <p>{project.description}</p>
                        )}
                    </li>
                ))}
            </ul>
        </>
    );
}
```

Link to it with `index()` from `@/routes/projects`. Shared props (`auth`, `name`, …) are typed on `usePage().props`; call `usePage()` without a type argument.

## 6. Render every shape in a feature test

The test renders the page with each nullable prop both `null` and set, and each list non-empty, or `ci:check:coverage` fails:

```php title="tests/Feature/Projects/ProjectIndexTest.php"
test('the projects page lists every project, with or without a description', function () {
    Project::query()->create(['name' => 'Launch', 'description' => 'The first release']);
    Project::query()->create(['name' => 'Backlog', 'description' => null]);

$this->actingAs(User::factory()->create())
        ->get(route('projects.index'))
        ->assertOk()
        ->assertInertia(fn (Assert $page) => $page
            ->component('projects/index')
            ->has('projects', 2)
            ->where('projects.0.name', 'Backlog')
            ->where('projects.1.description', 'The first release'));
});
```

Done when `composer ci:check` passes.

## When a check fails

- `ci:check:test` fails with `App\Pages\Labels\IndexPage::$labels is typed array<int, LabelData>,`: declare it `list<LabelData>`. A query's `->get()->map(…)->all()` is a list; after `filter()`, `unique()` or `sortBy()`, end with `->values()->all()`.
- `ci:check:test` fails with `Inertia::render() renders a page whose props have no generated type.`: return the Page class instead (`Route::inertia()` and `inertia('…')` read the same).
- `ci:check:tsc` fails with `error TS2344: Type '"projects/index"' does not satisfy the constraint 'keyof InertiaPages'.`: a file under `resources/js/pages` has no Page class. Add one, or run `php artisan make:page`.
- `ci:check:tsc` fails with `error TS2344: Type '{ unsentProps: "owner"; }' does not satisfy the constraint '(props: ProjectsShowPage) => unknown'.`: the component reads a prop the Page class doesn't send. Add it to the Page class.
- `ci:check:tsc` fails with `error TS2339: Property 'title' does not exist on type 'ProjectsShowPage'.`: the prop was renamed or removed in PHP. Follow the PHP name.
- `ci:check:coverage` fails with `✗ projects: no feature test renders this page: add a feature test that requests a route returning App\Pages\ProjectsPage.`: add the step 6 test.
- `ci:check:coverage` fails with `✗ MembersPage.members is only ever empty (at members › members): add a feature test that renders members with at least one item in members.`: create the records first (skill `strictly-close-coverage-gap`).
- `ci:check:test` fails with `Data class App\Data\ProjectData must be final.`: make it `final`.

Add a page

The worked example adds projects/index, listing the projects of a Project model (name, nullable description).

1. Create the Page class and its component

php artisan make:page projects/index

This writes app/Pages/Projects/IndexPage.php and resources/js/pages/projects/index.tsx, already in sync, and regenerates the types. The page component's path is the Page class's name: App\Pages\Projects\IndexPage renders resources/js/pages/projects/index.tsx.

2. Describe each value as a Data class

A Data class is final, lives in App\Data, and is built from its model by a named constructor, so only these fields reach the browser:

final class ProjectData extends Data
{
    public function __construct(
        public int $id,
        public string $name,
        public ?string $description,
    ) {}

    public static function fromModel(Project $project): self
    {
        return new self(
            id: $project->id,
            name: $project->name,
            description: $project->description,
        );
    }
}

3. Declare the props on the Page class

The constructor's promoted properties are the page's props. Declare a list as a list<…> of its item type, never array<int, …>, which PHP sends as a JSON object once a filter() leaves a gap in its keys:

final class IndexPage extends Page
{
    /**
     * @param  list<ProjectData>  $projects
     */
    public function __construct(
        public array $projects,
    ) {}
}

Prop types: a Data class, a scalar, a backed enum, ?X for a value that may be missing, Lazy|XData for a deferred prop (skill strictly-add-deferred-props).

4. Return the Page class from a controller action

    public function index(): IndexPage
    {
        return new IndexPage(
            projects: Project::query()
                ->orderBy('name')
                ->get()
                ->map(fn (Project $project): ProjectData => ProjectData::fromModel($project))
                ->all(),
        );
    }
    Route::get('projects', [ProjectController::class, 'index'])->name('projects.index');

5. Type the component by the page's props

import { Head } from '@inertiajs/react';
import type { PageProps } from '@/generated';

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

export default function Index({ projects }: Props) {
    return (
        <>
            <Head title="Projects" />
            <ul>
                {projects.map((project) => (
                    <li key={project.id}>
                        <h2>{project.name}</h2>
                        {project.description !== null && (
                            <p>{project.description}</p>
                        )}
                    </li>
                ))}
            </ul>
        </>
    );
}

Link to it with index() from @/routes/projects. Shared props (auth, name, …) are typed on usePage().props; call usePage() without a type argument.

6. Render every shape in a feature test

The test renders the page with each nullable prop both null and set, and each list non-empty, or ci:check:coverage fails:

test('the projects page lists every project, with or without a description', function () {
    Project::query()->create(['name' => 'Launch', 'description' => 'The first release']);
    Project::query()->create(['name' => 'Backlog', 'description' => null]);

$this->actingAs(User::factory()->create())
        ->get(route('projects.index'))
        ->assertOk()
        ->assertInertia(fn (Assert $page) => $page
            ->component('projects/index')
            ->has('projects', 2)
            ->where('projects.0.name', 'Backlog')
            ->where('projects.1.description', 'The first release'));
});

Done when composer ci:check passes.

When a check fails

  • ci:check:test fails with App\Pages\Labels\IndexPage::$labels is typed array<int, LabelData>,: declare it list<LabelData>. A query's ->get()->map(…)->all() is a list; after filter(), unique() or sortBy(), end with ->values()->all().
  • ci:check:test fails with Inertia::render() renders a page whose props have no generated type.: return the Page class instead (Route::inertia() and inertia('…') read the same).
  • ci:check:tsc fails with error TS2344: Type '"projects/index"' does not satisfy the constraint 'keyof InertiaPages'.: a file under resources/js/pages has no Page class. Add one, or run php artisan make:page.
  • ci:check:tsc fails with error TS2344: Type '{ unsentProps: "owner"; }' does not satisfy the constraint '(props: ProjectsShowPage) => unknown'.: the component reads a prop the Page class doesn't send. Add it to the Page class.
  • ci:check:tsc fails with error TS2339: Property 'title' does not exist on type 'ProjectsShowPage'.: the prop was renamed or removed in PHP. Follow the PHP name.
  • ci:check:coverage fails with ✗ projects: no feature test renders this page: add a feature test that requests a route returning App\Pages\ProjectsPage.: add the step 6 test.
  • ci:check:coverage fails with ✗ MembersPage.members is only ever empty (at members › members): add a feature test that renders members with at least one item in members.: create the records first (skill strictly-close-coverage-gap).
  • ci:check:test fails with Data class App\Data\ProjectData must be final.: make it final.
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.
mehedimi/laravel-amp-mail
happones/laravel-vue-i18n-generator
jwerd/inertia-table
siberfx/flysystem-wasabi
hollyit/token-replace
ippanelcom/laravel-sdk
adachsoft/changelog-linter
adachsoft/collection
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel