Output:
Wall time: 3.9514 seconds
Output:
[{"type":"text","text":"# Search Results\n\n## Eloquent Resources Writing Resources Conditional Attributes\nEloquent Resources | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/eloquent-resources.md\n\n\n### Conditional Attributes\n\nSometimes you may wish to only include an attribute in a resource response if a given condition is met. For example, you may wish to only include a value if the current user is an \"administrator\". Laravel provides a variety of helper methods to assist you in this situation. The `when` method may be used to conditionally add an attribute to a resource response:\n\n```php\n/**\n * Transform the resource into an array.\n *\n * @return array<string, mixed>\n */\npublic function toArray(Request $request): array\n{\n    return [\n        'id' => $this->id,\n        'name' => $this->name,\n        'email' => $this->email,\n        'secret' => $this->when($request->user()->isAdmin(), 'secret-value'),\n        'created_at' => $this->created_at,\n        'updated_at' => $this->updated_at,\n    ];\n}\n```\n\nIn this example, the `secret` key will only be returned in the final resource response if the authenticated user's `isAdmin` method returns `true`. If the method returns `false`, the `secret` key will be removed from the resource response before it is sent to the client. The `when` method allows you to expressively define your resources without resorting to conditional statements when building the array.\n\nThe `when` method also accepts a closure as its second argument, allowing you to calculate the resulting value only if the given condition is `true`:\n\n```php\n'secret' => $this->when($request->user()->isAdmin(), function () {\n    return 'secret-value';\n}),\n```\n\nThe `whenHas` method may be used to include an attribute if it is actually present on the underlying model:\n\n```php\n'name' => $this->whenHas('name'),\n```\n\nAdditionally, the `whenNotNull` method may be used to include an attribute in the resource response if the attribute is not null:\n\n```php\n'name' => $this->whenNotNull($this->name),\n```\n\n---\n\n## Validation Available Validation Rules date_format:_format_,...\nValidation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md\n\n\n#### date_format:_format_,...\n\nThe field under validation must match one of the given _formats_. You should use **either** `date` or `date_format` when validating a field, not both. This validation rule supports all formats supported by PHP's [DateTime](https://www.php.net/manual/en/class.datetime.php) class.\n\nFor convenience, date-based rules may be constructed using the fluent `date` rule builder:\n\n```php\nuse Illuminate\\Validation\\Rule;\n\n'start_date' => [\n    'required',\n    Rule::date()->format('Y-m-d'),\n],\n```\n\n---\n\n## Expectations `json()`\nExpectations | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/expectations.md\n\n\n### `json()`\n\nThe `json()` modifier decodes the current expectation `$value` from JSON to an array.\n\n```php\nexpect('{\"name\":\"Nuno\",\"credit\":1000.00}')\n    ->json()\n    ->toHaveCount(2)\n    ->name->toBe('Nuno')\n    ->credit->toBeFloat();\n\nexpect('not-a-json')->json(); //Fails\n```\n\n---\n\n## Vite Working With Blade and Routes Aliases\nVite | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/vite.md\n\n\n### Aliases\n\nIt is common in JavaScript applications to [create aliases](#aliases) to regularly referenced directories. But, you may also create aliases to use in Blade by using the `macro` method on the `Illuminate\\Support\\Facades\\Vite` class. Typically, \"macros\" should be defined within the `boot` method of a [service provider](/docs/{{version}}/providers):\n\n```php\n/**\n * Bootstrap any application services.\n */\npublic function boot(): void\n{\n    Vite::macro('image', fn (string $asset) => $this->asset(\"resources/images/{$asset}\"));\n}\n```\n\nOnce a macro has been defined, it can be invoked within your templates. For example, we can use the `image` macro defined above to reference an asset located at `resources/images/logo.png`:\n\n```blade\n<img src=\"{{ Vite::image('logo.png') }}\" alt=\"Laravel Logo\">\n```\n\n---\n\n## Validation Form Request Validation\nValidation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md\n\n\n## Form Request Validation\n\n---\n\n## Pest3 Now Available New Configuration API\nPest3 Now Available | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/pest3-now-available.md\n\n\n## New Configuration API\n\nPest 1 / Pest 2's configuration API was a little bit confusing, the `uses()` function that was originally made only for having the `$this` variable within closure bound to the test case instance, ended up being used for pretty much everything.\n\nIn Pest 3, we've introduced a new configuration API that is more intuitive and easier to use. The new configuration API is based on the `pest()` function, which allows you to configure Pest using a fluent and expressive API.\n\n> Note: the `uses()` function is still available in Pest 3, and we don't have plans to remove it. However, we recommend using the new configuration API for new projects.\n\n```diff\n-uses(TestCase::class)->in(__DIR__);\n+pest()->extends(TestCase::class);\n\n-uses(TestCase::class, RefreshDatabase::class)->in('Features');\n+pest()->extends(TestCase::class)->use(RefreshDatabase::class)->in('Features');\n\n-uses()->compact();\n+pest()->printer()->compact();\n```\n\nAnd of course, any method that was available on the `uses()` API, like `->beforeEach()` or `->group()` is still available on the new `pest()` configuration API; we've just made it more intuitive and easier to use.\n\n---\n\n## Expectations `toBeJson()`\nExpectations | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/expectations.md\n\n\n### `toBeJson()`\n\nThis expectation ensures that `$value` is a JSON string.\n\n```php\nexpect('{\"hello\":\"world\"}')->toBeJson();\n```\n\n---\n\n## Validation Manually Creating Validators Performing Additional Validation\nValidation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md\n\n\n### Performing Additional Validation\n\nSometimes you need to perform additional validation after your initial validation is complete. You can accomplish this using the validator's `after` method. The `after` method accepts a closure or an array of callables which will be invoked after validation is complete. The given callables will receive an `Illuminate\\Validation\\Validator` instance, allowing you to raise additional error messages if necessary:\n\n```php\nuse Illuminate\\Support\\Facades\\Validator;\n\n$validator = Validator::make(/* ... */);\n\n$validator->after(function ($validator) {\n    if ($this->somethingElseIsInvalid()) {\n        $validator->errors()->add(\n            'field', 'Something is wrong with this field!'\n        );\n    }\n});\n\nif ($validator->fails()) {\n    // ...\n}\n```\n\nAs noted, the `after` method also accepts an array of callables, which is particularly convenient if your \"after validation\" logic is encapsulated in invokable classes, which will receive an `Illuminate\\Validation\\Validator` instance via their `__invoke` method:\n\n```php\nuse App\\Validation\\ValidateShippingTime;\nuse App\\Validation\\ValidateUserStatus;\n\n$validator->after([\n    new ValidateUserStatus,\n    new ValidateShippingTime,\n    function ($validator) {\n        // ...\n    },\n]);\n```\n\n---\n\n## Vite Working With Blade and Routes Refreshing on Save\nVite | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/vite.md\n\n\n### Refreshing on Save\n\nWhen your application is built using traditional server-side rendering with Blade, Vite can improve your development workflow by automatically refreshing the browser when you make changes to view files in your application. To get started, you can simply specify the `refresh` option as `true`.\n\n```js\nimport { defineConfig } from 'vite';\nimport laravel from 'laravel-vite-plugin';\n\nexport default defineConfig({\n    plugins: [\n        laravel({\n            // ...\n            refresh: true,\n        }),\n    ],\n});\n```\n\nWhen the `refresh` option is `true`, saving files in the following directories will trigger the browser to perform a full page refresh while you are running `npm run dev`:\n\n- `app/Livewire/**`\n- `app/View/Components/**`\n- `lang/**`\n- `resources/lang/**`\n- `resources/views/**`\n- `routes/**`\n\nWatching the `routes/**` directory is useful if you are utilizing [Ziggy](https://github.com/tighten/ziggy) to generate route links within your application's frontend.\n\nIf these default paths do not suit your needs, you can specify your own list of paths to watch:\n\n```js\nimport { defineConfig } from 'vite';\nimport laravel from 'laravel-vite-plugin';\n\nexport default defineConfig({\n    plugins: [\n        laravel({\n            // ...\n            refresh: ['resources/views/**'],\n        }),\n    ],\n});\n```\n\nUnder the hood, the Laravel Vite plugin uses the [vite-plugin-full-reload](https://github.com/ElMassimo/vite-plugin-full-reload) package, which offers some advanced configuration options to fine-tune this feature's behavior. If you need this level of customization, you may provide a `config` definition:\n\n```js\nimport { defineConfig } from 'vite';\nimport laravel from 'laravel-vite-plugin';\n\nexport default defineConfig({\n    plugins: [\n        laravel({\n            // ...\n            refresh: [{\n                paths: ['path/to/watch/**'],\n                config: { delay: 300 }\n            }],\n        }),\n    ],\n});\n```\n\n---\n\n## Http Tests Available Assertions Response Assertions assertJsonStructure\nHttp Tests | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/http-tests.md\n\n\n#### assertJsonStructure\n\nAssert that the response has a given JSON structure:\n\n```php\n$response->assertJsonStructure(array $structure);\n```\n\nFor example, if the JSON response returned by your application contains the following data:\n\n```json\n{\n    \"user\": {\n        \"name\": \"Steve Schoger\"\n    }\n}\n```\n\nYou may assert that the JSON structure matches your expectations like so:\n\n```php\n$response->assertJsonStructure([\n    'user' => [\n        'name',\n    ]\n]);\n```\n\nSometimes, JSON responses returned by your application may contain arrays of objects:\n\n```json\n{\n    \"user\": [\n        {\n            \"name\": \"Steve Schoger\",\n            \"age\": 55,\n            \"location\": \"Earth\"\n        },\n        {\n            \"name\": \"Mary Schoger\",\n            \"age\": 60,\n            \"location\": \"Earth\"\n        }\n    ]\n}\n```\n\nIn this situation, you may use the `*` character to assert against the structure of all of the objects in the array:\n\n```php\n$response->assertJsonStructure([\n    'user' => [\n        '*' => [\n             'name',\n             'age',\n             'location'\n        ]\n    ]\n]);\n```\n\n---\n\n## Routing Route Groups Middleware\nRouting | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/routing.md\n\n\n### Middleware\n\nTo assign [middleware](/docs/{{version}}/middleware) to all routes within a group, you may use the `middleware` method before defining the group. Middleware are executed in the order they are listed in the array:\n\n```php\nRoute::middleware(['first', 'second'])->group(function () {\n    Route::get('/', function () {\n        // Uses first & second middleware...\n    });\n\n    Route::get('/user/profile', function () {\n        // Uses first & second middleware...\n    });\n});\n```\n\n---\n\n## Validation Form Request Validation Preparing Input for Validation\nValidation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md\n\n\n### Preparing Input for Validation\n\nIf you need to prepare or sanitize any data from the request before you apply your validation rules, you may use the `prepareForValidation` method:\n\n```php\nuse Illuminate\\Support\\Str;\n\n/**\n * Prepare the data for validation.\n */\nprotected function prepareForValidation(): void\n{\n    $this->merge([\n        'slug' => Str::slug($this->slug),\n    ]);\n}\n```\n\nLikewise, if you need to normalize any request data after validation is complete, you may use the `passedValidation` method:\n\n```php\n/**\n * Handle a passed validation attempt.\n */\nprotected function passedValidation(): void\n{\n    $this->replace(['name' => 'Taylor']);\n}\n```\n\n---\n\n## Cli Api Reference\nCli Api Reference | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/cli-api-reference.md\n\n---\ntitle: CLI API Reference\ndescription: In the preceding chapters of the Pest documentation, we have covered numerous CLI options that are available in Pest. Nevertheless, Pest provides many other options that could prove beneficial. The complete CLI API Reference is provided below for your convenience.\n---\n\n# CLI API Reference\n\nIn the preceding chapters of the Pest documentation, we have covered numerous CLI options that are available in Pest. Nevertheless, Pest provides many other options that could prove beneficial. The complete CLI API Reference is provided below for your convenience.\n\n## Configuration\n\n- `--init`: Initialize a standard Pest configuration.\n- `--bootstrap <file>` A PHP script that is included before the tests run.\n- `-c|--configuration <file>`: Read configuration from XML file.\n- `--no-configuration`: Ignore default configuration file (phpunit.xml).\n- `--extension <class>`: Register test runner extension with bootstrap <class>.\n- `--no-extensions`: Do not load PHPUnit extensions.\n- `--include-path <path(s)>`: Prepend PHP's include_path with given path(s).\n- `-d <key[=value]>`: Set a php.ini value.\n- `--cache-directory <dir>`: Specify cache directory.\n- `--generate-configuration`: Generate configuration file with suggested settings.\n- `--migrate-configuration`: Migrate configuration file to current format.\n- `--generate-baseline <file>`: Generate baseline for issues.\n- `--use-baseline <file>`: Use baseline to ignore issues.\n- `--ignore-baseline`: Do not use baseline to ignore issues.\n- `--test-directory`: Specify test directory containing Pest.php, TestCase.php, helpers and your tests. Default: tests\n\n## Selection\n\n- `--bail`: Stop execution upon first not-passed test.\n- `--ci`: Ignore focused tests using `->only()` and run the entire test suite.\n- `--todos`: Output to standard output the list of todos.\n- `--notes`: Output to standard output tests with notes.\n- `--issue`: Output to standard output tests with the given issue number.\n- `--pr`: Output to standard output tests with the given pull request number.\n- `--pull-request`: Output to standard output tests with the given pull request number (alias for `--pr`).\n- `--flaky`: Output to standard output tests marked as flaky.\n- `--retry`: Run non-passing tests first and stop execution upon first error or failure.\n- `--list-suites` List available test suites.\n- `--testsuite <name>`: Only run tests from the specified test suite(s).\n- `--exclude-testsuite <name>`: Exclude tests from the specified test suite(s).\n- `--list-groups`: List available test groups.\n- `--group <name>`: Only run tests from the specified group(s).\n- `--exclude-group <name>`: Exclude tests from the specified group(s).\n- `--covers <name>`: Only run tests that intend to cover `<name>`.\n- `--uses <name>`: Only run tests that intend to use `<name>`.\n- `--requires-php-extension <name>`: Only run tests that require PHP extension <name>.\n- `--list-test-files`: List available test files.\n- `--list-tests`: List available tests.\n- `--list-tests-xml <file>`: List available tests in XML format.\n- `--filter <pattern>`: Filter which tests to run\n- `--exclude-filter <pattern>`: Exclude tests for the specified filter pattern.\n- `--test-suffix <suffixes>`: Only search for test in files with specified suffix(es). Default: Test.php,.phpt\n\n## Execution\n\n- `--parallel` Run tests in parallel.\n- `--shard <index/total>`: Run only the given shard of tests (e.g. `--shard=1/5`). Uses time-balanced distribution when `tests/.pest/shards.json` exists.\n- `--update-shards`: Update `tests/.pest/shards.json` with test timing data for time-balanced sharding. Can be combined with `--parallel`.\n- `--update-snapshots`: Update snapshots for tests using the \"toMatchSnapshot\" expectation.\n- `--globals-backup`: Backup and restore $GLOBALS for each test.\n- `--static-backup`: Backup and restore static properties for each test.\n- `--strict-coverage`: Be strict about code coverage metadata.\n- `--strict-global-state`: Be strict about changes to global state.\n- `--disallow-test-output`: Be strict about output during tests.\n- `--enforce-time-limit`: Enforce time limit based on test size.\n- `--default-time-limit <sec>`: Timeout in seconds for tests that have no declared size.\n- `--dont-report-useless-tests`: Do not report tests that do not test anything.\n- `--stop-on-defect`: Stop execution upon first not-passed test.\n- `--stop-on-error`: Stop execution upon first error.\n- `--stop-on-failure`: Stop execution upon first error or failure.\n- `--stop-on-warning`: Stop execution upon first warning.\n- `--stop-on-risky`: Stop execution upon first risky test.\n- `--stop-on-deprecation`: Stop after first test that triggered a deprecation.\n- `--stop-on-notice`: Stop after first test that triggered a notice.\n- `--stop-on-skipped`: Stop execution upon first skipped test.\n- `--stop-on-incomplete`: Stop execution upon first incomplete test.\n- `--fail-on-empty-test-suite`: Signal failure using shell exit code when no tests were run.\n- `--fail-on-warning`: Treat tests with warnings as failures.\n- `--fail-on-risky`: Treat risky tests as failures.\n- `--fail-on-deprecation`: Signal failure using shell exit code when a deprecation was triggered.\n- `--fail-on-phpunit-deprecation`: Signal failure using shell exit code when a PHPUnit deprecation was triggered.\n- `--fail-on-notice`: Signal failure using shell exit code when a notice was triggered.\n- `--fail-on-skipped`: Treat skipped tests as failures.\n- `--fail-on-incomplete`: Signal failure using shell exit code when a test was marked incomplete.\n- `--cache-result`: Write test results to cache file.\n- `--do-not-cache-result`: Do not write test results to cache file\n- `--order-by <order>`: Run tests in order: default|defects|depends|duration|no-depends|random|reverse|size.\n- `--random-order-seed <N>`: Use the specified random seed when running tests in random order\n\n## Reporting\n\n- `--colors <flag>`: Use colors in output (\"never\", \"auto\" or \"always\").\n- `--columns <n>`: Number of columns to use for progress output.\n- `--columns max`: Use maximum number of columns for progress output.\n- `--stderr`: Write to STDERR instead of STDOUT.\n- `--no-progress`: Disable output of test execution progress.\n- `--no-results`: Disable output of test results.\n- `--no-output`: Disable all output.\n- `--display-incomplete`: Display details for incomplete tests.\n- `--display-skipped`: Display details for skipped tests.\n- `--display-deprecations`: Display details for deprecations triggered by tests.\n- `--display-phpunit-deprecations`: Display details for PHPUnit deprecations.\n- `--display-errors`: Display details for errors triggered by tests.\n- `--display-notices`: Display details for notices triggered by tests.\n- `--display-warnings`: Display details for warnings triggered by tests.\n- `--reverse-list`: Print defects in reverse order.\n- `--teamcity`: Replace default progress and result output with TeamCity format.\n- `--testdox`: Replace default result output with TestDox format\n- `--testdox-summary`: Repeat TestDox output for tests with errors, failures, or issues.\n- `--debug`: Replace default progress and result output with debugging information.\n- `--compact`:  Replace default result output with Compact format\n\n## Logging\n\n- `--log-junit <file>`: Write test results in JUnit XML format to file.\n- `--log-teamcity <file>`: Write test results in TeamCity format to file.\n- `--testdox-html <file>`: Write test results in TestDox format (HTML) to file.\n- `--testdox-text <file>`: Write test results in TestDox format (plain text) to file.\n- `--log-events-text <file>`: Stream events as plain text to file.\n- `--log-events-verbose-text <file>`: Stream events as plain text (with telemetry information) to file.\n- `--no-logging`: Ignore logging configured in the XML configuration file\n\n## Code Coverage\n\n- `--coverage`: Generate code coverage report and output to standard output.\n- `--coverage --min=<value>`: Set the minimum required coverage percentage, and fail if not met.\n- `--coverage --exactly=<value>`: Set the exact required coverage percentage, and fail if not met.\n- `--coverage --only-covered`: Hide files with 0% coverage from the code coverage report.\n- `--coverage-clover <file>`: Write code coverage report in Clover XML format to file.\n- `--coverage-cobertura <file>`: Write code coverage report in Cobertura XML format to file.\n- `--coverage-crap4j <file>`: Write code coverage report in Crap4J XML format to file.\n- `--coverage-html <dir>`: Write code coverage report in HTML format to directory.\n- `--coverage-php <file>`: Write serialized code coverage data to file.\n- `--coverage-text=<file>`: Write code coverage report in text format to file [default: standard output].\n- `--only-summary-for-coverage-text`: Option for code coverage report in text format: only show summary.\n- `--show-uncovered-for-coverage-text`: Option for code coverage report in text format: show uncovered files.\n- `--coverage-xml <dir>`: Write code coverage report in XML format to directory.\n- `--warm-coverage-cache`: Warm static analysis cache.\n- `--coverage-filter <dir>`: Include `<dir>`: in code coverage reporting.\n- `--path-coverage`: Report path coverage in addition to line coverage.\n- `--disable-coverage-ignore`: Disable metadata for ignoring code coverage.\n- `--no-coverage`: Ignore code coverage reporting configured in the XML configuration file\n\n## Mutation Testing\n\n- `--mutate`: Runs mutation testing, to understand the quality of your tests.\n- `--mutate --parallel`: Runs mutation testing in parallel.\n- `--mutate --min`: Set the minimum required mutation score, and fail if not met.\n- `--mutate --id`:  Run only the mutation with the given ID. But E.g. --id=ecb35ab30ffd3491. Note, you need to provide the same options as the original run.\n- `--mutate --covered-only`: Only generate mutations for classes that are covered by tests.\n- `--mutate --bail`: Stop mutation testing execution upon first untested or uncovered mutation.\n- `--mutate --class`: Generate mutations for the given class(es). E.g. --class=App\\\\Models.\n- `--mutate --ignore`: Ignore the given class(es) when generating mutations. E.g. --ignore=App\\\\Http\\\\Requests.\n- `--mutate --clear-cache`: Clear the mutation cache.\n- `--mutate --no-cache`: Clear the mutation cache.\n- `--mutate --ignore-min-score-on-zero-mutations`: Ignore the minimum score requirement when there are no mutations.\n- `--mutate --covered-only`: Only generate mutations for classes that are covered by tests.\n- `--mutate --everything`: Generate mutations for all classes, even if they are not covered by tests.\n- `--mutate --profile`: Output to standard output the top ten slowest mutations.\n- `--mutate --retry`: Run untested or uncovered mutations first and stop execution upon first error or failure.\n- `--mutate --stop-on-uncovered`: Stop mutation testing execution upon first untested mutation.\n- `--mutate --stop-on-untested`: Stop mutation testing execution upon first untested mutation.\n\n## Profiling\n\n- `--profile`: Output to standard output the top ten slowest tests\n\n---\n\nIn this chapter, you found a complete list of CLI options provided by Pest. In the subsequent documentation, we will explore the topic of test dependencies: [Test Dependencies](/docs/test-dependencies)\n\n\n---\n\n## Validation Available Validation Rules json\nValidation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md\n\n\n#### json\n\nThe field under validation must be a valid JSON string.\n\n---\n\n## Vite Working With Blade and Routes Processing Static Assets With Vite\nVite | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/vite.md\n\n\n### Processing Static Assets With Vite\n\nWhen referencing assets in your JavaScript or CSS, Vite automatically processes and versions them. In addition, when building Blade based applications, Vite can also process and version static assets that you reference solely in Blade templates.\n\nHowever, in order to accomplish this, you need to make Vite aware of your assets by importing the static assets into the application's entry point. For example, if you want to process and version all images stored in `resources/images` and all fonts stored in `resources/fonts`, you should add the following in your application's `resources/js/app.js` entry point:\n\n```js\nimport.meta.glob([\n  '../images/**',\n  '../fonts/**',\n]);\n```\n\nThese assets will now be processed by Vite when running `npm run build`. You can then reference these assets in Blade templates using the `Vite::asset` method, which will return the versioned URL for a given asset:\n\n```blade\n<img src=\"{{ Vite::asset('resources/images/logo.png') }}\">\n```\n\n---\n\n## Validation Form Request Validation Creating Form Requests\nValidation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md\n\n\n### Creating Form Requests\n\nFor more complex validation scenarios, you may wish to create a \"form request\". Form requests are custom request classes that encapsulate their own validation and authorization logic. To create a form request class, you may use the `make:request` Artisan CLI command:\n\n```shell\nphp artisan make:request StorePostRequest\n```\n\nThe generated form request class will be placed in the `app/Http/Requests` directory. If this directory does not exist, it will be created when you run the `make:request` command. Each form request generated by Laravel has two methods: `authorize` and `rules`.\n\nAs you might have guessed, the `authorize` method is responsible for determining if the currently authenticated user can perform the action represented by the request, while the `rules` method returns the validation rules that should apply to the request's data:\n\n```php\n/**\n * Get the validation rules that apply to the request.\n *\n * @return array<string, \\Illuminate\\Contracts\\Validation\\ValidationRule|array<mixed>|string>\n */\npublic function rules(): array\n{\n    return [\n        'title' => 'required|unique:posts|max:255',\n        'body' => 'required',\n    ];\n}\n```\n\n> [!NOTE]\n> You may type-hint any dependencies you require within the `rules` method's signature. They will automatically be resolved via the Laravel [service container](/docs/{{version}}/container).\n\nSo, how are the validation rules evaluated? All you need to do is type-hint the request on your controller method. The incoming form request is validated before the controller method is called, meaning you do not need to clutter your controller with any validation logic:\n\n```php\n/**\n * Store a new blog post.\n */\npublic function store(StorePostRequest $request): RedirectResponse\n{\n    // The incoming request is valid...\n\n    // Retrieve the validated input data...\n    $validated = $request->validated();\n\n    // Retrieve a portion of the validated input data...\n    $validated = $request->safe()->only(['name', 'email']);\n    $validated = $request->safe()->except(['name', 'email']);\n\n    // Store the blog post...\n\n    return redirect('/posts');\n}\n```\n\nIf validation fails, a redirect response will be generated to send the user back to their previous location. The errors will also be flashed to the session so they are available for display. If the request was an XHR request, an HTTP response with a 422 status code will be returned to the user including a [JSON representation of the validation errors](#validation-error-response-format).\n\n> [!NOTE]\n> Need to add real-time form request validation to your Inertia powered Laravel frontend? Check out [Laravel Precognition](/docs/{{version}}/precognition).\n\n---\n\n## Validation Form Request Validation Customizing the Error Messages Customizing the Validation Attributes\nValidation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md\n\n\n#### Customizing the Validation Attributes\n\nMany of Laravel's built-in validation rule error messages contain an `:attribute` placeholder. If you would like the `:attribute` placeholder of your validation message to be replaced with a custom attribute name, you may specify the custom names by overriding the `attributes` method. This method should return an array of attribute / name pairs:\n\n```php\n/**\n * Get custom attributes for validator errors.\n *\n * @return array<string, string>\n */\npublic function attributes(): array\n{\n    return [\n        'email' => 'email address',\n    ];\n}\n```\n\n---\n\n## Strings Fluent Strings `isJson` {.collection-method}\nStrings | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/strings.md\n\n\n#### `isJson` {.collection-method}\n\nThe `isJson` method determines if a given string is valid JSON:\n\n```php\nuse Illuminate\\Support\\Str;\n\n$result = Str::of('[1,2,3]')->isJson();\n\n// true\n\n$result = Str::of('{\"first\": \"John\", \"last\": \"Doe\"}')->isJson();\n\n// true\n\n$result = Str::of('{first: \"John\", last: \"Doe\"}')->isJson();\n\n// false\n```\n\n---\n\n## Sanctum Mobile Application Authentication Issuing API Tokens\nSanctum | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/sanctum.md\n\n\n### Issuing API Tokens\n\nTo get started, create a route that accepts the user's email / username, password, and device name, then exchanges those credentials for a new Sanctum token. The \"device name\" given to this endpoint is for informational purposes and may be any value you wish. In general, the device name value should be a name the user would recognize, such as \"Nuno's iPhone 17\".\n\nTypically, you will make a request to the token endpoint from your mobile application's \"login\" screen. The endpoint will return the plain-text API token which may then be stored on the mobile device and used to make additional API requests:\n\n```php\nuse App\\Models\\User;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Facades\\Hash;\nuse Illuminate\\Validation\\ValidationException;\n\nRoute::post('/sanctum/token', function (Request $request) {\n    $request->validate([\n        'email' => 'required|email',\n        'password' => 'required',\n        'device_name' => 'required',\n    ]);\n\n    $user = User::where('email', $request->email)->first();\n\n    if (! $user || ! Hash::check($request->password, $user->password)) {\n        throw ValidationException::withMessages([\n            'email' => ['The provided credentials are incorrect.'],\n        ]);\n    }\n\n    return $user->createToken($request->device_name)->plainTextToken;\n});\n```\n\nWhen the mobile application uses the token to make an API request to your application, it should pass the token in the `Authorization` header as a `Bearer` token.\n\n> [!NOTE]\n> When issuing tokens for a mobile application, you are also free to specify [token abilities](#token-abilities).\n\n---\n\n## Vite Working With Blade and Routes\nVite | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/vite.md\n\n\n## Working With Blade and Routes\n\n---\n\n## Validation Form Request Validation Authorizing Form Requests\nValidation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md\n\n\n### Authorizing Form Requests\n\nThe form request class also contains an `authorize` method. Within this method, you may determine if the authenticated user actually has the authority to update a given resource. For example, you may determine if a user actually owns a blog comment they are attempting to update. Most likely, you will interact with your [authorization gates and policies](/docs/{{version}}/authorization) within this method:\n\n```php\nuse App\\Models\\Comment;\n\n/**\n * Determine if the user is authorized to make this request.\n */\npublic function authorize(): bool\n{\n    $comment = Comment::find($this->route('comment'));\n\n    return $comment && $this->user()->can('update', $comment);\n}\n```\n\nSince all form requests extend the base Laravel request class, we may use the `user` method to access the currently authenticated user. Also, note the call to the `route` method in the example above. This method grants you access to the URI parameters defined on the route being called, such as the `{comment}` parameter in the example below:\n\n```php\nRoute::post('/comment/{comment}');\n```\n\nTherefore, if your application is taking advantage of [route model binding](/docs/{{version}}/routing#route-model-binding), your code may be made even more succinct by accessing the resolved model as a property of the request:\n\n```php\nreturn $this->user()->can('update', $this->comment);\n```\n\nIf the `authorize` method returns `false`, an HTTP response with a 403 status code will automatically be returned and your controller method will not execute.\n\nIf you plan to handle authorization logic for the request in another part of your application, you may remove the `authorize` method completely, or simply return `true`:\n\n```php\n/**\n * Determine if the user is authorized to make this request.\n */\npublic function authorize(): bool\n{\n    return true;\n}\n```\n\n> [!NOTE]\n> You may type-hint any dependencies you need within the `authorize` method's signature. They will automatically be resolved via the Laravel [service container](/docs/{{version}}/container).\n\n_[Truncated]_"}]


sed -n '1,240p' app/Http/Controllers/Api/Admin/V1/OrderController.php


sed -n '1,220p' app/Http/Middleware/AuthenticateAdminApi.php


sed -n '1,180p' app/Http/Resources/Admin/V1/OrderResource.php


varchar

varchar

varchar

varchar

Question: text


sed -n '1,180p' app/Http/Resources/Admin/V1/OrderResource.php

<?php

namespace App\Http\Resources\Admin\V1;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class OrderResource extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'store_id' => $this->store_id,
            'order_number' => $this->order_number,
            'email' => $this->email,
            'customer' => $this->whenLoaded('customer', fn () => $this->customer ? [
                'id' => $this->customer->id,
                'name' => $this->customer->name,
                'email' => $this->customer->email,
            ] : null),
            'status' => $this->status?->value,
            'financial_status' => $this->financial_status?->value,
            'fulfillment_status' => $this->fulfillment_status?->value,
            'payment_method' => $this->payment_method?->value,
            'currency' => $this->currency,
            'subtotal_amount' => $this->subtotal_amount,
            'discount_amount' => $this->discount_amount,
            'shipping_amount' => $this->shipping_amount,
            'tax_amount' => $this->tax_amount,
            'total_amount' => $this->total_amount,
            'lines_count' => $this->whenCounted('lines'),
            'lines' => $this->whenLoaded('lines', fn () => $this->lines->map(fn ($line): array => [
                'id' => $line->id,
                'product_id' => $line->product_id,
                'variant_id' => $line->variant_id,
                'title' => $line->title_snapshot,
                'sku' => $line->sku_snapshot,
                'quantity' => $line->quantity,
                'unit_price_amount' => $line->unit_price_amount,
                'total_amount' => $line->total_amount,
            ])->values()),
            'payments' => $this->whenLoaded('payments', fn () => $this->payments->map(fn ($payment): array => [
                'id' => $payment->id,
                'provider' => $payment->provider,
                'method' => $payment->method?->value,
                'provider_payment_id' => $payment->provider_payment_id,
                'status' => $payment->status?->value,
                'amount' => $payment->amount,
                'currency' => $payment->currency,
            ])->values()),
            'refunds' => RefundResource::collection($this->whenLoaded('refunds')),
            'fulfillments' => FulfillmentResource::collection($this->whenLoaded('fulfillments')),
            'shipping_address' => $this->shipping_address_json,
            'billing_address' => $this->billing_address_json,
            'placed_at' => $this->placed_at?->toIso8601String(),
            'created_at' => $this->created_at?->toIso8601String(),
        ];
    }
}


sed -n '1,240p' app/Http/Controllers/Api/Admin/V1/OrderController.php

<?php

namespace App\Http\Controllers\Api\Admin\V1;

use App\Http\Controllers\Controller;
use App\Http\Resources\Admin\V1\OrderResource;
use App\Models\Order;
use App\Models\Store;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Validation\Rule;

class OrderController extends Controller
{
    public function index(Request $request, Store $store): AnonymousResourceCollection
    {
        $this->authorizeStore($request, $store);

        $validated = $request->validate([
            'status' => ['nullable', Rule::in(['pending', 'paid', 'fulfilled', 'cancelled', 'refunded'])],
            'financial_status' => ['nullable', Rule::in(['pending', 'authorized', 'paid', 'partially_refunded', 'refunded', 'voided'])],
            'fulfillment_status' => ['nullable', Rule::in(['unfulfilled', 'partial', 'fulfilled'])],
            'query' => ['nullable', 'string', 'max:255'],
            'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
        ]);

        $orders = Order::withoutGlobalScopes()
            ->with('customer')
            ->withCount('lines')
            ->where('store_id', $store->getKey())
            ->when(data_get($validated, 'status'), fn (Builder $query, string $status) => $query->where('status', $status))
            ->when(data_get($validated, 'financial_status'), fn (Builder $query, string $status) => $query->where('financial_status', $status))
            ->when(data_get($validated, 'fulfillment_status'), fn (Builder $query, string $status) => $query->where('fulfillment_status', $status))
            ->when(data_get($validated, 'query'), function (Builder $query, string $search): void {
                $like = '%'.$search.'%';

                $query->where(function (Builder $query) use ($like): void {
                    $query
                        ->where('order_number', 'like', $like)
                        ->orWhere('email', 'like', $like)
                        ->orWhereHas('customer', fn (Builder $query) => $query->where('name', 'like', $like));
                });
            })
            ->latest('placed_at')
            ->latest('id')
            ->paginate((int) data_get($validated, 'per_page', 25));

        return OrderResource::collection($orders);
    }

    public function show(Request $request, Store $store, Order $order): OrderResource
    {
        $this->authorizeStore($request, $store);
        $this->abortUnlessOrderBelongsToStore($order, $store);

        return OrderResource::make($this->loadOrder($order));
    }

    private function authorizeStore(Request $request, Store $store): void
    {
        if (! $request->attributes->has('admin_api_oauth_token')) {
            abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403);
        }

        app()->instance('current_store', $store);
    }

    private function abortUnlessOrderBelongsToStore(Order $order, Store $store): void
    {
        abort_unless((int) $order->store_id === $store->getKey(), 404);
    }

    private function loadOrder(Order $order): Order
    {
        return $order->load([
            'customer',
            'lines',
            'payments',
            'refunds',
            'fulfillments.lines',
        ])->loadCount('lines');
    }
}


Output:
Chunk ID: b4c351
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 839
Output:
<?php

namespace App\Http\Controllers\Api\Admin\V1;

use App\Http\Controllers\Controller;
use App\Http\Resources\Admin\V1\OrderResource;
use App\Models\Order;
use App\Models\Store;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Validation\Rule;

class OrderController extends Controller
{
    public function index(Request $request, Store $store): AnonymousResourceCollection
    {
        $this->authorizeStore($request, $store);

        $validated = $request->validate([
            'status' => ['nullable', Rule::in(['pending', 'paid', 'fulfilled', 'cancelled', 'refunded'])],
            'financial_status' => ['nullable', Rule::in(['pending', 'authorized', 'paid', 'partially_refunded', 'refunded', 'voided'])],
            'fulfillment_status' => ['nullable', Rule::in(['unfulfilled', 'partial', 'fulfilled'])],
            'query' => ['nullable', 'string', 'max:255'],
            'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
        ]);

        $orders = Order::withoutGlobalScopes()
            ->with('customer')
            ->withCount('lines')
            ->where('store_id', $store->getKey())
            ->when(data_get($validated, 'status'), fn (Builder $query, string $status) => $query->where('status', $status))
            ->when(data_get($validated, 'financial_status'), fn (Builder $query, string $status) => $query->where('financial_status', $status))
            ->when(data_get($validated, 'fulfillment_status'), fn (Builder $query, string $status) => $query->where('fulfillment_status', $status))
            ->when(data_get($validated, 'query'), function (Builder $query, string $search): void {
                $like = '%'.$search.'%';

                $query->where(function (Builder $query) use ($like): void {
                    $query
                        ->where('order_number', 'like', $like)
                        ->orWhere('email', 'like', $like)
                        ->orWhereHas('customer', fn (Builder $query) => $query->where('name', 'like', $like));
                });
            })
            ->latest('placed_at')
            ->latest('id')
            ->paginate((int) data_get($validated, 'per_page', 25));

        return OrderResource::collection($orders);
    }

    public function show(Request $request, Store $store, Order $order): OrderResource
    {
        $this->authorizeStore($request, $store);
        $this->abortUnlessOrderBelongsToStore($order, $store);

        return OrderResource::make($this->loadOrder($order));
    }

    private function authorizeStore(Request $request, Store $store): void
    {
        if (! $request->attributes->has('admin_api_oauth_token')) {
            abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403);
        }

        app()->instance('current_store', $store);
    }

    private function abortUnlessOrderBelongsToStore(Order $order, Store $store): void
    {
        abort_unless((int) $order->store_id === $store->getKey(), 404);
    }

    private function loadOrder(Order $order): Order
    {
        return $order->load([
            'customer',
            'lines',
            'payments',
            'refunds',
            'fulfillments.lines',
        ])->loadCount('lines');
    }
}


sed -n '1,220p' app/Http/Middleware/AuthenticateAdminApi.php

<?php

namespace App\Http\Middleware;

use App\Enums\AppInstallationStatus;
use App\Models\OauthToken;
use App\Models\Store;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class AuthenticateAdminApi
{
    /**
     * Handle an incoming request.
     *
     * @param  \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response)  $next
     */
    public function handle(Request $request, Closure $next, string ...$abilities): Response
    {
        $store = $this->storeFromRoute($request);

        app()->forgetInstance('admin_api_oauth_token');
        app()->instance('current_store', $store);

        if ($request->user() !== null) {
            if ($request->user()->stores()->whereKey($store->getKey())->exists()) {
                return $next($request);
            }

            return response()->json(['message' => 'Forbidden.'], 403);
        }

        $plainTextToken = $request->bearerToken();

        if (! is_string($plainTextToken) || $plainTextToken === '') {
            return response()->json(['message' => 'Unauthenticated.'], 401);
        }

        $token = OauthToken::query()
            ->with('installation')
            ->where('access_token_hash', hash('sha256', $plainTextToken))
            ->first();

        if (! $token instanceof OauthToken || $token->isExpired()) {
            return response()->json(['message' => 'Unauthenticated.'], 401);
        }

        $installation = $token->installation;

        if ($installation === null
            || $installation->status !== AppInstallationStatus::Active
            || (int) $installation->store_id !== $store->getKey()) {
            return response()->json(['message' => 'Forbidden.'], 403);
        }

        if (! $this->hasAbilities($token, $abilities)) {
            return response()->json(['message' => 'Forbidden.'], 403);
        }

        $token->forceFill(['last_used_at' => now()])->save();

        $request->attributes->set('admin_api_oauth_token', $token);
        app()->instance('admin_api_oauth_token', $token);

        return $next($request);
    }

    private function storeFromRoute(Request $request): Store
    {
        $store = $request->route('store');

        if ($store instanceof Store) {
            return $store;
        }

        return Store::query()->findOrFail($store);
    }

    /**
     * @param  list<string>  $abilities
     */
    private function hasAbilities(OauthToken $token, array $abilities): bool
    {
        if ($abilities === []) {
            return true;
        }

        $tokenAbilities = $token->abilities_json ?? [];

        if (in_array('*', $tokenAbilities, true)) {
            return true;
        }

        foreach ($abilities as $ability) {
            if (! in_array($ability, $tokenAbilities, true)) {
                return false;
            }
        }

        return true;
    }
}


Output:
Chunk ID: 1dc185
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 731
Output:
<?php

namespace App\Http\Middleware;

use App\Enums\AppInstallationStatus;
use App\Models\OauthToken;
use App\Models\Store;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class AuthenticateAdminApi
{
    /**
     * Handle an incoming request.
     *
     * @param  \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response)  $next
     */
    public function handle(Request $request, Closure $next, string ...$abilities): Response
    {
        $store = $this->storeFromRoute($request);

        app()->forgetInstance('admin_api_oauth_token');
        app()->instance('current_store', $store);

        if ($request->user() !== null) {
            if ($request->user()->stores()->whereKey($store->getKey())->exists()) {
                return $next($request);
            }

            return response()->json(['message' => 'Forbidden.'], 403);
        }

        $plainTextToken = $request->bearerToken();

        if (! is_string($plainTextToken) || $plainTextToken === '') {
            return response()->json(['message' => 'Unauthenticated.'], 401);
        }

        $token = OauthToken::query()
            ->with('installation')
            ->where('access_token_hash', hash('sha256', $plainTextToken))
            ->first();

        if (! $token instanceof OauthToken || $token->isExpired()) {
            return response()->json(['message' => 'Unauthenticated.'], 401);
        }

        $installation = $token->installation;

        if ($installation === null
            || $installation->status !== AppInstallationStatus::Active
            || (int) $installation->store_id !== $store->getKey()) {
            return response()->json(['message' => 'Forbidden.'], 403);
        }

        if (! $this->hasAbilities($token, $abilities)) {
            return response()->json(['message' => 'Forbidden.'], 403);
        }

        $token->forceFill(['last_used_at' => now()])->save();

        $request->attributes->set('admin_api_oauth_token', $token);
        app()->instance('admin_api_oauth_token', $token);

        return $next($request);
    }

    private function storeFromRoute(Request $request): Store
    {
        $store = $request->route('store');

        if ($store instanceof Store) {
            return $store;
        }

        return Store::query()->findOrFail($store);
    }

    /**
     * @param  list<string>  $abilities
     */
    private function hasAbilities(OauthToken $token, array $abilities): bool
    {
        if ($abilities === []) {
            return true;
        }

        $tokenAbilities = $token->abilities_json ?? [];

        if (in_array('*', $tokenAbilities, true)) {
            return true;
        }

        foreach ($abilities as $ability) {
            if (! in_array($ability, $tokenAbilities, true)) {
                return false;
            }
        }

        return true;
    }
}


Output:
Chunk ID: 84eb06
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 718
Output:
<?php

namespace App\Http\Resources\Admin\V1;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class OrderResource extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'store_id' => $this->store_id,
            'order_number' => $this->order_number,
            'email' => $this->email,
            'customer' => $this->whenLoaded('customer', fn () => $this->customer ? [
                'id' => $this->customer->id,
                'name' => $this->customer->name,
                'email' => $this->customer->email,
            ] : null),
            'status' => $this->status?->value,
            'financial_status' => $this->financial_status?->value,
            'fulfillment_status' => $this->fulfillment_status?->value,
            'payment_method' => $this->payment_method?->value,
            'currency' => $this->currency,
            'subtotal_amount' => $this->subtotal_amount,
            'discount_amount' => $this->discount_amount,
            'shipping_amount' => $this->shipping_amount,
            'tax_amount' => $this->tax_amount,
            'total_amount' => $this->total_amount,
            'lines_count' => $this->whenCounted('lines'),
            'lines' => $this->whenLoaded('lines', fn () => $this->lines->map(fn ($line): array => [
                'id' => $line->id,
                'product_id' => $line->product_id,
                'variant_id' => $line->variant_id,
                'title' => $line->title_snapshot,
                'sku' => $line->sku_snapshot,
                'quantity' => $line->quantity,
                'unit_price_amount' => $line->unit_price_amount,
                'total_amount' => $line->total_amount,
            ])->values()),
            'payments' => $this->whenLoaded('payments', fn () => $this->payments->map(fn ($payment): array => [
                'id' => $payment->id,
                'provider' => $payment->provider,
                'method' => $payment->method?->value,
                'provider_payment_id' => $payment->provider_payment_id,
                'status' => $payment->status?->value,
                'amount' => $payment->amount,
                'currency' => $payment->currency,
            ])->values()),
            'refunds' => RefundResource::collection($this->whenLoaded('refunds')),
            'fulfillments' => FulfillmentResource::collection($this->whenLoaded('fulfillments')),
            'shipping_address' => $this->shipping_address_json,
            'billing_address' => $this->billing_address_json,
            'placed_at' => $this->placed_at?->toIso8601String(),
            'created_at' => $this->created_at?->toIso8601String(),
        ];
    }
}


sed -n '1390,1635p' specs/02-API-ROUTES.md


rg -n "Customer|customers|GET /api/admin/v1/stores/\{storeId\}/customers|read-customers" specs/02-API-ROUTES.md


sed -n '1,260p' app/Models/Product.php && sed -n '1,220p' app/Models/Customer.php && sed -n '1,220p' app/Models/ProductVariant.php


sed -n '1390,1635p' specs/02-API-ROUTES.md

---

### 3.2 Products

#### Admin Products Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/products` | `read-products` | List products with filtering and pagination | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/products` | `write-products` | Create a new product | `api.admin` (60/min) |
| GET | `/api/admin/v1/stores/{storeId}/products/{productId}` | `read-products` | Get single product with all relations | `api.admin` (60/min) |
| PUT | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Update a product (partial updates supported) | `api.admin` (60/min) |
| DELETE | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Archive a product (soft delete) | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload` | `write-products` | Generate presigned URL for media upload | `api.admin` (60/min) |

#### GET /api/admin/v1/stores/{storeId}/products

List products with filtering and pagination.

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `status` | string | No | Filter by status: `draft`, `active`, `archived`. |
| `query` | string | No | Search by title, vendor, or SKU. |
| `collection_id` | integer | No | Filter by collection membership. |
| `page` | integer | No | Page number. Default: 1. |
| `per_page` | integer | No | Results per page. Default: 25, max: 100. |
| `sort` | string | No | One of: `title_asc`, `title_desc`, `created_at_asc`, `created_at_desc`, `updated_at_desc`. Default: `updated_at_desc`. |

**Success response:** `200 OK`

```json
{
  "data": [
    {
      "id": 10,
      "store_id": 1,
      "title": "Classic T-Shirt",
      "handle": "classic-t-shirt",
      "status": "active",
      "vendor": "Acme Apparel",
      "product_type": "Apparel",
      "tags": ["organic", "cotton"],
      "variants_count": 6,
      "total_inventory": 150,
      "published_at": "2026-02-10T08:00:00Z",
      "created_at": "2026-02-09T14:00:00Z",
      "updated_at": "2026-02-11T09:00:00Z",
      "featured_image": {
        "url": "https://cdn.example.com/products/tshirt.jpg",
        "alt_text": "Classic T-Shirt in Blue"
      }
    }
  ],
  "meta": {
    "current_page": 1,
    "per_page": 25,
    "total": 42,
    "last_page": 2
  }
}
```

---

#### POST /api/admin/v1/stores/{storeId}/products

Create a new product.

**Request body:**

```json
{
  "title": "Classic T-Shirt",
  "handle": "classic-t-shirt",
  "description_html": "<p>A comfortable cotton t-shirt.</p>",
  "vendor": "Acme Apparel",
  "product_type": "Apparel",
  "status": "draft",
  "tags": ["organic", "cotton"],
  "options": [
    { "name": "Color", "position": 1 },
    { "name": "Size", "position": 2 }
  ],
  "variants": [
    {
      "sku": "TSH-BLU-S",
      "barcode": "1234567890123",
      "price_amount": 2500,
      "compare_at_amount": 3500,
      "currency": "EUR",
      "weight_g": 200,
      "requires_shipping": true,
      "is_default": true,
      "position": 1,
      "status": "active",
      "option_values": [
        { "option_name": "Color", "value": "Blue" },
        { "option_name": "Size", "value": "Small" }
      ],
      "inventory": {
        "quantity_on_hand": 50,
        "policy": "deny"
      }
    }
  ],
  "collections": [5]
}
```

**Product fields:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `title` | string | Yes | Max: 255 |
| `handle` | string | No | Auto-generated from title if not provided. Unique per store. Lowercase, hyphens only. Max: 255. |
| `description_html` | string | No | Max: 65535 |
| `vendor` | string | No | Max: 255 |
| `product_type` | string | No | Max: 255 |
| `status` | string | No | One of: `draft`, `active`. Default: `draft`. |
| `tags` | array of strings | No | Max: 50 tags, each max: 255. |
| `options` | array | No | Max: 3 options. |
| `options[].name` | string | Yes | Max: 255 |
| `options[].position` | integer | Yes | Min: 1, max: 3 |
| `collections` | array of integers | No | Collection IDs to add this product to. |

**Variant fields:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `variants` | array | Yes | At least one variant required. Max: 100. |
| `variants[].sku` | string | Yes | Max: 255. Unique per store (`store_id`, `sku`). |
| `variants[].barcode` | string | No | Max: 255 |
| `variants[].price_amount` | integer | Yes | Min: 0 (in minor currency units) |
| `variants[].compare_at_amount` | integer | No | Min: 0. Must be > price_amount if set. |
| `variants[].currency` | string | No | Defaults to store currency. |
| `variants[].weight_g` | integer | No | Min: 0 |
| `variants[].requires_shipping` | boolean | No | Default: true |
| `variants[].is_default` | boolean | No | Exactly one variant must be default. |
| `variants[].position` | integer | No | Auto-assigned if not provided. |
| `variants[].status` | string | No | One of: `active`, `archived`. Default: `active`. |
| `variants[].option_values` | array | No | Must match defined options. |
| `variants[].inventory.quantity_on_hand` | integer | No | Default: 0. Min: 0. |
| `variants[].inventory.policy` | string | No | One of: `deny`, `continue`. Default: `deny`. |

**Success response:** `201 Created`

Returns the full product resource with nested variants, options, and media.

**Error responses:**
- `403` - Insufficient permissions
- `422` - Validation errors (duplicate SKU, duplicate handle, missing required fields)

---

#### GET /api/admin/v1/stores/{storeId}/products/{productId}

Get a single product with all variants, options, media, and collections.

**Success response:** `200 OK`

```json
{
  "data": {
    "id": 10,
    "store_id": 1,
    "title": "Classic T-Shirt",
    "handle": "classic-t-shirt",
    "description_html": "
A comfortable cotton t-shirt.
",
    "vendor": "Acme Apparel",
    "product_type": "Apparel",
    "status": "active",
    "tags": ["organic", "cotton"],
    "published_at": "2026-02-10T08:00:00Z",
    "created_at": "2026-02-09T14:00:00Z",
    "updated_at": "2026-02-11T09:00:00Z",
    "options": [
      {
        "id": 1,
        "name": "Color",
        "position": 1,
        "values": [
          { "id": 1, "value": "Blue", "position": 1 },
          { "id": 2, "value": "Red", "position": 2 }
        ]
      },
      {
        "id": 2,
        "name": "Size",
        "position": 2,
        "values": [
          { "id": 3, "value": "Small", "position": 1 },
          { "id": 4, "value": "Medium", "position": 2 },
          { "id": 5, "value": "Large", "position": 3 }
        ]
      }
    ],
    "variants": [
      {
        "id": 101,
        "sku": "TSH-BLU-S",
        "barcode": "1234567890123",
        "price_amount": 2500,
        "compare_at_amount": 3500,
        "currency": "EUR",
        "weight_g": 200,
        "requires_shipping": true,
        "is_default": true,
        "position": 1,
        "status": "active",
        "option_values": [
          { "option_name": "Color", "value": "Blue" },
          { "option_name": "Size", "value": "Small" }
        ],
        "inventory": {
          "quantity_on_hand": 50,
          "quantity_reserved": 2,
          "policy": "deny"
        }
      }
    ],
    "media": [
      {
        "id": 1,
        "type": "image",
        "storage_key": "stores/1/products/10/main.jpg",
        "url": "https://cdn.example.com/stores/1/products/10/main.jpg",
        "alt_text": "Classic T-Shirt in Blue",
        "width": 1200,
        "height": 1600,
        "mime_type": "image/jpeg",
        "byte_size": 245000,
        "position": 1,
        "status": "ready"
      }
    ],
    "collections": [
      {
        "id": 5,
        "title": "Summer Collection",
        "handle": "summer-collection"
      }
    ]
  }


Output:
Chunk ID: 13577d
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1998
Output:

---

### 3.2 Products

#### Admin Products Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/products` | `read-products` | List products with filtering and pagination | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/products` | `write-products` | Create a new product | `api.admin` (60/min) |
| GET | `/api/admin/v1/stores/{storeId}/products/{productId}` | `read-products` | Get single product with all relations | `api.admin` (60/min) |
| PUT | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Update a product (partial updates supported) | `api.admin` (60/min) |
| DELETE | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Archive a product (soft delete) | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload` | `write-products` | Generate presigned URL for media upload | `api.admin` (60/min) |

#### GET /api/admin/v1/stores/{storeId}/products

List products with filtering and pagination.

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `status` | string | No | Filter by status: `draft`, `active`, `archived`. |
| `query` | string | No | Search by title, vendor, or SKU. |
| `collection_id` | integer | No | Filter by collection membership. |
| `page` | integer | No | Page number. Default: 1. |
| `per_page` | integer | No | Results per page. Default: 25, max: 100. |
| `sort` | string | No | One of: `title_asc`, `title_desc`, `created_at_asc`, `created_at_desc`, `updated_at_desc`. Default: `updated_at_desc`. |

**Success response:** `200 OK`

```json
{
  "data": [
    {
      "id": 10,
      "store_id": 1,
      "title": "Classic T-Shirt",
      "handle": "classic-t-shirt",
      "status": "active",
      "vendor": "Acme Apparel",
      "product_type": "Apparel",
      "tags": ["organic", "cotton"],
      "variants_count": 6,
      "total_inventory": 150,
      "published_at": "2026-02-10T08:00:00Z",
      "created_at": "2026-02-09T14:00:00Z",
      "updated_at": "2026-02-11T09:00:00Z",
      "featured_image": {
        "url": "https://cdn.example.com/products/tshirt.jpg",
        "alt_text": "Classic T-Shirt in Blue"
      }
    }
  ],
  "meta": {
    "current_page": 1,
    "per_page": 25,
    "total": 42,
    "last_page": 2
  }
}
```

---

#### POST /api/admin/v1/stores/{storeId}/products

Create a new product.

**Request body:**

```json
{
  "title": "Classic T-Shirt",
  "handle": "classic-t-shirt",
  "description_html": "<p>A comfortable cotton t-shirt.</p>",
  "vendor": "Acme Apparel",
  "product_type": "Apparel",
  "status": "draft",
  "tags": ["organic", "cotton"],
  "options": [
    { "name": "Color", "position": 1 },
    { "name": "Size", "position": 2 }
  ],
  "variants": [
    {
      "sku": "TSH-BLU-S",
      "barcode": "1234567890123",
      "price_amount": 2500,
      "compare_at_amount": 3500,
      "currency": "EUR",
      "weight_g": 200,
      "requires_shipping": true,
      "is_default": true,
      "position": 1,
      "status": "active",
      "option_values": [
        { "option_name": "Color", "value": "Blue" },
        { "option_name": "Size", "value": "Small" }
      ],
      "inventory": {
        "quantity_on_hand": 50,
        "policy": "deny"
      }
    }
  ],
  "collections": [5]
}
```

**Product fields:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `title` | string | Yes | Max: 255 |
| `handle` | string | No | Auto-generated from title if not provided. Unique per store. Lowercase, hyphens only. Max: 255. |
| `description_html` | string | No | Max: 65535 |
| `vendor` | string | No | Max: 255 |
| `product_type` | string | No | Max: 255 |
| `status` | string | No | One of: `draft`, `active`. Default: `draft`. |
| `tags` | array of strings | No | Max: 50 tags, each max: 255. |
| `options` | array | No | Max: 3 options. |
| `options[].name` | string | Yes | Max: 255 |
| `options[].position` | integer | Yes | Min: 1, max: 3 |
| `collections` | array of integers | No | Collection IDs to add this product to. |

**Variant fields:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `variants` | array | Yes | At least one variant required. Max: 100. |
| `variants[].sku` | string | Yes | Max: 255. Unique per store (`store_id`, `sku`). |
| `variants[].barcode` | string | No | Max: 255 |
| `variants[].price_amount` | integer | Yes | Min: 0 (in minor currency units) |
| `variants[].compare_at_amount` | integer | No | Min: 0. Must be > price_amount if set. |
| `variants[].currency` | string | No | Defaults to store currency. |
| `variants[].weight_g` | integer | No | Min: 0 |
| `variants[].requires_shipping` | boolean | No | Default: true |
| `variants[].is_default` | boolean | No | Exactly one variant must be default. |
| `variants[].position` | integer | No | Auto-assigned if not provided. |
| `variants[].status` | string | No | One of: `active`, `archived`. Default: `active`. |
| `variants[].option_values` | array | No | Must match defined options. |
| `variants[].inventory.quantity_on_hand` | integer | No | Default: 0. Min: 0. |
| `variants[].inventory.policy` | string | No | One of: `deny`, `continue`. Default: `deny`. |

**Success response:** `201 Created`

Returns the full product resource with nested variants, options, and media.

**Error responses:**
- `403` - Insufficient permissions
- `422` - Validation errors (duplicate SKU, duplicate handle, missing required fields)

---

#### GET /api/admin/v1/stores/{storeId}/products/{productId}

Get a single product with all variants, options, media, and collections.

**Success response:** `200 OK`

```json
{
  "data": {
    "id": 10,
    "store_id": 1,
    "title": "Classic T-Shirt",
    "handle": "classic-t-shirt",
    "description_html": "
A comfortable cotton t-shirt.
",
    "vendor": "Acme Apparel",
    "product_type": "Apparel",
    "status": "active",
    "tags": ["organic", "cotton"],
    "published_at": "2026-02-10T08:00:00Z",
    "created_at": "2026-02-09T14:00:00Z",
    "updated_at": "2026-02-11T09:00:00Z",
    "options": [
      {
        "id": 1,
        "name": "Color",
        "position": 1,
        "values": [
          { "id": 1, "value": "Blue", "position": 1 },
          { "id": 2, "value": "Red", "position": 2 }
        ]
      },
      {
        "id": 2,
        "name": "Size",
        "position": 2,
        "values": [
          { "id": 3, "value": "Small", "position": 1 },
          { "id": 4, "value": "Medium", "position": 2 },
          { "id": 5, "value": "Large", "position": 3 }
        ]
      }
    ],
    "variants": [
      {
        "id": 101,
        "sku": "TSH-BLU-S",
        "barcode": "1234567890123",
        "price_amount": 2500,
        "compare_at_amount": 3500,
        "currency": "EUR",
        "weight_g": 200,
        "requires_shipping": true,
        "is_default": true,
        "position": 1,
        "status": "active",
        "option_values": [
          { "option_name": "Color", "value": "Blue" },
          { "option_name": "Size", "value": "Small" }
        ],
        "inventory": {
          "quantity_on_hand": 50,
          "quantity_reserved": 2,
          "policy": "deny"
        }
      }
    ],
    "media": [
      {
        "id": 1,
        "type": "image",
        "storage_key": "stores/1/products/10/main.jpg",
        "url": "https://cdn.example.com/stores/1/products/10/main.jpg",
        "alt_text": "Classic T-Shirt in Blue",
        "width": 1200,
        "height": 1600,
        "mime_type": "image/jpeg",
        "byte_size": 245000,
        "position": 1,
        "status": "ready"
      }
    ],
    "collections": [
      {
        "id": 5,
        "title": "Summer Collection",
        "handle": "summer-collection"
      }
    ]
  }


sed -n '1,260p' app/Models/Product.php && sed -n '1,220p' app/Models/Customer.php && sed -n '1,220p' app/Models/ProductVariant.php

ProductVariant.php

<?php

namespace App\Models;

use App\Enums\ProductStatus;
use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Product extends Model
{
    /** @use HasFactory<\Database\Factories\ProductFactory> */
    use BelongsToStore, HasFactory;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'store_id',
        'title',
        'handle',
        'status',
        'description_html',
        'vendor',
        'product_type',
        'tags',
        'published_at',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'status' => 'draft',
        'tags' => '[]',
    ];

    /**
     * @return BelongsTo<Store, $this>
     */
    public function store(): BelongsTo
    {
        return $this->belongsTo(Store::class);
    }

    /**
     * @return HasMany<ProductOption, $this>
     */
    public function options(): HasMany
    {
        return $this->hasMany(ProductOption::class)->orderBy('position');
    }

    /**
     * @return HasMany<ProductVariant, $this>
     */
    public function variants(): HasMany
    {
        return $this->hasMany(ProductVariant::class)->orderBy('position');
    }

    /**
     * @return HasMany<ProductMedia, $this>
     */
    public function media(): HasMany
    {
        return $this->hasMany(ProductMedia::class)->orderBy('position');
    }

    /**
     * @return BelongsToMany<Collection, $this>
     */
    public function collections(): BelongsToMany
    {
        return $this->belongsToMany(Collection::class, 'collection_products')
            ->withPivot('position')
            ->orderByPivot('position');
    }

    public function isPublished(): bool
    {
        return $this->status === ProductStatus::Active && $this->published_at !== null;
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'status' => ProductStatus::class,
            'tags' => 'array',
            'published_at' => 'datetime',
        ];
    }
}
<?php

namespace App\Models;

use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\Hash;

class Customer extends Authenticatable
{
    /** @use HasFactory<\Database\Factories\CustomerFactory> */
    use BelongsToStore, HasFactory, Notifiable;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'store_id',
        'email',
        'password',
        'name',
        'marketing_opt_in',
    ];

    /**
     * @var list<string>
     */
    protected $hidden = [
        'password_hash',
        'remember_token',
    ];

    public function getAuthPassword(): ?string
    {
        return $this->password_hash;
    }

    public function setPasswordAttribute(?string $value): void
    {
        $this->attributes['password_hash'] = $value && Hash::needsRehash($value) ? Hash::make($value) : $value;
    }

    public function getPasswordAttribute(): ?string
    {
        return $this->password_hash;
    }

    /**
     * @return HasMany<Cart, $this>
     */
    public function carts(): HasMany
    {
        return $this->hasMany(Cart::class);
    }

    /**
     * @return HasMany<Checkout, $this>
     */
    public function checkouts(): HasMany
    {
        return $this->hasMany(Checkout::class);
    }

    /**
     * @return HasMany<CustomerAddress, $this>
     */
    public function addresses(): HasMany
    {
        return $this->hasMany(CustomerAddress::class);
    }

    /**
     * @return HasMany<Order, $this>
     */
    public function orders(): HasMany
    {
        return $this->hasMany(Order::class);
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'marketing_opt_in' => 'bool',
        ];
    }
}
<?php

namespace App\Models;

use App\Enums\InventoryPolicy;
use App\Enums\VariantStatus;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use RuntimeException;

class ProductVariant extends Model
{
    /** @use HasFactory<\Database\Factories\ProductVariantFactory> */
    use HasFactory;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'product_id',
        'sku',
        'barcode',
        'price_amount',
        'compare_at_amount',
        'currency',
        'weight_g',
        'requires_shipping',
        'is_default',
        'position',
        'status',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'price_amount' => 0,
        'currency' => 'USD',
        'requires_shipping' => true,
        'is_default' => false,
        'position' => 0,
        'status' => 'active',
    ];

    protected static function booted(): void
    {
        static::addGlobalScope('current_store', function (Builder $builder): void {
            if (! app()->bound('current_store')) {
                return;
            }

            $store = app('current_store');

            if (! $store instanceof Store) {
                return;
            }

            $builder->whereHas('product', function (Builder $query) use ($store): void {
                $query
                    ->withoutGlobalScopes()
                    ->where('store_id', $store->getKey());
            });
        });

        static::saving(function (ProductVariant $variant): void {
            $variant->assertSkuIsUniqueForStore();
        });

        static::created(function (ProductVariant $variant): void {
            if ($variant->inventoryItem()->exists()) {
                return;
            }

            $storeId = Product::withoutGlobalScopes()
                ->whereKey($variant->product_id)
                ->value('store_id');

            if (! $storeId) {
                return;
            }

            InventoryItem::withoutGlobalScopes()->create([
                'store_id' => $storeId,
                'variant_id' => $variant->getKey(),
                'quantity_on_hand' => 0,
                'quantity_reserved' => 0,
                'policy' => InventoryPolicy::Deny,
            ]);
        });
    }

    private function assertSkuIsUniqueForStore(): void
    {
        $sku = trim((string) $this->sku);

        if ($sku === '') {
            return;
        }

        $storeId = Product::withoutGlobalScopes()
            ->whereKey($this->product_id)
            ->value('store_id');

        if ($storeId === null) {
            return;
        }

        $query = self::withoutGlobalScopes()
            ->where('sku', $sku)
            ->whereHas('product', function (Builder $query) use ($storeId): void {
                $query
                    ->withoutGlobalScopes()
                    ->where('store_id', $storeId);
            });

        if ($this->exists) {
            $query->whereKeyNot($this->getKey());
        }

        if ($query->exists()) {
            throw new RuntimeException("The SKU [{$sku}] is already used in this store.");
        }
    }

    /**
     * @return BelongsTo<Product, $this>
     */
    public function product(): BelongsTo
    {
        return $this->belongsTo(Product::class);
    }

    /**
     * @return HasOne<InventoryItem, $this>
     */
    public function inventoryItem(): HasOne
    {
        return $this->hasOne(InventoryItem::class, 'variant_id');
    }

    /**
     * @return BelongsToMany<ProductOptionValue, $this>
     */
    public function optionValues(): BelongsToMany
    {
        return $this->belongsToMany(ProductOptionValue::class, 'variant_option_values', 'variant_id', 'product_option_value_id');
    }

    public function isPurchasable(): bool
    {
        return $this->status === VariantStatus::Active;
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'price_amount' => 'integer',
            'compare_at_amount' => 'integer',
            'weight_g' => 'integer',
            'requires_shipping' => 'bool',
            'is_default' => 'bool',
            'position' => 'integer',
            'status' => VariantStatus::class,
        ];
    }
}


rg -n "Customer|customers|GET /api/admin/v1/stores/\{storeId\}/customers|read-customers" specs/02-API-ROUTES.md

rg -n "Customer|customers|GET /api/admin/v1/stores/\\{storeId\\}/customers|read-customers" specs/02-API-ROUTES.md

Question: Customer|customers|GET /api/admin/v1/stores/\{storeId\}/customers|read-customers

73:#### Customers
77:| GET | `/admin/customers` | Customer list | `Admin\Customers\Index` |
78:| GET | `/admin/customers/{customer}` | Customer detail with order history, addresses | `Admin\Customers\Show` |
162:#### Customer Account Routes
168:| GET | `/account` | Customer account overview | `Storefront\Account\Dashboard` |
173:#### Customer Auth Routes (no auth required)
177:| GET | `/account/login` | Customer login page | `Storefront\Account\Auth\Login` |
178:| GET | `/account/register` | Customer registration page | `Storefront\Account\Auth\Register` |
421:    participant C as Customer Browser
1381:      "read-customers"
2086:  "reason": "Customer requested return for 1 item",
2116:    "reason": "Customer requested return for 1 item",
2230:| `rules_json.customer_eligibility` | string | No | One of: `all`, `specific_customers`, `specific_segments`. Default: `all`. |
2985:| GET | `/api/apps/v1/stores/{storeId}/customers` | `read-customers` | List customers |


Output:
Chunk ID: 7c27d8
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 244
Output:
73:#### Customers
77:| GET | `/admin/customers` | Customer list | `Admin\Customers\Index` |
78:| GET | `/admin/customers/{customer}` | Customer detail with order history, addresses | `Admin\Customers\Show` |
162:#### Customer Account Routes
168:| GET | `/account` | Customer account overview | `Storefront\Account\Dashboard` |
173:#### Customer Auth Routes (no auth required)
177:| GET | `/account/login` | Customer login page | `Storefront\Account\Auth\Login` |
178:| GET | `/account/register` | Customer registration page | `Storefront\Account\Auth\Register` |
421:    participant C as Customer Browser
1381:      "read-customers"
2086:  "reason": "Customer requested return for 1 item",
2116:    "reason": "Customer requested return for 1 item",
2230:| `rules_json.customer_eligibility` | string | No | One of: `all`, `specific_customers`, `specific_segments`. Default: `all`. |
2985:| GET | `/api/apps/v1/stores/{storeId}/customers` | `read-customers` | List customers |


Output:
Chunk ID: 02c13b
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 2198
Output:
<?php

namespace App\Models;

use App\Enums\ProductStatus;
use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Product extends Model
{
    /** @use HasFactory<\Database\Factories\ProductFactory> */
    use BelongsToStore, HasFactory;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'store_id',
        'title',
        'handle',
        'status',
        'description_html',
        'vendor',
        'product_type',
        'tags',
        'published_at',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'status' => 'draft',
        'tags' => '[]',
    ];

    /**
     * @return BelongsTo<Store, $this>
     */
    public function store(): BelongsTo
    {
        return $this->belongsTo(Store::class);
    }

    /**
     * @return HasMany<ProductOption, $this>
     */
    public function options(): HasMany
    {
        return $this->hasMany(ProductOption::class)->orderBy('position');
    }

    /**
     * @return HasMany<ProductVariant, $this>
     */
    public function variants(): HasMany
    {
        return $this->hasMany(ProductVariant::class)->orderBy('position');
    }

    /**
     * @return HasMany<ProductMedia, $this>
     */
    public function media(): HasMany
    {
        return $this->hasMany(ProductMedia::class)->orderBy('position');
    }

    /**
     * @return BelongsToMany<Collection, $this>
     */
    public function collections(): BelongsToMany
    {
        return $this->belongsToMany(Collection::class, 'collection_products')
            ->withPivot('position')
            ->orderByPivot('position');
    }

    public function isPublished(): bool
    {
        return $this->status === ProductStatus::Active && $this->published_at !== null;
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'status' => ProductStatus::class,
            'tags' => 'array',
            'published_at' => 'datetime',
        ];
    }
}
<?php

namespace App\Models;

use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\Hash;

class Customer extends Authenticatable
{
    /** @use HasFactory<\Database\Factories\CustomerFactory> */
    use BelongsToStore, HasFactory, Notifiable;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'store_id',
        'email',
        'password',
        'name',
        'marketing_opt_in',
    ];

    /**
     * @var list<string>
     */
    protected $hidden = [
        'password_hash',
        'remember_token',
    ];

    public function getAuthPassword(): ?string
    {
        return $this->password_hash;
    }

    public function setPasswordAttribute(?string $value): void
    {
        $this->attributes['password_hash'] = $value && Hash::needsRehash($value) ? Hash::make($value) : $value;
    }

    public function getPasswordAttribute(): ?string
    {
        return $this->password_hash;
    }

    /**
     * @return HasMany<Cart, $this>
     */
    public function carts(): HasMany
    {
        return $this->hasMany(Cart::class);
    }

    /**
     * @return HasMany<Checkout, $this>
     */
    public function checkouts(): HasMany
    {
        return $this->hasMany(Checkout::class);
    }

    /**
     * @return HasMany<CustomerAddress, $this>
     */
    public function addresses(): HasMany
    {
        return $this->hasMany(CustomerAddress::class);
    }

    /**
     * @return HasMany<Order, $this>
     */
    public function orders(): HasMany
    {
        return $this->hasMany(Order::class);
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'marketing_opt_in' => 'bool',
        ];
    }
}
<?php

namespace App\Models;

use App\Enums\InventoryPolicy;
use App\Enums\VariantStatus;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use RuntimeException;

class ProductVariant extends Model
{
    /** @use HasFactory<\Database\Factories\ProductVariantFactory> */
    use HasFactory;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'product_id',
        'sku',
        'barcode',
        'price_amount',
        'compare_at_amount',
        'currency',
        'weight_g',
        'requires_shipping',
        'is_default',
        'position',
        'status',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'price_amount' => 0,
        'currency' => 'USD',
        'requires_shipping' => true,
        'is_default' => false,
        'position' => 0,
        'status' => 'active',
    ];

    protected static function booted(): void
    {
        static::addGlobalScope('current_store', function (Builder $builder): void {
            if (! app()->bound('current_store')) {
                return;
            }

            $store = app('current_store');

            if (! $store instanceof Store) {
                return;
            }

            $builder->whereHas('product', function (Builder $query) use ($store): void {
                $query
                    ->withoutGlobalScopes()
                    ->where('store_id', $store->getKey());
            });
        });

        static::saving(function (ProductVariant $variant): void {
            $variant->assertSkuIsUniqueForStore();
        });

        static::created(function (ProductVariant $variant): void {
            if ($variant->inventoryItem()->exists()) {
                return;
            }

            $storeId = Product::withoutGlobalScopes()
                ->whereKey($variant->product_id)
                ->value('store_id');

            if (! $storeId) {
                return;
            }

            InventoryItem::withoutGlobalScopes()->create([
                'store_id' => $storeId,
                'variant_id' => $variant->getKey(),
                'quantity_on_hand' => 0,
                'quantity_reserved' => 0,
                'policy' => InventoryPolicy::Deny,
            ]);
        });
    }

    private function assertSkuIsUniqueForStore(): void
    {
        $sku = trim((string) $this->sku);

        if ($sku === '') {
            return;
        }

        $storeId = Product::withoutGlobalScopes()
            ->whereKey($this->product_id)
            ->value('store_id');

        if ($storeId === null) {
            return;
        }

        $query = self::withoutGlobalScopes()
            ->where('sku', $sku)
            ->whereHas('product', function (Builder $query) use ($storeId): void {
                $query
                    ->withoutGlobalScopes()
                    ->where('store_id', $storeId);
            });

        if ($this->exists) {
            $query->whereKeyNot($this->getKey());
        }

        if ($query->exists()) {
            throw new RuntimeException("The SKU [{$sku}] is already used in this store.");
        }
    }

    /**
     * @return BelongsTo<Product, $this>
     */
    public function product(): BelongsTo
    {
        return $this->belongsTo(Product::class);
    }

    /**
     * @return HasOne<InventoryItem, $this>
     */
    public function inventoryItem(): HasOne
    {
        return $this->hasOne(InventoryItem::class, 'variant_id');
    }

    /**
     * @return BelongsToMany<ProductOptionValue, $this>
     */
    public function optionValues(): BelongsToMany
    {
        return $this->belongsToMany(ProductOptionValue::class, 'variant_option_values', 'variant_id', 'product_option_value_id');
    }

    public function isPurchasable(): bool
    {
        return $this->status === VariantStatus::Active;
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'price_amount' => 'integer',
            'compare_at_amount' => 'integer',
            'weight_g' => 'integer',
            'requires_shipping' => 'bool',
            'is_default' => 'bool',
            'position' => 'integer',
            'status' => VariantStatus::class,
        ];
    }
}


sed -n '1635,1725p' specs/02-API-ROUTES.md


sed -n '1,220p' app/Models/ProductOption.php && sed -n '1,180p' app/Models/ProductOptionValue.php && sed -n '1,180p' app/Models/InventoryItem.php && sed -n '1,180p' app/Models/ProductMedia.php


sed -n '1,280p' tests/Feature/Api/AdminOrderApiTest.php


sed -n '1635,1725p' specs/02-API-ROUTES.md

  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `404` - Product not found (or belongs to another store)

---

#### PUT /api/admin/v1/stores/{storeId}/products/{productId}

Update a product. Supports partial updates.

Same fields as POST. Only provided fields are updated. Variants can be added, updated (by ID), or removed.

**Example partial update request:**

```json
{
  "title": "Classic Cotton T-Shirt",
  "status": "active",
  "tags": ["organic", "cotton", "bestseller"]
}
```

**Success response:** `200 OK`

Returns the full updated product resource.

**Error responses:**
- `403` - Insufficient permissions
- `404` - Product not found
- `422` - Validation errors

---

#### DELETE /api/admin/v1/stores/{storeId}/products/{productId}

Archive a product (soft delete). Products with existing orders cannot be hard-deleted.

**Success response:** `200 OK`

```json
{
  "data": {
    "id": 10,
    "status": "archived",
    "updated_at": "2026-02-11T11:00:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `404` - Product not found

---

#### POST /api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload

Generate a presigned URL for uploading product media directly to object storage (S3).

**Request body:**

```json
{
  "filename": "tshirt-front.jpg",
  "content_type": "image/jpeg",
  "byte_size": 245000
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `filename` | string | Yes | Max: 255. Must have a valid extension. |
| `content_type` | string | Yes | One of: `image/jpeg`, `image/png`, `image/webp`, `image/avif`, `video/mp4`. |
| `byte_size` | integer | Yes | Min: 1, max: configurable (default 50MB for images, 500MB for video). |

**Success response:** `201 Created`

```json
{
  "upload_url": "https://s3.example.com/...presigned...",
  "method": "PUT",
  "headers": {
    "Content-Type": "image/jpeg"
  },
  "storage_key": "stores/1/products/10/media/uuid.jpg",
  "media_id": 2,
  "expires_at": "2026-02-11T10:10:00Z"


Output:
Chunk ID: af9f74
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 511
Output:
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `404` - Product not found (or belongs to another store)

---

#### PUT /api/admin/v1/stores/{storeId}/products/{productId}

Update a product. Supports partial updates.

Same fields as POST. Only provided fields are updated. Variants can be added, updated (by ID), or removed.

**Example partial update request:**

```json
{
  "title": "Classic Cotton T-Shirt",
  "status": "active",
  "tags": ["organic", "cotton", "bestseller"]
}
```

**Success response:** `200 OK`

Returns the full updated product resource.

**Error responses:**
- `403` - Insufficient permissions
- `404` - Product not found
- `422` - Validation errors

---

#### DELETE /api/admin/v1/stores/{storeId}/products/{productId}

Archive a product (soft delete). Products with existing orders cannot be hard-deleted.

**Success response:** `200 OK`

```json
{
  "data": {
    "id": 10,
    "status": "archived",
    "updated_at": "2026-02-11T11:00:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `404` - Product not found

---

#### POST /api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload

Generate a presigned URL for uploading product media directly to object storage (S3).

**Request body:**

```json
{
  "filename": "tshirt-front.jpg",
  "content_type": "image/jpeg",
  "byte_size": 245000
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `filename` | string | Yes | Max: 255. Must have a valid extension. |
| `content_type` | string | Yes | One of: `image/jpeg`, `image/png`, `image/webp`, `image/avif`, `video/mp4`. |
| `byte_size` | integer | Yes | Min: 1, max: configurable (default 50MB for images, 500MB for video). |

**Success response:** `201 Created`

```json
{
  "upload_url": "https://s3.example.com/...presigned...",
  "method": "PUT",
  "headers": {
    "Content-Type": "image/jpeg"
  },
  "storage_key": "stores/1/products/10/media/uuid.jpg",
  "media_id": 2,
  "expires_at": "2026-02-11T10:10:00Z"