Input:
*** Begin Patch
*** Update File: specs/progress.md
@@
-| Business logic | `specs/05-BUSINESS-LOGIC.md` | partial | `ResolveStore`, `BelongsToStore`, `StoreScope`, role checks, customer guard provider, store-scoped `CustomerPasswordResetService`, catalog product lifecycle transitions, SKU uniqueness, variant matrix rebuilding, inventory reserve/release/commit/restock, automatic variant inventory, variant option mapping, media resize/cleanup job, `SearchService`, product observer FTS sync, search query logging, search API rate limiting, `AnalyticsService`, analytics event deduplication, daily analytics aggregation, scheduled `AggregateAnalytics`, analytics API rate limiting, `WebhookService`, timestamped HMAC signing/verification, database-queued `DeliverWebhook`, delivery logging/retry backoff metadata, webhook circuit breaker pausing, domain-event webhook dispatch listener, developer token generation backed by `oauth_tokens`, `CartService`, `DiscountService` including one-per-customer code discount redemption checks, `ShippingCalculator`, `TaxCalculator`, `PricingEngine`, checkout transitions through completion, checkout expiration, abandoned cart cleanup, session cart lookup, cart discount handoff to checkout, customer login cart merge, product add-to-cart mutations, `PaymentService`, mock PSP, idempotent order creation, checkout UI/API order submission, session/customer/token-gated order confirmation and lookup, customer order-history scoping, admin dashboard order aggregates, admin analytics reporting, admin customer address management, admin discount rule management, admin content/theme/navigation/settings/search/apps/developers orchestration, admin order UI/API action orchestration, `RefundService`, `FulfillmentService`, bank-transfer payment confirmation, and unpaid bank-transfer cancellation are implemented. |
+| Business logic | `specs/05-BUSINESS-LOGIC.md` | partial | `ResolveStore`, `BelongsToStore`, `StoreScope`, role checks, customer guard provider, store-scoped `CustomerPasswordResetService`, catalog product lifecycle transitions, SKU uniqueness, variant matrix rebuilding, inventory reserve/release/commit/restock, automatic variant inventory, variant option mapping, media resize/cleanup job, `SearchService` with per-store synonym expansion and stop-word removal, product observer FTS sync, search query logging, search API rate limiting, `AnalyticsService`, analytics event deduplication, daily analytics aggregation, scheduled `AggregateAnalytics`, analytics API rate limiting, `WebhookService`, timestamped HMAC signing/verification, database-queued `DeliverWebhook`, delivery logging/retry backoff metadata, webhook circuit breaker pausing, domain-event webhook dispatch listener, developer token generation backed by `oauth_tokens`, `CartService`, `DiscountService` including one-per-customer code discount redemption checks, `ShippingCalculator`, `TaxCalculator`, `PricingEngine`, checkout transitions through completion, checkout expiration, abandoned cart cleanup, session cart lookup, cart discount handoff to checkout, customer login cart merge, product add-to-cart mutations, `PaymentService`, mock PSP, idempotent order creation, checkout UI/API order submission, session/customer/token-gated order confirmation and lookup, customer order-history scoping, admin dashboard order aggregates, admin analytics reporting, admin customer address management, admin discount rule management, admin content/theme/navigation/settings/search/apps/developers orchestration, admin order UI/API action orchestration, `RefundService`, `FulfillmentService`, bank-transfer payment confirmation, and unpaid bank-transfer cancellation are implemented. |
@@
 - 2026-05-04: `php artisan test --compact tests/Feature/Checkout/PricingServicesTest.php tests/Feature/Orders/OrderServiceTest.php` passed: 11 tests, 63 assertions.
 - 2026-05-04: `php artisan test --compact tests/Feature/Checkout tests/Feature/Orders tests/Feature/Api/StorefrontCheckoutApiTest.php tests/Feature/Storefront/CartCheckoutUiTest.php` passed after the discount redemption-history changes: 33 tests, 196 assertions.
 - 2026-05-04: `php artisan test --compact` passed after the discount redemption-history changes: 192 tests, 966 assertions.
+- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 query/database docs and Pest 4 testing docs before the search synonym/stop-word parser changes.
+- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the search synonym/stop-word parser changes.
+- 2026-05-04: `php artisan test --compact tests/Feature/Search/SearchServiceTest.php` passed after the search synonym/stop-word parser changes: 5 tests, 15 assertions.
+- 2026-05-04: `php artisan test --compact tests/Feature/Search/SearchServiceTest.php tests/Feature/Api/StorefrontSearchApiTest.php tests/Feature/Admin/SearchSettingsTest.php` passed after the search synonym/stop-word parser changes: 12 tests, 50 assertions.
+- 2026-05-04: `php artisan test --compact` passed after the search synonym/stop-word parser changes: 194 tests, 970 assertions.
@@
-- Search settings capture synonyms and stop words now, but the initial FTS implementation does not expand synonyms or remove stop words from queries yet; the admin page can rebuild the per-store index synchronously for this self-contained app.
+- Search settings apply at query time: per-store stop words are removed, synonym groups expand into sanitized SQLite FTS5 `OR` terms, and terms are joined with explicit `AND`; the admin page can rebuild the per-store index synchronously for this self-contained app.
@@
-- Search synonyms and stop words are stored through the admin UI but are not yet applied during FTS query parsing.
*** End Patch


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

@@ -32,3 +32,3 @@
 | Storefront UI | `specs/04-STOREFRONT-UI.md` | partial | Storefront layout, home, collections index/detail, product detail, SQLite-backed search with filters/sort/pagination, breadcrumbs, price display, product cards, DB-backed pages, seeded navigation menus, cached theme settings, product add-to-cart persistence, cart drawer, cart page with discount and shipping estimates, checkout address/shipping/discount/payment-selection/order-submission UI, order confirmation, customer password reset forms, and customer order list/detail views render seeded and runtime data. |
-| Business logic | `specs/05-BUSINESS-LOGIC.md` | partial | `ResolveStore`, `BelongsToStore`, `StoreScope`, role checks, customer guard provider, store-scoped `CustomerPasswordResetService`, catalog product lifecycle transitions, SKU uniqueness, variant matrix rebuilding, inventory reserve/release/commit/restock, automatic variant inventory, variant option mapping, media resize/cleanup job, `SearchService`, product observer FTS sync, search query logging, search API rate limiting, `AnalyticsService`, analytics event deduplication, daily analytics aggregation, scheduled `AggregateAnalytics`, analytics API rate limiting, `WebhookService`, timestamped HMAC signing/verification, database-queued `DeliverWebhook`, delivery logging/retry backoff metadata, webhook circuit breaker pausing, domain-event webhook dispatch listener, developer token generation backed by `oauth_tokens`, `CartService`, `DiscountService` including one-per-customer code discount redemption checks, `ShippingCalculator`, `TaxCalculator`, `PricingEngine`, checkout transitions through completion, checkout expiration, abandoned cart cleanup, session cart lookup, cart discount handoff to checkout, customer login cart merge, product add-to-cart mutations, `PaymentService`, mock PSP, idempotent order creation, checkout UI/API order submission, session/customer/token-gated order confirmation and lookup, customer order-history scoping, admin dashboard order aggregates, admin analytics reporting, admin customer address management, admin discount rule management, admin content/theme/navigation/settings/search/apps/developers orchestration, admin order UI/API action orchestration, `RefundService`, `FulfillmentService`, bank-transfer payment confirmation, and unpaid bank-transfer cancellation are implemented. |
+| Business logic | `specs/05-BUSINESS-LOGIC.md` | partial | `ResolveStore`, `BelongsToStore`, `StoreScope`, role checks, customer guard provider, store-scoped `CustomerPasswordResetService`, catalog product lifecycle transitions, SKU uniqueness, variant matrix rebuilding, inventory reserve/release/commit/restock, automatic variant inventory, variant option mapping, media resize/cleanup job, `SearchService` with per-store synonym expansion and stop-word removal, product observer FTS sync, search query logging, search API rate limiting, `AnalyticsService`, analytics event deduplication, daily analytics aggregation, scheduled `AggregateAnalytics`, analytics API rate limiting, `WebhookService`, timestamped HMAC signing/verification, database-queued `DeliverWebhook`, delivery logging/retry backoff metadata, webhook circuit breaker pausing, domain-event webhook dispatch listener, developer token generation backed by `oauth_tokens`, `CartService`, `DiscountService` including one-per-customer code discount redemption checks, `ShippingCalculator`, `TaxCalculator`, `PricingEngine`, checkout transitions through completion, checkout expiration, abandoned cart cleanup, session cart lookup, cart discount handoff to checkout, customer login cart merge, product add-to-cart mutations, `PaymentService`, mock PSP, idempotent order creation, checkout UI/API order submission, session/customer/token-gated order confirmation and lookup, customer order-history scoping, admin dashboard order aggregates, admin analytics reporting, admin customer address management, admin discount rule management, admin content/theme/navigation/settings/search/apps/developers orchestration, admin order UI/API action orchestration, `RefundService`, `FulfillmentService`, bank-transfer payment confirmation, and unpaid bank-transfer cancellation are implemented. |
 | Auth/security | `specs/06-AUTH-AND-SECURITY.md` | partial | Admin Livewire login/logout, customer guard/provider, customer login/register, customer password reset with store-scoped hashed tokens, reset-link throttling, generic reset responses, login rate limiting, session hardening, resource policies, encrypted app client/webhook secrets, and one-time display developer token generation are implemented. Sanctum token auth is still missing. |
@@ -254,2 +254,7 @@
 - 2026-05-04: `php artisan test --compact` passed after the discount redemption-history changes: 192 tests, 966 assertions.
+- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 query/database docs and Pest 4 testing docs before the search synonym/stop-word parser changes.
+- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the search synonym/stop-word parser changes.
+- 2026-05-04: `php artisan test --compact tests/Feature/Search/SearchServiceTest.php` passed after the search synonym/stop-word parser changes: 5 tests, 15 assertions.
+- 2026-05-04: `php artisan test --compact tests/Feature/Search/SearchServiceTest.php tests/Feature/Api/StorefrontSearchApiTest.php tests/Feature/Admin/SearchSettingsTest.php` passed after the search synonym/stop-word parser changes: 12 tests, 50 assertions.
+- 2026-05-04: `php artisan test --compact` passed after the search synonym/stop-word parser changes: 194 tests, 970 assertions.

@@ -293,3 +298,3 @@
 - Search uses a local SQLite FTS5 virtual table (`products_fts`) with `SearchService` as the only query/sync boundary; product observer events keep the index synchronized for create/update/delete.
-- Search settings capture synonyms and stop words now, but the initial FTS implementation does not expand synonyms or remove stop words from queries yet; the admin page can rebuild the per-store index synchronously for this self-contained app.
+- Search settings apply at query time: per-store stop words are removed, synonym groups expand into sanitized SQLite FTS5 `OR` terms, and terms are joined with explicit `AND`; the admin page can rebuild the per-store index synchronously for this self-contained app.
 - Search API rate limiting is registered as `search` (30/minute per IP), analytics ingestion uses the `analytics` limiter (60/minute per IP), and a `webhooks` limiter is registered for future inbound app endpoints.
@@ -317,3 +322,2 @@
 - Navigation drag-and-drop nesting is still missing; the current admin supports flat ordered menu items with up/down controls.
-- Search synonyms and stop words are stored through the admin UI but are not yet applied during FTS query parsing.
 - OAuth/Passport app API endpoints and broader admin REST endpoints outside order management are still missing; outbound webhook subscription management is implemented through the admin UI only, as specified for the initial implementation.


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


The progress log now reflects the parser behavior and removes the stale search gap. I’m doing a quick diff hygiene check, then I’ll stage and commit this slice.


 app/Services/SearchService.php             | 95 ++++++++++++++++++++++++++++--
 specs/progress.md                          | 10 +++-
 tests/Feature/Search/SearchServiceTest.php | 51 ++++++++++++++++
 3 files changed, 147 insertions(+), 9 deletions(-)


Output:
Chunk ID: 5b4c93
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 0
Output:


 M app/Services/SearchService.php
 M specs/progress.md
 M tests/Feature/Search/SearchServiceTest.php


Output:
Chunk ID: 6c3a22
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 26
Output:
 M app/Services/SearchService.php
 M specs/progress.md
 M tests/Feature/Search/SearchServiceTest.php


Output:
Chunk ID: 713d85
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 63
Output:
 app/Services/SearchService.php             | 95 ++++++++++++++++++++++++++++--
 specs/progress.md                          | 10 +++-
 tests/Feature/Search/SearchServiceTest.php | 51 ++++++++++++++++
 3 files changed, 147 insertions(+), 9 deletions(-)


git add app/Services/SearchService.php tests/Feature/Search/SearchServiceTest.php specs/progress.md && git commit -m "Apply search synonyms and stop words"


git add app/Services/SearchService.php tests/Feature/Search/SearchServiceTest.php specs/progress.md && git commit -m "Apply search synonyms and stop words"

[2026-05-04-codex-5-5-goal 054eefac] Apply search synonyms and stop words
 3 files changed, 147 insertions(+), 9 deletions(-)


Output:
Chunk ID: c741f4
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 32
Output:
[2026-05-04-codex-5-5-goal 054eefac] Apply search synonyms and stop words
 3 files changed, 147 insertions(+), 9 deletions(-)


Search parser is committed as `054eefac`. The next narrow gap I’m taking is automatic discount attribution, because reporting cannot be complete while automatic discounts affect totals but do not leave an order-line allocation trail.


eloquent json casts query testing
database transactions service tests
json assertions pest


# Search Results

## Upgrade Guide
Upgrade Guide | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/upgrade-guide.md

---
title: Upgrade Guide
description: Upgrading To 4.x From 3.x
---

## Upgrading To 4.x From 3.x

> **Estimated Upgrade Time**: 2 minutes

We make an effort to document every potential breaking change, but some of these changes may exist in less frequently used sections of the framework. As a result, only a subset of these changes may impact your application.

### Updating Dependencies

> Likelihood Of Impact: High

Pest 4 now requires PHP 8.3.0 or greater. To start migrating from Pest 3 to Pest 4, update the `pestphp/pest` dependency to `^4.0` in your application's `composer.json` file.

```diff
-    "pestphp/pest": "^3.0",
+    "pestphp/pest": "^4.0",
```

All other Pest maintained plugins should be updated to version `^4.0` in your application's `composer.json` file.

```diff
-    "pestphp/pest-plugin-laravel": "^3.0",
+    "pestphp/pest-plugin-laravel": "^4.0",
```

### Snapshot Testing Changes

> Likelihood Of Impact: High

If you were using the `toMatchSnapshot`, Pest 4 changes the way snapshots names are generated. As such, you will need to update your snapshot names using the `--update-snapshots` option:

```bash
./vendor/bin/pest --update-snapshots
```

### PHPUnit 12 Changes

> Likelihood Of Impact: Medium

Pest 4 is built on top of PHPUnit 12. This means that any notable changes made to PHPUnit 12 might have an impact on your test suite. To examine all the changes introduced in PHPUnit 12, please consult the [PHPUnit 12 changelog](https://github.com/sebastianbergmann/phpunit/blob/12.0.0/ChangeLog-12.0.md).

### Watch & Faker Plugin Deprecations

> Likelihood Of Impact: Low

The `pestphp/pest-plugin-watch` and `pestphp/pest-plugin-faker` plugins have been archived and are no longer maintained. The functionality provided by these plugins was not widely used, and therefore, they have been removed from Pest 4.

---

## Upgrading To 3.x From 2.x

> **Estimated Upgrade Time**: 2 minutes

We make an effort to document every potential breaking change, but some of these changes may exist in less frequently used sections of the framework. As a result, only a subset of these changes may impact your application.

### Updating Dependencies

> Likelihood Of Impact: High

Pest 3 now requires PHP 8.2.0 or greater. To start migrating from Pest 2 to Pest 3, update the `pestphp/pest` dependency to `^3.0` in your application's `composer.json` file.

```diff
-    "pestphp/pest": "^2.0",
+    "pestphp/pest": "^3.0",
```

In addition, if you are using Laravel, please upgrade Collision to version 8. Note that, Laravel 11 is required.

```diff
-    "nunomaduro/collision": "^7.0",
+    "nunomaduro/collision": "^8.0",
```

All other Pest maintained plugins should be updated to version `^3.0` in your application's `composer.json` file.

```diff
-    "pestphp/pest-plugin-laravel": "^2.0",
+    "pestphp/pest-plugin-laravel": "^3.0",
```

### PHPUnit 11 Changes

> Likelihood Of Impact: Medium

Pest 3 is built on top of PHPUnit 11. This means that any notable changes made to PHPUnit 11 might have an impact on your test suite. To examine all the changes introduced in PHPUnit 11, please consult the [PHPUnit 11 changelog](https://github.com/sebastianbergmann/phpunit/blob/11.0.0/ChangeLog-11.0.md).

### `toHaveMethod` and `toHaveMethods` Expectations

> Likelihood Of Impact: Low

The `toHaveMethod` and `toHaveMethods` expectations were replaced by the `toHaveMethod` and `toHaveMethods` architectural expectations. If you were using these expectations, you can no longer provide a object as architectural expectations expect an namespace or a class name.

```diff
-expect($object)->toHaveMethod('method');
+expect($object::class)->toHaveMethod('method');
```

### `pest()`

### Pest 2 Deprecations

During Pest 2 release, some features were deprecated and are now removed in Pest 3. Here are the changes you should be aware of:

#### `tap()` Method

> Likelihood Of Impact: Low

When performing high order testing, you might have utilized the `tap()` method to invoke assertions on an object that needs lazy evaluation during runtime. With Pest 2, the `tap()` method is deprecated, and on Pest 3 it was removed. Instead, you should use the `defer()` method.

```diff
it('creates admins')
-    ->tap(fn () => $this->artisan('user:create --admin'))
+    ->defer(fn () => $this->artisan('user:create --admin'))
     ->assertDatabaseHas('users', ['id' => 1]);
```

---

## Upgrading To 2.x From 1.x

> **Estimated Upgrade Time**: 2 minutes

We make an effort to document every potential breaking change, but some of these changes may exist in less frequently used sections of the framework. As a result, only a subset of these changes may impact your application.

### Updating Dependencies

> Likelihood Of Impact: High

Pest 2 requires PHP 8.1.0 or greater. To start migrating from Pest 1 to Pest 2, update the `pestphp/pest` dependency to `^2.0` in your application's `composer.json` file.

```diff
-    "pestphp/pest": "^1.22",
+    "pestphp/pest": "^2.0",
```

Next, you can remove PHPUnit from your list of dependencies if it is included.

```diff
-    "phpunit/phpunit": "^9.5.10",
```

In addition, if you are using Laravel, please upgrade Collision to version 7. Note that, Laravel 10 is required.

```diff
-    "nunomaduro/collision": "^6.0",
+    "nunomaduro/collision": "^7.0",
```

If you are using the Parallel Plugin (or Paratest), you may remove it from your dependencies since it is now included with Pest by default.

```diff
-    "brianium/paratest": "^6.8.1",
-    "pestphp/pest-plugin-parallel": "^1.2.1",
```

The Global Assertions Plugin is archived and should be removed from your dependencies.

```diff
-    "pestphp/pest-plugin-global-assertions": "^1.0.0",
```

If you relied on the Global Assertions Plugin, you may access the same underlying assertions using the `$this` variable. Alternatively, you may migrate to the [Expectation API](/docs/expectations).

```diff
test('sum', function () {
    $result = sum(1, 2);

-   assertSame(3, $result);
+   $this->assertSame(3, $result); // or expect($result)->toBe(3)
});
```

All other Pest maintained plugins should be updated to version `^2.0` in your application's `composer.json` file.

```diff
-    "pestphp/pest-plugin-laravel": "^1.4",
+    "pestphp/pest-plugin-laravel": "^2.0",
```

If you are using the Faker Plugin, the `faker()` function has been renamed to `fake()`. You will need to update all uses.

```diff
- use function Pest\Faker\faker;
+ use function Pest\Faker\fake;

test('faker', function () {
-   expect(faker()->name())->toBeString();
+   expect(fake()->name())->toBeString();
});
```

### PHPUnit 10 Changes

> Likelihood Of Impact: Medium

If you were previously using PHPUnit instead of Pest, it's possible that your `phpunit.xml` file needs to be updated. When this is the case, you may encounter the following message when running Pest 2 for the first time.

```php
  WARN  Your XML configuration validates against a deprecated schema. Migrate your XML configuration using "--migrate-configuration"!
```

To address this issue, simply re-run Pest with the `--migrate-configuration` option.

```bash
./vendor/bin/pest --migrate-configuration
```

Pest 2 is built on top of PHPUnit 10. This means that any notable changes made to PHPUnit 10 might have an impact on your test suite. To examine all the changes introduced in PHPUnit 10, please consult the [PHPUnit 10 changelog](https://github.com/sebastianbergmann/phpunit/blob/10.0.0/ChangeLog-10.0.md#1000---2023-02-03).

### High Order Testing

> Likelihood Of Impact: Low

When performing high order testing, you might have utilized the `tap()` method to invoke assertions on an object that needs lazy evaluation during runtime. With Pest 2, the `tap()` method is deprecated. Instead, you should use the `defer()` method.

```diff
it('creates admins')
-    ->tap(fn () => $this->artisan('user:create --admin'))
+    ->defer(fn () => $this->artisan('user:create --admin'))
     ->assertDatabaseHas('users', ['id' => 1]);
```

### Datasets

#### Bound Datasets

> Likelihood Of Impact: Very low

If you are utilizing "bound" datasets and binding a single dataset argument, you must now type the corresponding test parameter.

```diff
-it('can generate the full name of a user', function ($user, $fullName) {
+it('can generate the full name of a user', function (User $user, $fullName) {
    expect($user->full_name)->toBe($fullName);
})->with([
    [fn() => User::factory()->create(['first_name' => 'Nuno', 'last_name' => 'Maduro']), 'Nuno Maduro'],
    [fn() => User::factory()->create(['first_name' => 'Luke', 'last_name' => 'Downing']), 'Luke Downing'],
    [fn() => User::factory()->create(['first_name' => 'Freek', 'last_name' => 'Van Der Herten']), 'Freek Van Der Herten'],
]);
```

#### Scoped Datasets

> Likelihood Of Impact: Very low

Although we previously documented in Pest 1 that datasets should only be declared using the `dataset` function in the `tests/Pest.php` or `tests/Datasets.php` files, you could actually declare datasets in any test file within your test suite. However, in Pest 2, with the introduction of [scoped datasets](/docs/datasets#content-scoped-datasets), datasets declared in a test file can only be utilized within that same test file. Therefore, if you have a dataset that needs to be accessible globally, please ensure that it is placed in either the `tests/Pest.php` or `tests/Datasets.php` files.

---

This concludes the Pest 2 upgrade guide. On the next chapter, we'll cover how can you easily migrate your tests from PHPUnit to Pest: [Migrating From PHPUnit](/docs/migrating-from-phpunit-guide)

---

## Verification Introduction Database Preparation
Verification | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/verification.md

### Database Preparation

Next, your `users` table must contain an `email_verified_at` column to store the date and time that the user's email address was verified. Typically, this is included in Laravel's default `0001_01_01_000000_create_users_table.php` database migration.

---

## Urls URLs for Named Routes Eloquent Models
Urls | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/urls.md

#### Eloquent Models

You will often be generating URLs using the route key (typically the primary key) of [Eloquent models](/docs/{{version}}/eloquent). For this reason, you may pass Eloquent models as parameter values. The `route` helper will automatically extract the model's route key:

```php
echo route('post.show', ['post' => $post]);
```

---

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

## Available Assertions

---

## Http Tests Available Assertions Response Assertions
Http Tests | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/http-tests.md

### Response Assertions

Laravel's `Illuminate\Testing\TestResponse` class provides a variety of custom assertion methods that you may utilize when testing your application. These assertions may be accessed on the response that is returned by the `json`, `get`, `post`, `put`, and `delete` test methods:

[assertAccepted](#assert-accepted)
[assertBadRequest](#assert-bad-request)
[assertClientError](#assert-client-error)
[assertConflict](#assert-conflict)
[assertCookie](#assert-cookie)
[assertCookieExpired](#assert-cookie-expired)
[assertCookieNotExpired](#assert-cookie-not-expired)
[assertCookieMissing](#assert-cookie-missing)
[assertCreated](#assert-created)
[assertDontSee](#assert-dont-see)
[assertDontSeeText](#assert-dont-see-text)
[assertDownload](#assert-download)
[assertExactJson](#assert-exact-json)
[assertExactJsonStructure](#assert-exact-json-structure)
[assertFailedDependency](#assert-failed-dependency)
[assertForbidden](#assert-forbidden)
[assertFound](#assert-found)
[assertGone](#assert-gone)
[assertHeader](#assert-header)
[assertHeaderContains](#assert-header-contains)
[assertHeaderMissing](#assert-header-missing)
[assertInternalServerError](#assert-internal-server-error)
[assertJson](#assert-json)
[assertJsonCount](#assert-json-count)
[assertJsonFragment](#assert-json-fragment)
[assertJsonIsArray](#assert-json-is-array)
[assertJsonIsObject](#assert-json-is-object)
[assertJsonMissing](#assert-json-missing)
[assertJsonMissingExact](#assert-json-missing-exact)
[assertJsonMissingValidationErrors](#assert-json-missing-validation-errors)
[assertJsonPath](#assert-json-path)
[assertJsonMissingPath](#assert-json-missing-path)
[assertJsonStructure](#assert-json-structure)
[assertJsonValidationErrors](#assert-json-validation-errors)
[assertJsonValidationErrorFor](#assert-json-validation-error-for)
[assertLocation](#assert-location)
[assertMethodNotAllowed](#assert-method-not-allowed)
[assertMovedPermanently](#assert-moved-permanently)
[assertContent](#assert-content)
[assertNoContent](#assert-no-content)
[assertStreamed](#assert-streamed)
[assertStreamedContent](#assert-streamed-content)
[assertNotFound](#assert-not-found)
[assertOk](#assert-ok)
[assertPaymentRequired](#assert-payment-required)
[assertPlainCookie](#assert-plain-cookie)
[assertRedirect](#assert-redirect)
[assertRedirectBack](#assert-redirect-back)
[assertRedirectBackWithErrors](#assert-redirect-back-with-errors)
[assertRedirectBackWithoutErrors](#assert-redirect-back-without-errors)
[assertRedirectContains](#assert-redirect-contains)
[assertRedirectToRoute](#assert-redirect-to-route)
[assertRedirectToSignedRoute](#assert-redirect-to-signed-route)
[assertRequestTimeout](#assert-request-timeout)
[assertSee](#assert-see)
[assertSeeInOrder](#assert-see-in-order)
[assertSeeText](#assert-see-text)
[assertSeeTextInOrder](#assert-see-text-in-order)
[assertServerError](#assert-server-error)
[assertServiceUnavailable](#assert-service-unavailable)
[assertSessionHas](#assert-session-has)
[assertSessionHasInput](#assert-session-has-input)
[assertSessionHasAll](#assert-session-has-all)
[assertSessionHasErrors](#assert-session-has-errors)
[assertSessionHasErrorsIn](#assert-session-has-errors-in)
[assertSessionHasNoErrors](#assert-session-has-no-errors)
[assertSessionDoesntHaveErrors](#assert-session-doesnt-have-errors)
[assertSessionMissing](#assert-session-missing)
[assertStatus](#assert-status)
[assertSuccessful](#assert-successful)
[assertTooManyRequests](#assert-too-many-requests)
[assertUnauthorized](#assert-unauthorized)
[assertUnprocessable](#assert-unprocessable)
[assertUnsupportedMediaType](#assert-unsupported-media-type)
[assertValid](#assert-valid)
[assertInvalid](#assert-invalid)
[assertViewHas](#assert-view-has)
[assertViewHasAll](#assert-view-has-all)
[assertViewIs](#assert-view-is)
[assertViewMissing](#assert-view-missing)

---

## Upgrade Upgrading To 12.0 From 11.x Database Database Constructor Signature Changes
Upgrade | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/upgrade.md

#### Database Constructor Signature Changes

**Likelihood Of Impact: Very Low**

In Laravel 12, several low-level database classes now require an `Illuminate\Database\Connection` instance to be provided via their constructors.

**These changes are primarily applicable to database package maintainers - it is extremely unlikely any of these changes affect normal application development.**

`Illuminate\Database\Schema\Blueprint`

The constructor of the `Illuminate\Database\Schema\Blueprint` class now expects a `Connection` instance as its first argument. This primarily affects applications or packages that manually instantiate `Blueprint` instances.

`Illuminate\Database\Grammar`

The constructor of the `Illuminate\Database\Grammar` class also now requires a `Connection` instance. In previous versions, the connection was assigned after construction using the `setConnection()` method. This method has been removed in Laravel 12:

```php
// Laravel <= 11.x
$grammar = new MySqlGrammar;
$grammar->setConnection($connection);

// Laravel >= 12.x
$grammar = new MySqlGrammar($connection);
````

In addition, the following APIs have been removed or deprecated:

- The `Blueprint::getPrefix()` method is deprecated.
- The `Connection::withTablePrefix()` method has been removed.
- The `Grammar::getTablePrefix()` and `setTablePrefix()` methods are deprecated.
- The `Grammar::setConnection()` method has been removed.

When working with table prefixes, you should now retrieve them directly from the database connection:

```php
$prefix = $connection->getTablePrefix();
```

If you maintain custom database drivers, schema builders, or grammar implementations, you should review their constructors and ensure a `Connection` instance is provided.

---

## Upgrade Upgrading To 12.0 From 11.x Eloquent Models and UUIDv7
Upgrade | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/upgrade.md

#### Models and UUIDv7

**Likelihood Of Impact: Medium**

The `HasUuids` trait now returns UUIDs that are compatible with version 7 of the UUID spec (ordered UUIDs). If you would like to continue using ordered UUIDv4 strings for your model's IDs, you should now use the `HasVersion4Uuids` trait:

```php
use Illuminate\Database\Eloquent\Concerns\HasUuids; // [tl! remove]
use Illuminate\Database\Eloquent\Concerns\HasVersion4Uuids as HasUuids; // [tl! add]
```

The `HasVersion7Uuids` trait has been removed. If you were previously using this trait, you should use the `HasUuids` trait instead, which now provides the same behavior.

---

## Database Database Transactions
Database | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/database.md

## Database Transactions

You may use the `transaction` method provided by the `DB` facade to run a set of operations within a database transaction. If an exception is thrown within the transaction closure, the transaction will automatically be rolled back and the exception is re-thrown. If the closure executes successfully, the transaction will automatically be committed. You don't need to worry about manually rolling back or committing while using the `transaction` method:

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

DB::transaction(function () {
    DB::update('update users set votes = 1');

    DB::delete('delete from posts');
});
```

---

## Eloquent Mutators Attribute Casting Array and JSON Casting JSON and Unicode
Eloquent Mutators | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/eloquent-mutators.md

#### JSON and Unicode

If you would like to store an array attribute as JSON with unescaped Unicode characters, you may use the `json:unicode` cast:

```php
/**
 * Get the attributes that should be cast.
 *
 * @return array<string, string>
 */
protected function casts(): array
{
    return [
        'options' => 'json:unicode',
    ];
}
```

---

## Database Testing Available Assertions
Database Testing | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/database-testing.md

## Available Assertions

Laravel provides several database assertions for your [Pest](https://pestphp.com) or [PHPUnit](https://phpunit.de) feature tests. We'll discuss each of these assertions below.

_[Truncated]_


Output:
Wall time: 3.5629 seconds
Output:
[{"type":"text","text":"# Search Results\n\n## Upgrade Guide\nUpgrade Guide | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/upgrade-guide.md\n\n---\ntitle: Upgrade Guide\ndescription: Upgrading To 4.x From 3.x\n---\n\n## Upgrading To 4.x From 3.x\n\n> **Estimated Upgrade Time**: 2 minutes\n\nWe make an effort to document every potential breaking change, but some of these changes may exist in less frequently used sections of the framework. As a result, only a subset of these changes may impact your application.\n\n### Updating Dependencies\n\n> Likelihood Of Impact: High\n\nPest 4 now requires PHP 8.3.0 or greater. To start migrating from Pest 3 to Pest 4, update the `pestphp/pest` dependency to `^4.0` in your application's `composer.json` file.\n\n```diff\n-    \"pestphp/pest\": \"^3.0\",\n+    \"pestphp/pest\": \"^4.0\",\n```\n\nAll other Pest maintained plugins should be updated to version `^4.0` in your application's `composer.json` file.\n\n```diff\n-    \"pestphp/pest-plugin-laravel\": \"^3.0\",\n+    \"pestphp/pest-plugin-laravel\": \"^4.0\",\n```\n\n### Snapshot Testing Changes\n\n> Likelihood Of Impact: High\n\nIf you were using the `toMatchSnapshot`, Pest 4 changes the way snapshots names are generated. As such, you will need to update your snapshot names using the `--update-snapshots` option:\n\n```bash\n./vendor/bin/pest --update-snapshots\n```\n\n### PHPUnit 12 Changes\n\n> Likelihood Of Impact: Medium\n\nPest 4 is built on top of PHPUnit 12. This means that any notable changes made to PHPUnit 12 might have an impact on your test suite. To examine all the changes introduced in PHPUnit 12, please consult the [PHPUnit 12 changelog](https://github.com/sebastianbergmann/phpunit/blob/12.0.0/ChangeLog-12.0.md).\n\n### Watch & Faker Plugin Deprecations\n\n> Likelihood Of Impact: Low\n\nThe `pestphp/pest-plugin-watch` and `pestphp/pest-plugin-faker` plugins have been archived and are no longer maintained. The functionality provided by these plugins was not widely used, and therefore, they have been removed from Pest 4.\n\n---\n\n## Upgrading To 3.x From 2.x\n\n> **Estimated Upgrade Time**: 2 minutes\n\nWe make an effort to document every potential breaking change, but some of these changes may exist in less frequently used sections of the framework. As a result, only a subset of these changes may impact your application.\n\n### Updating Dependencies\n\n> Likelihood Of Impact: High\n\nPest 3 now requires PHP 8.2.0 or greater. To start migrating from Pest 2 to Pest 3, update the `pestphp/pest` dependency to `^3.0` in your application's `composer.json` file.\n\n```diff\n-    \"pestphp/pest\": \"^2.0\",\n+    \"pestphp/pest\": \"^3.0\",\n```\n\nIn addition, if you are using Laravel, please upgrade Collision to version 8. Note that, Laravel 11 is required.\n\n```diff\n-    \"nunomaduro/collision\": \"^7.0\",\n+    \"nunomaduro/collision\": \"^8.0\",\n```\n\nAll other Pest maintained plugins should be updated to version `^3.0` in your application's `composer.json` file.\n\n```diff\n-    \"pestphp/pest-plugin-laravel\": \"^2.0\",\n+    \"pestphp/pest-plugin-laravel\": \"^3.0\",\n```\n\n### PHPUnit 11 Changes\n\n> Likelihood Of Impact: Medium\n\nPest 3 is built on top of PHPUnit 11. This means that any notable changes made to PHPUnit 11 might have an impact on your test suite. To examine all the changes introduced in PHPUnit 11, please consult the [PHPUnit 11 changelog](https://github.com/sebastianbergmann/phpunit/blob/11.0.0/ChangeLog-11.0.md).\n\n### `toHaveMethod` and `toHaveMethods` Expectations\n\n> Likelihood Of Impact: Low\n\nThe `toHaveMethod` and `toHaveMethods` expectations were replaced by the `toHaveMethod` and `toHaveMethods` architectural expectations. If you were using these expectations, you can no longer provide a object as architectural expectations expect an namespace or a class name.\n\n```diff\n-expect($object)->toHaveMethod('method');\n+expect($object::class)->toHaveMethod('method');\n```\n\n### `pest()` \n\n### Pest 2 Deprecations\n\nDuring Pest 2 release, some features were deprecated and are now removed in Pest 3. Here are the changes you should be aware of:\n\n#### `tap()` Method\n\n> Likelihood Of Impact: Low\n\nWhen performing high order testing, you might have utilized the `tap()` method to invoke assertions on an object that needs lazy evaluation during runtime. With Pest 2, the `tap()` method is deprecated, and on Pest 3 it was removed. Instead, you should use the `defer()` method.\n\n```diff\nit('creates admins')\n-    ->tap(fn () => $this->artisan('user:create --admin'))\n+    ->defer(fn () => $this->artisan('user:create --admin'))\n     ->assertDatabaseHas('users', ['id' => 1]);\n```\n\n---\n\n## Upgrading To 2.x From 1.x\n\n> **Estimated Upgrade Time**: 2 minutes\n\nWe make an effort to document every potential breaking change, but some of these changes may exist in less frequently used sections of the framework. As a result, only a subset of these changes may impact your application.\n\n### Updating Dependencies\n\n> Likelihood Of Impact: High\n\nPest 2 requires PHP 8.1.0 or greater. To start migrating from Pest 1 to Pest 2, update the `pestphp/pest` dependency to `^2.0` in your application's `composer.json` file.\n\n```diff\n-    \"pestphp/pest\": \"^1.22\",\n+    \"pestphp/pest\": \"^2.0\",\n```\n\nNext, you can remove PHPUnit from your list of dependencies if it is included.\n\n```diff\n-    \"phpunit/phpunit\": \"^9.5.10\",\n```\n\nIn addition, if you are using Laravel, please upgrade Collision to version 7. Note that, Laravel 10 is required.\n\n```diff\n-    \"nunomaduro/collision\": \"^6.0\",\n+    \"nunomaduro/collision\": \"^7.0\",\n```\n\nIf you are using the Parallel Plugin (or Paratest), you may remove it from your dependencies since it is now included with Pest by default.\n\n```diff\n-    \"brianium/paratest\": \"^6.8.1\",\n-    \"pestphp/pest-plugin-parallel\": \"^1.2.1\",\n```\n\nThe Global Assertions Plugin is archived and should be removed from your dependencies.\n\n```diff\n-    \"pestphp/pest-plugin-global-assertions\": \"^1.0.0\",\n```\n\nIf you relied on the Global Assertions Plugin, you may access the same underlying assertions using the `$this` variable. Alternatively, you may migrate to the [Expectation API](/docs/expectations).\n\n```diff\ntest('sum', function () {\n    $result = sum(1, 2);\n\n-   assertSame(3, $result);\n+   $this->assertSame(3, $result); // or expect($result)->toBe(3)\n});\n```\n\nAll other Pest maintained plugins should be updated to version `^2.0` in your application's `composer.json` file.\n\n```diff\n-    \"pestphp/pest-plugin-laravel\": \"^1.4\",\n+    \"pestphp/pest-plugin-laravel\": \"^2.0\",\n```\n\nIf you are using the Faker Plugin, the `faker()` function has been renamed to `fake()`. You will need to update all uses.\n\n```diff\n- use function Pest\\Faker\\faker;\n+ use function Pest\\Faker\\fake;\n\ntest('faker', function () {\n-   expect(faker()->name())->toBeString();\n+   expect(fake()->name())->toBeString();\n});\n```\n\n### PHPUnit 10 Changes\n\n> Likelihood Of Impact: Medium\n\nIf you were previously using PHPUnit instead of Pest, it's possible that your `phpunit.xml` file needs to be updated. When this is the case, you may encounter the following message when running Pest 2 for the first time.\n\n```php\n  WARN  Your XML configuration validates against a deprecated schema. Migrate your XML configuration using \"--migrate-configuration\"!\n```\n\nTo address this issue, simply re-run Pest with the `--migrate-configuration` option.\n\n```bash\n./vendor/bin/pest --migrate-configuration\n```\n\nPest 2 is built on top of PHPUnit 10. This means that any notable changes made to PHPUnit 10 might have an impact on your test suite. To examine all the changes introduced in PHPUnit 10, please consult the [PHPUnit 10 changelog](https://github.com/sebastianbergmann/phpunit/blob/10.0.0/ChangeLog-10.0.md#1000---2023-02-03).\n\n### High Order Testing\n\n> Likelihood Of Impact: Low\n\nWhen performing high order testing, you might have utilized the `tap()` method to invoke assertions on an object that needs lazy evaluation during runtime. With Pest 2, the `tap()` method is deprecated. Instead, you should use the `defer()` method.\n\n```diff\nit('creates admins')\n-    ->tap(fn () => $this->artisan('user:create --admin'))\n+    ->defer(fn () => $this->artisan('user:create --admin'))\n     ->assertDatabaseHas('users', ['id' => 1]);\n```\n\n### Datasets\n\n#### Bound Datasets\n\n> Likelihood Of Impact: Very low\n\nIf you are utilizing \"bound\" datasets and binding a single dataset argument, you must now type the corresponding test parameter.\n\n```diff\n-it('can generate the full name of a user', function ($user, $fullName) {\n+it('can generate the full name of a user', function (User $user, $fullName) {\n    expect($user->full_name)->toBe($fullName);\n})->with([\n    [fn() => User::factory()->create(['first_name' => 'Nuno', 'last_name' => 'Maduro']), 'Nuno Maduro'],\n    [fn() => User::factory()->create(['first_name' => 'Luke', 'last_name' => 'Downing']), 'Luke Downing'],\n    [fn() => User::factory()->create(['first_name' => 'Freek', 'last_name' => 'Van Der Herten']), 'Freek Van Der Herten'],\n]);\n```\n\n#### Scoped Datasets\n\n> Likelihood Of Impact: Very low\n\nAlthough we previously documented in Pest 1 that datasets should only be declared using the `dataset` function in the `tests/Pest.php` or `tests/Datasets.php` files, you could actually declare datasets in any test file within your test suite. However, in Pest 2, with the introduction of [scoped datasets](/docs/datasets#content-scoped-datasets), datasets declared in a test file can only be utilized within that same test file. Therefore, if you have a dataset that needs to be accessible globally, please ensure that it is placed in either the `tests/Pest.php` or `tests/Datasets.php` files.\n\n---\n\nThis concludes the Pest 2 upgrade guide. On the next chapter, we'll cover how can you easily migrate your tests from PHPUnit to Pest: [Migrating From PHPUnit](/docs/migrating-from-phpunit-guide)\n\n\n---\n\n## Verification Introduction Database Preparation\nVerification | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/verification.md\n\n\n### Database Preparation\n\nNext, your `users` table must contain an `email_verified_at` column to store the date and time that the user's email address was verified. Typically, this is included in Laravel's default `0001_01_01_000000_create_users_table.php` database migration.\n\n---\n\n## Urls URLs for Named Routes Eloquent Models\nUrls | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/urls.md\n\n\n#### Eloquent Models\n\nYou will often be generating URLs using the route key (typically the primary key) of [Eloquent models](/docs/{{version}}/eloquent). For this reason, you may pass Eloquent models as parameter values. The `route` helper will automatically extract the model's route key:\n\n```php\necho route('post.show', ['post' => $post]);\n```\n\n---\n\n## Http Tests Available Assertions\nHttp Tests | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/http-tests.md\n\n\n## Available Assertions\n\n---\n\n## Http Tests Available Assertions Response Assertions\nHttp Tests | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/http-tests.md\n\n\n### Response Assertions\n\nLaravel's `Illuminate\\Testing\\TestResponse` class provides a variety of custom assertion methods that you may utilize when testing your application. These assertions may be accessed on the response that is returned by the `json`, `get`, `post`, `put`, and `delete` test methods:\n\n\n\n
\n\n[assertAccepted](#assert-accepted)\n[assertBadRequest](#assert-bad-request)\n[assertClientError](#assert-client-error)\n[assertConflict](#assert-conflict)\n[assertCookie](#assert-cookie)\n[assertCookieExpired](#assert-cookie-expired)\n[assertCookieNotExpired](#assert-cookie-not-expired)\n[assertCookieMissing](#assert-cookie-missing)\n[assertCreated](#assert-created)\n[assertDontSee](#assert-dont-see)\n[assertDontSeeText](#assert-dont-see-text)\n[assertDownload](#assert-download)\n[assertExactJson](#assert-exact-json)\n[assertExactJsonStructure](#assert-exact-json-structure)\n[assertFailedDependency](#assert-failed-dependency)\n[assertForbidden](#assert-forbidden)\n[assertFound](#assert-found)\n[assertGone](#assert-gone)\n[assertHeader](#assert-header)\n[assertHeaderContains](#assert-header-contains)\n[assertHeaderMissing](#assert-header-missing)\n[assertInternalServerError](#assert-internal-server-error)\n[assertJson](#assert-json)\n[assertJsonCount](#assert-json-count)\n[assertJsonFragment](#assert-json-fragment)\n[assertJsonIsArray](#assert-json-is-array)\n[assertJsonIsObject](#assert-json-is-object)\n[assertJsonMissing](#assert-json-missing)\n[assertJsonMissingExact](#assert-json-missing-exact)\n[assertJsonMissingValidationErrors](#assert-json-missing-validation-errors)\n[assertJsonPath](#assert-json-path)\n[assertJsonMissingPath](#assert-json-missing-path)\n[assertJsonStructure](#assert-json-structure)\n[assertJsonValidationErrors](#assert-json-validation-errors)\n[assertJsonValidationErrorFor](#assert-json-validation-error-for)\n[assertLocation](#assert-location)\n[assertMethodNotAllowed](#assert-method-not-allowed)\n[assertMovedPermanently](#assert-moved-permanently)\n[assertContent](#assert-content)\n[assertNoContent](#assert-no-content)\n[assertStreamed](#assert-streamed)\n[assertStreamedContent](#assert-streamed-content)\n[assertNotFound](#assert-not-found)\n[assertOk](#assert-ok)\n[assertPaymentRequired](#assert-payment-required)\n[assertPlainCookie](#assert-plain-cookie)\n[assertRedirect](#assert-redirect)\n[assertRedirectBack](#assert-redirect-back)\n[assertRedirectBackWithErrors](#assert-redirect-back-with-errors)\n[assertRedirectBackWithoutErrors](#assert-redirect-back-without-errors)\n[assertRedirectContains](#assert-redirect-contains)\n[assertRedirectToRoute](#assert-redirect-to-route)\n[assertRedirectToSignedRoute](#assert-redirect-to-signed-route)\n[assertRequestTimeout](#assert-request-timeout)\n[assertSee](#assert-see)\n[assertSeeInOrder](#assert-see-in-order)\n[assertSeeText](#assert-see-text)\n[assertSeeTextInOrder](#assert-see-text-in-order)\n[assertServerError](#assert-server-error)\n[assertServiceUnavailable](#assert-service-unavailable)\n[assertSessionHas](#assert-session-has)\n[assertSessionHasInput](#assert-session-has-input)\n[assertSessionHasAll](#assert-session-has-all)\n[assertSessionHasErrors](#assert-session-has-errors)\n[assertSessionHasErrorsIn](#assert-session-has-errors-in)\n[assertSessionHasNoErrors](#assert-session-has-no-errors)\n[assertSessionDoesntHaveErrors](#assert-session-doesnt-have-errors)\n[assertSessionMissing](#assert-session-missing)\n[assertStatus](#assert-status)\n[assertSuccessful](#assert-successful)\n[assertTooManyRequests](#assert-too-many-requests)\n[assertUnauthorized](#assert-unauthorized)\n[assertUnprocessable](#assert-unprocessable)\n[assertUnsupportedMediaType](#assert-unsupported-media-type)\n[assertValid](#assert-valid)\n[assertInvalid](#assert-invalid)\n[assertViewHas](#assert-view-has)\n[assertViewHasAll](#assert-view-has-all)\n[assertViewIs](#assert-view-is)\n[assertViewMissing](#assert-view-missing)\n\n
\n\n---\n\n## Upgrade Upgrading To 12.0 From 11.x Database Database Constructor Signature Changes\nUpgrade | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/upgrade.md\n\n\n#### Database Constructor Signature Changes\n\n**Likelihood Of Impact: Very Low**\n\nIn Laravel 12, several low-level database classes now require an `Illuminate\\Database\\Connection` instance to be provided via their constructors.\n\n**These changes are primarily applicable to database package maintainers - it is extremely unlikely any of these changes affect normal application development.**\n\n`Illuminate\\Database\\Schema\\Blueprint`\n\nThe constructor of the `Illuminate\\Database\\Schema\\Blueprint` class now expects a `Connection` instance as its first argument. This primarily affects applications or packages that manually instantiate `Blueprint` instances.\n\n`Illuminate\\Database\\Grammar`\n\nThe constructor of the `Illuminate\\Database\\Grammar` class also now requires a `Connection` instance. In previous versions, the connection was assigned after construction using the `setConnection()` method. This method has been removed in Laravel 12:\n\n```php\n// Laravel <= 11.x\n$grammar = new MySqlGrammar;\n$grammar->setConnection($connection);\n\n// Laravel >= 12.x\n$grammar = new MySqlGrammar($connection);\n````\n\nIn addition, the following APIs have been removed or deprecated:\n\n
\n\n- The `Blueprint::getPrefix()` method is deprecated.\n- The `Connection::withTablePrefix()` method has been removed.\n- The `Grammar::getTablePrefix()` and `setTablePrefix()` methods are deprecated.\n- The `Grammar::setConnection()` method has been removed.\n\n
\n\nWhen working with table prefixes, you should now retrieve them directly from the database connection:\n\n```php\n$prefix = $connection->getTablePrefix();\n```\n\nIf you maintain custom database drivers, schema builders, or grammar implementations, you should review their constructors and ensure a `Connection` instance is provided.\n\n---\n\n## Upgrade Upgrading To 12.0 From 11.x Eloquent Models and UUIDv7\nUpgrade | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/upgrade.md\n\n\n#### Models and UUIDv7\n\n**Likelihood Of Impact: Medium**\n\nThe `HasUuids` trait now returns UUIDs that are compatible with version 7 of the UUID spec (ordered UUIDs). If you would like to continue using ordered UUIDv4 strings for your model's IDs, you should now use the `HasVersion4Uuids` trait:\n\n```php\nuse Illuminate\\Database\\Eloquent\\Concerns\\HasUuids; // [tl! remove]\nuse Illuminate\\Database\\Eloquent\\Concerns\\HasVersion4Uuids as HasUuids; // [tl! add]\n```\n\nThe `HasVersion7Uuids` trait has been removed. If you were previously using this trait, you should use the `HasUuids` trait instead, which now provides the same behavior.\n\n---\n\n## Database Database Transactions\nDatabase | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/database.md\n\n\n## Database Transactions\n\nYou may use the `transaction` method provided by the `DB` facade to run a set of operations within a database transaction. If an exception is thrown within the transaction closure, the transaction will automatically be rolled back and the exception is re-thrown. If the closure executes successfully, the transaction will automatically be committed. You don't need to worry about manually rolling back or committing while using the `transaction` method:\n\n```php\nuse Illuminate\\Support\\Facades\\DB;\n\nDB::transaction(function () {\n    DB::update('update users set votes = 1');\n\n    DB::delete('delete from posts');\n});\n```\n\n---\n\n## Eloquent Mutators Attribute Casting Array and JSON Casting JSON and Unicode\nEloquent Mutators | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/eloquent-mutators.md\n\n\n#### JSON and Unicode\n\nIf you would like to store an array attribute as JSON with unescaped Unicode characters, you may use the `json:unicode` cast:\n\n```php\n/**\n * Get the attributes that should be cast.\n *\n * @return array<string, string>\n */\nprotected function casts(): array\n{\n    return [\n        'options' => 'json:unicode',\n    ];\n}\n```\n\n---\n\n## Database Testing Available Assertions\nDatabase Testing | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/database-testing.md\n\n\n## Available Assertions\n\nLaravel provides several database assertions for your [Pest](https://pestphp.com) or [PHPUnit](https://phpunit.de) feature tests. We'll discuss each of these assertions below.\n\n_[Truncated]_"}]


The docs pass is done. I’m reading the pricing and order services plus the existing tests now, so the attribution change follows the current service boundary instead of adding new persistence shape casually.


sed -n '1,260p' app/Services/PricingEngine.php


sed -n '1,320p' app/Services/DiscountService.php


sed -n '1,360p' app/Services/OrderService.php


sed -n '1,260p' tests/Feature/Orders/OrderServiceTest.php && sed -n '1,280p' tests/Feature/Checkout/PricingServicesTest.php


rg -n "discount_allocations|automatic|DiscountService|discount" app tests database specs/05-BUSINESS-LOGIC.md


sed -n '1,260p' app/Services/PricingEngine.php

<?php

namespace App\Services;

use App\Exceptions\InvalidCheckoutTransitionException;
use App\Models\CartLine;
use App\Models\Checkout;
use App\Models\ShippingRate;
use App\Models\TaxSettings;
use App\ValueObjects\DiscountResult;
use App\ValueObjects\PricingResult;
use Illuminate\Support\Facades\DB;

class PricingEngine
{
    public function __construct(
        private readonly DiscountService $discounts,
        private readonly ShippingCalculator $shipping,
        private readonly TaxCalculator $taxes,
    ) {}

    public function calculate(Checkout $checkout): PricingResult
    {
        return DB::transaction(function () use ($checkout): PricingResult {
            $checkout = Checkout::withoutGlobalScopes()
                ->with(['cart.lines'])
                ->whereKey($checkout->getKey())
                ->firstOrFail();
            $cart = $checkout->cart;

            $this->resetLineAmounts($checkout);

            $lines = CartLine::withoutGlobalScopes()
                ->where('cart_id', $cart->getKey())
                ->orderBy('id')
                ->get();
            $subtotal = $lines->sum('line_subtotal_amount');
            $discountAmount = 0;
            $freeShipping = false;

            if ($checkout->discount_code) {
                $discount = $this->discounts->validate($checkout->discount_code, $checkout->store, $cart);
                $discountResult = $this->discounts->applyToCart($cart, $discount);
                $discountAmount += $discountResult->amount;
                $freeShipping = $freeShipping || $discountResult->freeShipping;
            }

            foreach ($this->discounts->automaticForCart($checkout->store, $cart) as $discount) {
                $discountResult = $this->discounts->applyToCart($cart, $discount);
                $discountAmount += $discountResult->amount;
                $freeShipping = $freeShipping || $discountResult->freeShipping;
            }

            $lines = CartLine::withoutGlobalScopes()
                ->where('cart_id', $cart->getKey())
                ->orderBy('id')
                ->get();
            $discountResult = new DiscountResult($discountAmount, [], $freeShipping);
            $shippingAmount = $this->shippingAmount($checkout, $discountResult);
            $taxSettings = $this->taxSettings($checkout);
            $taxResult = $this->taxes->calculateForAmounts(
                $lines->pluck('line_total_amount')->map(fn (int $amount): int => $amount)->all(),
                $shippingAmount,
                $taxSettings,
                $checkout->shipping_address_json ?? [],
            );
            $discountedSubtotal = $subtotal - $discountResult->amount;
            $total = $taxSettings->prices_include_tax
                ? $discountedSubtotal + $shippingAmount
                : $discountedSubtotal + $shippingAmount + $taxResult->totalAmount;

            $result = new PricingResult(
                subtotal: $subtotal,
                discount: $discountResult->amount,
                shipping: $shippingAmount,
                taxLines: $taxResult->taxLines,
                taxTotal: $taxResult->totalAmount,
                total: $total,
                currency: $cart->currency,
            );

            $checkout->forceFill([
                'tax_provider_snapshot_json' => $taxResult->toArray(),
                'totals_json' => $result->toArray(),
            ])->save();

            return $result;
        });
    }

    private function resetLineAmounts(Checkout $checkout): void
    {
        CartLine::withoutGlobalScopes()
            ->where('cart_id', $checkout->cart_id)
            ->get()
            ->each(function (CartLine $line): void {
                $subtotal = $line->unit_price_amount * $line->quantity;

                $line->forceFill([
                    'line_subtotal_amount' => $subtotal,
                    'line_discount_amount' => 0,
                    'line_total_amount' => $subtotal,
                ])->save();
            });
    }

    private function shippingAmount(Checkout $checkout, DiscountResult $discountResult): int
    {
        if (! $this->shipping->requiresShipping($checkout->cart)) {
            return 0;
        }

        if ($checkout->shipping_method_id === null) {
            return 0;
        }

        $rate = ShippingRate::withoutGlobalScopes()->find($checkout->shipping_method_id);

        if (! $rate instanceof ShippingRate) {
            throw InvalidCheckoutTransitionException::because('Shipping rate is not available.');
        }

        $amount = $this->shipping->calculate($rate, $checkout->cart);

        if ($amount === null) {
            throw InvalidCheckoutTransitionException::because('Shipping rate is not available for this cart.');
        }

        return $discountResult->freeShipping ? 0 : $amount;
    }

    private function taxSettings(Checkout $checkout): TaxSettings
    {
        return TaxSettings::withoutGlobalScopes()
            ->where('store_id', $checkout->store_id)
            ->first() ?? new TaxSettings([
                'store_id' => $checkout->store_id,
                'mode' => 'manual',
                'provider' => 'none',
                'prices_include_tax' => false,
                'config_json' => [
                    'default_rate_bps' => 0,
                    'shipping_taxable' => false,
                ],
            ]);
    }
}


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

namespace App\Services;

use App\Exceptions\InvalidCheckoutTransitionException;
use App\Models\CartLine;
use App\Models\Checkout;
use App\Models\ShippingRate;
use App\Models\TaxSettings;
use App\ValueObjects\DiscountResult;
use App\ValueObjects\PricingResult;
use Illuminate\Support\Facades\DB;

class PricingEngine
{
    public function __construct(
        private readonly DiscountService $discounts,
        private readonly ShippingCalculator $shipping,
        private readonly TaxCalculator $taxes,
    ) {}

    public function calculate(Checkout $checkout): PricingResult
    {
        return DB::transaction(function () use ($checkout): PricingResult {
            $checkout = Checkout::withoutGlobalScopes()
                ->with(['cart.lines'])
                ->whereKey($checkout->getKey())
                ->firstOrFail();
            $cart = $checkout->cart;

            $this->resetLineAmounts($checkout);

            $lines = CartLine::withoutGlobalScopes()
                ->where('cart_id', $cart->getKey())
                ->orderBy('id')
                ->get();
            $subtotal = $lines->sum('line_subtotal_amount');
            $discountAmount = 0;
            $freeShipping = false;

            if ($checkout->discount_code) {
                $discount = $this->discounts->validate($checkout->discount_code, $checkout->store, $cart);
                $discountResult = $this->discounts->applyToCart($cart, $discount);
                $discountAmount += $discountResult->amount;
                $freeShipping = $freeShipping || $discountResult->freeShipping;
            }

            foreach ($this->discounts->automaticForCart($checkout->store, $cart) as $discount) {
                $discountResult = $this->discounts->applyToCart($cart, $discount);
                $discountAmount += $discountResult->amount;
                $freeShipping = $freeShipping || $discountResult->freeShipping;
            }

            $lines = CartLine::withoutGlobalScopes()
                ->where('cart_id', $cart->getKey())
                ->orderBy('id')
                ->get();
            $discountResult = new DiscountResult($discountAmount, [], $freeShipping);
            $shippingAmount = $this->shippingAmount($checkout, $discountResult);
            $taxSettings = $this->taxSettings($checkout);
            $taxResult = $this->taxes->calculateForAmounts(
                $lines->pluck('line_total_amount')->map(fn (int $amount): int => $amount)->all(),
                $shippingAmount,
                $taxSettings,
                $checkout->shipping_address_json ?? [],
            );
            $discountedSubtotal = $subtotal - $discountResult->amount;
            $total = $taxSettings->prices_include_tax
                ? $discountedSubtotal + $shippingAmount
                : $discountedSubtotal + $shippingAmount + $taxResult->totalAmount;

            $result = new PricingResult(
                subtotal: $subtotal,
                discount: $discountResult->amount,
                shipping: $shippingAmount,
                taxLines: $taxResult->taxLines,
                taxTotal: $taxResult->totalAmount,
                total: $total,
                currency: $cart->currency,
            );

            $checkout->forceFill([
                'tax_provider_snapshot_json' => $taxResult->toArray(),
                'totals_json' => $result->toArray(),
            ])->save();

            return $result;
        });
    }

    private function resetLineAmounts(Checkout $checkout): void
    {
        CartLine::withoutGlobalScopes()
            ->where('cart_id', $checkout->cart_id)
            ->get()
            ->each(function (CartLine $line): void {
                $subtotal = $line->unit_price_amount * $line->quantity;

                $line->forceFill([
                    'line_subtotal_amount' => $subtotal,
                    'line_discount_amount' => 0,
                    'line_total_amount' => $subtotal,
                ])->save();
            });
    }

    private function shippingAmount(Checkout $checkout, DiscountResult $discountResult): int
    {
        if (! $this->shipping->requiresShipping($checkout->cart)) {
            return 0;
        }

        if ($checkout->shipping_method_id === null) {
            return 0;
        }

        $rate = ShippingRate::withoutGlobalScopes()->find($checkout->shipping_method_id);

        if (! $rate instanceof ShippingRate) {
            throw InvalidCheckoutTransitionException::because('Shipping rate is not available.');
        }

        $amount = $this->shipping->calculate($rate, $checkout->cart);

        if ($amount === null) {
            throw InvalidCheckoutTransitionException::because('Shipping rate is not available for this cart.');
        }

        return $discountResult->freeShipping ? 0 : $amount;
    }

    private function taxSettings(Checkout $checkout): TaxSettings
    {
        return TaxSettings::withoutGlobalScopes()
            ->where('store_id', $checkout->store_id)
            ->first() ?? new TaxSettings([
                'store_id' => $checkout->store_id,
                'mode' => 'manual',
                'provider' => 'none',
                'prices_include_tax' => false,
                'config_json' => [
                    'default_rate_bps' => 0,
                    'shipping_taxable' => false,
                ],
            ]);
    }
}


sed -n '1,320p' app/Services/DiscountService.php

<?php

namespace App\Services;

use App\Enums\DiscountStatus;
use App\Enums\DiscountType;
use App\Enums\DiscountValueType;
use App\Exceptions\InvalidDiscountException;
use App\Models\Cart;
use App\Models\CartLine;
use App\Models\Discount;
use App\Models\OrderLine;
use App\Models\ProductVariant;
use App\Models\Store;
use App\ValueObjects\DiscountResult;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;

class DiscountService
{
    public function validate(string $code, Store $store, Cart $cart): Discount
    {
        $normalizedCode = mb_strtolower(trim($code));

        $discount = Discount::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->where('type', DiscountType::Code)
            ->whereRaw('lower(code) = ?', [$normalizedCode])
            ->first();

        if (! $discount instanceof Discount) {
            throw InvalidDiscountException::because('discount_not_found', 'Discount code was not found.');
        }

        $this->validateDiscountForCart($discount, $cart);

        return $discount;
    }

    /**
     * @return Collection<int, Discount>
     */
    public function automaticForCart(Store $store, Cart $cart): Collection
    {
        return Discount::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->where('type', DiscountType::Automatic->value)
            ->where('status', DiscountStatus::Active->value)
            ->orderBy('id')
            ->get()
            ->filter(function (Discount $discount) use ($cart): bool {
                try {
                    $this->validateDiscountForCart($discount, $cart);

                    return true;
                } catch (InvalidDiscountException) {
                    return false;
                }
            })
            ->values();
    }

    private function validateDiscountForCart(Discount $discount, Cart $cart): void
    {
        if ($discount->status !== DiscountStatus::Active) {
            throw InvalidDiscountException::because('discount_expired', 'Discount is not active.');
        }

        if ($discount->starts_at->isFuture()) {
            throw InvalidDiscountException::because('discount_not_yet_active', 'Discount is not active yet.');
        }

        if ($discount->ends_at !== null && $discount->ends_at->isPast()) {
            throw InvalidDiscountException::because('discount_expired', 'Discount has expired.');
        }

        if ($discount->usage_limit !== null && $discount->usage_count >= $discount->usage_limit) {
            throw InvalidDiscountException::because('discount_usage_limit_reached', 'Discount usage limit has been reached.');
        }

        if ($this->onePerCustomer($discount) && $this->customerHasUsedDiscount($discount, $cart)) {
            throw InvalidDiscountException::because('discount_usage_limit_reached', 'Discount has already been used by this customer.');
        }

        $lines = $this->cartLines($cart);
        $subtotal = $lines->sum('line_subtotal_amount');
        $minimum = (int) data_get($discount->rules_json, 'min_purchase_amount', data_get($discount->rules_json, 'minimum_purchase', 0));

        if ($minimum > 0 && $subtotal < $minimum) {
            throw InvalidDiscountException::because('discount_min_purchase_not_met', 'Cart does not meet the minimum purchase amount.');
        }

        if ($this->qualifyingLines($discount, $lines)->isEmpty()) {
            throw InvalidDiscountException::because('discount_not_applicable', 'Discount does not apply to these cart lines.');
        }
    }

    /**
     * @param  array<int, CartLine>  $lines
     */
    public function calculate(Discount $discount, int $subtotal, array $lines): DiscountResult
    {
        if ($discount->value_type === DiscountValueType::FreeShipping) {
            return new DiscountResult(0, [], true);
        }

        $qualifyingLines = $this->qualifyingLines($discount, collect($lines));
        $qualifyingSubtotal = $qualifyingLines->sum('line_total_amount');

        if ($qualifyingSubtotal <= 0) {
            return new DiscountResult(0, []);
        }

        $discountAmount = match ($discount->value_type) {
            DiscountValueType::Percent => (int) round($qualifyingSubtotal * $discount->value_amount / 100),
            DiscountValueType::Fixed => min($discount->value_amount, $qualifyingSubtotal),
            DiscountValueType::FreeShipping => 0,
        };

        $remaining = $discountAmount;
        $allocations = [];
        $lastIndex = $qualifyingLines->keys()->last();

        foreach ($qualifyingLines as $index => $line) {
            if ($index === $lastIndex) {
                $allocations[$line->getKey()] = $remaining;

                continue;
            }

            $allocation = (int) round($discountAmount * $line->line_total_amount / $qualifyingSubtotal);
            $allocations[$line->getKey()] = $allocation;
            $remaining -= $allocation;
        }

        return new DiscountResult($discountAmount, $allocations);
    }

    public function applyToCart(Cart $cart, Discount $discount): DiscountResult
    {
        return DB::transaction(function () use ($cart, $discount): DiscountResult {
            $lines = $this->cartLines($cart);
            $result = $this->calculate($discount, $lines->sum('line_subtotal_amount'), $lines->all());

            $lines->each(function (CartLine $line) use ($result): void {
                $discountAmount = $result->allocations[$line->getKey()] ?? 0;

                $line->forceFill([
                    'line_discount_amount' => $line->line_discount_amount + $discountAmount,
                    'line_total_amount' => max(0, $line->line_total_amount - $discountAmount),
                ])->save();
            });

            return $result;
        });
    }

    /**
     * @return Collection<int, CartLine>
     */
    private function cartLines(Cart $cart): Collection
    {
        return CartLine::withoutGlobalScopes()
            ->where('cart_id', $cart->getKey())
            ->orderBy('id')
            ->get();
    }

    /**
     * @param  Collection<int, CartLine>  $lines
     * @return Collection<int, CartLine>
     */
    private function qualifyingLines(Discount $discount, Collection $lines): Collection
    {
        $productIds = collect(data_get($discount->rules_json, 'applicable_product_ids', []))
            ->filter()
            ->map(fn (mixed $id): int => (int) $id)
            ->values();
        $collectionIds = collect(data_get($discount->rules_json, 'applicable_collection_ids', []))
            ->filter()
            ->map(fn (mixed $id): int => (int) $id)
            ->values();

        if ($productIds->isEmpty() && $collectionIds->isEmpty()) {
            return $lines;
        }

        return $lines->filter(function (CartLine $line) use ($productIds, $collectionIds): bool {
            $variant = ProductVariant::withoutGlobalScopes()
                ->with(['product' => fn ($query) => $query->withoutGlobalScopes()->with('collections')])
                ->find($line->variant_id);

            if (! $variant instanceof ProductVariant || $variant->product === null) {
                return false;
            }

            if ($productIds->contains((int) $variant->product_id)) {
                return true;
            }

            return $variant->product->collections
                ->pluck('id')
                ->map(fn (int $id): int => $id)
                ->intersect($collectionIds)
                ->isNotEmpty();
        });
    }

    private function onePerCustomer(Discount $discount): bool
    {
        return (bool) data_get($discount->rules_json, 'one_per_customer', false);
    }

    private function customerHasUsedDiscount(Discount $discount, Cart $cart): bool
    {
        if ($cart->customer_id === null) {
            return false;
        }

        $discountCode = $discount->code ? mb_strtolower($discount->code) : null;

        return OrderLine::query()
            ->whereHas('order', function ($query) use ($discount, $cart): void {
                $query
                    ->withoutGlobalScopes()
                    ->where('store_id', $discount->store_id)
                    ->where('customer_id', $cart->customer_id)
                    ->where('discount_amount', '>', 0);
            })
            ->get()
            ->contains(function (OrderLine $line) use ($discount, $discountCode): bool {
                return collect($line->discount_allocations_json ?? [])
                    ->contains(function (mixed $allocation) use ($discount, $discountCode): bool {
                        $allocationDiscountId = data_get($allocation, 'discount_id');

                        if ($allocationDiscountId !== null && (int) $allocationDiscountId === $discount->getKey()) {
                            return true;
                        }

                        if ($discountCode === null) {
                            return false;
                        }

                        return mb_strtolower((string) data_get($allocation, 'code', '')) === $discountCode;
                    });
            });
    }
}


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

namespace App\Services;

use App\Enums\DiscountStatus;
use App\Enums\DiscountType;
use App\Enums\DiscountValueType;
use App\Exceptions\InvalidDiscountException;
use App\Models\Cart;
use App\Models\CartLine;
use App\Models\Discount;
use App\Models\OrderLine;
use App\Models\ProductVariant;
use App\Models\Store;
use App\ValueObjects\DiscountResult;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;

class DiscountService
{
    public function validate(string $code, Store $store, Cart $cart): Discount
    {
        $normalizedCode = mb_strtolower(trim($code));

        $discount = Discount::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->where('type', DiscountType::Code)
            ->whereRaw('lower(code) = ?', [$normalizedCode])
            ->first();

        if (! $discount instanceof Discount) {
            throw InvalidDiscountException::because('discount_not_found', 'Discount code was not found.');
        }

        $this->validateDiscountForCart($discount, $cart);

        return $discount;
    }

    /**
     * @return Collection<int, Discount>
     */
    public function automaticForCart(Store $store, Cart $cart): Collection
    {
        return Discount::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->where('type', DiscountType::Automatic->value)
            ->where('status', DiscountStatus::Active->value)
            ->orderBy('id')
            ->get()
            ->filter(function (Discount $discount) use ($cart): bool {
                try {
                    $this->validateDiscountForCart($discount, $cart);

                    return true;
                } catch (InvalidDiscountException) {
                    return false;
                }
            })
            ->values();
    }

    private function validateDiscountForCart(Discount $discount, Cart $cart): void
    {
        if ($discount->status !== DiscountStatus::Active) {
            throw InvalidDiscountException::because('discount_expired', 'Discount is not active.');
        }

        if ($discount->starts_at->isFuture()) {
            throw InvalidDiscountException::because('discount_not_yet_active', 'Discount is not active yet.');
        }

        if ($discount->ends_at !== null && $discount->ends_at->isPast()) {
            throw InvalidDiscountException::because('discount_expired', 'Discount has expired.');
        }

        if ($discount->usage_limit !== null && $discount->usage_count >= $discount->usage_limit) {
            throw InvalidDiscountException::because('discount_usage_limit_reached', 'Discount usage limit has been reached.');
        }

        if ($this->onePerCustomer($discount) && $this->customerHasUsedDiscount($discount, $cart)) {
            throw InvalidDiscountException::because('discount_usage_limit_reached', 'Discount has already been used by this customer.');
        }

        $lines = $this->cartLines($cart);
        $subtotal = $lines->sum('line_subtotal_amount');
        $minimum = (int) data_get($discount->rules_json, 'min_purchase_amount', data_get($discount->rules_json, 'minimum_purchase', 0));

        if ($minimum > 0 && $subtotal < $minimum) {
            throw InvalidDiscountException::because('discount_min_purchase_not_met', 'Cart does not meet the minimum purchase amount.');
        }

        if ($this->qualifyingLines($discount, $lines)->isEmpty()) {
            throw InvalidDiscountException::because('discount_not_applicable', 'Discount does not apply to these cart lines.');
        }
    }

    /**
     * @param  array<int, CartLine>  $lines
     */
    public function calculate(Discount $discount, int $subtotal, array $lines): DiscountResult
    {
        if ($discount->value_type === DiscountValueType::FreeShipping) {
            return new DiscountResult(0, [], true);
        }

        $qualifyingLines = $this->qualifyingLines($discount, collect($lines));
        $qualifyingSubtotal = $qualifyingLines->sum('line_total_amount');

        if ($qualifyingSubtotal <= 0) {
            return new DiscountResult(0, []);
        }

        $discountAmount = match ($discount->value_type) {
            DiscountValueType::Percent => (int) round($qualifyingSubtotal * $discount->value_amount / 100),
            DiscountValueType::Fixed => min($discount->value_amount, $qualifyingSubtotal),
            DiscountValueType::FreeShipping => 0,
        };

        $remaining = $discountAmount;
        $allocations = [];
        $lastIndex = $qualifyingLines->keys()->last();

        foreach ($qualifyingLines as $index => $line) {
            if ($index === $lastIndex) {
                $allocations[$line->getKey()] = $remaining;

                continue;
            }

            $allocation = (int) round($discountAmount * $line->line_total_amount / $qualifyingSubtotal);
            $allocations[$line->getKey()] = $allocation;
            $remaining -= $allocation;
        }

        return new DiscountResult($discountAmount, $allocations);
    }

    public function applyToCart(Cart $cart, Discount $discount): DiscountResult
    {
        return DB::transaction(function () use ($cart, $discount): DiscountResult {
            $lines = $this->cartLines($cart);
            $result = $this->calculate($discount, $lines->sum('line_subtotal_amount'), $lines->all());

            $lines->each(function (CartLine $line) use ($result): void {
                $discountAmount = $result->allocations[$line->getKey()] ?? 0;

                $line->forceFill([
                    'line_discount_amount' => $line->line_discount_amount + $discountAmount,
                    'line_total_amount' => max(0, $line->line_total_amount - $discountAmount),
                ])->save();
            });

            return $result;
        });
    }

    /**
     * @return Collection<int, CartLine>
     */
    private function cartLines(Cart $cart): Collection
    {
        return CartLine::withoutGlobalScopes()
            ->where('cart_id', $cart->getKey())
            ->orderBy('id')
            ->get();
    }

    /**
     * @param  Collection<int, CartLine>  $lines
     * @return Collection<int, CartLine>
     */
    private function qualifyingLines(Discount $discount, Collection $lines): Collection
    {
        $productIds = collect(data_get($discount->rules_json, 'applicable_product_ids', []))
            ->filter()
            ->map(fn (mixed $id): int => (int) $id)
            ->values();
        $collectionIds = collect(data_get($discount->rules_json, 'applicable_collection_ids', []))
            ->filter()
            ->map(fn (mixed $id): int => (int) $id)
            ->values();

        if ($productIds->isEmpty() && $collectionIds->isEmpty()) {
            return $lines;
        }

        return $lines->filter(function (CartLine $line) use ($productIds, $collectionIds): bool {
            $variant = ProductVariant::withoutGlobalScopes()
                ->with(['product' => fn ($query) => $query->withoutGlobalScopes()->with('collections')])
                ->find($line->variant_id);

            if (! $variant instanceof ProductVariant || $variant->product === null) {
                return false;
            }

            if ($productIds->contains((int) $variant->product_id)) {
                return true;
            }

            return $variant->product->collections
                ->pluck('id')
                ->map(fn (int $id): int => $id)
                ->intersect($collectionIds)
                ->isNotEmpty();
        });
    }

    private function onePerCustomer(Discount $discount): bool
    {
        return (bool) data_get($discount->rules_json, 'one_per_customer', false);
    }

    private function customerHasUsedDiscount(Discount $discount, Cart $cart): bool
    {
        if ($cart->customer_id === null) {
            return false;
        }

        $discountCode = $discount->code ? mb_strtolower($discount->code) : null;

        return OrderLine::query()
            ->whereHas('order', function ($query) use ($discount, $cart): void {
                $query
                    ->withoutGlobalScopes()
                    ->where('store_id', $discount->store_id)
                    ->where('customer_id', $cart->customer_id)
                    ->where('discount_amount', '>', 0);
            })
            ->get()
            ->contains(function (OrderLine $line) use ($discount, $discountCode): bool {
                return collect($line->discount_allocations_json ?? [])
                    ->contains(function (mixed $allocation) use ($discount, $discountCode): bool {
                        $allocationDiscountId = data_get($allocation, 'discount_id');

                        if ($allocationDiscountId !== null && (int) $allocationDiscountId === $discount->getKey()) {
                            return true;
                        }

                        if ($discountCode === null) {
                            return false;
                        }

                        return mb_strtolower((string) data_get($allocation, 'code', '')) === $discountCode;
                    });
            });
    }
}


sed -n '1,360p' app/Services/OrderService.php

<?php

namespace App\Services;

use App\Enums\CartStatus;
use App\Enums\CheckoutStatus;
use App\Enums\FinancialStatus;
use App\Enums\FulfillmentStatus;
use App\Enums\OrderStatus;
use App\Enums\PaymentMethod;
use App\Enums\PaymentStatus;
use App\Events\OrderCancelled;
use App\Events\OrderCreated;
use App\Events\OrderPaid;
use App\Exceptions\InvalidCheckoutTransitionException;
use App\Exceptions\InvalidOrderOperationException;
use App\Exceptions\PaymentFailedException;
use App\Models\CartLine;
use App\Models\Checkout;
use App\Models\Discount;
use App\Models\InventoryItem;
use App\Models\Order;
use App\Models\OrderLine;
use App\Models\ProductOptionValue;
use App\Models\ProductVariant;
use App\Models\Store;
use App\Models\StoreSettings;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use ValueError;

class OrderService
{
    public function __construct(
        private readonly PaymentService $payments,
        private readonly InventoryService $inventory,
        private readonly PricingEngine $pricing,
        private readonly FulfillmentService $fulfillments,
    ) {}

    /**
     * Create an order from a checkout after payment has been selected.
     *
     * @param  array<string, mixed>  $paymentMethodData
     */
    public function createFromCheckout(Checkout $checkout, array $paymentMethodData = []): Order
    {
        try {
            return DB::transaction(function () use ($checkout, $paymentMethodData): Order {
                $checkout = $this->freshCheckout($checkout);
                $existingOrder = Order::withoutGlobalScopes()
                    ->where('checkout_id', $checkout->getKey())
                    ->first();

                if ($existingOrder instanceof Order) {
                    return $existingOrder->load(['lines', 'payments', 'refunds', 'fulfillments.lines']);
                }

                if ($checkout->status !== CheckoutStatus::PaymentSelected) {
                    throw InvalidCheckoutTransitionException::because("Checkout cannot transition from {$checkout->status->value}.");
                }

                if ($checkout->totals_json === null) {
                    $this->pricing->calculate($checkout);
                    $checkout = $this->freshCheckout($checkout);
                }

                $method = $this->paymentMethod($checkout);
                $paymentResult = $this->payments->charge($checkout, $method, $paymentMethodData);

                if (! $paymentResult->success) {
                    throw PaymentFailedException::fromResult($paymentResult);
                }

                $paidImmediately = $paymentResult->status === PaymentStatus::Captured;
                $totals = $checkout->totals_json ?? [];

                $order = Order::withoutGlobalScopes()->create([
                    'store_id' => $checkout->store_id,
                    'checkout_id' => $checkout->getKey(),
                    'customer_id' => $checkout->customer_id,
                    'order_number' => $this->nextOrderNumber($checkout->store),
                    'payment_method' => $method,
                    'status' => $paidImmediately ? OrderStatus::Paid : OrderStatus::Pending,
                    'financial_status' => $paidImmediately ? FinancialStatus::Paid : FinancialStatus::Pending,
                    'fulfillment_status' => FulfillmentStatus::Unfulfilled,
                    'currency' => (string) data_get($totals, 'currency', $checkout->cart->currency),
                    'subtotal_amount' => (int) data_get($totals, 'subtotal', 0),
                    'discount_amount' => (int) data_get($totals, 'discount', 0),
                    'shipping_amount' => (int) data_get($totals, 'shipping', 0),
                    'tax_amount' => (int) data_get($totals, 'tax', 0),
                    'total_amount' => (int) data_get($totals, 'total', 0),
                    'email' => $checkout->email,
                    'billing_address_json' => $checkout->billing_address_json,
                    'shipping_address_json' => $checkout->shipping_address_json,
                    'placed_at' => now(),
                ]);

                $this->createOrderLines($checkout, $order);

                $order->payments()->create([
                    'provider' => 'mock',
                    'method' => $method,
                    'provider_payment_id' => $paymentResult->referenceId,
                    'status' => $paymentResult->status,
                    'amount' => $order->total_amount,
                    'currency' => $order->currency,
                    'raw_json_encrypted' => $paymentResult->toArray(),
                ]);

                if ($paidImmediately) {
                    $this->commitReservedInventory($checkout);
                }

                $checkout->cart->forceFill([
                    'status' => CartStatus::Converted,
                ])->save();

                $checkout->forceFill([
                    'status' => CheckoutStat…3280 chars truncated…nt method is invalid.');
        }
    }

    private function nextOrderNumber(Store $store): string
    {
        $settings = StoreSettings::query()
            ->where('store_id', $store->getKey())
            ->first()
            ?->settings_json ?? [];
        $prefix = (string) data_get($settings, 'order_number_prefix', '#');
        $start = max(1, (int) data_get($settings, 'order_number_start', 1001));
        $maxExisting = Order::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->pluck('order_number')
            ->map(fn (string $orderNumber): ?int => $this->sequenceFromOrderNumber($orderNumber))
            ->filter()
            ->max();

        return $prefix.((int) max($start - 1, $maxExisting ?? 0) + 1);
    }

    private function sequenceFromOrderNumber(string $orderNumber): ?int
    {
        $digits = preg_replace('/\D+/', '', $orderNumber);

        return $digits === '' ? null : (int) $digits;
    }

    private function createOrderLines(Checkout $checkout, Order $order): void
    {
        $this->cartLines($checkout)->each(function (CartLine $line) use ($checkout, $order): void {
            $variant = $line->variant;

            $order->lines()->create([
                'product_id' => $variant?->product_id,
                'variant_id' => $variant?->getKey(),
                'title_snapshot' => $this->titleSnapshot($variant),
                'sku_snapshot' => $variant?->sku,
                'quantity' => $line->quantity,
                'unit_price_amount' => $line->unit_price_amount,
                'total_amount' => $line->line_total_amount,
                'tax_lines_json' => [],
                'discount_allocations_json' => $this->discountAllocations($checkout, $line),
            ]);
        });
    }

    private function titleSnapshot(?ProductVariant $variant): string
    {
        $title = $variant?->product?->title ?? 'Product';
        $optionValues = $variant?->optionValues
            ->sortBy(fn (ProductOptionValue $value): int => $value->option?->position ?? $value->position)
            ->pluck('value')
            ->filter()
            ->implode(' / ');

        return $optionValues ? "{$title} - {$optionValues}" : $title;
    }

    /**
     * @return array<int, array{discount_id: int|null, code: string, amount: int}>
     */
    private function discountAllocations(Checkout $checkout, CartLine $line): array
    {
        if ($checkout->discount_code === null || $line->line_discount_amount <= 0) {
            return [];
        }

        $code = trim($checkout->discount_code);
        $discount = Discount::withoutGlobalScopes()
            ->where('store_id', $checkout->store_id)
            ->whereRaw('lower(code) = ?', [mb_strtolower($code)])
            ->first();

        return [[
            'discount_id' => $discount?->getKey(),
            'code' => $code,
            'amount' => $line->line_discount_amount,
        ]];
    }

    private function commitReservedInventory(Checkout $checkout): void
    {
        $this->cartLines($checkout)->each(function (CartLine $line): void {
            $this->inventory->commit($this->inventoryItemForVariant($line->variant_id), $line->quantity);
        });
    }

    private function commitReservedOrderInventory(Order $order): void
    {
        $order->lines->each(function (OrderLine $line): void {
            if ($line->variant_id !== null) {
                $this->inventory->commit($this->inventoryItemForVariant($line->variant_id), $line->quantity);
            }
        });
    }

    private function releaseReservedOrderInventory(Order $order): void
    {
        $order->lines->each(function (OrderLine $line): void {
            if ($line->variant_id !== null) {
                $this->inventory->release($this->inventoryItemForVariant($line->variant_id), $line->quantity);
            }
        });
    }

    private function releaseFailedPaymentReservation(Checkout $checkout): void
    {
        DB::transaction(function () use ($checkout): void {
            $checkout = $this->freshCheckout($checkout);

            if ($checkout->status !== CheckoutStatus::PaymentSelected) {
                return;
            }

            $this->cartLines($checkout)->each(function (CartLine $line): void {
                $this->inventory->release($this->inventoryItemForVariant($line->variant_id), $line->quantity);
            });

            $checkout->forceFill([
                'status' => CheckoutStatus::ShippingSelected,
                'payment_method' => null,
                'expires_at' => null,
            ])->save();
        });
    }

    private function incrementDiscountUsage(Checkout $checkout): void
    {
        $code = trim((string) $checkout->discount_code);

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

        Discount::withoutGlobalScopes()
            ->where('store_id', $checkout->store_id)
            ->whereRaw('lower(code) = ?', [mb_strtolower($code)])