server: laravel-boost

tool: search-docs

type: text

text:
# Search Results

## Helpers Other Utilities URI Manipulating Query Strings
Helpers | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/helpers.md

#### Manipulating Query Strings

The `Uri` class offers several methods that may be used to manipulate a URI's query string. The `withQuery` method may be used to merge additional query string parameters into the existing query string:

```php
$uri = $uri->withQuery(['sort' => 'name']);
```

The `withQueryIfMissing` method may be used to merge additional query string parameters into the existing query string if the given keys do not already exist in the query string:

```php
$uri = $uri->withQueryIfMissing(['page' => 1]);
```

The `replaceQuery` method may be used to complete replace the existing query string with a new one:

```php
$uri = $uri->replaceQuery(['page' => 1]);
```

The `pushOntoQuery` method may be used to push additional parameters onto a query string parameter that has an array value:

```php
$uri = $uri->pushOntoQuery('filter', ['active', 'pending']);
```

The `withoutQuery` method may be used to remove parameters from the query string:

```php
$uri = $uri->withoutQuery(['page']);
```

---

## Pest3 Now Available New Configuration API
Pest3 Now Available | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/pest3-now-available.md

## New Configuration API

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

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

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

```diff
-uses(TestCase::class)->in(__DIR__);
+pest()->extends(TestCase::class);

-uses(TestCase::class, RefreshDatabase::class)->in('Features');
+pest()->extends(TestCase::class)->use(RefreshDatabase::class)->in('Features');

-uses()->compact();
+pest()->printer()->compact();
```

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

---

## Vite Working With Blade and Routes Aliases
Vite | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/vite.md

### Aliases

It 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):

```php
/**
 * Bootstrap any application services.
 */
public function boot(): void
{
    Vite::macro('image', fn (string $asset) => $this->asset("resources/images/{$asset}"));
}
```

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

```blade
<img src="{{ Vite::image('logo.png') }}" alt="Laravel Logo">
```

---

## Cli Api Reference
Cli Api Reference | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/cli-api-reference.md

---
title: CLI API Reference
description: 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.
---

# CLI API Reference

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.

## Configuration

- `--init`: Initialize a standard Pest configuration.
- `--bootstrap <file>` A PHP script that is included before the tests run.
- `-c|--configuration <file>`: Read configuration from XML file.
- `--no-configuration`: Ignore default configuration file (phpunit.xml).
- `--extension <class>`: Register test runner extension with bootstrap <class>.
- `--no-extensions`: Do not load PHPUnit extensions.
- `--include-path <path(s)>`: Prepend PHP's include_path with given path(s).
- `-d <key[=value]>`: Set a php.ini value.
- `--cache-directory <dir>`: Specify cache directory.
- `--generate-configuration`: Generate configuration file with suggested settings.
- `--migrate-configuration`: Migrate configuration file to current format.
- `--generate-baseline <file>`: Generate baseline for issues.
- `--use-baseline <file>`: Use baseline to ignore issues.
- `--ignore-baseline`: Do not use baseline to ignore issues.
- `--test-directory`: Specify test directory containing Pest.php, TestCase.php, helpers and your tests. Default: tests

## Selection

- `--bail`: Stop execution upon first not-passed test.
- `--ci`: Ignore focused tests using `->only()` and run the entire test suite.
- `--todos`: Output to standard output the list of todos.
- `--notes`: Output to standard output tests with notes.
- `--issue`: Output to standard output tests with the given issue number.
- `--pr`: Output to standard output tests with the given pull request number.
- `--pull-request`: Output to standard output tests with the given pull request number (alias for `--pr`).
- `--flaky`: Output to standard output tests marked as flaky.
- `--retry`: Run non-passing tests first and stop execution upon first error or failure.
- `--list-suites` List available test suites.
- `--testsuite <name>`: Only run tests from the specified test suite(s).
- `--exclude-testsuite <name>`: Exclude tests from the specified test suite(s).
- `--list-groups`: List available test groups.
- `--group <name>`: Only run tests from the specified group(s).
- `--exclude-group <name>`: Exclude tests from the specified group(s).
- `--covers <name>`: Only run tests that intend to cover `<name>`.
- `--uses <name>`: Only run tests that intend to use `<name>`.
- `--requires-php-extension <name>`: Only run tests that require PHP extension <name>.
- `--list-test-files`: List available test files.
- `--list-tests`: List available tests.
- `--list-tests-xml <file>`: List available tests in XML format.
- `--filter <pattern>`: Filter which tests to run
- `--exclude-filter <pattern>`: Exclude tests for the specified filter pattern.
- `--test-suffix <suffixes>`: Only search for test in files with specified suffix(es). Default: Test.php,.phpt

## Execution

- `--parallel` Run tests in parallel.
- `--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.
- `--update-shards`: Update `tests/.pest/shards.json` with test timing data for time-balanced sharding. Can be combined with `--parallel`.
- `--update-snapshots`: Update snapshots for tests using the "toMatchSnapshot" expectation.
- `--globals-backup`: Backup and restore $GLOBALS for each test.
- `--static-backup`: Backup and restore static properties for each test.
- `--strict-coverage`: Be strict about code coverage metadata.
- `--strict-global-state`: Be strict about changes to global state.
- `--disallow-test-output`: Be strict about output during tests.
- `--enforce-time-limit`: Enforce time limit based on test size.
- `--default-time-limit <sec>`: Timeout in seconds for tests that have no declared size.
- `--dont-report-useless-tests`: Do not report tests that do not test anything.
- `--stop-on-defect`: Stop execution upon first not-passed test.
- `--stop-on-error`: Stop execution upon first error.
- `--stop-on-failure`: Stop execution upon first error or failure.
- `--stop-on-warning`: Stop execution upon first warning.
- `--stop-on-risky`: Stop execution upon first risky test.
- `--stop-on-deprecation`: Stop after first test that triggered a deprecation.
- `--stop-on-notice`: Stop after first test that triggered a notice.
- `--stop-on-skipped`: Stop execution upon first skipped test.
- `--stop-on-incomplete`: Stop execution upon first incomplete test.
- `--fail-on-empty-test-suite`: Signal failure using shell exit code when no tests were run.
- `--fail-on-warning`: Treat tests with warnings as failures.
- `--fail-on-risky`: Treat risky tests as failures.
- `--fail-on-deprecation`: Signal failure using shell exit code when a deprecation was triggered.
- `--fail-on-phpunit-deprecation`: Signal failure using shell exit code when a PHPUnit deprecation was triggered.
- `--fail-on-notice`: Signal failure using shell exit code when a notice was triggered.
- `--fail-on-skipped`: Treat skipped tests as failures.
- `--fail-on-incomplete`: Signal failure using shell exit code when a test was marked incomplete.
- `--cache-result`: Write test results to cache file.
- `--do-not-cache-result`: Do not write test results to cache file
- `--order-by <order>`: Run tests in order: default|defects|depends|duration|no-depends|random|reverse|size.
- `--random-order-seed <N>`: Use the specified random seed when running tests in random order

## Reporting

- `--colors <flag>`: Use colors in output ("never", "auto" or "always").
- `--columns <n>`: Number of columns to use for progress output.
- `--columns max`: Use maximum number of columns for progress output.
- `--stderr`: Write to STDERR instead of STDOUT.
- `--no-progress`: Disable output of test execution progress.
- `--no-results`: Disable output of test results.
- `--no-output`: Disable all output.
- `--display-incomplete`: Display details for incomplete tests.
- `--display-skipped`: Display details for skipped tests.
- `--display-deprecations`: Display details for deprecations triggered by tests.
- `--display-phpunit-deprecations`: Display details for PHPUnit deprecations.
- `--display-errors`: Display details for errors triggered by tests.
- `--display-notices`: Display details for notices triggered by tests.
- `--display-warnings`: Display details for warnings triggered by tests.
- `--reverse-list`: Print defects in reverse order.
- `--teamcity`: Replace default progress and result output with TeamCity format.
- `--testdox`: Replace default result output with TestDox format
- `--testdox-summary`: Repeat TestDox output for tests with errors, failures, or issues.
- `--debug`: Replace default progress and result output with debugging information.
- `--compact`:  Replace default result output with Compact format

## Logging

- `--log-junit <file>`: Write test results in JUnit XML format to file.
- `--log-teamcity <file>`: Write test results in TeamCity format to file.
- `--testdox-html <file>`: Write test results in TestDox format (HTML) to file.
- `--testdox-text <file>`: Write test results in TestDox format (plain text) to file.
- `--log-events-text <file>`: Stream events as plain text to file.
- `--log-events-verbose-text <file>`: Stream events as plain text (with telemetry information) to file.
- `--no-logging`: Ignore logging configured in the XML configuration file

## Code Coverage

- `--coverage`: Generate code coverage report and output to standard output.
- `--coverage --min=<value>`: Set the minimum required coverage percentage, and fail if not met.
- `--coverage --exactly=<value>`: Set the exact required coverage percentage, and fail if not met.
- `--coverage --only-covered`: Hide files with 0% coverage from the code coverage report.
- `--coverage-clover <file>`: Write code coverage report in Clover XML format to file.
- `--coverage-cobertura <file>`: Write code coverage report in Cobertura XML format to file.
- `--coverage-crap4j <file>`: Write code coverage report in Crap4J XML format to file.
- `--coverage-html <dir>`: Write code coverage report in HTML format to directory.
- `--coverage-php <file>`: Write serialized code coverage data to file.
- `--coverage-text=<file>`: Write code coverage report in text format to file [default: standard output].
- `--only-summary-for-coverage-text`: Option for code coverage report in text format: only show summary.
- `--show-uncovered-for-coverage-text`: Option for code coverage report in text format: show uncovered files.
- `--coverage-xml <dir>`: Write code coverage report in XML format to directory.
- `--warm-coverage-cache`: Warm static analysis cache.
- `--coverage-filter <dir>`: Include `<dir>`: in code coverage reporting.
- `--path-coverage`: Report path coverage in addition to line coverage.
- `--disable-coverage-ignore`: Disable metadata for ignoring code coverage.
- `--no-coverage`: Ignore code coverage reporting configured in the XML configuration file

## Mutation Testing

- `--mutate`: Runs mutation testing, to understand the quality of your tests.
- `--mutate --parallel`: Runs mutation testing in parallel.
- `--mutate --min`: Set the minimum required mutation score, and fail if not met.
- `--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.
- `--mutate --covered-only`: Only generate mutations for classes that are covered by tests.
- `--mutate --bail`: Stop mutation testing execution upon first untested or uncovered mutation.
- `--mutate --class`: Generate mutations for the given class(es). E.g. --class=App\\Models.
- `--mutate --ignore`: Ignore the given class(es) when generating mutations. E.g. --ignore=App\\Http\\Requests.
- `--mutate --clear-cache`: Clear the mutation cache.
- `--mutate --no-cache`: Clear the mutation cache.
- `--mutate --ignore-min-score-on-zero-mutations`: Ignore the minimum score requirement when there are no mutations.
- `--mutate --covered-only`: Only generate mutations for classes that are covered by tests.
- `--mutate --everything`: Generate mutations for all classes, even if they are not covered by tests.
- `--mutate --profile`: Output to standard output the top ten slowest mutations.
- `--mutate --retry`: Run untested or uncovered mutations first and stop execution upon first error or failure.
- `--mutate --stop-on-uncovered`: Stop mutation testing execution upon first untested mutation.
- `--mutate --stop-on-untested`: Stop mutation testing execution upon first untested mutation.

## Profiling

- `--profile`: Output to standard output the top ten slowest tests

---

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

---

## Vite Working With Blade and Routes Refreshing on Save
Vite | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/vite.md

### Refreshing on Save

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

```js
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';

export default defineConfig({
    plugins: [
        laravel({
            // ...
            refresh: true,
        }),
    ],
});
```

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

- `app/Livewire/**`
- `app/View/Components/**`
- `lang/**`
- `resources/lang/**`
- `resources/views/**`
- `routes/**`

Watching the `routes/**` directory is useful if you are utilizing [Ziggy](https://github.com/tighten/ziggy) to generate route links within your application's frontend.

If these default paths do not suit your needs, you can specify your own list of paths to watch:

```js
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';

export default defineConfig({
    plugins: [
        laravel({
            // ...
            refresh: ['resources/views/**'],
        }),
    ],
});
```

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

```js
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';

export default defineConfig({
    plugins: [
        laravel({
            // ...
            refresh: [{
                paths: ['path/to/watch/**'],
                config: { delay: 300 }
            }],
        }),
    ],
});
```

---

## Controllers Resource Controllers Partial Resource Routes API Resource Routes
Controllers | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/controllers.md

#### API Resource Routes

When declaring resource routes that will be consumed by APIs, you will commonly want to exclude routes that present HTML templates such as `create` and `edit`. For convenience, you may use the `apiResource` method to automatically exclude these two routes:

```php
use App\Http\Controllers\PhotoController;

Route::apiResource('photos', PhotoController::class);
```

You may register many API resource controllers at once by passing an array to the `apiResources` method:

```php
use App\Http\Controllers\PhotoController;
use App\Http\Controllers\PostController;

Route::apiResources([
    'photos' => PhotoController::class,
    'posts' => PostController::class,
]);
```

To quickly generate an API resource controller that does not include the `create` or `edit` methods, use the `--api` switch when executing the `make:controller` command:

```shell
php artisan make:controller PhotoController --api
```

---

## Routing Route Groups Middleware
Routing | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/routing.md

### Middleware

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

```php
Route::middleware(['first', 'second'])->group(function () {
    Route::get('/', function () {
        // Uses first & second middleware...
    });

    Route::get('/user/profile', function () {
        // Uses first & second middleware...
    });
});
```

---

## Sanctum Mobile Application Authentication Issuing API Tokens
Sanctum | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/sanctum.md

### Issuing API Tokens

To 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".

Typically, 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:

```php
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;

Route::post('/sanctum/token', function (Request $request) {
    $request->validate([
        'email' => 'required|email',
        'password' => 'required',
        'device_name' => 'required',
    ]);

    $user = User::where('email', $request->email)->first();

    if (! $user || ! Hash::check($request->password, $user->password)) {
        throw ValidationException::withMessages([
            'email' => ['The provided credentials are incorrect.'],
        ]);
    }

    return $user->createToken($request->device_name)->plainTextToken;
});
```

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

> [!NOTE]
> When issuing tokens for a mobile application, you are also free to specify [token abilities](#token-abilities).

---

## Vite Working With Blade and Routes Processing Static Assets With Vite
Vite | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/vite.md

### Processing Static Assets With Vite

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

However, 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:

```js
import.meta.glob([
  '../images/**',
  '../fonts/**',
]);
```

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

```blade
<img src="{{ Vite::asset('resources/images/logo.png') }}">
```

---

## Vite Working With Blade and Routes
Vite | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/vite.md

## Working With Blade and Routes

---

## Sanctum API Token Authentication Token Expiration
Sanctum | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/sanctum.md

### Token Expiration

By default, Sanctum tokens never expire and may only be invalidated by [revoking the token](#revoking-tokens). However, if you would like to configure an expiration time for your application's API tokens, you may do so via the `expiration` configuration option defined in your application's `sanctum` configuration file. This configuration option defines the number of minutes until an issued token will be considered expired:

```php
'expiration' => 525600,
```

If you would like to specify the expiration time of each token independently, you may do so by providing the expiration time as the third argument to the `createToken` method:

```php
return $user->createToken(
    'token-name', ['*'], now()->plus(weeks: 1)
)->plainTextToken;
```

If you have configured a token expiration time for your application, you may also wish to [schedule a task](/docs/{{version}}/scheduling) to prune your application's expired tokens. Thankfully, Sanctum includes a `sanctum:prune-expired` Artisan command that you may use to accomplish this. For example, you may configure a scheduled task to delete all expired token database records that have been expired for at least 24 hours:

```php
use Illuminate\Support\Facades\Schedule;

Schedule::command('sanctum:prune-expired --hours=24')->daily();
```

---

## Controllers Resource Controllers Singleton Resource Controllers API Singleton Resources
Controllers | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/controllers.md

#### API Singleton Resources

The `apiSingleton` method may be used to register a singleton resource that will be manipulated via an API, thus rendering the `create` and `edit` routes unnecessary:

```php
Route::apiSingleton('profile', ProfileController::class);
```

Of course, API singleton resources may also be `creatable`, which will register `store` and `destroy` routes for the resource:

```php
Route::apiSingleton('photos.thumbnail', ProfileController::class)->creatable();
```

---

## Sanctum API Token Authentication Protecting Routes
Sanctum | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/sanctum.md

### Protecting Routes

To protect routes so that all incoming requests must be authenticated, you should attach the `sanctum` authentication guard to your protected routes within your `routes/web.php` and `routes/api.php` route files. This guard will ensure that incoming requests are authenticated as either stateful, cookie authenticated requests or contain a valid API token header if the request is from a third party.

You may be wondering why we suggest that you authenticate the routes within your application's `routes/web.php` file using the `sanctum` guard. Remember, Sanctum will first attempt to authenticate incoming requests using Laravel's typical session authentication cookie. If that cookie is not present then Sanctum will attempt to authenticate the request using a token in the request's `Authorization` header. In addition, authenticating all requests using Sanctum ensures that we may always call the `tokenCan` method on the currently authenticated user instance:

```php
use Illuminate\Http\Request;

Route::get('/user', function (Request $request) {
    return $request->user();
})->middleware('auth:sanctum');
```

---

## Routing Route Groups
Routing | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/routing.md

## Route Groups

Route groups allow you to share route attributes, such as middleware, across a large number of routes without needing to define those attributes on each individual route.

Nested groups attempt to intelligently "merge" attributes with their parent group. Middleware and `where` conditions are merged while names and prefixes are appended. Namespace delimiters and slashes in URI prefixes are automatically added where appropriate.

---

## Verification Routing Protecting Routes
Verification | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/verification.md

### Protecting Routes

[Route middleware](/docs/{{version}}/middleware) may be used to only allow verified users to access a given route. Laravel includes a `verified` [middleware alias](/docs/{{version}}/middleware#middleware-aliases), which is an alias for the `Illuminate\Auth\Middleware\EnsureEmailIsVerified` middleware class. Since this alias is already automatically registered by Laravel, all you need to do is attach the `verified` middleware to a route definition. Typically, this middleware is paired with the `auth` middleware:

```php
Route::get('/profile', function () {
    // Only verified users may access this route...
})->middleware(['auth', 'verified']);
```

If an unverified user attempts to access a route that has been assigned this middleware, they will automatically be redirected to the `verification.notice` [named route](/docs/{{version}}/routing#named-routes).

---

## Sanctum API Token Authentication Revoking Tokens
Sanctum | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/sanctum.md

### Revoking Tokens

You may "revoke" tokens by deleting them from your database using the `tokens` relationship that is provided by the `Laravel\Sanctum\HasApiTokens` trait:

```php
// Revoke all tokens...
$user->tokens()->delete();

// Revoke the token that was used to authenticate the current request...
$request->user()->currentAccessToken()->delete();

// Revoke a specific token...
$user->tokens()->where('id', $tokenId)->delete();
```

---

## Http Tests Testing JSON APIs Fluent JSON Testing Scoping JSON Collection Assertions
Http Tests | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/http-tests.md

#### Scoping JSON Collection Assertions

Sometimes, your application's routes will return JSON collections that are assigned named keys:

```php
Route::get('/users', function () {
    return [
        'meta' => [...],
        'users' => User::all(),
    ];
})
```

When testing these routes, you may use the `has` method to assert against the number of items in the collection. In addition, you may use the `has` method to scope a chain of assertions:

```php
$response
    ->assertJson(fn (AssertableJson $json) =>
        $json->has('meta')
            ->has('users', 3)
            ->has('users.0', fn (AssertableJson $json) =>
                $json->where('id', 1)
                    ->where('name', 'Victoria Faith')
                    ->where('email', fn (string $email) => str($email)->is('victoria@gmail.com'))
                    ->missing('password')
                    ->etc()
            )
    );
```

However, instead of making two separate calls to the `has` method to assert against the `users` collection, you may make a single call which provides a closure as its third parameter. When doing so, the closure will automatically be invoked and scoped to the first item in the collection:

```php
$response
    ->assertJson(fn (AssertableJson $json) =>
        $json->has('meta')
            ->has('users', 3, fn (AssertableJson $json) =>
                $json->where('id', 1)
                    ->where('name', 'Victoria Faith')
                    ->where('email', fn (string $email) => str($email)->is('victoria@gmail.com'))
                    ->missing('password')
                    ->etc()
            )
    );
```

---

## Verification Routing Resending the Verification Email
Verification | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/verification.md

### Resending the Verification Email

Sometimes a user may misplace or accidentally delete the email address verification email. To accommodate this, you may wish to define a route to allow the user to request that the verification email be resent. You may then make a request to this route by placing a simple form submission button within your [verification notice view](#the-email-verification-notice):

```php
use Illuminate\Http\Request;

Route::post('/email/verification-notification', function (Request $request) {
    $request->user()->sendEmailVerificationNotification();

    return back()->with('message', 'Verification link sent!');
})->middleware(['auth', 'throttle:6,1'])->name('verification.send');
```

---

## Sanctum API Token Authentication Token Abilities First-Party UI Initiated Requests
Sanctum | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/sanctum.md

#### First-Party UI Initiated Requests

For convenience, the `tokenCan` method will always return `true` if the incoming authenticated request was from your first-party SPA and you are using Sanctum's built-in [SPA authentication](#spa-authentication).

However, this does not necessarily mean that your application has to allow the user to perform the action. Typically, your application's [authorization policies](/docs/{{version}}/authorization#creating-policies) will determine if the token has been granted the permission to perform the abilities as well as check that the user instance itself should be allowed to perform the action.

For example, if we imagine an application that manages servers, this might mean checking that the token is authorized to update servers **and** that the server belongs to the user:

```php
return $request->user()->id === $server->user_id &&
       $request->user()->tokenCan('server:update')
```

At first, allowing the `tokenCan` method to be called and always return `true` for first-party UI initiated requests may seem strange; however, it is convenient to be able to always assume an API token is available and can be inspected via the `tokenCan` method. By taking this approach, you may always call the `tokenCan` method within your application's authorization policies without worrying about whether the request was triggered from your application's UI or was initiated by one of your API's third-party consumers.

_[Truncated]_


output:
Wall time: 3.3585 seconds
Output:
[{"type":"text","text":"# Search Results\n\n## Helpers Other Utilities URI Manipulating Query Strings\nHelpers | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/helpers.md\n\n\n#### Manipulating Query Strings\n\nThe `Uri` class offers several methods that may be used to manipulate a URI's query string. The `withQuery` method may be used to merge additional query string parameters into the existing query string:\n\n```php\n$uri = $uri->withQuery(['sort' => 'name']);\n```\n\nThe `withQueryIfMissing` method may be used to merge additional query string parameters into the existing query string if the given keys do not already exist in the query string:\n\n```php\n$uri = $uri->withQueryIfMissing(['page' => 1]);\n```\n\nThe `replaceQuery` method may be used to complete replace the existing query string with a new one:\n\n```php\n$uri = $uri->replaceQuery(['page' => 1]);\n```\n\nThe `pushOntoQuery` method may be used to push additional parameters onto a query string parameter that has an array value:\n\n```php\n$uri = $uri->pushOntoQuery('filter', ['active', 'pending']);\n```\n\nThe `withoutQuery` method may be used to remove parameters from the query string:\n\n```php\n$uri = $uri->withoutQuery(['page']);\n```\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## 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## 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## 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## Controllers Resource Controllers Partial Resource Routes API Resource Routes\nControllers | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/controllers.md\n\n\n#### API Resource Routes\n\nWhen declaring resource routes that will be consumed by APIs, you will commonly want to exclude routes that present HTML templates such as `create` and `edit`. For convenience, you may use the `apiResource` method to automatically exclude these two routes:\n\n```php\nuse App\\Http\\Controllers\\PhotoController;\n\nRoute::apiResource('photos', PhotoController::class);\n```\n\nYou may register many API resource controllers at once by passing an array to the `apiResources` method:\n\n```php\nuse App\\Http\\Controllers\\PhotoController;\nuse App\\Http\\Controllers\\PostController;\n\nRoute::apiResources([\n    'photos' => PhotoController::class,\n    'posts' => PostController::class,\n]);\n```\n\nTo quickly generate an API resource controller that does not include the `create` or `edit` methods, use the `--api` switch when executing the `make:controller` command:\n\n```shell\nphp artisan make:controller PhotoController --api\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## 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 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## 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## Sanctum API Token Authentication Token Expiration\nSanctum | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/sanctum.md\n\n\n### Token Expiration\n\nBy default, Sanctum tokens never expire and may only be invalidated by [revoking the token](#revoking-tokens). However, if you would like to configure an expiration time for your application's API tokens, you may do so via the `expiration` configuration option defined in your application's `sanctum` configuration file. This configuration option defines the number of minutes until an issued token will be considered expired:\n\n```php\n'expiration' => 525600,\n```\n\nIf you would like to specify the expiration time of each token independently, you may do so by providing the expiration time as the third argument to the `createToken` method:\n\n```php\nreturn $user->createToken(\n    'token-name', ['*'], now()->plus(weeks: 1)\n)->plainTextToken;\n```\n\nIf you have configured a token expiration time for your application, you may also wish to [schedule a task](/docs/{{version}}/scheduling) to prune your application's expired tokens. Thankfully, Sanctum includes a `sanctum:prune-expired` Artisan command that you may use to accomplish this. For example, you may configure a scheduled task to delete all expired token database records that have been expired for at least 24 hours:\n\n```php\nuse Illuminate\\Support\\Facades\\Schedule;\n\nSchedule::command('sanctum:prune-expired --hours=24')->daily();\n```\n\n---\n\n## Controllers Resource Controllers Singleton Resource Controllers API Singleton Resources\nControllers | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/controllers.md\n\n\n#### API Singleton Resources\n\nThe `apiSingleton` method may be used to register a singleton resource that will be manipulated via an API, thus rendering the `create` and `edit` routes unnecessary:\n\n```php\nRoute::apiSingleton('profile', ProfileController::class);\n```\n\nOf course, API singleton resources may also be `creatable`, which will register `store` and `destroy` routes for the resource:\n\n```php\nRoute::apiSingleton('photos.thumbnail', ProfileController::class)->creatable();\n```\n\n---\n\n## Sanctum API Token Authentication Protecting Routes\nSanctum | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/sanctum.md\n\n\n### Protecting Routes\n\nTo protect routes so that all incoming requests must be authenticated, you should attach the `sanctum` authentication guard to your protected routes within your `routes/web.php` and `routes/api.php` route files. This guard will ensure that incoming requests are authenticated as either stateful, cookie authenticated requests or contain a valid API token header if the request is from a third party.\n\nYou may be wondering why we suggest that you authenticate the routes within your application's `routes/web.php` file using the `sanctum` guard. Remember, Sanctum will first attempt to authenticate incoming requests using Laravel's typical session authentication cookie. If that cookie is not present then Sanctum will attempt to authenticate the request using a token in the request's `Authorization` header. In addition, authenticating all requests using Sanctum ensures that we may always call the `tokenCan` method on the currently authenticated user instance:\n\n```php\nuse Illuminate\\Http\\Request;\n\nRoute::get('/user', function (Request $request) {\n    return $request->user();\n})->middleware('auth:sanctum');\n```\n\n---\n\n## Routing Route Groups\nRouting | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/routing.md\n\n\n## Route Groups\n\nRoute groups allow you to share route attributes, such as middleware, across a large number of routes without needing to define those attributes on each individual route.\n\nNested groups attempt to intelligently \"merge\" attributes with their parent group. Middleware and `where` conditions are merged while names and prefixes are appended. Namespace delimiters and slashes in URI prefixes are automatically added where appropriate.\n\n---\n\n## Verification Routing Protecting Routes\nVerification | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/verification.md\n\n\n### Protecting Routes\n\n[Route middleware](/docs/{{version}}/middleware) may be used to only allow verified users to access a given route. Laravel includes a `verified` [middleware alias](/docs/{{version}}/middleware#middleware-aliases), which is an alias for the `Illuminate\\Auth\\Middleware\\EnsureEmailIsVerified` middleware class. Since this alias is already automatically registered by Laravel, all you need to do is attach the `verified` middleware to a route definition. Typically, this middleware is paired with the `auth` middleware:\n\n```php\nRoute::get('/profile', function () {\n    // Only verified users may access this route...\n})->middleware(['auth', 'verified']);\n```\n\nIf an unverified user attempts to access a route that has been assigned this middleware, they will automatically be redirected to the `verification.notice` [named route](/docs/{{version}}/routing#named-routes).\n\n---\n\n## Sanctum API Token Authentication Revoking Tokens\nSanctum | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/sanctum.md\n\n\n### Revoking Tokens\n\nYou may \"revoke\" tokens by deleting them from your database using the `tokens` relationship that is provided by the `Laravel\\Sanctum\\HasApiTokens` trait:\n\n```php\n// Revoke all tokens...\n$user->tokens()->delete();\n\n// Revoke the token that was used to authenticate the current request...\n$request->user()->currentAccessToken()->delete();\n\n// Revoke a specific token...\n$user->tokens()->where('id', $tokenId)->delete();\n```\n\n---\n\n## Http Tests Testing JSON APIs Fluent JSON Testing Scoping JSON Collection Assertions\nHttp Tests | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/http-tests.md\n\n\n#### Scoping JSON Collection Assertions\n\nSometimes, your application's routes will return JSON collections that are assigned named keys:\n\n```php\nRoute::get('/users', function () {\n    return [\n        'meta' => [...],\n        'users' => User::all(),\n    ];\n})\n```\n\nWhen testing these routes, you may use the `has` method to assert against the number of items in the collection. In addition, you may use the `has` method to scope a chain of assertions:\n\n```php\n$response\n    ->assertJson(fn (AssertableJson $json) =>\n        $json->has('meta')\n            ->has('users', 3)\n            ->has('users.0', fn (AssertableJson $json) =>\n                $json->where('id', 1)\n                    ->where('name', 'Victoria Faith')\n                    ->where('email', fn (string $email) => str($email)->is('victoria@gmail.com'))\n                    ->missing('password')\n                    ->etc()\n            )\n    );\n```\n\nHowever, instead of making two separate calls to the `has` method to assert against the `users` collection, you may make a single call which provides a closure as its third parameter. When doing so, the closure will automatically be invoked and scoped to the first item in the collection:\n\n```php\n$response\n    ->assertJson(fn (AssertableJson $json) =>\n        $json->has('meta')\n            ->has('users', 3, fn (AssertableJson $json) =>\n                $json->where('id', 1)\n                    ->where('name', 'Victoria Faith')\n                    ->where('email', fn (string $email) => str($email)->is('victoria@gmail.com'))\n                    ->missing('password')\n                    ->etc()\n            )\n    );\n```\n\n---\n\n## Verification Routing Resending the Verification Email\nVerification | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/verification.md\n\n\n### Resending the Verification Email\n\nSometimes a user may misplace or accidentally delete the email address verification email. To accommodate this, you may wish to define a route to allow the user to request that the verification email be resent. You may then make a request to this route by placing a simple form submission button within your [verification notice view](#the-email-verification-notice):\n\n```php\nuse Illuminate\\Http\\Request;\n\nRoute::post('/email/verification-notification', function (Request $request) {\n    $request->user()->sendEmailVerificationNotification();\n\n    return back()->with('message', 'Verification link sent!');\n})->middleware(['auth', 'throttle:6,1'])->name('verification.send');\n```\n\n---\n\n## Sanctum API Token Authentication Token Abilities First-Party UI Initiated Requests\nSanctum | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/sanctum.md\n\n\n#### First-Party UI Initiated Requests\n\nFor convenience, the `tokenCan` method will always return `true` if the incoming authenticated request was from your first-party SPA and you are using Sanctum's built-in [SPA authentication](#spa-authentication).\n\nHowever, this does not necessarily mean that your application has to allow the user to perform the action. Typically, your application's [authorization policies](/docs/{{version}}/authorization#creating-policies) will determine if the token has been granted the permission to perform the abilities as well as check that the user instance itself should be allowed to perform the action.\n\nFor example, if we imagine an application that manages servers, this might mean checking that the token is authorized to update servers **and** that the server belongs to the user:\n\n```php\nreturn $request->user()->id === $server->user_id &&\n       $request->user()->tokenCan('server:update')\n```\n\nAt first, allowing the `tokenCan` method to be called and always return `true` for first-party UI initiated requests may seem strange; however, it is convenient to be able to always assume an API token is available and can be inspected via the `tokenCan` method. By taking this approach, you may always call the `tokenCan` method within your application's authorization policies without worrying about whether the request was triggered from your application's UI or was initiated by one of your API's third-party consumers.\n\n_[Truncated]_"}]


message: I’m using the same API resource/controller pattern as the product/customer endpoints. Collections need scoped handle uniqueness, product-id validation, and pivot positions when syncing products.

phase: commentary


type: message

role: assistant

text: I’m using the same API resource/controller pattern as the product/customer endpoints. Collections need scoped handle uniqueness, product-id validation, and pivot positions when syncing products.


cmd: php artisan make:controller Api/Admin/V1/CollectionController --no-interaction


cmd: php artisan make:resource Admin/V1/CollectionResource --no-interaction


cmd: php artisan make:test Api/AdminCollectionApiTest --pest --no-interaction


output:
Chunk ID: 1bd601
Wall time: 0.1749 seconds
Process exited with code 0
Original token count: 27
Output:

   INFO  Controller [app/Http/Controllers/Api/Admin/V1/CollectionController.php] created successfully.


output:
Chunk ID: e1eff9
Wall time: 0.1676 seconds
Process exited with code 0
Original token count: 25
Output:

   INFO  Resource [app/Http/Resources/Admin/V1/CollectionResource.php] created successfully.


output:
Chunk ID: d33055
Wall time: 0.1667 seconds
Process exited with code 0
Original token count: 22
Output:

   INFO  Test [tests/Feature/Api/AdminCollectionApiTest.php] created successfully.


command: php artisan make:test Api/AdminCollectionApiTest --pest --no-interaction

type: unknown

aggregated_output: INFO  Test [tests/Feature/Api/AdminCollectionApiTest.php] created successfully.

status: completed


command: php artisan make:resource Admin/V1/CollectionResource --no-interaction

aggregated_output: INFO  Resource [app/Http/Resources/Admin/V1/CollectionResource.php] created successfully.


command: php artisan make:controller Api/Admin/V1/CollectionController --no-interaction

aggregated_output: INFO  Controller [app/Http/Controllers/Api/Admin/V1/CollectionController.php] created successfully.


status: completed

input:
*** Begin Patch
*** Delete File: /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Admin/V1/CollectionController.php
*** End Patch


stdout:
Success. Updated the following files:
D /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Admin/V1/CollectionController.php

type: delete

content:
<?php

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

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;

class CollectionController extends Controller
{
    //
}


output: {"output":"Success. Updated the following files:\nD /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Admin/V1/CollectionController.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Add File: /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Admin/V1/CollectionController.php
+<?php
+
+namespace App\Http\Controllers\Api\Admin\V1;
+
+use App\Http\Controllers\Controller;
+use App\Http\Resources\Admin\V1\CollectionResource;
+use App\Models\Collection;
+use App\Models\Store;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
+use Illuminate\Support\Arr;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Str;
+use Illuminate\Validation\Rule;
+
+class CollectionController extends Controller
+{
+    public function index(Request $request, Store $store): AnonymousResourceCollection
+    {
+        $this->authorizeStore($request, $store);
+
+        $validated = $request->validate([
+            'status' => ['nullable', Rule::in(['draft', 'active', 'archived'])],
+            'query' => ['nullable', 'string', 'max:255'],
+            'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
+        ]);
+
+        $collections = Collection::withoutGlobalScopes()
+            ->withCount('products')
+            ->where('store_id', $store->getKey())
+            ->when(data_get($validated, 'status'), fn (Builder $query, string $status) => $query->where('status', $status))
+            ->when(data_get($validated, 'query'), function (Builder $query, string $search): void {
+                $query->where('title', 'like', '%'.$search.'%');
+            })
+            ->latest('updated_at')
+            ->latest('id')
+            ->paginate((int) data_get($validated, 'per_page', 25));
+
+        return CollectionResource::collection($collections);
+    }
+
+    public function store(Request $request, Store $store): CollectionResource
+    {
+        $this->authorizeStore($request, $store);
+
+        $validated = $this->validatePayload($request, $store);
+
+        $collection = DB::transaction(function () use ($store, $validated): Collection {
+            $collection = Collection::withoutGlobalScopes()->create([
+                'store_id' => $store->getKey(),
+                ...$this->attributesForCreate($validated),
+            ]);
+
+            if (array_key_exists('product_ids', $validated)) {
+                $this->replaceProducts($collection, $validated['product_ids']);
+            }
+
+            return $collection;
+        });
+
+        return CollectionResource::make($this->loadCollection($collection))
+            ->response()
+            ->setStatusCode(201);
+    }
+
+    public function update(Request $request, Store $store, Collection $collection): CollectionResource
+    {
+        $this->authorizeStore($request, $store);
+        $this->abortUnlessCollectionBelongsToStore($collection, $store);
+
+        $validated = $this->validatePayload($request, $store, $collection);
+
+        DB::transaction(function () use ($collection, $validated): void {
+            $attributes = $this->attributesForUpdate($validated);
+
+            if ($attributes !== []) {
+                $collection->update($attributes);
+            }
+
+            if (array_key_exists('product_ids', $validated)) {
+                $this->replaceProducts($collection, $validated['product_ids']);
+            } else {
+                $this->addProducts($collection, $validated['add_product_ids'] ?? []);
+                $this->removeProducts($collection, $validated['remove_product_ids'] ?? []);
+            }
+        });
+
+        return CollectionResource::make($this->loadCollection($collection->refresh()));
+    }
+
+    public function destroy(Request $request, Store $store, Collection $collection): JsonResponse
+    {
+        $this->authorizeStore($request, $store);
+        $this->abortUnlessCollectionBelongsToStore($collection, $store);
+
+        $collection->delete();
+
+        return response()->json(['message' => 'Collection deleted']);
+    }
+
+    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 abortUnlessCollectionBelongsToStore(Collection $collection, Store $store): void
+    {
+        abort_unless((int) $collection->store_id === $store->getKey(), 404);
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    private function validatePayload(Request $request, Store $store, ?Collection $collection = null): array
+    {
+        $creating = $collection === null;
+
+        return $request->validate([
+            'title' => [$creating ? 'required' : 'sometimes', 'string', 'max:255'],
+            'handle' => [
+                'sometimes',
+                'nullable',
+                'string',
+                'max:255',
+                Rule::unique('collections', 'handle')
+                    ->where('store_id', $store->getKey())
+                    ->ignore($collection?->getKey()),
+            ],
+            'description_html' => ['sometimes', 'nullable', 'string', 'max:65535'],
+            'type' => [$creating ? 'required' : 'sometimes', Rule::in(['manual', 'automated'])],
+            'status' => ['sometimes', Rule::in(['draft', 'active', 'archived'])],
+            'product_ids' => ['sometimes', 'array'],
+            'product_ids.*' => ['integer', Rule::exists('products', 'id')->where('store_id', $store->getKey())],
+            'add_product_ids' => ['sometimes', 'array'],
+            'add_product_ids.*' => ['integer', Rule::exists('products', 'id')->where('store_id', $store->getKey())],
+            'remove_product_ids' => ['sometimes', 'array'],
+            'remove_product_ids.*' => ['integer', Rule::exists('products', 'id')->where('store_id', $store->getKey())],
+        ]);
+    }
+
+    /**
+     * @param  array<string, mixed>  $validated
+     * @return array<string, mixed>
+     */
+    private function attributesForCreate(array $validated): array
+    {
+        $title = (string) $validated['title'];
+        $handle = filled($validated['handle'] ?? null) ? (string) $validated['handle'] : $title;
+
+        return [
+            'title' => $title,
+            'handle' => Str::slug($handle),
+            'description_html' => $validated['description_html'] ?? null,
+            'type' => $validated['type'],
+            'status' => $validated['status'] ?? 'active',
+        ];
+    }
+
+    /**
+     * @param  array<string, mixed>  $validated
+     * @return array<string, mixed>
+     */
+    private function attributesForUpdate(array $validated): array
+    {
+        $attributes = Arr::only($validated, ['title', 'description_html', 'type', 'status']);
+
+        if (array_key_exists('handle', $validated) && filled($validated['handle'])) {
+            $attributes['handle'] = Str::slug((string) $validated['handle']);
+        }
+
+        return $attributes;
+    }
+
+    /**
+     * @param  array<int, int>  $productIds
+     */
+    private function replaceProducts(Collection $collection, array $productIds): void
+    {
+        $collection->products()->sync($this->positionedProductIds($productIds));
+    }
+
+    /**
+     * @param  array<int, int>  $productIds
+     */
+    private function addProducts(Collection $collection, array $productIds): void
+    {
+        if ($productIds === []) {
+            return;
+        }
+
+        $startPosition = (int) $collection->products()->max('collection_products.position') + 1;
+
+        $collection->products()->syncWithoutDetaching($this->positionedProductIds($productIds, $startPosition));
+    }
+
+    /**
+     * @param  array<int, int>  $productIds
+     */
+    private function removeProducts(Collection $collection, array $productIds): void
+    {
+        if ($productIds === []) {
+            return;
+        }
+
+        $collection->products()->detach($productIds);
+    }
+
+    /**
+     * @param  array<int, int>  $productIds
+     * @return array<int, array{position: int}>
+     */
+    private function positionedProductIds(array $productIds, int $startPosition = 0): array
+    {
+        return collect($productIds)
+            ->unique()
+            ->values()
+            ->mapWithKeys(fn (int $productId, int $position): array => [$productId => ['position' => $startPosition + $position]])
+            ->all();
+    }
+
+    private function loadCollection(Collection $collection): Collection
+    {
+        return $collection->load('products')->loadCount('products');
+    }
+}
*** End Patch


stdout:
Success. Updated the following files:
A /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Admin/V1/CollectionController.php

type: add

content:
<?php

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

use App\Http\Controllers\Controller;
use App\Http\Resources\Admin\V1\CollectionResource;
use App\Models\Collection;
use App\Models\Store;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;

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

        $validated = $request->validate([
            'status' => ['nullable', Rule::in(['draft', 'active', 'archived'])],
            'query' => ['nullable', 'string', 'max:255'],
            'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
        ]);

        $collections = Collection::withoutGlobalScopes()
            ->withCount('products')
            ->where('store_id', $store->getKey())
            ->when(data_get($validated, 'status'), fn (Builder $query, string $status) => $query->where('status', $status))
            ->when(data_get($validated, 'query'), function (Builder $query, string $search): void {
                $query->where('title', 'like', '%'.$search.'%');
            })
            ->latest('updated_at')
            ->latest('id')
            ->paginate((int) data_get($validated, 'per_page', 25));

        return CollectionResource::collection($collections);
    }

    public function store(Request $request, Store $store): CollectionResource
    {
        $this->authorizeStore($request, $store);

        $validated = $this->validatePayload($request, $store);

        $collection = DB::transaction(function () use ($store, $validated): Collection {
            $collection = Collection::withoutGlobalScopes()->create([
                'store_id' => $store->getKey(),
                ...$this->attributesForCreate($validated),
            ]);

            if (array_key_exists('product_ids', $validated)) {
                $this->replaceProducts($collection, $validated['product_ids']);
            }

            return $collection;
        });

        return CollectionResource::make($this->loadCollection($collection))
            ->response()
            ->setStatusCode(201);
    }

    public function update(Request $request, Store $store, Collection $collection): CollectionResource
    {
        $this->authorizeStore($request, $store);
        $this->abortUnlessCollectionBelongsToStore($collection, $store);

        $validated = $this->validatePayload($request, $store, $collection);

        DB::transaction(function () use ($collection, $validated): void {
            $attributes = $this->attributesForUpdate($validated);

            if ($attributes !== []) {
                $collection->update($attributes);
            }

            if (array_key_exists('product_ids', $validated)) {
                $this->replaceProducts($collection, $validated['product_ids']);
            } else {
                $this->addProducts($collection, $validated['add_product_ids'] ?? []);
                $this->removeProducts($collection, $validated['remove_product_ids'] ?? []);
            }
        });

        return CollectionResource::make($this->loadCollection($collection->refresh()));
    }

    public function destroy(Request $request, Store $store, Collection $collection): JsonResponse
    {
        $this->authorizeStore($request, $store);
        $this->abortUnlessCollectionBelongsToStore($collection, $store);

        $collection->delete();

        return response()->json(['message' => 'Collection deleted']);
    }

    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 abortUnlessCollectionBelongsToStore(Collection $collection, Store $store): void
    {
        abort_unless((int) $collection->store_id === $store->getKey(), 404);
    }

    /**
     * @return array<string, mixed>
     */
    private function validatePayload(Request $request, Store $store, ?Collection $collection = null): array
    {
        $creating = $collection === null;

        return $request->validate([
            'title' => [$creating ? 'required' : 'sometimes', 'string', 'max:255'],
            'handle' => [
                'sometimes',
                'nullable',
                'string',
                'max:255',
                Rule::unique('collections', 'handle')
                    ->where('store_id', $store->getKey())
                    ->ignore($collection?->getKey()),
            ],
            'description_html' => ['sometimes', 'nullable', 'string', 'max:65535'],
            'type' => [$creating ? 'required' : 'sometimes', Rule::in(['manual', 'automated'])],
            'status' => ['sometimes', Rule::in(['draft', 'active', 'archived'])],
            'product_ids' => ['sometimes', 'array'],
            'product_ids.*' => ['integer', Rule::exists('products', 'id')->where('store_id', $store->getKey())],
            'add_product_ids' => ['sometimes', 'array'],
            'add_product_ids.*' => ['integer', Rule::exists('products', 'id')->where('store_id', $store->getKey())],
            'remove_product_ids' => ['sometimes', 'array'],
            'remove_product_ids.*' => ['integer', Rule::exists('products', 'id')->where('store_id', $store->getKey())],
        ]);
    }

    /**
     * @param  array<string, mixed>  $validated
     * @return array<string, mixed>
     */
    private function attributesForCreate(array $validated): array
    {
        $title = (string) $validated['title'];
        $handle = filled($validated['handle'] ?? null) ? (string) $validated['handle'] : $title;

        return [
            'title' => $title,
            'handle' => Str::slug($handle),
            'description_html' => $validated['description_html'] ?? null,
            'type' => $validated['type'],
            'status' => $validated['status'] ?? 'active',
        ];
    }

    /**
     * @param  array<string, mixed>  $validated
     * @return array<string, mixed>
     */
    private function attributesForUpdate(array $validated): array
    {
        $attributes = Arr::only($validated, ['title', 'description_html', 'type', 'status']);

        if (array_key_exists('handle', $validated) && filled($validated['handle'])) {
            $attributes['handle'] = Str::slug((string) $validated['handle']);
        }

        return $attributes;
    }

    /**
     * @param  array<int, int>  $productIds
     */
    private function replaceProducts(Collection $collection, array $productIds): void
    {
        $collection->products()->sync($this->positionedProductIds($productIds));
    }

    /**
     * @param  array<int, int>  $productIds
     */
    private function addProducts(Collection $collection, array $productIds): void
    {
        if ($productIds === []) {
            return;
        }

        $startPosition = (int) $collection->products()->max('collection_products.position') + 1;

        $collection->products()->syncWithoutDetaching($this->positionedProductIds($productIds, $startPosition));
    }

    /**
     * @param  array<int, int>  $productIds
     */
    private function removeProducts(Collection $collection, array $productIds): void
    {
        if ($productIds === []) {
            return;
        }

        $collection->products()->detach($productIds);
    }

    /**
     * @param  array<int, int>  $productIds
     * @return array<int, array{position: int}>
     */
    private function positionedProductIds(array $productIds, int $startPosition = 0): array
    {
        return collect($productIds)
            ->unique()
            ->values()
            ->mapWithKeys(fn (int $productId, int $position): array => [$productId => ['position' => $startPosition + $position]])
            ->all();
    }

    private function loadCollection(Collection $collection): Collection
    {
        return $collection->load('products')->loadCount('products');
    }
}


output: {"output":"Success. Updated the following files:\nA /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Admin/V1/CollectionController.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Delete File: /Users/fabianwesner/Herd/shop/app/Http/Resources/Admin/V1/CollectionResource.php
*** End Patch


stdout:
Success. Updated the following files:
D /Users/fabianwesner/Herd/shop/app/Http/Resources/Admin/V1/CollectionResource.php

content:
<?php

namespace App\Http\Resources\Admin\V1;

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

class CollectionResource extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        return parent::toArray($request);
    }
}


output: {"output":"Success. Updated the following files:\nD /Users/fabianwesner/Herd/shop/app/Http/Resources/Admin/V1/CollectionResource.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Add File: /Users/fabianwesner/Herd/shop/app/Http/Resources/Admin/V1/CollectionResource.php
+<?php
+
+namespace App\Http\Resources\Admin\V1;
+
+use Illuminate\Http\Request;
+use Illuminate\Http\Resources\Json\JsonResource;
+
+class CollectionResource 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,
+            'title' => $this->title,
+            'handle' => $this->handle,
+            'description_html' => $this->description_html,
+            'type' => $this->type?->value,
+            'status' => $this->status?->value,
+            'products_count' => $this->whenCounted('products'),
+            'products' => $this->whenLoaded('products', fn () => $this->products->map(fn ($product): array => [
+                'id' => $product->id,
+                'title' => $product->title,
+                'handle' => $product->handle,
+                'position' => $product->pivot?->position,
+            ])->values()),
+            'created_at' => $this->created_at?->toIso8601String(),
+            'updated_at' => $this->updated_at?->toIso8601String(),
+        ];
+    }
+}
*** End Patch


stdout:
Success. Updated the following files:
A /Users/fabianwesner/Herd/shop/app/Http/Resources/Admin/V1/CollectionResource.php

content:
<?php

namespace App\Http\Resources\Admin\V1;

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

class CollectionResource 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,
            'title' => $this->title,
            'handle' => $this->handle,
            'description_html' => $this->description_html,
            'type' => $this->type?->value,
            'status' => $this->status?->value,
            'products_count' => $this->whenCounted('products'),
            'products' => $this->whenLoaded('products', fn () => $this->products->map(fn ($product): array => [
                'id' => $product->id,
                'title' => $product->title,
                'handle' => $product->handle,
                'position' => $product->pivot?->position,
            ])->values()),
            'created_at' => $this->created_at?->toIso8601String(),
            'updated_at' => $this->updated_at?->toIso8601String(),
        ];
    }
}


output: {"output":"Success. Updated the following files:\nA /Users/fabianwesner/Herd/shop/app/Http/Resources/Admin/V1/CollectionResource.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/routes/api.php
@@
+use App\Http\Controllers\Api\Admin\V1\CollectionController as AdminCollectionController;
 use App\Http\Controllers\Api\Admin\V1\CustomerController as AdminCustomerController;
@@
         Route::middleware('admin.api:read-customers')->group(function (): void {
             Route::get('customers', [AdminCustomerController::class, 'index'])->name('customers.index');
             Route::get('customers/{customer}', [AdminCustomerController::class, 'show'])->name('customers.show');
         });

+        Route::middleware('admin.api:read-collections')->group(function (): void {
+            Route::get('collections', [AdminCollectionController::class, 'index'])->name('collections.index');
+        });
+
+        Route::middleware('admin.api:write-collections')->group(function (): void {
+            Route::post('collections', [AdminCollectionController::class, 'store'])->name('collections.store');
+            Route::put('collections/{collection}', [AdminCollectionController::class, 'update'])->name('collections.update');
+            Route::delete('collections/{collection}', [AdminCollectionController::class, 'destroy'])->name('collections.destroy');
+        });
+
         Route::middleware('admin.api:read-orders')->group(function (): void {
             Route::get('orders', [AdminOrderController::class, 'index'])->name('orders.index');
             Route::get('orders/{order}', [AdminOrderController::class, 'show'])->name('orders.show');
*** End Patch


stdout:
Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/routes/api.php

type: update

unified_diff:
@@ -2,2 +2,3 @@

+use App\Http\Controllers\Api\Admin\V1\CollectionController as AdminCollectionController;
 use App\Http\Controllers\Api\Admin\V1\CustomerController as AdminCustomerController;
@@ -64,2 +65,12 @@

+        Route::middleware('admin.api:read-collections')->group(function (): void {
+            Route::get('collections', [AdminCollectionController::class, 'index'])->name('collections.index');
+        });
+
+        Route::middleware('admin.api:write-collections')->group(function (): void {
+            Route::post('collections', [AdminCollectionController::class, 'store'])->name('collections.store');
+            Route::put('collections/{collection}', [AdminCollectionController::class, 'update'])->name('collections.update');
+            Route::delete('collections/{collection}', [AdminCollectionController::class, 'destroy'])->name('collections.destroy');
+        });
+
         Route::middleware('admin.api:read-orders')->group(function (): void {


output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/routes/api.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


cmd: sed -n '1,80p' tests/Feature/Api/AdminCollectionApiTest.php


command: sed -n '1,80p' tests/Feature/Api/AdminCollectionApiTest.php

AdminCollectionApiTest.php

type: read

aggregated_output:
<?php

test('example', function () {
    $response = $this->get('/');

    $response->assertStatus(200);
});


output:
Chunk ID: 6922d9
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 28
Output:
<?php

test('example', function () {
    $response = $this->get('/');

    $response->assertStatus(200);
});


status: completed

input:
*** Begin Patch
*** Delete File: /Users/fabianwesner/Herd/shop/tests/Feature/Api/AdminCollectionApiTest.php
*** End Patch


stdout:
Success. Updated the following files:
D /Users/fabianwesner/Herd/shop/tests/Feature/Api/AdminCollectionApiTest.php

content:
<?php

test('example', function () {
    $response = $this->get('/');

    $response->assertStatus(200);
});


output: {"output":"Success. Updated the following files:\nD /Users/fabianwesner/Herd/shop/tests/Feature/Api/AdminCollectionApiTest.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Add File: /Users/fabianwesner/Herd/shop/tests/Feature/Api/AdminCollectionApiTest.php
+<?php
+
+use App\Models\Collection;
+use App\Models\Product;
+use App\Models\Store;
+use App\Models\User;
+use App\Services\WebhookService;
+use Database\Seeders\DatabaseSeeder;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+
+uses(RefreshDatabase::class);
+
+beforeEach(function (): void {
+    $this->withoutVite();
+    $this->seed(DatabaseSeeder::class);
+});
+
+function adminCollectionApiStore(): Store
+{
+    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
+}
+
+function adminCollectionApiUser(): User
+{
+    return User::query()->where('email', 'admin@acme.test')->firstOrFail();
+}
+
+/**
+ * @param  list<string>  $abilities
+ * @return array{token: \App\Models\OauthToken, plain_text: string}
+ */
+function adminCollectionApiToken(Store $store, array $abilities): array
+{
+    return app(WebhookService::class)->createApiToken($store, 'Collection integration', $abilities);
+}
+
+test('admin collection api lists creates updates and deletes collections', function (): void {
+    $store = adminCollectionApiStore();
+    $products = Product::factory()
+        ->count(3)
+        ->withDefaultVariant()
+        ->create(['store_id' => $store->getKey()]);
+    Collection::factory()->create([
+        'store_id' => Store::factory()->create()->getKey(),
+        'title' => 'Other Store Collection',
+    ]);
+    $user = adminCollectionApiUser();
+
+    $this->actingAs($user)
+        ->getJson("/api/admin/v1/stores/{$store->getKey()}/collections?query=New")
+        ->assertOk()
+        ->assertJsonMissing(['title' => 'Other Store Collection']);
+
+    $createResponse = $this->actingAs($user)
+        ->postJson("/api/admin/v1/stores/{$store->getKey()}/collections", [
+            'title' => 'API Winter',
+            'description_html' => '
Cold weather goods.
',
+            'type' => 'manual',
+            'status' => 'active',
+            'product_ids' => $products->take(2)->pluck('id')->all(),
+        ])
+        ->assertCreated()
+        ->assertJsonPath('data.title', 'API Winter')
+        ->assertJsonPath('data.handle', 'api-winter')
+        ->assertJsonPath('data.products_count', 2);
+
+    $collectionId = $createResponse->json('data.id');
+    $collection = Collection::withoutGlobalScopes()->findOrFail($collectionId);
+
+    expect($collection->products()->pluck('collection_products.position', 'products.id')->all())
+        ->toBe([
+            $products[0]->getKey() => 0,
+            $products[1]->getKey() => 1,
+        ]);
+
+    $this->actingAs($user)
+        ->putJson("/api/admin/v1/stores/{$store->getKey()}/collections/{$collection->getKey()}", [
+            'title' => 'API Winter Edit',
+            'status' => 'draft',
+            'add_product_ids' => [$products[2]->getKey()],
+            'remove_product_ids' => [$products[0]->getKey()],
+        ])
+        ->assertOk()
+        ->assertJsonPath('data.title', 'API Winter Edit')
+        ->assertJsonPath('data.status', 'draft')
+        ->assertJsonPath('data.products_count', 2);
+
+    expect($collection->refresh()->products()->pluck('products.id')->all())
+        ->toBe([$products[1]->getKey(), $products[2]->getKey()]);
+
+    $this->actingAs($user)
+        ->deleteJson("/api/admin/v1/stores/{$store->getKey()}/collections/{$collection->getKey()}")
+        ->assertOk()
+        ->assertJsonPath('message', 'Collection deleted');
+
+    expect(Collection::withoutGlobalScopes()->whereKey($collection->getKey())->exists())->toBeFalse();
+});
+
+test('admin collection api enforces token abilities and store scope', function (): void {
+    $store = adminCollectionApiStore();
+    $otherStore = Store::factory()->create();
+    $collection = Collection::factory()->create([
+        'store_id' => $store->getKey(),
+        'title' => 'Token Collection',
+    ]);
+    $readToken = adminCollectionApiToken($store, ['read-collections']);
+    $writeToken = adminCollectionApiToken($store, ['write-collections']);
+    $otherStoreToken = adminCollectionApiToken($otherStore, ['read-collections']);
+
+    $this->withToken($readToken['plain_text'])
+        ->getJson("/api/admin/v1/stores/{$store->getKey()}/collections?query=Token")
+        ->assertOk()
+        ->assertJsonPath('data.0.title', 'Token Collection');
+
+    $this->withToken($readToken['plain_text'])
+        ->deleteJson("/api/admin/v1/stores/{$store->getKey()}/collections/{$collection->getKey()}")
+        ->assertForbidden();
+
+    $this->withToken($otherStoreToken['plain_text'])
+        ->getJson("/api/admin/v1/stores/{$store->getKey()}/collections")
+        ->assertForbidden();
+
+    $this->withToken($writeToken['plain_text'])
+        ->deleteJson("/api/admin/v1/stores/{$store->getKey()}/collections/{$collection->getKey()}")
+        ->assertOk();
+});
+
+test('admin collection api validates handles and product store ownership', function (): void {
+    $store = adminCollectionApiStore();
+    $existing = Collection::factory()->create([
+        'store_id' => $store->getKey(),
+        'handle' => 'existing-api-collection',
+    ]);
+    $otherStoreProduct = Product::factory()
+        ->withDefaultVariant()
+        ->create(['store_id' => Store::factory()->create()->getKey()]);
+
+    $this->actingAs(adminCollectionApiUser())
+        ->postJson("/api/admin/v1/stores/{$store->getKey()}/collections", [
+            'title' => 'Invalid API Collection',
+            'handle' => $existing->handle,
+            'type' => 'manual',
+            'product_ids' => [$otherStoreProduct->getKey()],
+        ])
+        ->assertUnprocessable()
+        ->assertJsonValidationErrors(['handle', 'product_ids.0']);
+});
*** End Patch