stdout:
Success. Updated the following files:
M specs/progress.md

type: update

unified_diff:
@@ -604,2 +604,11 @@
 - 2026-05-04: Affected browser regression suites passed: `php artisan test --compact tests/Browser/Storefront/CheckoutTest.php tests/Browser/Storefront/AccessibilityTest.php tests/Browser/Storefront/ResponsiveTest.php` with 32 tests and 178 assertions.
+- 2026-05-04: Admin API regression tests passed after replacing stale token attribute names with `sanctum_personal_access_token`: `php artisan test --compact tests/Feature/Api` with 63 tests and 518 assertions.
+- 2026-05-04: Final formatter check `vendor/bin/pint --dirty --format agent` passed after the checkout Livewire test alignment.
+- 2026-05-04: Final production frontend build `npm run build` passed with Vite assets generated under `public/build`.
+- 2026-05-04: Full Pest rerun passed after the checkout Livewire test alignment: `php -d memory_limit=512M vendor/bin/pest --compact` with 416 tests and 3030 assertions, duration 602.29s.
+- 2026-05-04: Final database reset `php artisan migrate:fresh --seed --no-interaction` passed before manual browser verification.
+- 2026-05-04: Playwright MCP verified the post-audit customer flow on `http://shop.test`: product detail add-to-cart, cart drawer checkout redirect to `/checkout/1`, address/shipping/discount/payment submission, and order confirmation at `/checkout/1/confirmation` for order `#1016`.
+- 2026-05-04: Playwright MCP verified admin order management for the same order: `/admin/orders` listed `#1016`, `/admin/orders/19` showed payment, discount, total, timeline, customer, and line-item details, and admin fulfillment creation succeeded with visible `Fulfillment created` feedback and fulfilled order status.
+- 2026-05-04: Playwright MCP verified `/admin/developers` generates a one-time `shop_...` Admin API token backed by `personal_access_tokens` and lists it with type `Admin API`.
+- 2026-05-04: Playwright MCP verified the optional `/checkout` compatibility route renders the empty-cart state cleanly; current Playwright console checks reported 0 warnings and 0 errors. Boost `browser_logs` still contained older May 4 `/checkout` Flux errors from before the final verification refresh, not from the current Playwright run.

@@ -703,2 +712,2 @@

-Completion is pending the final post-audit verification refresh. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, deterministic Spec 07 customer/address/order/payment/fulfillment/refund seed data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer account order/address/logout views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, platform/store-membership APIs, product/customer/collection/discount/content page/store settings/search/analytics/shipping/tax/theme/order/export API surfaces, admin product create/update/archive/media-presign API surfaces, deferred OAuth/app route stubs, all 18 Spec 08 automated browser suites, outbound webhook delivery foundations, spec-root customer password reset routes, representative database CHECK constraint coverage, Sanctum-compatible Admin API bearer tokens, policy-backed API/Livewire mutations, rich HTML sanitization, checkout-id routes, and checkout/order idempotency hardening are implemented. Remaining gates: production build, full Pest suite, Playwright MCP customer/admin smoke verification, fresh independent QA, and a final commit.
+Completion is pending the final post-audit independent QA response and final commit. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, deterministic Spec 07 customer/address/order/payment/fulfillment/refund seed data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer account order/address/logout views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, platform/store-membership APIs, product/customer/collection/discount/content page/store settings/search/analytics/shipping/tax/theme/order/export API surfaces, admin product create/update/archive/media-presign API surfaces, deferred OAuth/app route stubs, all 18 Spec 08 automated browser suites, outbound webhook delivery foundations, spec-root customer password reset routes, representative database CHECK constraint coverage, Sanctum-compatible Admin API bearer tokens, policy-backed API/Livewire mutations, rich HTML sanitization, checkout-id routes, and checkout/order idempotency hardening are implemented. Final post-audit verification has passed with Pint, Vite build, fresh migrate/seed, full Pest, and Playwright MCP customer/admin smoke flows.


output: {"output":"Success. Updated the following files:\nM specs/progress.md\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


message: Progress evidence is now current through the full suite and manual browser verification. I’m waiting on the three independent QA agents before I do the final commit and completion audit.

phase: commentary


type: message

role: assistant

text: Progress evidence is now current through the full suite and manual browser verification. I’m waiting on the three independent QA agents before I do the final commit and completion audit.


agent_nickname: Anscombe

agent_role: explorer

completed:
**Findings**

- **Critical: Admin API personal access tokens are not tenant-scoped.**
  [personal_access_tokens migration](/Users/fabianwesner/Herd/shop/database/migrations/2026_05_04_180719_create_personal_access_tokens_table.php:14) stores only `tokenable_*`, `token`, and `abilities`, with no `store_id` or installation binding. [WebhookService::createApiToken](/Users/fabianwesner/Herd/shop/app/Services/WebhookService.php:65) accepts a `Store` but persists only the user and abilities at lines 74-80. Then [AuthenticateAdminApi](/Users/fabianwesner/Herd/shop/app/Http/Middleware/AuthenticateAdminApi.php:33) authorizes the bearer token against whatever `{store}` is in the route by checking the tokenable user’s role for that store at lines 116-134.
  Exploit: an integration token generated for Store A by a user who also has access to Store B can call `/api/admin/v1/stores/{storeB}/...` with the same token if abilities match. That breaks tenant isolation and expands a leaked Store A token across every store the user can access. The developer token list/revoke path has the same scoping problem by listing tokens for all users in the store, not tokens issued for the store: [Developers/Index.php](/Users/fabianwesner/Herd/shop/app/Livewire/Admin/Developers/Index.php:148) and line 183.

- **High: Storefront checkout API has IDOR on checkout IDs.**
  Storefront checkout routes expose `{checkout}` under only throttle middleware: [routes/api.php](/Users/fabianwesner/Herd/shop/routes/api.php:54). [CheckoutController::show/address/shipping/discount/payment/pay](/Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Storefront/V1/CheckoutController.php:50) accept the route-bound checkout and never verify session cart ownership, customer ownership, or a checkout access token before returning PII or mutating/payment-completing the checkout. The payment request also explicitly authorizes everyone: [CompleteCheckoutPaymentRequest.php](/Users/fabianwesner/Herd/shop/app/Http/Requests/Api/Storefront/V1/CompleteCheckoutPaymentRequest.php:9).
  Exploit: any client who guesses/enumerates a checkout id for the same store can read checkout contents, overwrite address/discount/payment method, or call `pay` and complete someone else’s checkout. The Livewire checkout path added session/customer checks, but the API path remains unprotected.

**Medium/Low Concerns**

- Stale OAuth-token paths remain after Admin API moved to `personal_access_tokens`: [AppSeeder.php](/Users/fabianwesner/Herd/shop/database/seeders/AppSeeder.php:75) still seeds `oauth_tokens`, [AppInstallation.php](/Users/fabianwesner/Herd/shop/app/Models/AppInstallation.php:57) still exposes `oauthTokens`, and [Apps/Show.php](/Users/fabianwesner/Herd/shop/app/Livewire/Admin/Apps/Show.php:63) still loads them. Not a current auth bypass because middleware no longer accepts `OauthToken`, but it is likely to mislead operators/tests.

I did not edit files or run tests.


output: {"status":{"019df484-b47b-7b32-8d3c-16c9ffc06a7a":{"completed":"**Findings**\n\n- **Critical: Admin API personal access tokens are not tenant-scoped.**  \n  [personal_access_tokens migration](/Users/fabianwesner/Herd/shop/database/migrations/2026_05_04_180719_create_personal_access_tokens_table.php:14) stores only `tokenable_*`, `token`, and `abilities`, with no `store_id` or installation binding. [WebhookService::createApiToken](/Users/fabianwesner/Herd/shop/app/Services/WebhookService.php:65) accepts a `Store` but persists only the user and abilities at lines 74-80. Then [AuthenticateAdminApi](/Users/fabianwesner/Herd/shop/app/Http/Middleware/AuthenticateAdminApi.php:33) authorizes the bearer token against whatever `{store}` is in the route by checking the tokenable user’s role for that store at lines 116-134.  \n  Exploit: an integration token generated for Store A by a user who also has access to Store B can call `/api/admin/v1/stores/{storeB}/...` with the same token if abilities match. That breaks tenant isolation and expands a leaked Store A token across every store the user can access. The developer token list/revoke path has the same scoping problem by listing tokens for all users in the store, not tokens issued for the store: [Developers/Index.php](/Users/fabianwesner/Herd/shop/app/Livewire/Admin/Developers/Index.php:148) and line 183.\n\n- **High: Storefront checkout API has IDOR on checkout IDs.**  \n  Storefront checkout routes expose `{checkout}` under only throttle middleware: [routes/api.php](/Users/fabianwesner/Herd/shop/routes/api.php:54). [CheckoutController::show/address/shipping/discount/payment/pay](/Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Storefront/V1/CheckoutController.php:50) accept the route-bound checkout and never verify session cart ownership, customer ownership, or a checkout access token before returning PII or mutating/payment-completing the checkout. The payment request also explicitly authorizes everyone: [CompleteCheckoutPaymentRequest.php](/Users/fabianwesner/Herd/shop/app/Http/Requests/Api/Storefront/V1/CompleteCheckoutPaymentRequest.php:9).  \n  Exploit: any client who guesses/enumerates a checkout id for the same store can read checkout contents, overwrite address/discount/payment method, or call `pay` and complete someone else’s checkout. The Livewire checkout path added session/customer checks, but the API path remains unprotected.\n\n**Medium/Low Concerns**\n\n- Stale OAuth-token paths remain after Admin API moved to `personal_access_tokens`: [AppSeeder.php](/Users/fabianwesner/Herd/shop/database/seeders/AppSeeder.php:75) still seeds `oauth_tokens`, [AppInstallation.php](/Users/fabianwesner/Herd/shop/app/Models/AppInstallation.php:57) still exposes `oauthTokens`, and [Apps/Show.php](/Users/fabianwesner/Herd/shop/app/Livewire/Admin/Apps/Show.php:63) still loads them. Not a current auth bypass because middleware no longer accepts `OauthToken`, but it is likely to mislead operators/tests.\n\nI did not edit files or run tests."}},"timed_out":false}


role: user

text:
<subagent_notification>
{"agent_path":"019df484-b47b-7b32-8d3c-16c9ffc06a7a","status":{"completed":"**Findings**\n\n- **Critical: Admin API personal access tokens are not tenant-scoped.**  \n  [personal_access_tokens migration](/Users/fabianwesner/Herd/shop/database/migrations/2026_05_04_180719_create_personal_access_tokens_table.php:14) stores only `tokenable_*`, `token`, and `abilities`, with no `store_id` or installation binding. [WebhookService::createApiToken](/Users/fabianwesner/Herd/shop/app/Services/WebhookService.php:65) accepts a `Store` but persists only the user and abilities at lines 74-80. Then [AuthenticateAdminApi](/Users/fabianwesner/Herd/shop/app/Http/Middleware/AuthenticateAdminApi.php:33) authorizes the bearer token against whatever `{store}` is in the route by checking the tokenable user’s role for that store at lines 116-134.  \n  Exploit: an integration token generated for Store A by a user who also has access to Store B can call `/api/admin/v1/stores/{storeB}/...` with the same token if abilities match. That breaks tenant isolation and expands a leaked Store A token across every store the user can access. The developer token list/revoke path has the same scoping problem by listing tokens for all users in the store, not tokens issued for the store: [Developers/Index.php](/Users/fabianwesner/Herd/shop/app/Livewire/Admin/Developers/Index.php:148) and line 183.\n\n- **High: Storefront checkout API has IDOR on checkout IDs.**  \n  Storefront checkout routes expose `{checkout}` under only throttle middleware: [routes/api.php](/Users/fabianwesner/Herd/shop/routes/api.php:54). [CheckoutController::show/address/shipping/discount/payment/pay](/Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Storefront/V1/CheckoutController.php:50) accept the route-bound checkout and never verify session cart ownership, customer ownership, or a checkout access token before returning PII or mutating/payment-completing the checkout. The payment request also explicitly authorizes everyone: [CompleteCheckoutPaymentRequest.php](/Users/fabianwesner/Herd/shop/app/Http/Requests/Api/Storefront/V1/CompleteCheckoutPaymentRequest.php:9).  \n  Exploit: any client who guesses/enumerates a checkout id for the same store can read checkout contents, overwrite address/discount/payment method, or call `pay` and complete someone else’s checkout. The Livewire checkout path added session/customer checks, but the API path remains unprotected.\n\n**Medium/Low Concerns**\n\n- Stale OAuth-token paths remain after Admin API moved to `personal_access_tokens`: [AppSeeder.php](/Users/fabianwesner/Herd/shop/database/seeders/AppSeeder.php:75) still seeds `oauth_tokens`, [AppInstallation.php](/Users/fabianwesner/Herd/shop/app/Models/AppInstallation.php:57) still exposes `oauthTokens`, and [Apps/Show.php](/Users/fabianwesner/Herd/shop/app/Livewire/Admin/Apps/Show.php:63) still loads them. Not a current auth bypass because middleware no longer accepts `OauthToken`, but it is likely to mislead operators/tests.\n\nI did not edit files or run tests."}}
</subagent_notification>


message: The independent reviewer found two real blockers: Admin API tokens need store scoping, and storefront checkout API endpoints need checkout ownership checks. I’m fixing those before any completion claim.


text: The independent reviewer found two real blockers: Admin API tokens need store scoping, and storefront checkout API endpoints need checkout ownership checks. I’m fixing those before any completion claim.


packages: laravel/framework pestphp/pest

queries: migration foreign key nullable constrained cascade null

queries: request authorize route model binding session guard api

queries: personal access token abilities custom guard


server: laravel-boost

tool: search-docs

type: text

text:
# Search Results

## Sanctum SPA 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 API routes within your `routes/api.php` file. This guard will ensure that incoming requests are authenticated as either stateful authenticated requests from your SPA or contain a valid API token header if the request is from a third party:

```php
use Illuminate\Http\Request;

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

---

## Passport Token Scopes Assigning Scopes to Tokens When Issuing Personal Access Tokens
Passport | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/passport.md

#### When Issuing Personal Access Tokens

If you are issuing personal access tokens using the `App\Models\User` model's `createToken` method, you may pass the array of desired scopes as the second argument to the method:

```php
$token = $user->createToken('My Token', ['orders:create'])->accessToken;
```

---

## Expectations `json()`
Expectations | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/expectations.md

### `json()`

The `json()` modifier decodes the current expectation `$value` from JSON to an array.

```php
expect('{"name":"Nuno","credit":1000.00}')
    ->json()
    ->toHaveCount(2)
    ->name->toBe('Nuno')
    ->credit->toBeFloat();

expect('not-a-json')->json(); //Fails
```

---

## Migrating From Phpunit Guide
Migrating From Phpunit Guide | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/migrating-from-phpunit-guide.md

---
title: Migrating from PHPUnit
description: Migrating from PHPUnit to Pest is a simple process that can be completed in just a few steps.
---

# Migrating from PHPUnit

Pest is built on top of PHPUnit, so migrating from PHPUnit to Pest is a simple process that can be completed in just a few steps. Once you have Pest installed, you should require the `pestphp/pest-plugin-drift` package as a "dev" dependency in your project.

```bash
composer require pestphp/pest-plugin-drift --dev
```

Drift is a simple yet powerful plugin that will automatically convert your PHPUnit tests to Pest, simply by running the `--drift` option.

```bash
./vendor/bin/pest --drift
```

So, typically, a PHPUnit test looks like this:

```php
<?php

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;

class ExampleTest extends TestCase
{
    public function test_that_true_is_true(): void
    {
        $this->assertTrue(true);
    }
}
```

Should look like this after running `--drift`:

```php
test('true is true', function () {
    expect(true)->toBeTrue();
});
```

---
## Convert Phpunit tests only for certain folder

If you want to convert Phpunit tests only for certain folder, you can pass _path_ as first argument when calling `--drift`:

Example call, if you want to run the conversion for folder `/tests/Helpers`:
```console
/vendor/bin/pest --drift tests/Helpers
```

Output:
```console
/vendor/bin/pest --drift tests/Helpers

✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔

INFO  The [tests/Helpers] directory has been migrated to PEST with XY files changed.
```

---

The output will contain a summary of the conversion process, as well as a list of the files that were converted.

While most of your tests should be converted automatically, and you should be able to run them without any issues, there are some cases where you may need to manually convert some of your tests.

---

Of course, this particular chapter is only for those who are migrating from PHPUnit. Next, let's learn how you can contribute to the growth of Pest: [Community Guide](/docs/community-guide)

---

## Passport Personal Access Tokens Managing Personal Access Tokens
Passport | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/passport.md

### Managing Personal Access Tokens

Once you have created a personal access client, you may issue tokens for a given user using the `createToken` method on the `App\Models\User` model instance. The `createToken` method accepts the name of the token as its first argument and an optional array of [scopes](#token-scopes) as its second argument:

```php
use App\Models\User;
use Illuminate\Support\Facades\Date;
use Laravel\Passport\Token;

$user = User::find($userId);

// Creating a token without scopes...
$token = $user->createToken('My Token')->accessToken;

// Creating a token with scopes...
$token = $user->createToken('My Token', ['user:read', 'orders:create'])->accessToken;

// Creating a token with all scopes...
$token = $user->createToken('My Token', ['*'])->accessToken;

// Retrieving all the valid personal access tokens that belong to the user...
$tokens = $user->tokens()
    ->with('client')
    ->where('revoked', false)
    ->where('expires_at', '>', Date::now())
    ->get()
    ->filter(fn (Token $token) => $token->client->hasGrantType('personal_access'));
```

---

## Passport Personal Access Tokens
Passport | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/passport.md

## Personal Access Tokens

Sometimes, your users may want to issue access tokens to themselves without going through the typical authorization code redirect flow. Allowing users to issue tokens to themselves via your application's UI can be useful for allowing users to experiment with your API or may serve as a simpler approach to issuing access tokens in general.

> [!NOTE]
> If your application is using Passport primarily to issue personal access tokens, consider using [Laravel Sanctum](/docs/{{version}}/sanctum), Laravel's light-weight first-party library for issuing API access tokens.

---

## Migrations Indexes Foreign Key Constraints
Migrations | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/migrations.md

### Foreign Key Constraints

Laravel also provides support for creating foreign key constraints, which are used to force referential integrity at the database level. For example, let's define a `user_id` column on the `posts` table that references the `id` column on a `users` table:

```php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

Schema::table('posts', function (Blueprint $table) {
    $table->unsignedBigInteger('user_id');

    $table->foreign('user_id')->references('id')->on('users');
});
```

Since this syntax is rather verbose, Laravel provides additional, terser methods that use conventions to provide a better developer experience. When using the `foreignId` method to create your column, the example above can be rewritten like so:

```php
Schema::table('posts', function (Blueprint $table) {
    $table->foreignId('user_id')->constrained();
});
```

The `foreignId` method creates an `UNSIGNED BIGINT` equivalent column, while the `constrained` method will use conventions to determine the table and column being referenced. If your table name does not match Laravel's conventions, you may manually provide it to the `constrained` method. In addition, the name that should be assigned to the generated index may be specified as well:

```php
Schema::table('posts', function (Blueprint $table) {
    $table->foreignId('user_id')->constrained(
        table: 'users', indexName: 'posts_user_id'
    );
});
```

You may also specify the desired action for the "on delete" and "on update" properties of the constraint:

```php
$table->foreignId('user_id')
    ->constrained()
    ->onUpdate('cascade')
    ->onDelete('cascade');
```

An alternative, expressive syntax is also provided for these actions:

| Method                        | Description                                       |
| ----------------------------- | ------------------------------------------------- |
| `$table->cascadeOnUpdate();`  | Updates should cascade.                           |
| `$table->restrictOnUpdate();` | Updates should be restricted.                     |
| `$table->nullOnUpdate();`     | Updates should set the foreign key value to null. |
| `$table->noActionOnUpdate();` | No action on updates.                             |
| `$table->cascadeOnDelete();`  | Deletes should cascade.                           |
| `$table->restrictOnDelete();` | Deletes should be restricted.                     |
| `$table->nullOnDelete();`     | Deletes should set the foreign key value to null. |
| `$table->noActionOnDelete();` | Prevents deletes if child records exist.          |

Any additional [column modifiers](#column-modifiers) must be called before the `constrained` method:

```php
$table->foreignId('user_id')
    ->nullable()
    ->constrained();
```

---

## Passport Personal Access Tokens Customizing the User Provider
Passport | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/passport.md

### Customizing the User Provider

If your application uses more than one [authentication user provider](/docs/{{version}}/authentication#introduction), you may specify which user provider the personal access grant client uses by providing a `--provider` option when creating the client via the `artisan passport:client --personal` command. The given provider name should match a valid provider defined in your application's `config/auth.php` configuration file. You can then [protect your route using middleware](#multiple-authentication-guards) to ensure that only users from the guard's specified provider are authorized.

---

## Expectations `toBeJson()`
Expectations | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/expectations.md

### `toBeJson()`

This expectation ensures that `$value` is a JSON string.

```php
expect('{"hello":"world"}')->toBeJson();
```

---

## Packages Resources Migrations
Packages | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/packages.md

### Migrations

If your package contains [database migrations](/docs/{{version}}/migrations), you may use the `publishesMigrations` method to inform Laravel that the given directory or file contains migrations. When Laravel publishes the migrations, it will automatically update the timestamp within their filename to reflect the current date and time:

```php
/**
 * Bootstrap any package services.
 */
public function boot(): void
{
    $this->publishesMigrations([
        __DIR__.'/../database/migrations' => database_path('migrations'),
    ]);
}
```

---

## Stress Testing The Stress function Requests Upload Data Rate
Stress Testing | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/stress-testing.md

### Requests Upload Data Rate

Returns the request upload data rate in bytes per second.

```php
$result->requests()->upload()->data()->rate();
```

---

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

## Testing JSON APIs

Laravel also provides several helpers for testing JSON APIs and their responses. For example, the `json`, `getJson`, `postJson`, `putJson`, `patchJson`, `deleteJson`, and `optionsJson` methods may be used to issue JSON requests with various HTTP verbs. You may also easily pass data and headers to these methods. To get started, let's write a test to make a `POST` request to `/api/user` and assert that the expected JSON data was returned:

```php tab=Pest
<?php

test('making an api request', function () {
    $response = $this->postJson('/api/user', ['name' => 'Sally']);

    $response
        ->assertStatus(201)
        ->assertJson([
            'created' => true,
        ]);
});
```

```php tab=PHPUnit
<?php

namespace Tests\Feature;

use Tests\TestCase;

class ExampleTest extends TestCase
{
    /**
     * A basic functional test example.
     */
    public function test_making_an_api_request(): void
    {
        $response = $this->postJson('/api/user', ['name' => 'Sally']);

        $response
            ->assertStatus(201)
            ->assertJson([
                'created' => true,
            ]);
    }
}
```

In addition, JSON response data may be accessed as array variables on the response, making it convenient for you to inspect the individual values returned within a JSON response:

```php tab=Pest
expect($response['created'])->toBeTrue();
```

```php tab=PHPUnit
$this->assertTrue($response['created']);
```

> [!NOTE]
> The `assertJson` method converts the response to an array to verify that the given array exists within the JSON response returned by the application. So, if there are other properties in the JSON response, this test will still pass as long as the given fragment is present.

---

## Migrations Indexes Foreign Key Constraints Dropping Foreign Keys
Migrations | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/migrations.md

#### Dropping Foreign Keys

To drop a foreign key, you may use the `dropForeign` method, passing the name of the foreign key constraint to be deleted as an argument. Foreign key constraints use the same naming convention as indexes. In other words, the foreign key constraint name is based on the name of the table and the columns in the constraint, followed by a "\_foreign" suffix:

```php
$table->dropForeign('posts_user_id_foreign');
```

Alternatively, you may pass an array containing the column name that holds the foreign key to the `dropForeign` method. The array will be converted to a foreign key constraint name using Laravel's constraint naming conventions:

```php
$table->dropForeign(['user_id']);
```

---

## Migrations Indexes Foreign Key Constraints Toggling Foreign Key Constraints
Migrations | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/migrations.md

#### Toggling Foreign Key Constraints

You may enable or disable foreign key constraints within your migrations by using the following methods:

```php
Schema::enableForeignKeyConstraints();

Schema::disableForeignKeyConstraints();

Schema::withoutForeignKeyConstraints(function () {
    // Constraints disabled within this closure...
});
```

> [!WARNING]
> SQLite disables foreign key constraints by default. When using SQLite, make sure to [enable foreign key support](/docs/{{version}}/database#configuration) in your database configuration before attempting to create them in your migrations.

---

## Passport Personal Access Tokens Creating a Personal Access Client
Passport | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/passport.md

### Creating a Personal Access Client

Before your application can issue personal access tokens, you will need to create a personal access client. You may do this by executing the `passport:client` Artisan command with the `--personal` option. If you have already run the `passport:install` command, you do not need to run this command:

```shell
php artisan passport:client --personal
```

---

## Validation Available Validation Rules json
Validation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md

#### json

The field under validation must be a valid JSON string.

---

## Migrations Events
Migrations | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/migrations.md

## Events

For convenience, each migration operation will dispatch an [event](/docs/{{version}}/events). All of the following events extend the base `Illuminate\Database\Events\MigrationEvent` class:

| Class                                            | Description                                      |
| ------------------------------------------------ | ------------------------------------------------ |
| `Illuminate\Database\Events\MigrationsStarted`   | A batch of migrations is about to be executed.   |
| `Illuminate\Database\Events\MigrationsEnded`     | A batch of migrations has finished executing.    |
| `Illuminate\Database\Events\MigrationStarted`    | A single migration is about to be executed.      |
| `Illuminate\Database\Events\MigrationEnded`      | A single migration has finished executing.       |
| `Illuminate\Database\Events\NoPendingMigrations` | A migration command found no pending migrations. |
| `Illuminate\Database\Events\SchemaDumped`        | A database schema dump has completed.            |
| `Illuminate\Database\Events\SchemaLoaded`        | An existing database schema dump has loaded.     |

_[Truncated]_


output:
Wall time: 3.9291 seconds
Output:
[{"type":"text","text":"# Search Results\n\n## Sanctum SPA 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 API routes within your `routes/api.php` file. This guard will ensure that incoming requests are authenticated as either stateful authenticated requests from your SPA or contain a valid API token header if the request is from a third party:\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## Passport Token Scopes Assigning Scopes to Tokens When Issuing Personal Access Tokens\nPassport | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/passport.md\n\n\n#### When Issuing Personal Access Tokens\n\nIf you are issuing personal access tokens using the `App\\Models\\User` model's `createToken` method, you may pass the array of desired scopes as the second argument to the method:\n\n```php\n$token = $user->createToken('My Token', ['orders:create'])->accessToken;\n```\n\n---\n\n## Expectations `json()`\nExpectations | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/expectations.md\n\n\n### `json()`\n\nThe `json()` modifier decodes the current expectation `$value` from JSON to an array.\n\n```php\nexpect('{\"name\":\"Nuno\",\"credit\":1000.00}')\n    ->json()\n    ->toHaveCount(2)\n    ->name->toBe('Nuno')\n    ->credit->toBeFloat();\n\nexpect('not-a-json')->json(); //Fails\n```\n\n---\n\n## Migrating From Phpunit Guide\nMigrating From Phpunit Guide | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/migrating-from-phpunit-guide.md\n\n---\ntitle: Migrating from PHPUnit\ndescription: Migrating from PHPUnit to Pest is a simple process that can be completed in just a few steps.\n---\n\n# Migrating from PHPUnit\n\nPest is built on top of PHPUnit, so migrating from PHPUnit to Pest is a simple process that can be completed in just a few steps. Once you have Pest installed, you should require the `pestphp/pest-plugin-drift` package as a \"dev\" dependency in your project.\n\n```bash\ncomposer require pestphp/pest-plugin-drift --dev\n```\n\nDrift is a simple yet powerful plugin that will automatically convert your PHPUnit tests to Pest, simply by running the `--drift` option.\n\n```bash\n./vendor/bin/pest --drift\n```\n\nSo, typically, a PHPUnit test looks like this:\n\n```php\n<?php\n\nnamespace Tests\\Unit;\n\nuse PHPUnit\\Framework\\TestCase;\n\nclass ExampleTest extends TestCase\n{\n    public function test_that_true_is_true(): void\n    {\n        $this->assertTrue(true);\n    }\n}\n```\n\nShould look like this after running `--drift`:\n\n```php\ntest('true is true', function () {\n    expect(true)->toBeTrue();\n});\n```\n\n---\n## Convert Phpunit tests only for certain folder\n\nIf you want to convert Phpunit tests only for certain folder, you can pass _path_ as first argument when calling `--drift`:\n\nExample call, if you want to run the conversion for folder `/tests/Helpers`: \n```console \n/vendor/bin/pest --drift tests/Helpers\n```\n\nOutput:\n```console\n/vendor/bin/pest --drift tests/Helpers\n\n✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔\n\nINFO  The [tests/Helpers] directory has been migrated to PEST with XY files changed.\n```\n\n---\n\nThe output will contain a summary of the conversion process, as well as a list of the files that were converted.\n\nWhile most of your tests should be converted automatically, and you should be able to run them without any issues, there are some cases where you may need to manually convert some of your tests.\n\n---\n\nOf course, this particular chapter is only for those who are migrating from PHPUnit. Next, let's learn how you can contribute to the growth of Pest: [Community Guide](/docs/community-guide)\n\n\n---\n\n## Passport Personal Access Tokens Managing Personal Access Tokens\nPassport | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/passport.md\n\n\n### Managing Personal Access Tokens\n\nOnce you have created a personal access client, you may issue tokens for a given user using the `createToken` method on the `App\\Models\\User` model instance. The `createToken` method accepts the name of the token as its first argument and an optional array of [scopes](#token-scopes) as its second argument:\n\n```php\nuse App\\Models\\User;\nuse Illuminate\\Support\\Facades\\Date;\nuse Laravel\\Passport\\Token;\n\n$user = User::find($userId);\n\n// Creating a token without scopes...\n$token = $user->createToken('My Token')->accessToken;\n\n// Creating a token with scopes...\n$token = $user->createToken('My Token', ['user:read', 'orders:create'])->accessToken;\n\n// Creating a token with all scopes...\n$token = $user->createToken('My Token', ['*'])->accessToken;\n\n// Retrieving all the valid personal access tokens that belong to the user...\n$tokens = $user->tokens()\n    ->with('client')\n    ->where('revoked', false)\n    ->where('expires_at', '>', Date::now())\n    ->get()\n    ->filter(fn (Token $token) => $token->client->hasGrantType('personal_access'));\n```\n\n---\n\n## Passport Personal Access Tokens\nPassport | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/passport.md\n\n\n## Personal Access Tokens\n\nSometimes, your users may want to issue access tokens to themselves without going through the typical authorization code redirect flow. Allowing users to issue tokens to themselves via your application's UI can be useful for allowing users to experiment with your API or may serve as a simpler approach to issuing access tokens in general.\n\n> [!NOTE]\n> If your application is using Passport primarily to issue personal access tokens, consider using [Laravel Sanctum](/docs/{{version}}/sanctum), Laravel's light-weight first-party library for issuing API access tokens.\n\n---\n\n## Migrations Indexes Foreign Key Constraints\nMigrations | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/migrations.md\n\n\n### Foreign Key Constraints\n\nLaravel also provides support for creating foreign key constraints, which are used to force referential integrity at the database level. For example, let's define a `user_id` column on the `posts` table that references the `id` column on a `users` table:\n\n```php\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nSchema::table('posts', function (Blueprint $table) {\n    $table->unsignedBigInteger('user_id');\n\n    $table->foreign('user_id')->references('id')->on('users');\n});\n```\n\nSince this syntax is rather verbose, Laravel provides additional, terser methods that use conventions to provide a better developer experience. When using the `foreignId` method to create your column, the example above can be rewritten like so:\n\n```php\nSchema::table('posts', function (Blueprint $table) {\n    $table->foreignId('user_id')->constrained();\n});\n```\n\nThe `foreignId` method creates an `UNSIGNED BIGINT` equivalent column, while the `constrained` method will use conventions to determine the table and column being referenced. If your table name does not match Laravel's conventions, you may manually provide it to the `constrained` method. In addition, the name that should be assigned to the generated index may be specified as well:\n\n```php\nSchema::table('posts', function (Blueprint $table) {\n    $table->foreignId('user_id')->constrained(\n        table: 'users', indexName: 'posts_user_id'\n    );\n});\n```\n\nYou may also specify the desired action for the \"on delete\" and \"on update\" properties of the constraint:\n\n```php\n$table->foreignId('user_id')\n    ->constrained()\n    ->onUpdate('cascade')\n    ->onDelete('cascade');\n```\n\nAn alternative, expressive syntax is also provided for these actions:\n\n
\n\n| Method                        | Description                                       |\n| ----------------------------- | ------------------------------------------------- |\n| `$table->cascadeOnUpdate();`  | Updates should cascade.                           |\n| `$table->restrictOnUpdate();` | Updates should be restricted.                     |\n| `$table->nullOnUpdate();`     | Updates should set the foreign key value to null. |\n| `$table->noActionOnUpdate();` | No action on updates.                             |\n| `$table->cascadeOnDelete();`  | Deletes should cascade.                           |\n| `$table->restrictOnDelete();` | Deletes should be restricted.                     |\n| `$table->nullOnDelete();`     | Deletes should set the foreign key value to null. |\n| `$table->noActionOnDelete();` | Prevents deletes if child records exist.          |\n\n
\n\nAny additional [column modifiers](#column-modifiers) must be called before the `constrained` method:\n\n```php\n$table->foreignId('user_id')\n    ->nullable()\n    ->constrained();\n```\n\n---\n\n## Passport Personal Access Tokens Customizing the User Provider\nPassport | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/passport.md\n\n\n### Customizing the User Provider\n\nIf your application uses more than one [authentication user provider](/docs/{{version}}/authentication#introduction), you may specify which user provider the personal access grant client uses by providing a `--provider` option when creating the client via the `artisan passport:client --personal` command. The given provider name should match a valid provider defined in your application's `config/auth.php` configuration file. You can then [protect your route using middleware](#multiple-authentication-guards) to ensure that only users from the guard's specified provider are authorized.\n\n---\n\n## Expectations `toBeJson()`\nExpectations | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/expectations.md\n\n\n### `toBeJson()`\n\nThis expectation ensures that `$value` is a JSON string.\n\n```php\nexpect('{\"hello\":\"world\"}')->toBeJson();\n```\n\n---\n\n## Packages Resources Migrations\nPackages | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/packages.md\n\n\n### Migrations\n\nIf your package contains [database migrations](/docs/{{version}}/migrations), you may use the `publishesMigrations` method to inform Laravel that the given directory or file contains migrations. When Laravel publishes the migrations, it will automatically update the timestamp within their filename to reflect the current date and time:\n\n```php\n/**\n * Bootstrap any package services.\n */\npublic function boot(): void\n{\n    $this->publishesMigrations([\n        __DIR__.'/../database/migrations' => database_path('migrations'),\n    ]);\n}\n```\n\n---\n\n## Stress Testing The Stress function Requests Upload Data Rate\nStress Testing | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/stress-testing.md\n\n\n### Requests Upload Data Rate\n\nReturns the request upload data rate in bytes per second.\n\n```php\n$result->requests()->upload()->data()->rate();\n```\n\n---\n\n## Http Tests Testing JSON APIs\nHttp Tests | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/http-tests.md\n\n\n## Testing JSON APIs\n\nLaravel also provides several helpers for testing JSON APIs and their responses. For example, the `json`, `getJson`, `postJson`, `putJson`, `patchJson`, `deleteJson`, and `optionsJson` methods may be used to issue JSON requests with various HTTP verbs. You may also easily pass data and headers to these methods. To get started, let's write a test to make a `POST` request to `/api/user` and assert that the expected JSON data was returned:\n\n```php tab=Pest\n<?php\n\ntest('making an api request', function () {\n    $response = $this->postJson('/api/user', ['name' => 'Sally']);\n\n    $response\n        ->assertStatus(201)\n        ->assertJson([\n            'created' => true,\n        ]);\n});\n```\n\n```php tab=PHPUnit\n<?php\n\nnamespace Tests\\Feature;\n\nuse Tests\\TestCase;\n\nclass ExampleTest extends TestCase\n{\n    /**\n     * A basic functional test example.\n     */\n    public function test_making_an_api_request(): void\n    {\n        $response = $this->postJson('/api/user', ['name' => 'Sally']);\n\n        $response\n            ->assertStatus(201)\n            ->assertJson([\n                'created' => true,\n            ]);\n    }\n}\n```\n\nIn addition, JSON response data may be accessed as array variables on the response, making it convenient for you to inspect the individual values returned within a JSON response:\n\n```php tab=Pest\nexpect($response['created'])->toBeTrue();\n```\n\n```php tab=PHPUnit\n$this->assertTrue($response['created']);\n```\n\n> [!NOTE]\n> The `assertJson` method converts the response to an array to verify that the given array exists within the JSON response returned by the application. So, if there are other properties in the JSON response, this test will still pass as long as the given fragment is present.\n\n---\n\n## Migrations Indexes Foreign Key Constraints Dropping Foreign Keys\nMigrations | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/migrations.md\n\n\n#### Dropping Foreign Keys\n\nTo drop a foreign key, you may use the `dropForeign` method, passing the name of the foreign key constraint to be deleted as an argument. Foreign key constraints use the same naming convention as indexes. In other words, the foreign key constraint name is based on the name of the table and the columns in the constraint, followed by a \"\\_foreign\" suffix:\n\n```php\n$table->dropForeign('posts_user_id_foreign');\n```\n\nAlternatively, you may pass an array containing the column name that holds the foreign key to the `dropForeign` method. The array will be converted to a foreign key constraint name using Laravel's constraint naming conventions:\n\n```php\n$table->dropForeign(['user_id']);\n```\n\n---\n\n## Migrations Indexes Foreign Key Constraints Toggling Foreign Key Constraints\nMigrations | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/migrations.md\n\n\n#### Toggling Foreign Key Constraints\n\nYou may enable or disable foreign key constraints within your migrations by using the following methods:\n\n```php\nSchema::enableForeignKeyConstraints();\n\nSchema::disableForeignKeyConstraints();\n\nSchema::withoutForeignKeyConstraints(function () {\n    // Constraints disabled within this closure...\n});\n```\n\n> [!WARNING]\n> SQLite disables foreign key constraints by default. When using SQLite, make sure to [enable foreign key support](/docs/{{version}}/database#configuration) in your database configuration before attempting to create them in your migrations.\n\n---\n\n## Passport Personal Access Tokens Creating a Personal Access Client\nPassport | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/passport.md\n\n\n### Creating a Personal Access Client\n\nBefore your application can issue personal access tokens, you will need to create a personal access client. You may do this by executing the `passport:client` Artisan command with the `--personal` option. If you have already run the `passport:install` command, you do not need to run this command:\n\n```shell\nphp artisan passport:client --personal\n```\n\n---\n\n## Validation Available Validation Rules json\nValidation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md\n\n\n#### json\n\nThe field under validation must be a valid JSON string.\n\n---\n\n## Migrations Events\nMigrations | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/migrations.md\n\n\n## Events\n\nFor convenience, each migration operation will dispatch an [event](/docs/{{version}}/events). All of the following events extend the base `Illuminate\\Database\\Events\\MigrationEvent` class:\n\n
\n\n| Class                                            | Description                                      |\n| ------------------------------------------------ | ------------------------------------------------ |\n| `Illuminate\\Database\\Events\\MigrationsStarted`   | A batch of migrations is about to be executed.   |\n| `Illuminate\\Database\\Events\\MigrationsEnded`     | A batch of migrations has finished executing.    |\n| `Illuminate\\Database\\Events\\MigrationStarted`    | A single migration is about to be executed.      |\n| `Illuminate\\Database\\Events\\MigrationEnded`      | A single migration has finished executing.       |\n| `Illuminate\\Database\\Events\\NoPendingMigrations` | A migration command found no pending migrations. |\n| `Illuminate\\Database\\Events\\SchemaDumped`        | A database schema dump has completed.            |\n| `Illuminate\\Database\\Events\\SchemaLoaded`        | An existing database schema dump has loaded.     |\n\n
\n\n_[Truncated]_"}]


cmd: sed -n '1,220p' database/migrations/2026_05_04_180719_create_personal_access_tokens_table.php


cmd: sed -n '1,220p' app/Models/PersonalAccessToken.php


cmd: sed -n '1,260p' app/Http/Controllers/Api/Storefront/V1/CheckoutController.php


cmd: sed -n '1,180p' tests/Feature/Api/StorefrontCheckoutApiTest.php


command: sed -n '1,220p' database/migrations/2026_05_04_180719_create_personal_access_tokens_table.php

type: read

aggregated_output:
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('personal_access_tokens', function (Blueprint $table) {
            $table->id();
            $table->morphs('tokenable');
            $table->string('name');
            $table->string('token', 64)->unique();
            $table->text('abilities')->nullable();
            $table->timestamp('last_used_at')->nullable();
            $table->timestamp('expires_at')->nullable();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('personal_access_tokens');
    }
};

status: completed


command: sed -n '1,220p' app/Models/PersonalAccessToken.php

aggregated_output:
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;

class PersonalAccessToken extends Model
{
    /**
     * @var list<string>
     */
    protected $fillable = [
        'tokenable_type',
        'tokenable_id',
        'name',
        'token',
        'abilities',
        'last_used_at',
        'expires_at',
    ];

    /**
     * @return MorphTo<Model, $this>
     */
    public function tokenable(): MorphTo
    {
        return $this->morphTo();
    }

    public function can(string $ability): bool
    {
        $abilities = $this->abilities ?? [];

        return in_array('*', $abilities, true) || in_array($ability, $abilities, true);
    }

    public function isExpired(): bool
    {
        return $this->expires_at !== null && $this->expires_at->isPast();
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'abilities' => 'array',
            'last_used_at' => 'datetime',
            'expires_at' => 'datetime',
        ];
    }
}


command: sed -n '1,260p' app/Http/Controllers/Api/Storefront/V1/CheckoutController.php

aggregated_output:
<?php

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

use App\Exceptions\InsufficientInventoryException;
use App\Exceptions\InvalidCheckoutTransitionException;
use App\Exceptions\InvalidDiscountException;
use App\Exceptions\PaymentFailedException;
use App\Exceptions\UnserviceableShippingAddressException;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\Storefront\V1\ApplyCheckoutDiscountRequest;
use App\Http\Requests\Api\Storefront\V1\CompleteCheckoutPaymentRequest;
use App\Http\Requests\Api\Storefront\V1\SelectCheckoutPaymentRequest;
use App\Http\Requests\Api\Storefront\V1\SetCheckoutAddressRequest;
use App\Http\Requests\Api\Storefront\V1\SetCheckoutShippingRequest;
use App\Http\Requests\Api\Storefront\V1\StoreCheckoutRequest;
use App\Http\Resources\Storefront\V1\CheckoutResource;
use App\Http\Resources\Storefront\V1\OrderResource;
use App\Models\Cart;
use App\Models\Checkout;
use App\Models\Order;
use App\Models\ShippingRate;
use App\Services\CheckoutService;
use App\Services\PricingEngine;
use App\Services\ShippingCalculator;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Collection;

class CheckoutController extends Controller
{
    public function store(StoreCheckoutRequest $request, CheckoutService $checkouts, PricingEngine $pricing): CheckoutResource|JsonResponse
    {
        $cart = Cart::query()->findOrFail($request->validated('cart_id'));

        try {
            $checkout = $checkouts->createFromCart($cart);
            $checkout->forceFill([
                'email' => (string) $request->validated('email'),
            ])->save();
            $pricing->calculate($checkout);

            return CheckoutResource::make($this->loadCheckout(
                $checkout->refresh(),
            ))->response()->setStatusCode(201);
        } catch (InvalidCheckoutTransitionException $exception) {
            return response()->json(['message' => $exception->getMessage()], 422);
        }
    }

    public function show(Checkout $checkout): CheckoutResource
    {
        return CheckoutResource::make($this->loadCheckout($checkout));
    }

    public function address(SetCheckoutAddressRequest $request, Checkout $checkout, CheckoutService $checkouts): CheckoutResource|JsonResponse
    {
        $addressData = $request->validated();
        $addressData['email'] ??= $checkout->email;

        try {
            return CheckoutResource::make($this->loadCheckout($checkouts->setAddress($checkout, $addressData)));
        } catch (InvalidCheckoutTransitionException $exception) {
            return response()->json(['message' => $exception->getMessage()], 422);
        }
    }

    public function shippingMethod(SetCheckoutShippingRequest $request, Checkout $checkout, CheckoutService $checkouts): CheckoutResource|JsonResponse
    {
        try {
            return CheckoutResource::make($this->loadCheckout($checkouts->setShippingMethod(
                $checkout,
                $request->validated('shipping_rate_id'),
            )));
        } catch (InvalidCheckoutTransitionException|UnserviceableShippingAddressException $exception) {
            return response()->json(['message' => $exception->getMessage()], 422);
        }
    }

    public function applyDiscount(ApplyCheckoutDiscountRequest $request, Checkout $checkout, PricingEngine $pricing): CheckoutResource|JsonResponse
    {
        $checkout->forceFill([
            'discount_code' => trim((string) $request->validated('code')) ?: null,
        ])->save();

        try {
            $pricing->calculate($checkout);

            return CheckoutResource::make($this->loadCheckout($checkout->refresh()));
        } catch (InvalidDiscountException $exception) {
            $checkout->forceFill(['discount_code' => null])->save();
            $pricing->calculate($checkout);

            return response()->json([
                'message' => $exception->getMessage(),
                'reason' => $exception->reasonCode,
            ], 422);
        }
    }

    public function destroyDiscount(Checkout $checkout, PricingEngine $pricing): CheckoutResource
    {
        abort_if($checkout->discount_code === null, 404);

        $checkout->forceFill(['discount_code' => null])->save();
        $pricing->calculate($checkout);

        return CheckoutResource::make($this->loadCheckout($checkout->refresh()));
    }

    public function paymentMethod(SelectCheckoutPaymentRequest $request, Checkout $checkout, CheckoutService $checkouts): CheckoutResource|JsonResponse
    {
        try {
            return CheckoutResource::make($this->loadCheckout($checkouts->selectPaymentMethod(
                $checkout,
                (string) $request->validated('payment_method'),
            )));
        } catch (InsufficientInventoryException|InvalidCheckoutTransitionException $exception) {
            return response()->json(['message' => $exception->getMessage()], 422);
        }
    }

    public function pay(CompleteCheckoutPaymentRequest $request, Checkout $checkout, CheckoutService $checkouts): OrderResource|JsonResponse
    {
        try {
            if ($checkout->payment_method !== $request->validated('payment_method')) {
                $checkout = $checkouts->selectPaymentMethod($checkout, (string) $request->validated('payment_method'));
            }

            $order = $checkouts->completeCheckout($checkout, [
                'card_number' => $request->validated('card_number'),
                'cardholder_name' => $request->validated('card_holder'),
                'expiry' => $request->validated('card_expiry'),
                'cvc' => $request->validated('card_cvc'),
            ]);

            return OrderResource::make($this->loadOrder($order))
                ->response()
                ->setStatusCode(200);
        } catch (PaymentFailedException $exception) {
            return response()->json(['message' => $exception->getMessage()], 402);
        } catch (InsufficientInventoryException|InvalidCheckoutTransitionException $exception) {
            return response()->json(['message' => $exception->getMessage()], 422);
        }
    }

    private function loadCheckout(Checkout $checkout): Checkout
    {
        $checkout = $checkout->load([
            'cart.lines.variant.product',
            'cart.lines.variant.optionValues.option',
            'store',
        ]);

        $checkout->setRelation('availableRates', $this->availableRates($checkout));

        return $checkout;
    }

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

    /**
     * @return Collection<int, ShippingRate>
     */
    private function availableRates(Checkout $checkout): Collection
    {
        if (! is_array($checkout->shipping_address_json) || $checkout->shipping_address_json === []) {
            return collect();
        }

        return app(ShippingCalculator::class)
            ->getAvailableRates($checkout->store, $checkout->shipping_address_json)
            ->map(function (ShippingRate $rate) use ($checkout): ShippingRate {
                $rate->setAttribute('calculated_amount', app(ShippingCalculator::class)->calculate($rate, $checkout->cart));

                return $rate;
            });
    }
}


command: sed -n '1,180p' tests/Feature/Api/StorefrontCheckoutApiTest.php

aggregated_output:
<?php

use App\Enums\CheckoutStatus;
use App\Models\Checkout;
use App\Models\InventoryItem;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\ShippingRate;
use App\Models\Store;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

beforeEach(function (): void {
    $this->withoutVite();
    $this->seed(DatabaseSeeder::class);
});

function storefrontApiCheckoutStore(): Store
{
    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
}

function storefrontApiCheckoutVariant(Store $store): ProductVariant
{
    $product = Product::withoutGlobalScopes()
        ->where('store_id', $store->getKey())
        ->where('handle', 'classic-cotton-t-shirt')
        ->firstOrFail();

    $variant = ProductVariant::withoutGlobalScopes()
        ->where('product_id', $product->getKey())
        ->oldest('position')
        ->firstOrFail();

    InventoryItem::withoutGlobalScopes()
        ->where('variant_id', $variant->getKey())
        ->update([
            'quantity_on_hand' => 20,
            'quantity_reserved' => 0,
        ]);

    return $variant->refresh();
}

/**
 * @return array<string, string>
 */
function storefrontApiCheckoutAddress(string $country = 'DE'): array
{
    return [
        'first_name' => 'Test',
        'last_name' => 'Buyer',
        'address1' => 'Main Street 1',
        'city' => 'Berlin',
        'province_code' => 'BE',
        'country' => $country,
        'country_code' => $country,
        'postal_code' => '10115',
    ];
}

test('storefront checkout api progresses through address shipping discount removal and payment selection', function (): void {
    $store = storefrontApiCheckoutStore();
    $variant = storefrontApiCheckoutVariant($store);
    $api = fn () => $this->withServerVariables(['REMOTE_ADDR' => '10.0.0.21'])->withHeader('Host', 'shop.test');

    $cartId = $api()
        ->postJson('/api/storefront/v1/carts')
        ->assertCreated()['data']['id'];

    $api()
        ->postJson("/api/storefront/v1/carts/{$cartId}/lines", [
            'variant_id' => $variant->getKey(),
            'quantity' => 2,
        ])
        ->assertCreated();

    $checkoutResponse = $api()
        ->postJson('/api/storefront/v1/checkouts', [
            'cart_id' => $cartId,
            'email' => 'buyer@example.test',
        ]);

    $checkoutResponse
        ->assertCreated()
        ->assertJsonPath('data.status', 'started')
        ->assertJsonPath('data.email', 'buyer@example.test')
        ->assertJsonPath('data.totals.subtotal', 4998)
        ->assertJsonPath('data.cart.line_count', 2);

    $checkoutId = $checkoutResponse['data']['id'];

    $addressResponse = $api()
        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/address", [
            'shipping_address' => storefrontApiCheckoutAddress(),
        ]);

    $addressResponse
        ->assertOk()
        ->assertJsonPath('data.status', 'addressed')
        ->assertJsonPath('data.shipping_address.country', 'DE')
        ->assertJsonCount(3, 'data.available_shipping_rates');

    $shippingRateId = $addressResponse['data']['available_shipping_rates'][0]['id'];

    $api()
        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/shipping-method", [
            'shipping_rate_id' => $shippingRateId,
        ])
        ->assertOk()
        ->assertJsonPath('data.status', 'shipping_selected')
        ->assertJsonPath('data.shipping_method_id', $shippingRateId)
        ->assertJsonPath('data.totals.shipping', 499);

    $api()
        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/apply-discount", ['code' => 'SAVE10'])
        ->assertOk()
        ->assertJsonPath('data.discount_code', 'SAVE10')
        ->assertJsonPath('data.totals.discount', 500);

    $api()
        ->deleteJson("/api/storefront/v1/checkouts/{$checkoutId}/discount")
        ->assertOk()
        ->assertJsonPath('data.discount_code', null)
        ->assertJsonPath('data.totals.discount', 0);

    $api()
        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/payment-method", [
            'payment_method' => 'credit_card',
        ])
        ->assertOk()
        ->assertJsonPath('data.status', 'payment_selected')
        ->assertJsonPath('data.payment_method', 'credit_card');

    $checkout = Checkout::withoutGlobalScopes()->findOrFail($checkoutId);
    $inventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->firstOrFail();

    expect($checkout->status)->toBe(CheckoutStatus::PaymentSelected)
        ->and($inventory->quantity_reserved)->toBe(2);
});

test('storefront checkout api rejects invalid addresses shipping methods and discounts', function (): void {
    $store = storefrontApiCheckoutStore();
    $variant = storefrontApiCheckoutVariant($store);
    $api = fn () => $this->withServerVariables(['REMOTE_ADDR' => '10.0.0.22'])->withHeader('Host', 'shop.test');

    $cartId = $api()
        ->postJson('/api/storefront/v1/carts')
        ->assertCreated()['data']['id'];

    $api()
        ->postJson("/api/storefront/v1/carts/{$cartId}/lines", [
            'variant_id' => $variant->getKey(),
            'quantity' => 1,
        ])
        ->assertCreated();

    $checkoutId = $api()
        ->postJson('/api/storefront/v1/checkouts', [
            'cart_id' => $cartId,
            'email' => 'buyer@example.test',
        ])
        ->assertCreated()['data']['id'];

    $api()
        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/address", [
            'shipping_address' => array_diff_key(storefrontApiCheckoutAddress(), ['first_name' => true]),
        ])
        ->assertUnprocessable()
        ->assertJsonValidationErrors('shipping_address.first_name');

    $api()
        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/address", [
            'shipping_address' => storefrontApiCheckoutAddress(),
        ])
        ->assertOk();

    $otherStoreRate = ShippingRate::withoutGlobalScopes()


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

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('personal_access_tokens', function (Blueprint $table) {
            $table->id();
            $table->morphs('tokenable');
            $table->string('name');
            $table->string('token', 64)->unique();
            $table->text('abilities')->nullable();
            $table->timestamp('last_used_at')->nullable();
            $table->timestamp('expires_at')->nullable();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('personal_access_tokens');
    }
};


output:
Chunk ID: 9acaa1
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 276
Output:
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;

class PersonalAccessToken extends Model
{
    /**
     * @var list<string>
     */
    protected $fillable = [
        'tokenable_type',
        'tokenable_id',
        'name',
        'token',
        'abilities',
        'last_used_at',
        'expires_at',
    ];

    /**
     * @return MorphTo<Model, $this>
     */
    public function tokenable(): MorphTo
    {
        return $this->morphTo();
    }

    public function can(string $ability): bool
    {
        $abilities = $this->abilities ?? [];

        return in_array('*', $abilities, true) || in_array($ability, $abilities, true);
    }

    public function isExpired(): bool
    {
        return $this->expires_at !== null && $this->expires_at->isPast();
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'abilities' => 'array',
            'last_used_at' => 'datetime',
            'expires_at' => 'datetime',
        ];
    }
}


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

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

use App\Exceptions\InsufficientInventoryException;
use App\Exceptions\InvalidCheckoutTransitionException;
use App\Exceptions\InvalidDiscountException;
use App\Exceptions\PaymentFailedException;
use App\Exceptions\UnserviceableShippingAddressException;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\Storefront\V1\ApplyCheckoutDiscountRequest;
use App\Http\Requests\Api\Storefront\V1\CompleteCheckoutPaymentRequest;
use App\Http\Requests\Api\Storefront\V1\SelectCheckoutPaymentRequest;
use App\Http\Requests\Api\Storefront\V1\SetCheckoutAddressRequest;
use App\Http\Requests\Api\Storefront\V1\SetCheckoutShippingRequest;
use App\Http\Requests\Api\Storefront\V1\StoreCheckoutRequest;
use App\Http\Resources\Storefront\V1\CheckoutResource;
use App\Http\Resources\Storefront\V1\OrderResource;
use App\Models\Cart;
use App\Models\Checkout;
use App\Models\Order;
use App\Models\ShippingRate;
use App\Services\CheckoutService;
use App\Services\PricingEngine;
use App\Services\ShippingCalculator;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Collection;

class CheckoutController extends Controller
{
    public function store(StoreCheckoutRequest $request, CheckoutService $checkouts, PricingEngine $pricing): CheckoutResource|JsonResponse
    {
        $cart = Cart::query()->findOrFail($request->validated('cart_id'));

        try {
            $checkout = $checkouts->createFromCart($cart);
            $checkout->forceFill([
                'email' => (string) $request->validated('email'),
            ])->save();
            $pricing->calculate($checkout);

            return CheckoutResource::make($this->loadCheckout(
                $checkout->refresh(),
            ))->response()->setStatusCode(201);
        } catch (InvalidCheckoutTransitionException $exception) {
            return response()->json(['message' => $exception->getMessage()], 422);
        }
    }

    public function show(Checkout $checkout): CheckoutResource
    {
        return CheckoutResource::make($this->loadCheckout($checkout));
    }

    public function address(SetCheckoutAddressRequest $request, Checkout $checkout, CheckoutService $checkouts): CheckoutResource|JsonResponse
    {
        $addressData = $request->validated();
        $addressData['email'] ??= $checkout->email;

        try {
            return CheckoutResource::make($this->loadCheckout($checkouts->setAddress($checkout, $addressData)));
        } catch (InvalidCheckoutTransitionException $exception) {
            return response()->json(['message' => $exception->getMessage()], 422);
        }
    }

    public function shippingMethod(SetCheckoutShippingRequest $request, Checkout $checkout, CheckoutService $checkouts): CheckoutResource|JsonResponse
    {
        try {
            return CheckoutResource::make($this->loadCheckout($checkouts->setShippingMethod(
                $checkout,
                $request->validated('shipping_rate_id'),
            )));
        } catch (InvalidCheckoutTransitionException|UnserviceableShippingAddressException $exception) {
            return response()->json(['message' => $exception->getMessage()], 422);
        }
    }

    public function applyDiscount(ApplyCheckoutDiscountRequest $request, Checkout $checkout, PricingEngine $pricing): CheckoutResource|JsonResponse
    {
        $checkout->forceFill([
            'discount_code' => trim((string) $request->validated('code')) ?: null,
        ])->save();

        try {
            $pricing->calculate($checkout);

            return CheckoutResource::make($this->loadCheckout($checkout->refresh()));
        } catch (InvalidDiscountException $exception) {
            $checkout->forceFill(['discount_code' => null])->save();
            $pricing->calculate($checkout);

            return response()->json([
                'message' => $exception->getMessage(),
                'reason' => $exception->reasonCode,
            ], 422);
        }
    }

    public function destroyDiscount(Checkout $checkout, PricingEngine $pricing): CheckoutResource
    {
        abort_if($checkout->discount_code === null, 404);

        $checkout->forceFill(['discount_code' => null])->save();
        $pricing->calculate($checkout);

        return CheckoutResource::make($this->loadCheckout($checkout->refresh()));
    }

    public function paymentMethod(SelectCheckoutPaymentRequest $request, Checkout $checkout, CheckoutService $checkouts): CheckoutResource|JsonResponse
    {
        try {
            return CheckoutResource::make($this->loadCheckout($checkouts->selectPaymentMethod(
                $checkout,
                (string) $request->validated('payment_method'),
            )));
        } catch (InsufficientInventoryException|InvalidCheckoutTransitionException $exception) {
            return response()->json(['message' => $exception->getMessage()], 422);
        }
    }

    public function pay(CompleteCheckoutPaymentRequest $request, Checkout $checkout, CheckoutService $checkouts): OrderResource|JsonResponse
    {
        try {
            if ($checkout->payment_method !== $request->validated('payment_method')) {
                $checkout = $checkouts->selectPaymentMethod($checkout, (string) $request->validated('payment_method'));
            }

            $order = $checkouts->completeCheckout($checkout, [
                'card_number' => $request->validated('card_number'),
                'cardholder_name' => $request->validated('card_holder'),
                'expiry' => $request->validated('card_expiry'),
                'cvc' => $request->validated('card_cvc'),
            ]);

            return OrderResource::make($this->loadOrder($order))
                ->response()
                ->setStatusCode(200);
        } catch (PaymentFailedException $exception) {
            return response()->json(['message' => $exception->getMessage()], 402);
        } catch (InsufficientInventoryException|InvalidCheckoutTransitionException $exception) {
            return response()->json(['message' => $exception->getMessage()], 422);
        }
    }

    private function loadCheckout(Checkout $checkout): Checkout
    {
        $checkout = $checkout->load([
            'cart.lines.variant.product',
            'cart.lines.variant.optionValues.option',
            'store',
        ]);

        $checkout->setRelation('availableRates', $this->availableRates($checkout));

        return $checkout;
    }

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

    /**
     * @return Collection<int, ShippingRate>
     */
    private function availableRates(Checkout $checkout): Collection
    {
        if (! is_array($checkout->shipping_address_json) || $checkout->shipping_address_json === []) {
            return collect();
        }

        return app(ShippingCalculator::class)
            ->getAvailableRates($checkout->store, $checkout->shipping_address_json)
            ->map(function (ShippingRate $rate) use ($checkout): ShippingRate {
                $rate->setAttribute('calculated_amount', app(ShippingCalculator::class)->calculate($rate, $checkout->cart));

                return $rate;
            });
    }
}


output:
Chunk ID: 50a643
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1505
Output:
<?php

use App\Enums\CheckoutStatus;
use App\Models\Checkout;
use App\Models\InventoryItem;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\ShippingRate;
use App\Models\Store;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

beforeEach(function (): void {
    $this->withoutVite();
    $this->seed(DatabaseSeeder::class);
});

function storefrontApiCheckoutStore(): Store
{
    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
}

function storefrontApiCheckoutVariant(Store $store): ProductVariant
{
    $product = Product::withoutGlobalScopes()
        ->where('store_id', $store->getKey())
        ->where('handle', 'classic-cotton-t-shirt')
        ->firstOrFail();

    $variant = ProductVariant::withoutGlobalScopes()
        ->where('product_id', $product->getKey())
        ->oldest('position')
        ->firstOrFail();

    InventoryItem::withoutGlobalScopes()
        ->where('variant_id', $variant->getKey())
        ->update([
            'quantity_on_hand' => 20,
            'quantity_reserved' => 0,
        ]);

    return $variant->refresh();
}

/**
 * @return array<string, string>
 */
function storefrontApiCheckoutAddress(string $country = 'DE'): array
{
    return [
        'first_name' => 'Test',
        'last_name' => 'Buyer',
        'address1' => 'Main Street 1',
        'city' => 'Berlin',
        'province_code' => 'BE',
        'country' => $country,
        'country_code' => $country,
        'postal_code' => '10115',
    ];
}

test('storefront checkout api progresses through address shipping discount removal and payment selection', function (): void {
    $store = storefrontApiCheckoutStore();
    $variant = storefrontApiCheckoutVariant($store);
    $api = fn () => $this->withServerVariables(['REMOTE_ADDR' => '10.0.0.21'])->withHeader('Host', 'shop.test');

    $cartId = $api()
        ->postJson('/api/storefront/v1/carts')
        ->assertCreated()['data']['id'];

    $api()
        ->postJson("/api/storefront/v1/carts/{$cartId}/lines", [
            'variant_id' => $variant->getKey(),
            'quantity' => 2,
        ])
        ->assertCreated();

    $checkoutResponse = $api()
        ->postJson('/api/storefront/v1/checkouts', [
            'cart_id' => $cartId,
            'email' => 'buyer@example.test',
        ]);

    $checkoutResponse
        ->assertCreated()
        ->assertJsonPath('data.status', 'started')
        ->assertJsonPath('data.email', 'buyer@example.test')
        ->assertJsonPath('data.totals.subtotal', 4998)
        ->assertJsonPath('data.cart.line_count', 2);

    $checkoutId = $checkoutResponse['data']['id'];

    $addressResponse = $api()
        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/address", [
            'shipping_address' => storefrontApiCheckoutAddress(),
        ]);

    $addressResponse
        ->assertOk()
        ->assertJsonPath('data.status', 'addressed')
        ->assertJsonPath('data.shipping_address.country', 'DE')
        ->assertJsonCount(3, 'data.available_shipping_rates');

    $shippingRateId = $addressResponse['data']['available_shipping_rates'][0]['id'];

    $api()
        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/shipping-method", [
            'shipping_rate_id' => $shippingRateId,
        ])
        ->assertOk()
        ->assertJsonPath('data.status', 'shipping_selected')
        ->assertJsonPath('data.shipping_method_id', $shippingRateId)
        ->assertJsonPath('data.totals.shipping', 499);

    $api()
        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/apply-discount", ['code' => 'SAVE10'])
        ->assertOk()
        ->assertJsonPath('data.discount_code', 'SAVE10')
        ->assertJsonPath('data.totals.discount', 500);

    $api()
        ->deleteJson("/api/storefront/v1/checkouts/{$checkoutId}/discount")
        ->assertOk()
        ->assertJsonPath('data.discount_code', null)
        ->assertJsonPath('data.totals.discount', 0);

    $api()
        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/payment-method", [
            'payment_method' => 'credit_card',
        ])
        ->assertOk()
        ->assertJsonPath('data.status', 'payment_selected')
        ->assertJsonPath('data.payment_method', 'credit_card');

    $checkout = Checkout::withoutGlobalScopes()->findOrFail($checkoutId);
    $inventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->firstOrFail();

    expect($checkout->status)->toBe(CheckoutStatus::PaymentSelected)
        ->and($inventory->quantity_reserved)->toBe(2);
});

test('storefront checkout api rejects invalid addresses shipping methods and discounts', function (): void {
    $store = storefrontApiCheckoutStore();
    $variant = storefrontApiCheckoutVariant($store);
    $api = fn () => $this->withServerVariables(['REMOTE_ADDR' => '10.0.0.22'])->withHeader('Host', 'shop.test');

    $cartId = $api()
        ->postJson('/api/storefront/v1/carts')
        ->assertCreated()['data']['id'];

    $api()
        ->postJson("/api/storefront/v1/carts/{$cartId}/lines", [
            'variant_id' => $variant->getKey(),
            'quantity' => 1,
        ])
        ->assertCreated();

    $checkoutId = $api()
        ->postJson('/api/storefront/v1/checkouts', [
            'cart_id' => $cartId,
            'email' => 'buyer@example.test',
        ])
        ->assertCreated()['data']['id'];

    $api()
        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/address", [
            'shipping_address' => array_diff_key(storefrontApiCheckoutAddress(), ['first_name' => true]),
        ])
        ->assertUnprocessable()
        ->assertJsonValidationErrors('shipping_address.first_name');

    $api()
        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/address", [
            'shipping_address' => storefrontApiCheckoutAddress(),
        ])
        ->assertOk();

    $otherStoreRate = ShippingRate::withoutGlobalScopes()


cmd: sed -n '180,420p' tests/Feature/Api/StorefrontCheckoutApiTest.php


command: sed -n '180,420p' tests/Feature/Api/StorefrontCheckoutApiTest.php

aggregated_output:
    $otherStoreRate = ShippingRate::withoutGlobalScopes()
        ->whereHas('zone', fn ($query) => $query->withoutGlobalScopes()->where('store_id', Store::query()->whereKeyNot($store->getKey())->firstOrFail()->getKey()))
        ->firstOrFail();

    $api()
        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/shipping-method", [
            'shipping_rate_id' => $otherStoreRate->getKey(),
        ])
        ->assertUnprocessable();

    $api()
        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/apply-discount", ['code' => 'NOTREAL'])
        ->assertUnprocessable()
        ->assertJsonPath('reason', 'discount_not_found');
});


output:
Chunk ID: 819f7b
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 164
Output:
    $otherStoreRate = ShippingRate::withoutGlobalScopes()
        ->whereHas('zone', fn ($query) => $query->withoutGlobalScopes()->where('store_id', Store::query()->whereKeyNot($store->getKey())->firstOrFail()->getKey()))
        ->firstOrFail();

    $api()
        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/shipping-method", [
            'shipping_rate_id' => $otherStoreRate->getKey(),
        ])
        ->assertUnprocessable();

    $api()
        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/apply-discount", ['code' => 'NOTREAL'])
        ->assertUnprocessable()
        ->assertJsonPath('reason', 'discount_not_found');
});


cmd: sed -n '1,240p' app/Http/Controllers/Api/Storefront/V1/CartController.php


cmd: sed -n '1,220p' app/Http/Controllers/Api/Storefront/V1/CartLineController.php


cmd: rg -n "cart_token|access_token|session\(\['cart_id'|cart_id" app/Http app/Services app/Models database/migrations tests/Feature/Api/Storefront*


command: sed -n '1,240p' app/Http/Controllers/Api/Storefront/V1/CartController.php

aggregated_output:
<?php

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

use App\Http\Controllers\Controller;
use App\Http\Requests\Api\Storefront\V1\StoreCartRequest;
use App\Http\Resources\Storefront\V1\CartResource;
use App\Models\Cart;
use App\Models\Store;
use App\Services\CartService;
use Illuminate\Http\JsonResponse;

class CartController extends Controller
{
    public function store(StoreCartRequest $request, CartService $carts): JsonResponse
    {
        $request->validated();

        return CartResource::make($this->loadCart($carts->create($this->currentStore())))
            ->response()
            ->setStatusCode(201);
    }

    public function show(Cart $cart): CartResource
    {
        return CartResource::make($this->loadCart($cart));
    }

    private function currentStore(): Store
    {
        $store = app('current_store');

        abort_unless($store instanceof Store, 404);

        return $store;
    }

    private function loadCart(Cart $cart): Cart
    {
        return $cart->load([
            'lines.variant.product',
            'lines.variant.optionValues.option',
        ]);
    }
}


command: sed -n '1,220p' app/Http/Controllers/Api/Storefront/V1/CartLineController.php

aggregated_output:
<?php

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

use App\Exceptions\CartVersionMismatchException;
use App\Exceptions\InsufficientInventoryException;
use App\Exceptions\InvalidCartOperationException;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\Storefront\V1\DestroyCartLineRequest;
use App\Http\Requests\Api\Storefront\V1\StoreCartLineRequest;
use App\Http\Requests\Api\Storefront\V1\UpdateCartLineRequest;
use App\Http\Resources\Storefront\V1\CartResource;
use App\Models\Cart;
use App\Models\CartLine;
use App\Services\CartService;
use Illuminate\Http\JsonResponse;

class CartLineController extends Controller
{
    public function store(StoreCartLineRequest $request, Cart $cart, CartService $carts): CartResource|JsonResponse
    {
        try {
            $carts->addLine(
                $cart,
                (int) $request->validated('variant_id'),
                (int) $request->validated('quantity'),
                $request->expectedVersion(),
            );

            return CartResource::make($this->loadCart($cart->refresh()))
                ->response()
                ->setStatusCode(201);
        } catch (CartVersionMismatchException $exception) {
            return $this->versionMismatch($exception, $cart);
        } catch (InsufficientInventoryException|InvalidCartOperationException $exception) {
            return response()->json(['message' => $exception->getMessage()], 422);
        }
    }

    public function update(UpdateCartLineRequest $request, Cart $cart, CartLine $cartLine, CartService $carts): CartResource|JsonResponse
    {
        abort_unless((int) $cartLine->cart_id === (int) $cart->getKey(), 404);

        try {
            $carts->updateLineQuantity(
                $cart,
                $cartLine->getKey(),
                (int) $request->validated('quantity'),
                $request->expectedVersion(),
            );

            return CartResource::make($this->loadCart($cart->refresh()));
        } catch (CartVersionMismatchException $exception) {
            return $this->versionMismatch($exception, $cart);
        } catch (InsufficientInventoryException|InvalidCartOperationException $exception) {
            return response()->json(['message' => $exception->getMessage()], 422);
        }
    }

    public function destroy(DestroyCartLineRequest $request, Cart $cart, CartLine $cartLine, CartService $carts): CartResource|JsonResponse
    {
        abort_unless((int) $cartLine->cart_id === (int) $cart->getKey(), 404);

        try {
            $carts->removeLine($cart, $cartLine->getKey(), $request->expectedVersion());
        } catch (CartVersionMismatchException $exception) {
            return $this->versionMismatch($exception, $cart);
        } catch (InvalidCartOperationException $exception) {
            return response()->json(['message' => $exception->getMessage()], 422);
        }

        return CartResource::make($this->loadCart($cart->refresh()));
    }

    private function versionMismatch(CartVersionMismatchException $exception, Cart $cart): JsonResponse
    {
        return response()->json([
            'message' => $exception->getMessage(),
            'expected_cart_version' => $exception->expectedVersion,
            'current_cart_version' => $exception->currentVersion,
            'cart' => CartResource::make($this->loadCart($cart->refresh())),
        ], 409);
    }

    private function loadCart(Cart $cart): Cart
    {
        return $cart->load([
            'lines.variant.product',
            'lines.variant.optionValues.option',
        ]);
    }
}


command: rg -n "cart_token|access_token|session\(\['cart_id'|cart_id" app/Http app/Services app/Models database/migrations tests/Feature/Api/Storefront*

type: search

cmd: rg -n "cart_token|access_token|session\\(\\['cart_id'|cart_id" app/Http app/Services app/Models database/migrations 'tests/Feature/Api/Storefront*'

query: cart_token|access_token|session\(\['cart_id'|cart_id

path: Http

aggregated_output:
tests/Feature/Api/StorefrontCheckoutApiTest.php:82:            'cart_id' => $cartId,
tests/Feature/Api/StorefrontCheckoutApiTest.php:162:            'cart_id' => $cartId,
tests/Feature/Api/StorefrontOrderApiTest.php:70:            'cart_id' => $cartId,
tests/Feature/Api/StorefrontOrderApiTest.php:116:    $token = $payResponse['data']['access_token'];
app/Services/DiscountService.php:164:            ->where('cart_id', $cart->getKey())
app/Services/ShippingCalculator.php:111:            ->where('cart_id', $cart->getKey())
app/Services/ShippingCalculator.php:132:            ->where('cart_id', $cart->getKey())
app/Services/PricingEngine.php:34:                ->where('cart_id', $cart->getKey())
app/Services/PricingEngine.php:55:                ->where('cart_id', $cart->getKey())
app/Services/PricingEngine.php:94:            ->where('cart_id', $checkout->cart_id)
app/Models/OauthToken.php:22:        'access_token_hash',
app/Services/CartService.php:37:        $sessionCartId = session('cart_id');
app/Services/CartService.php:58:                session()->forget('cart_id');
app/Services/CartService.php:77:        session(['cart_id' => $cart->getKey()]);
app/Services/CartService.php:97:        $sessionCartId = session('cart_id');
app/Services/CartService.php:131:                ->where('cart_id', $lockedCart->getKey())
app/Services/CartService.php:140:                    'cart_id' => $lockedCart->getKey(),
app/Services/CartService.php:168:                ->where('cart_id', $lockedCart->getKey())
app/Services/CartService.php:189:                ->where('cart_id', $lockedCart->getKey())
app/Services/CartService.php:204:                ->where('cart_id', $guestCart->getKey())
app/Services/CartService.php:208:                        ->where('cart_id', $customerCart->getKey())
app/Services/CartService.php:221:                    $guestLine->forceFill(['cart_id' => $customerCart->getKey()])->save();
app/Models/CartLine.php:21:        'cart_id',
app/Http/Controllers/Api/Admin/V1/PageController.php:103:        if (! $request->attributes->has('sanctum_personal_access_token')) {
app/Services/CheckoutService.php:40:            if (! CartLine::withoutGlobalScopes()->where('cart_id', $cart->getKey())->exists()) {
app/Services/CheckoutService.php:45:                ->where('cart_id', $cart->getKey())
app/Services/CheckoutService.php:57:                'cart_id' => $cart->getKey(),
app/Services/CheckoutService.php:162:                ->where('cart_id', $checkout->cart_id)
app/Services/CheckoutService.php:193:                    ->where('cart_id', $checkout->cart_id)
app/Http/Controllers/Api/Admin/V1/ThemeSettingsController.php:46:        if (! $request->attributes->has('sanctum_personal_access_token')) {
app/Services/OrderService.php:487:            ->where('cart_id', $checkout->cart_id)
app/Http/Controllers/Api/Admin/V1/ShippingZoneController.php:60:        if (! $request->attributes->has('sanctum_personal_access_token')) {
database/migrations/2026_05_04_034611_create_oauth_tokens_table.php:18:            $table->string('access_token_hash')->unique('idx_oauth_tokens_access_hash');
app/Http/Controllers/Api/Admin/V1/TaxSettingsController.php:59:        if (! $request->attributes->has('sanctum_personal_access_token')) {
app/Http/Controllers/Api/Admin/V1/CustomerController.php:56:        if (! $request->attributes->has('sanctum_personal_access_token')) {
app/Http/Controllers/Api/Admin/V1/OrderRefundController.php:46:        if (! $request->attributes->has('sanctum_personal_access_token')) {
database/migrations/2026_05_03_225405_create_cart_lines_table.php:16:            $table->foreignId('cart_id')->constrained()->cascadeOnDelete();
database/migrations/2026_05_03_225405_create_cart_lines_table.php:24:            $table->index('cart_id');
database/migrations/2026_05_03_225405_create_cart_lines_table.php:25:            $table->unique(['cart_id', 'variant_id']);
app/Http/Controllers/Api/Admin/V1/StoreSettingsController.php:90:        if (! $request->attributes->has('sanctum_personal_access_token')) {
app/Http/Controllers/Api/Admin/V1/ProductController.php:231:        if (! $request->attributes->has('sanctum_personal_access_token')) {
app/Http/Requests/Api/Storefront/V1/StoreCheckoutRequest.php:25:            'cart_id' => ['required', 'integer', 'exists:carts,id'],
app/Http/Controllers/Api/Admin/V1/ThemeController.php:67:        if (! $request->attributes->has('sanctum_personal_access_token')) {
app/Http/Controllers/Api/Admin/V1/DiscountController.php:77:        if (! $request->attributes->has('sanctum_personal_access_token')) {
app/Http/Controllers/Api/Admin/V1/OrderFulfillmentController.php:40:        if (! $request->attributes->has('sanctum_personal_access_token')) {
app/Http/Controllers/Api/Admin/V1/StoreMembershipController.php:16:        $token = $request->attributes->get('sanctum_personal_access_token');
app/Http/Controllers/Api/Admin/V1/SearchIndexController.php:68:        if (! $request->attributes->has('sanctum_personal_access_token')) {
app/Models/Checkout.php:21:        'cart_id',
app/Http/Controllers/Api/Admin/V1/CollectionController.php:110:        if (! $request->attributes->has('sanctum_personal_access_token')) {
app/Http/Controllers/Api/Admin/V1/ShippingRateController.php:54:        if (! $request->attributes->has('sanctum_personal_access_token')) {
database/migrations/2026_05_03_225409_create_checkouts_table.php:17:            $table->foreignId('cart_id')->constrained()->cascadeOnDelete();
database/migrations/2026_05_03_225409_create_checkouts_table.php:32:            $table->index('cart_id');
app/Http/Controllers/Api/Admin/V1/OrderController.php:62:        if (! $request->attributes->has('sanctum_personal_access_token')) {
app/Http/Controllers/Api/Storefront/V1/CartLineController.php:42:        abort_unless((int) $cartLine->cart_id === (int) $cart->getKey(), 404);
app/Http/Controllers/Api/Storefront/V1/CartLineController.php:62:        abort_unless((int) $cartLine->cart_id === (int) $cart->getKey(), 404);
database/migrations/2026_05_04_180719_create_personal_access_tokens_table.php:14:        Schema::create('personal_access_tokens', function (Blueprint $table) {
database/migrations/2026_05_04_180719_create_personal_access_tokens_table.php:31:        Schema::dropIfExists('personal_access_tokens');
app/Http/Controllers/Api/Admin/V1/StoreInviteController.php:42:        if ($request->attributes->has('sanctum_personal_access_token')) {
app/Http/Controllers/Api/Storefront/V1/CheckoutController.php:33:        $cart = Cart::query()->findOrFail($request->validated('cart_id'));
app/Http/Controllers/Api/Admin/V1/OrderExportController.php:50:        if (! $request->attributes->has('sanctum_personal_access_token')) {
app/Http/Controllers/Api/Admin/V1/AnalyticsSummaryController.php:66:        if (! $request->attributes->has('sanctum_personal_access_token')) {
app/Http/Middleware/AuthenticatePlatformApi.php:46:        $token = $request->attributes->get('sanctum_personal_access_token');
app/Http/Middleware/AuthenticatePlatformApi.php:67:        $request->attributes->set('sanctum_personal_access_token', $token);
app/Http/Middleware/AuthenticatePlatformApi.php:68:        app()->instance('sanctum_personal_access_token', $token);
app/Http/Middleware/AuthenticateAdminApi.php:59:        $token = $request->attributes->get('sanctum_personal_access_token');
app/Http/Middleware/AuthenticateAdminApi.php:80:        $request->attributes->set('sanctum_personal_access_token', $token);
app/Http/Middleware/AuthenticateAdminApi.php:81:        app()->instance('sanctum_personal_access_token', $token);
app/Http/Resources/Storefront/V1/OrderResource.php:22:            'access_token' => OrderAccessToken::make($this->resource),
app/Http/Resources/Storefront/V1/CheckoutResource.php:20:            'cart_id' => $this->cart_id,


output:
Chunk ID: 79ac72
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 280
Output:
<?php

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

use App\Http\Controllers\Controller;
use App\Http\Requests\Api\Storefront\V1\StoreCartRequest;
use App\Http\Resources\Storefront\V1\CartResource;
use App\Models\Cart;
use App\Models\Store;
use App\Services\CartService;
use Illuminate\Http\JsonResponse;

class CartController extends Controller
{
    public function store(StoreCartRequest $request, CartService $carts): JsonResponse
    {
        $request->validated();

        return CartResource::make($this->loadCart($carts->create($this->currentStore())))
            ->response()
            ->setStatusCode(201);
    }

    public function show(Cart $cart): CartResource
    {
        return CartResource::make($this->loadCart($cart));
    }

    private function currentStore(): Store
    {
        $store = app('current_store');

        abort_unless($store instanceof Store, 404);

        return $store;
    }

    private function loadCart(Cart $cart): Cart
    {
        return $cart->load([
            'lines.variant.product',
            'lines.variant.optionValues.option',
        ]);
    }
}