sed -n '1,220p' tests/Feature/Api/AdminSearchIndexApiTest.php

<?php

use App\Models\Product;
use App\Models\SearchSettings;
use App\Models\Store;
use App\Models\User;
use App\Services\SearchService;
use App\Services\WebhookService;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;

uses(RefreshDatabase::class);

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

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

function adminSearchIndexApiUser(): User
{
    return User::query()->where('email', 'admin@acme.test')->firstOrFail();
}

/**
 * @param  list<string>  $abilities
 * @return array{token: \App\Models\OauthToken, plain_text: string}
 */
function adminSearchIndexApiToken(Store $store, array $abilities): array
{
    return app(WebhookService::class)->createApiToken($store, 'Search integration', $abilities);
}

test('admin search index api reports status and rebuilds stale documents', function (): void {
    $store = adminSearchIndexApiStore();
    $product = Product::factory()
        ->for($store)
        ->withDefaultVariant(2999)
        ->create([
            'title' => 'API Reindex Linen Jacket',
            'handle' => 'api-reindex-linen-jacket',
        ]);

    DB::table('products_fts')->where('product_id', $product->getKey())->delete();

    expect(app(SearchService::class)->search($store, 'api reindex linen', [], 12)->total())->toBe(0);

    $this->actingAs(adminSearchIndexApiUser())
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/search/status")
        ->assertOk()
        ->assertJsonPath('data.index_status', 'stale')
        ->assertJsonPath('data.pending_updates', 1);

    $this->actingAs(adminSearchIndexApiUser())
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/search/reindex")
        ->assertAccepted()
        ->assertJsonPath('status', 'completed')
        ->assertJsonPath('documents_count', Product::withoutGlobalScopes()->where('store_id', $store->getKey())->count());

    expect(app(SearchService::class)->search($store, 'api reindex linen', [], 12)->total())->toBe(1)
        ->and(SearchSettings::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail()->updated_at)->not->toBeNull();

    $this->actingAs(adminSearchIndexApiUser())
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/search/status")
        ->assertOk()
        ->assertJsonPath('data.index_status', 'ready')
        ->assertJsonPath('data.pending_updates', 0);
});

test('admin search index api enforces token abilities and store scope', function (): void {
    $store = adminSearchIndexApiStore();
    $otherStore = Store::factory()->create();
    $readToken = adminSearchIndexApiToken($store, ['read-settings']);
    $writeToken = adminSearchIndexApiToken($store, ['write-settings']);
    $otherStoreToken = adminSearchIndexApiToken($otherStore, ['read-settings']);

    $this->withToken($readToken['plain_text'])
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/search/status")
        ->assertOk();

    $this->withToken($readToken['plain_text'])
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/search/reindex")
        ->assertForbidden();

    $this->withToken($otherStoreToken['plain_text'])
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/search/status")
        ->assertForbidden();

    $this->withToken($writeToken['plain_text'])
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/search/reindex")
        ->assertAccepted();
});


Output:
Chunk ID: 45ad5e
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 38
Output:
 M routes/api.php
 M specs/progress.md
?? app/Http/Controllers/Api/Admin/V1/SearchIndexController.php
?? tests/Feature/Api/AdminSearchIndexApiTest.php


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

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

use App\Http\Controllers\Controller;
use App\Models\Product;
use App\Models\SearchSettings;
use App\Models\Store;
use App\Services\SearchService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class SearchIndexController extends Controller
{
    public function reindex(Request $request, Store $store, SearchService $search): JsonResponse
    {
        $this->authorizeStore($request, $store);

        $startedAt = microtime(true);
        $count = $search->reindex($store);
        $duration = (int) ceil(microtime(true) - $startedAt);

        $settings = $this->settings($store);
        $settings->forceFill([
            'updated_at' => now(),
        ])->save();

        return response()->json([
            'message' => __('Reindex completed.'),
            'job_id' => null,
            'status' => 'completed',
            'documents_count' => $count,
            'last_reindex_duration_seconds' => $duration,
        ], 202);
    }

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

        $productCount = Product::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->count();

        $documentsCount = DB::table('products_fts')
            ->where('store_id', $store->getKey())
            ->distinct()
            ->count('product_id');

        $pendingUpdates = abs($productCount - $documentsCount);
        $settings = $this->settings($store);

        return response()->json([
            'data' => [
                'store_id' => $store->getKey(),
                'index_status' => $pendingUpdates === 0 ? 'ready' : 'stale',
                'last_reindex_at' => $settings->updated_at?->toIso8601String(),
                'last_reindex_duration_seconds' => 0,
                'documents_count' => $documentsCount,
                'pending_updates' => $pendingUpdates,
            ],
        ]);
    }

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

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

    private function settings(Store $store): SearchSettings
    {
        return SearchSettings::withoutGlobalScopes()->firstOrCreate([
            'store_id' => $store->getKey(),
        ]);
    }
}


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

use App\Models\Product;
use App\Models\SearchSettings;
use App\Models\Store;
use App\Models\User;
use App\Services\SearchService;
use App\Services\WebhookService;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;

uses(RefreshDatabase::class);

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

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

function adminSearchIndexApiUser(): User
{
    return User::query()->where('email', 'admin@acme.test')->firstOrFail();
}

/**
 * @param  list<string>  $abilities
 * @return array{token: \App\Models\OauthToken, plain_text: string}
 */
function adminSearchIndexApiToken(Store $store, array $abilities): array
{
    return app(WebhookService::class)->createApiToken($store, 'Search integration', $abilities);
}

test('admin search index api reports status and rebuilds stale documents', function (): void {
    $store = adminSearchIndexApiStore();
    $product = Product::factory()
        ->for($store)
        ->withDefaultVariant(2999)
        ->create([
            'title' => 'API Reindex Linen Jacket',
            'handle' => 'api-reindex-linen-jacket',
        ]);

    DB::table('products_fts')->where('product_id', $product->getKey())->delete();

    expect(app(SearchService::class)->search($store, 'api reindex linen', [], 12)->total())->toBe(0);

    $this->actingAs(adminSearchIndexApiUser())
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/search/status")
        ->assertOk()
        ->assertJsonPath('data.index_status', 'stale')
        ->assertJsonPath('data.pending_updates', 1);

    $this->actingAs(adminSearchIndexApiUser())
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/search/reindex")
        ->assertAccepted()
        ->assertJsonPath('status', 'completed')
        ->assertJsonPath('documents_count', Product::withoutGlobalScopes()->where('store_id', $store->getKey())->count());

    expect(app(SearchService::class)->search($store, 'api reindex linen', [], 12)->total())->toBe(1)
        ->and(SearchSettings::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail()->updated_at)->not->toBeNull();

    $this->actingAs(adminSearchIndexApiUser())
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/search/status")
        ->assertOk()
        ->assertJsonPath('data.index_status', 'ready')
        ->assertJsonPath('data.pending_updates', 0);
});

test('admin search index api enforces token abilities and store scope', function (): void {
    $store = adminSearchIndexApiStore();
    $otherStore = Store::factory()->create();
    $readToken = adminSearchIndexApiToken($store, ['read-settings']);
    $writeToken = adminSearchIndexApiToken($store, ['write-settings']);
    $otherStoreToken = adminSearchIndexApiToken($otherStore, ['read-settings']);

    $this->withToken($readToken['plain_text'])
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/search/status")
        ->assertOk();

    $this->withToken($readToken['plain_text'])
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/search/reindex")
        ->assertForbidden();

    $this->withToken($otherStoreToken['plain_text'])
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/search/status")
        ->assertForbidden();

    $this->withToken($writeToken['plain_text'])
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/search/reindex")
        ->assertAccepted();
});


git add app/Http/Controllers/Api/Admin/V1/SearchIndexController.php routes/api.php specs/progress.md tests/Feature/Api/AdminSearchIndexApiTest.php


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


 .../Api/Admin/V1/SearchIndexController.php         | 81 ++++++++++++++++++
 routes/api.php                                     |  9 ++
 specs/progress.md                                  | 16 +++-
 tests/Feature/Api/AdminSearchIndexApiTest.php      | 97 ++++++++++++++++++++++
 4 files changed, 199 insertions(+), 4 deletions(-)


Output:
Chunk ID: 950d78
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 83
Output:
 .../Api/Admin/V1/SearchIndexController.php         | 81 ++++++++++++++++++
 routes/api.php                                     |  9 ++
 specs/progress.md                                  | 16 +++-
 tests/Feature/Api/AdminSearchIndexApiTest.php      | 97 ++++++++++++++++++++++
 4 files changed, 199 insertions(+), 4 deletions(-)


A  app/Http/Controllers/Api/Admin/V1/SearchIndexController.php
M  routes/api.php
M  specs/progress.md
A  tests/Feature/Api/AdminSearchIndexApiTest.php


Output:
Chunk ID: fc186d
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 38
Output:
A  app/Http/Controllers/Api/Admin/V1/SearchIndexController.php
M  routes/api.php
M  specs/progress.md
A  tests/Feature/Api/AdminSearchIndexApiTest.php


git commit -m "Add admin search index API endpoints"


git commit -m "Add admin search index API endpoints"

[2026-05-04-codex-5-5-goal 99670769] Add admin search index API endpoints
 4 files changed, 199 insertions(+), 4 deletions(-)
 create mode 100644 app/Http/Controllers/Api/Admin/V1/SearchIndexController.php
 create mode 100644 tests/Feature/Api/AdminSearchIndexApiTest.php


Output:
Chunk ID: 7499ae
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 68
Output:
[2026-05-04-codex-5-5-goal 99670769] Add admin search index API endpoints
 4 files changed, 199 insertions(+), 4 deletions(-)
 create mode 100644 app/Http/Controllers/Api/Admin/V1/SearchIndexController.php
 create mode 100644 tests/Feature/Api/AdminSearchIndexApiTest.php


Committed `99670769` for the search index API. I’m moving to analytics summary next because the spec defines a single read endpoint and the admin UI already has analytics aggregation code to reuse.


date validation before_or_equal after_or_equal date_format query parameters

JSON API testing assertJsonPath validation errors

database aggregate group by sum count query builder


sed -n '2764,2848p' specs/02-API-ROUTES.md


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


sed -n '1,280p' app/Livewire/Admin/Analytics/Index.php


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


# Search Results

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

#### date_format:_format_,...

The field under validation must match one of the given _formats_. You should use **either** `date` or `date_format` when validating a field, not both. This validation rule supports all formats supported by PHP's [DateTime](https://www.php.net/manual/en/class.datetime.php) class.

For convenience, date-based rules may be constructed using the fluent `date` rule builder:

```php
use Illuminate\Validation\Rule;

'start_date' => [
    'required',
    Rule::date()->format('Y-m-d'),
],
```

---

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

### `json()`

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

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

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

---

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

---

## Validation Available Validation Rules before\_or\_equal:_date_
Validation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md

#### before\_or\_equal:_date_

The field under validation must be a value preceding or equal to the given date. The dates will be passed into the PHP `strtotime` function in order to be converted into a valid `DateTime` instance. In addition, like the [after](#rule-after) rule, the name of another field under validation may be supplied as the value of `date`.

For convenience, date-based rules may also be constructed using the fluent `date` rule builder:

```php
use Illuminate\Validation\Rule;

'start_date' => [
    'required',
    Rule::date()->beforeOrEqual(today()->subDays(7)),
],
```

---

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

#### date_equals:_date_

The field under validation must be equal to the given date. The dates will be passed into the PHP `strtotime` function in order to be converted into a valid `DateTime` instance.

---

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

### `toBeJson()`

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

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

---

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

---

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

#### assertJsonPath

Assert that the response contains the given data at the specified path:

```php
$response->assertJsonPath($path, $expectedValue);
```

For example, if the following JSON response is returned by your application:

```json
{
    "user": {
        "name": "Steve Schoger"
    }
}
```

You may assert that the `name` property of the `user` object matches a given value like so:

```php
$response->assertJsonPath('user.name', 'Steve Schoger');
```

---

## Queries Running Database Queries Aggregates
Queries | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/queries.md

### Aggregates

The query builder also provides a variety of methods for retrieving aggregate values like `count`, `max`, `min`, `avg`, and `sum`. You may call any of these methods after constructing your query:

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

$users = DB::table('users')->count();

$price = DB::table('orders')->max('price');
```

Of course, you may combine these methods with other clauses to fine-tune how your aggregate value is calculated:

```php
$price = DB::table('orders')
    ->where('finalized', 1)
    ->avg('price');
```

---

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

#### date

The field under validation must be a valid, non-relative date according to the `strtotime` PHP function.

---

## Validation Available Validation Rules after\_or\_equal:_date_
Validation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md

#### after\_or\_equal:_date_

The field under validation must be a value after or equal to the given date. For more information, see the [after](#rule-after) rule.

For convenience, date-based rules may be constructed using the fluent `date` rule builder:

```php
use Illuminate\Validation\Rule;

'start_date' => [
    'required',
    Rule::date()->afterOrEqual(today()->addDays(7)),
],
```

---

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

#### json

The field under validation must be a valid JSON string.

---

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

#### before:_date_

The field under validation must be a value preceding the given date. The dates will be passed into the PHP `strtotime` function in order to be converted into a valid `DateTime` instance. In addition, like the [after](#rule-after) rule, the name of another field under validation may be supplied as the value of `date`.

For convenience, date-based rules may also be constructed using the fluent `date` rule builder:

```php
use Illuminate\Validation\Rule;

'start_date' => [
    'required',
    Rule::date()->before(today()->subDays(7)),
],
```

The `beforeToday` and `todayOrBefore` methods may be used to fluently express the date and must be before today, or today or before, respectively:

```php
'start_date' => [
    'required',
    Rule::date()->beforeToday(),
],
```

---

## Upgrade Upgrading To 12.0 From 11.x Database Multi-Schema Database Inspecting
Upgrade | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/upgrade.md

#### Multi-Schema Database Inspecting

**Likelihood Of Impact: Low**

The `Schema::getTables()`, `Schema::getViews()`, and `Schema::getTypes()` methods now include the results from all schemas by default. You may pass the `schema` argument to retrieve the result for the given schema only:

```php
// All tables on all schemas...
$tables = Schema::getTables();

// All tables on the 'main' schema...
$tables = Schema::getTables(schema: 'main');

// All tables on the 'main' and 'blog' schemas...
$tables = Schema::getTables(schema: ['main', 'blog']);
```

The `Schema::getTableListing()` method now returns schema-qualified table names by default. You may pass the `schemaQualified` argument to change the behavior as desired:

```php
$tables = Schema::getTableListing();
// ['main.migrations', 'main.users', 'blog.posts']

$tables = Schema::getTableListing(schema: 'main');
// ['main.migrations', 'main.users']

$tables = Schema::getTableListing(schema: 'main', schemaQualified: false);
// ['migrations', 'users']
```

The `db:table` and `db:show` commands now output the results of all schemas on MySQL, MariaDB, and SQLite, just like PostgreSQL and SQL Server.

---

## Strings Fluent Strings `isJson` {.collection-method}
Strings | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/strings.md

#### `isJson` {.collection-method}

The `isJson` method determines if a given string is valid JSON:

```php
use Illuminate\Support\Str;

$result = Str::of('[1,2,3]')->isJson();

// true

$result = Str::of('{"first": "John", "last": "Doe"}')->isJson();

// true

$result = Str::of('{first: "John", last: "Doe"}')->isJson();

// false
```

---

## Queries Running Database Queries Aggregates Determining if Records Exist
Queries | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/queries.md

#### Determining if Records Exist

Instead of using the `count` method to determine if any records exist that match your query's constraints, you may use the `exists` and `doesntExist` methods:

```php
if (DB::table('orders')->where('finalized', 1)->exists()) {
    // ...
}

if (DB::table('orders')->where('finalized', 1)->doesntExist()) {
    // ...
}
```

---

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

#### Updated `DatabaseTokenRepository` Constructor Signature

**Likelihood Of Impact: Very Low**

The constructor of the `Illuminate\Auth\Passwords\DatabaseTokenRepository` class now expects the `$expires` parameter to be given in seconds, rather than minutes.

---

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

#### after:_date_

The field under validation must be a value after a given date. The dates will be passed into the `strtotime` PHP function in order to be converted to a valid `DateTime` instance:

```php
'start_date' => 'required|date|after:tomorrow'
```

Instead of passing a date string to be evaluated by `strtotime`, you may specify another field to compare against the date:

```php
'finish_date' => 'required|date|after:start_date'
```

For convenience, date-based rules may be constructed using the fluent `date` rule builder:

```php
use Illuminate\Validation\Rule;

'start_date' => [
    'required',
    Rule::date()->after(today()->addDays(7)),
],
```

The `afterToday` and `todayOrAfter` methods may be used to fluently express the date and must be after today, or today or after, respectively:

```php
'start_date' => [
    'required',
    Rule::date()->afterToday(),
],
```

---

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

#### Asserting on JSON Paths

If you would like to verify that the JSON response contains the given data at a specified path, you should use the `assertJsonPath` method:

```php tab=Pest
<?php

test('asserting a json path value', function () {
    $response = $this->postJson('/user', ['name' => 'Sally']);

    $response
        ->assertStatus(201)
        ->assertJsonPath('team.owner.name', 'Darian');
});
```

```php tab=PHPUnit
<?php

namespace Tests\Feature;

use Tests\TestCase;

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

        $response
            ->assertStatus(201)
            ->assertJsonPath('team.owner.name', 'Darian');
    }
}
```

The `assertJsonPath` method also accepts a closure, which may be used to dynamically determine if the assertion should pass:

```php
$response->assertJsonPath('team.owner.name', fn (string $name) => strlen($name) >= 3);
```

---

## Strings Strings `Str::isJson()` {.collection-method}
Strings | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/strings.md

#### `Str::isJson()` {.collection-method}

The `Str::isJson` method determines if the given string is valid JSON:

```php
use Illuminate\Support\Str;

$result = Str::isJson('[1,2,3]');

// true

$result = Str::isJson('{"first": "John", "last": "Doe"}');

// true

$result = Str::isJson('{first: "John", last: "Doe"}');

// false
```

---

## Testing Running Tests Running Tests in Parallel Parallel Testing and Databases
Testing | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/testing.md

#### Parallel Testing and Databases

As long as you have configured a primary database connection, Laravel automatically handles creating and migrating a test database for each parallel process that is running your tests. The test databases will be suffixed with a process token which is unique per process. For example, if you have two parallel test processes, Laravel will create and use `your_db_test_1` and `your_db_test_2` test databases.

By default, test databases persist between calls to the `test` Artisan command so that they can be used again by subsequent `test` invocations. However, you may re-create them using the `--recreate-databases` option:

```shell
php artisan test --parallel --recreate-databases
```

---

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

#### assertJsonMissingPath

Assert that the response does not contain the given path:

```php
$response->assertJsonMissingPath($path);
```

For example, if the following JSON response is returned by your application:

```json
{
    "user": {
        "name": "Steve Schoger"
    }
}
```

You may assert that it does not contain the `email` property of the `user` object:

```php
$response->assertJsonMissingPath('user.email');
```

---

## Queries Running Database Queries Chunking Results
Queries | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/queries.md

### Chunking Results

If you need to work with thousands of database records, consider using the `chunk` method provided by the `DB` facade. This method retrieves a small chunk of results at a time and feeds each chunk into a closure for processing. For example, let's retrieve the entire `users` table in chunks of 100 records at a time:

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

DB::table('users')->orderBy('id')->chunk(100, function (Collection $users) {
    foreach ($users as $user) {
        // ...
    }
});
```

You may stop further chunks from being processed by returning `false` from the closure:

```php
DB::table('users')->orderBy('id')->chunk(100, function (Collection $users) {
    // Process the records...

    return false;
});
```

If you are updating database records while chunking results, your chunk results could change in unexpected ways. If you plan to update the retrieved records while chunking, it is always best to use the `chunkById` method instead. This method will automatically paginate the results based on the record's primary key:

```php
DB::table('users')->where('active', false)
    ->chunkById(100, function (Collection $users) {
        foreach ($users as $user) {
            DB::table('users')
                ->where('id', $user->id)
                ->update(['active' => true]);
        }
    });
```

Since the `chunkById` and `lazyById` methods add their own "where" conditions to the query being executed, you should typically [logically group](#logical-grouping) your own conditions within a closure:

```php
DB::table('users')->where(function ($query) {
    $query->where('credits', 1)->orWhere('credits', 2);
})->chunkById(100, function (Collection $users) {
    foreach ($users as $user) {
        DB::table('users')
            ->where('id', $user->id)
            ->update(['credits' => 3]);
    }
});
```

> [!WARNING]
> When updating or deleting records inside the chunk callback, any changes to the primary key or foreign keys could affect the chunk query. This could potentially result in records not being included in the chunked results.

---

## Responses Streamed Responses Streamed JSON Responses
Responses | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/responses.md

### Streamed JSON Responses

If you need to stream JSON data incrementally, you may utilize the `streamJson` method. This method is especially useful for large datasets that need to be sent progressively to the browser in a format that can be easily parsed by JavaScript:

```php
use App\Models\User;

Route::get('/users.json', function () {
    return response()->streamJson([
        'users' => User::cursor(),
    ]);
});
```

The `useJsonStream` hook is identical to the [useStream hook](#consuming-streamed-responses) except that it will attempt to parse the data as JSON once it has finished streaming:

```tsx tab=React
import { useJsonStream } from "@laravel/stream-react";

type User = {
    id: number;
    name: string;
    email: string;
};

function App() {
    const { data, send } = useJsonStream<{ users: User[] }>("users");

    const loadUsers = () => {
        send({
            query: "taylor",
        });
    };

    return (
        <div>
            <ul>
                {data?.users.map((user) => (
                    <li>
                        {user.id}: {user.name}
                    </li>
                ))}
            </ul>
            <button onClick={loadUsers}>Load Users</button>
        </div>
    );
}
```

```vue tab=Vue
<script setup lang="ts">
import { useJsonStream } from "@laravel/stream-vue";

type User = {
    id: number;
    name: string;
    email: string;
};

const { data, send } = useJsonStream<{ users: User[] }>("users");

const loadUsers = () => {
    send({
        query: "taylor",
    });
};
</script>

<template>
    <div>
        <ul>
            <li v-for="user in data?.users" :key="user.id">
                {{ user.id }}: {{ user.name }}
            </li>
        </ul>
        <button @click="loadUsers">Load Users</button>
    </div>
</template>
```

```svelte tab=Svelte
<script>
import { useJsonStream } from "@laravel/stream-svelte";

const stream = useJsonStream("users");

const loadUsers = () => {
    stream.send({
        query: "taylor",
    });
};
</script>

<div>
    <ul>
        {#if $stream.data?.users}
            {#each $stream.data.users as user (user.id)}
                <li>{user.id}: {user.name}</li>
            {/each}
        {/if}
    </ul>
    <button onclick={loadUsers}>Load Users</button>
</div>
```

---

## Structure The Root Directory The Database Directory
Structure | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/structure.md

### The Database Directory

The `database` directory contains your database migrations, model factories, and seeds. If you wish, you may also use this directory to hold an SQLite database.

---

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

#### assertJsonValidationErrors

Assert that the response has the given JSON validation errors for the given keys. This method should be used when asserting against responses where the validation errors are returned as a JSON structure instead of being flashed to the session:

```php
$response->assertJsonValidationErrors(array $data, $responseKey = 'errors');
```

> [!NOTE]
> The more generic [assertInvalid](#assert-invalid) method may be used to assert that a response has validation errors returned as JSON **or** that errors were flashed to session storage.

---

## Responses Other Response Types JSON Responses
Responses | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/responses.md

### JSON Responses

The `json` method will automatically set the `Content-Type` header to `application/json`, as well as convert the given array to JSON using the `json_encode` PHP function:

```php
return response()->json([
    'name' => 'Abigail',
    'state' => 'CA',
]);
```

If you would like to create a JSONP response, you may use the `json` method in combination with the `withCallback` method:

```php
return response()
    ->json(['name' => 'Abigail', 'state' => 'CA'])
    ->withCallback($request->input('callback'));
```

---

## Session Introduction Driver Prerequisites Database
Session | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/session.md

#### Database

When using the `database` session driver, you will need to ensure that you have a database table to contain the session data. Typically, this is included in Laravel's default `0001_01_01_000000_create_users_table.php` [database migration](/docs/{{version}}/migrations); however, if for any reason you do not have a `sessions` table, you may use the `make:session-table` Artisan command to generate this migration:

```shell
php artisan make:session-table

php artisan migrate
```

---

## Requests Input Retrieving Input Retrieving Date Input Values
Requests | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/requests.md

#### Retrieving Date Input Values

For convenience, input values containing dates / times may be retrieved as Carbon instances using the `date` method. If the request does not contain an input value with the given name, `null` will be returned:

```php
$birthday = $request->date('birthday');
```

The second and third arguments accepted by the `date` method may be used to specify the date's format and timezone, respectively:

```php
$elapsed = $request->date('elapsed', '!H:i', 'Europe/Madrid');
```

If the input value is present but has an invalid format, an `InvalidArgumentException` will be thrown; therefore, it is recommended that you validate the input before invoking the `date` method.

---

## Validation Working With Validated Input
Validation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md

## Working With Validated Input

After validating incoming request data using a form request or a manually created validator instance, you may wish to retrieve the incoming request data that actually underwent validation. This can be accomplished in several ways. First, you may call the `validated` method on a form request or validator instance. This method returns an array of the data that was validated:

```php
$validated = $request->validated();

$validated = $validator->validated();
```

Alternatively, you may call the `safe` method on a form request or validator instance. This method returns an instance of `Illuminate\Support\ValidatedInput`. This object exposes `only`, `except`, and `all` methods to retrieve a subset of the validated data or the entire array of validated data:

```php
$validated = $request->safe()->only(['name', 'email']);

$validated = $request->safe()->except(['name', 'email']);

$validated = $request->safe()->all();
```

In addition, the `Illuminate\Support\ValidatedInput` instance may be iterated over and accessed like an array:

```php
// Validated data may be iterated...
foreach ($request->safe() as $key => $value) {
    // ...
}

// Validated data may be accessed as an array...
$validated = $request->safe();

$email = $validated['email'];
```

If you would like to add additional fields to the validated data, you may call the `merge` method:

```php
$validated = $request->safe()->merge(['name' => 'Taylor Otwell']);
```

If you would like to retrieve the validated data as a [collection](/docs/{{version}}/collections) instance, you may call the `collect` method:

```php
$collection = $request->safe()->collect();
```

---

## Search Laravel Scout Database Engine
Search | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/search.md

### Database Engine

Scout's built-in database engine performs full-text and `LIKE` searches against your existing database — no external service or extra infrastructure required. Simply add the `Searchable` trait to your model and define a `toSearchableArray` method that returns the columns you want to be searchable.

You may use PHP attributes to control the search strategy for each column. `SearchUsingFullText` will use your database's full-text index, `SearchUsingPrefix` will only match from the beginning of the string (`example%`), and any columns without an attribute use a default `LIKE` strategy with wildcards on both sides (`%example%`):

```php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Laravel\Scout\Attributes\SearchUsingFullText;
use Laravel\Scout\Attributes\SearchUsingPrefix;
use Laravel\Scout\Searchable;

class Article extends Model
{
    use Searchable;

    #[SearchUsingPrefix(['id'])]
    #[SearchUsingFullText(['title', 'body'])]
    public function toSearchableArray(): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'body' => $this->body,
        ];
    }
}
```

> [!WARNING]
> Before specifying that a column should use full-text query constraints, ensure that the column has been assigned a [full-text index](/docs/{{version}}/migrations#available-index-types).

Once the trait is added, you may search your model using Scout's `search` method. Scout's database engine will automatically order results by relevance, even on PostgreSQL:

```php
$articles = Article::search('Laravel')->get();
```

The database engine is a great choice when your search needs are moderate and you want the convenience of Scout's automatic index syncing without deploying an external service. It handles the most common search use cases well, including filtering, pagination, and soft-deleted record handling. For complete details, consult the [Scout documentation](/docs/{{version}}/scout#database-engine).

---

## Requests Input Retrieving Input Retrieving JSON Input Values
Requests | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/requests.md

#### Retrieving JSON Input Values

When sending JSON requests to your application, you may access the JSON data via the `input` method as long as the `Content-Type` header of the request is properly set to `application/json`. You may even use "dot" syntax to retrieve values that are nested within JSON arrays / objects:

```php
$name = $request->input('user.name');
```

---

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

#### assertJsonMissingValidationErrors

Assert that the response has no JSON validation errors for the given keys:

```php
$response->assertJsonMissingValidationErrors($keys);
```

> [!NOTE]
> The more generic [assertValid](#assert-valid) method may be used to assert that a response does not have validation errors that were returned as JSON **and** that no errors were flashed to session storage.

---

## Queries Update Statements Updating JSON Columns
Queries | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/queries.md

### Updating JSON Columns

When updating a JSON column, you should use `->` syntax to update the appropriate key in the JSON object. This operation is supported on MariaDB 10.3+, MySQL 5.7+, and PostgreSQL 9.5+:

```php
$affected = DB::table('users')
    ->where('id', 1)
    ->update(['options->enabled' => true]);
```

---

## Scout Database / Collection Engines Collection Engine
Scout | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/scout.md

### Collection Engine

The "collection" engine is intended for quick prototypes, extremely small datasets (a few hundred records), or running tests. It retrieves all possible records from your database and uses Laravel's `Str::is` helper to filter them in PHP, so it does not require any indexing or database-specific features. For anything beyond trivial use cases, you should use the [database engine](#database-engine) instead.

To use the collection engine, you may simply set the value of the `SCOUT_DRIVER` environment variable to `collection`, or specify the `collection` driver directly in your application's `scout` configuration file:

```ini
SCOUT_DRIVER=collection
```

Once you have specified the collection driver as your preferred driver, you may start [executing search queries](#searching) against your models. Search engine indexing, such as the indexing needed to seed Algolia, Meilisearch, or Typesense indexes, is unnecessary when using the collection engine.

#### Differences From Database Engine

While the database engine uses full-text indexes and `LIKE` clauses to find matching records efficiently, the collection engine pulls all records and filters them in PHP. The collection engine is the most portable option as it works across all relational databases supported by Laravel (including SQLite and SQL Server); however, it is significantly less efficient than the database engine and should not be used with large datasets.

_[Truncated]_


Output:
Wall time: 3.3922 seconds
Output:
[{"type":"text","text":"# Search Results\n\n## Validation Available Validation Rules date_format:_format_,...\nValidation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md\n\n\n#### date_format:_format_,...\n\nThe field under validation must match one of the given _formats_. You should use **either** `date` or `date_format` when validating a field, not both. This validation rule supports all formats supported by PHP's [DateTime](https://www.php.net/manual/en/class.datetime.php) class.\n\nFor convenience, date-based rules may be constructed using the fluent `date` rule builder:\n\n```php\nuse Illuminate\\Validation\\Rule;\n\n'start_date' => [\n    'required',\n    Rule::date()->format('Y-m-d'),\n],\n```\n\n---\n\n## Expectations `json()`\nExpectations | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/expectations.md\n\n\n### `json()`\n\nThe `json()` modifier decodes the current expectation `$value` from JSON to an array.\n\n```php\nexpect('{\"name\":\"Nuno\",\"credit\":1000.00}')\n    ->json()\n    ->toHaveCount(2)\n    ->name->toBe('Nuno')\n    ->credit->toBeFloat();\n\nexpect('not-a-json')->json(); //Fails\n```\n\n---\n\n## 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## Validation Available Validation Rules before\\_or\\_equal:_date_\nValidation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md\n\n\n#### before\\_or\\_equal:_date_\n\nThe field under validation must be a value preceding or equal to the given date. The dates will be passed into the PHP `strtotime` function in order to be converted into a valid `DateTime` instance. In addition, like the [after](#rule-after) rule, the name of another field under validation may be supplied as the value of `date`.\n\nFor convenience, date-based rules may also be constructed using the fluent `date` rule builder:\n\n```php\nuse Illuminate\\Validation\\Rule;\n\n'start_date' => [\n    'required',\n    Rule::date()->beforeOrEqual(today()->subDays(7)),\n],\n```\n\n---\n\n## Validation Available Validation Rules date_equals:_date_\nValidation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md\n\n\n#### date_equals:_date_\n\nThe field under validation must be equal to the given date. The dates will be passed into the PHP `strtotime` function in order to be converted into a valid `DateTime` instance.\n\n---\n\n## Expectations `toBeJson()`\nExpectations | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/expectations.md\n\n\n### `toBeJson()`\n\nThis expectation ensures that `$value` is a JSON string.\n\n```php\nexpect('{\"hello\":\"world\"}')->toBeJson();\n```\n\n---\n\n## 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## Http Tests Available Assertions Response Assertions assertJsonPath\nHttp Tests | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/http-tests.md\n\n\n#### assertJsonPath\n\nAssert that the response contains the given data at the specified path:\n\n```php\n$response->assertJsonPath($path, $expectedValue);\n```\n\nFor example, if the following JSON response is returned by your application:\n\n```json\n{\n    \"user\": {\n        \"name\": \"Steve Schoger\"\n    }\n}\n```\n\nYou may assert that the `name` property of the `user` object matches a given value like so:\n\n```php\n$response->assertJsonPath('user.name', 'Steve Schoger');\n```\n\n---\n\n## Queries Running Database Queries Aggregates\nQueries | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/queries.md\n\n\n### Aggregates\n\nThe query builder also provides a variety of methods for retrieving aggregate values like `count`, `max`, `min`, `avg`, and `sum`. You may call any of these methods after constructing your query:\n\n```php\nuse Illuminate\\Support\\Facades\\DB;\n\n$users = DB::table('users')->count();\n\n$price = DB::table('orders')->max('price');\n```\n\nOf course, you may combine these methods with other clauses to fine-tune how your aggregate value is calculated:\n\n```php\n$price = DB::table('orders')\n    ->where('finalized', 1)\n    ->avg('price');\n```\n\n---\n\n## Validation Available Validation Rules date\nValidation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md\n\n\n#### date\n\nThe field under validation must be a valid, non-relative date according to the `strtotime` PHP function.\n\n---\n\n## Validation Available Validation Rules after\\_or\\_equal:_date_\nValidation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md\n\n\n#### after\\_or\\_equal:_date_\n\nThe field under validation must be a value after or equal to the given date. For more information, see the [after](#rule-after) rule.\n\nFor convenience, date-based rules may be constructed using the fluent `date` rule builder:\n\n```php\nuse Illuminate\\Validation\\Rule;\n\n'start_date' => [\n    'required',\n    Rule::date()->afterOrEqual(today()->addDays(7)),\n],\n```\n\n---\n\n## Validation Available Validation Rules json\nValidation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md\n\n\n#### json\n\nThe field under validation must be a valid JSON string.\n\n---\n\n## Validation Available Validation Rules before:_date_\nValidation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md\n\n\n#### before:_date_\n\nThe field under validation must be a value preceding the given date. The dates will be passed into the PHP `strtotime` function in order to be converted into a valid `DateTime` instance. In addition, like the [after](#rule-after) rule, the name of another field under validation may be supplied as the value of `date`.\n\nFor convenience, date-based rules may also be constructed using the fluent `date` rule builder:\n\n```php\nuse Illuminate\\Validation\\Rule;\n\n'start_date' => [\n    'required',\n    Rule::date()->before(today()->subDays(7)),\n],\n```\n\nThe `beforeToday` and `todayOrBefore` methods may be used to fluently express the date and must be before today, or today or before, respectively:\n\n```php\n'start_date' => [\n    'required',\n    Rule::date()->beforeToday(),\n],\n```\n\n---\n\n## Upgrade Upgrading To 12.0 From 11.x Database Multi-Schema Database Inspecting\nUpgrade | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/upgrade.md\n\n\n#### Multi-Schema Database Inspecting\n\n**Likelihood Of Impact: Low**\n\nThe `Schema::getTables()`, `Schema::getViews()`, and `Schema::getTypes()` methods now include the results from all schemas by default. You may pass the `schema` argument to retrieve the result for the given schema only:\n\n```php\n// All tables on all schemas...\n$tables = Schema::getTables();\n\n// All tables on the 'main' schema...\n$tables = Schema::getTables(schema: 'main');\n\n// All tables on the 'main' and 'blog' schemas...\n$tables = Schema::getTables(schema: ['main', 'blog']);\n```\n\nThe `Schema::getTableListing()` method now returns schema-qualified table names by default. You may pass the `schemaQualified` argument to change the behavior as desired:\n\n```php\n$tables = Schema::getTableListing();\n// ['main.migrations', 'main.users', 'blog.posts']\n\n$tables = Schema::getTableListing(schema: 'main');\n// ['main.migrations', 'main.users']\n\n$tables = Schema::getTableListing(schema: 'main', schemaQualified: false);\n// ['migrations', 'users']\n```\n\nThe `db:table` and `db:show` commands now output the results of all schemas on MySQL, MariaDB, and SQLite, just like PostgreSQL and SQL Server.\n\n---\n\n## Strings Fluent Strings `isJson` {.collection-method}\nStrings | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/strings.md\n\n\n#### `isJson` {.collection-method}\n\nThe `isJson` method determines if a given string is valid JSON:\n\n```php\nuse Illuminate\\Support\\Str;\n\n$result = Str::of('[1,2,3]')->isJson();\n\n// true\n\n$result = Str::of('{\"first\": \"John\", \"last\": \"Doe\"}')->isJson();\n\n// true\n\n$result = Str::of('{first: \"John\", last: \"Doe\"}')->isJson();\n\n// false\n```\n\n---\n\n## Queries Running Database Queries Aggregates Determining if Records Exist\nQueries | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/queries.md\n\n\n#### Determining if Records Exist\n\nInstead of using the `count` method to determine if any records exist that match your query's constraints, you may use the `exists` and `doesntExist` methods:\n\n```php\nif (DB::table('orders')->where('finalized', 1)->exists()) {\n    // ...\n}\n\nif (DB::table('orders')->where('finalized', 1)->doesntExist()) {\n    // ...\n}\n```\n\n---\n\n## Upgrade Upgrading To 12.0 From 11.x Authentication Updated `DatabaseTokenRepository` Constructor Signature\nUpgrade | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/upgrade.md\n\n\n#### Updated `DatabaseTokenRepository` Constructor Signature\n\n**Likelihood Of Impact: Very Low**\n\nThe constructor of the `Illuminate\\Auth\\Passwords\\DatabaseTokenRepository` class now expects the `$expires` parameter to be given in seconds, rather than minutes.\n\n---\n\n## Validation Available Validation Rules after:_date_\nValidation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md\n\n\n#### after:_date_\n\nThe field under validation must be a value after a given date. The dates will be passed into the `strtotime` PHP function in order to be converted to a valid `DateTime` instance:\n\n```php\n'start_date' => 'required|date|after:tomorrow'\n```\n\nInstead of passing a date string to be evaluated by `strtotime`, you may specify another field to compare against the date:\n\n```php\n'finish_date' => 'required|date|after:start_date'\n```\n\nFor convenience, date-based rules may be constructed using the fluent `date` rule builder:\n\n```php\nuse Illuminate\\Validation\\Rule;\n\n'start_date' => [\n    'required',\n    Rule::date()->after(today()->addDays(7)),\n],\n```\n\nThe `afterToday` and `todayOrAfter` methods may be used to fluently express the date and must be after today, or today or after, respectively:\n\n```php\n'start_date' => [\n    'required',\n    Rule::date()->afterToday(),\n],\n```\n\n---\n\n## Http Tests Testing JSON APIs Asserting on JSON Paths\nHttp Tests | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/http-tests.md\n\n\n#### Asserting on JSON Paths\n\nIf you would like to verify that the JSON response contains the given data at a specified path, you should use the `assertJsonPath` method:\n\n```php tab=Pest\n<?php\n\ntest('asserting a json path value', function () {\n    $response = $this->postJson('/user', ['name' => 'Sally']);\n\n    $response\n        ->assertStatus(201)\n        ->assertJsonPath('team.owner.name', 'Darian');\n});\n```\n\n```php tab=PHPUnit\n<?php\n\nnamespace Tests\\Feature;\n\nuse Tests\\TestCase;\n\nclass ExampleTest extends TestCase\n{\n    /**\n     * A basic functional test example.\n     */\n    public function test_asserting_a_json_paths_value(): void\n    {\n        $response = $this->postJson('/user', ['name' => 'Sally']);\n\n        $response\n            ->assertStatus(201)\n            ->assertJsonPath('team.owner.name', 'Darian');\n    }\n}\n```\n\nThe `assertJsonPath` method also accepts a closure, which may be used to dynamically determine if the assertion should pass:\n\n```php\n$response->assertJsonPath('team.owner.name', fn (string $name) => strlen($name) >= 3);\n```\n\n---\n\n## Strings Strings `Str::isJson()` {.collection-method}\nStrings | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/strings.md\n\n\n#### `Str::isJson()` {.collection-method}\n\nThe `Str::isJson` method determines if the given string is valid JSON:\n\n```php\nuse Illuminate\\Support\\Str;\n\n$result = Str::isJson('[1,2,3]');\n\n// true\n\n$result = Str::isJson('{\"first\": \"John\", \"last\": \"Doe\"}');\n\n// true\n\n$result = Str::isJson('{first: \"John\", last: \"Doe\"}');\n\n// false\n```\n\n---\n\n## Testing Running Tests Running Tests in Parallel Parallel Testing and Databases\nTesting | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/testing.md\n\n\n#### Parallel Testing and Databases\n\nAs long as you have configured a primary database connection, Laravel automatically handles creating and migrating a test database for each parallel process that is running your tests. The test databases will be suffixed with a process token which is unique per process. For example, if you have two parallel test processes, Laravel will create and use `your_db_test_1` and `your_db_test_2` test databases.\n\nBy default, test databases persist between calls to the `test` Artisan command so that they can be used again by subsequent `test` invocations. However, you may re-create them using the `--recreate-databases` option:\n\n```shell\nphp artisan test --parallel --recreate-databases\n```\n\n---\n\n## Http Tests Available Assertions Response Assertions assertJsonMissingPath\nHttp Tests | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/http-tests.md\n\n\n#### assertJsonMissingPath\n\nAssert that the response does not contain the given path:\n\n```php\n$response->assertJsonMissingPath($path);\n```\n\nFor example, if the following JSON response is returned by your application:\n\n```json\n{\n    \"user\": {\n        \"name\": \"Steve Schoger\"\n    }\n}\n```\n\nYou may assert that it does not contain the `email` property of the `user` object:\n\n```php\n$response->assertJsonMissingPath('user.email');\n```\n\n---\n\n## Queries Running Database Queries Chunking Results\nQueries | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/queries.md\n\n\n### Chunking Results\n\nIf you need to work with thousands of database records, consider using the `chunk` method provided by the `DB` facade. This method retrieves a small chunk of results at a time and feeds each chunk into a closure for processing. For example, let's retrieve the entire `users` table in chunks of 100 records at a time:\n\n```php\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\DB;\n\nDB::table('users')->orderBy('id')->chunk(100, function (Collection $users) {\n    foreach ($users as $user) {\n        // ...\n    }\n});\n```\n\nYou may stop further chunks from being processed by returning `false` from the closure:\n\n```php\nDB::table('users')->orderBy('id')->chunk(100, function (Collection $users) {\n    // Process the records...\n\n    return false;\n});\n```\n\nIf you are updating database records while chunking results, your chunk results could change in unexpected ways. If you plan to update the retrieved records while chunking, it is always best to use the `chunkById` method instead. This method will automatically paginate the results based on the record's primary key:\n\n```php\nDB::table('users')->where('active', false)\n    ->chunkById(100, function (Collection $users) {\n        foreach ($users as $user) {\n            DB::table('users')\n                ->where('id', $user->id)\n                ->update(['active' => true]);\n        }\n    });\n```\n\nSince the `chunkById` and `lazyById` methods add their own \"where\" conditions to the query being executed, you should typically [logically group](#logical-grouping) your own conditions within a closure:\n\n```php\nDB::table('users')->where(function ($query) {\n    $query->where('credits', 1)->orWhere('credits', 2);\n})->chunkById(100, function (Collection $users) {\n    foreach ($users as $user) {\n        DB::table('users')\n            ->where('id', $user->id)\n            ->update(['credits' => 3]);\n    }\n});\n```\n\n> [!WARNING]\n> When updating or deleting records inside the chunk callback, any changes to the primary key or foreign keys could affect the chunk query. This could potentially result in records not being included in the chunked results.\n\n---\n\n## Responses Streamed Responses Streamed JSON Responses\nResponses | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/responses.md\n\n\n### Streamed JSON Responses\n\nIf you need to stream JSON data incrementally, you may utilize the `streamJson` method. This method is especially useful for large datasets that need to be sent progressively to the browser in a format that can be easily parsed by JavaScript:\n\n```php\nuse App\\Models\\User;\n\nRoute::get('/users.json', function () {\n    return response()->streamJson([\n        'users' => User::cursor(),\n    ]);\n});\n```\n\nThe `useJsonStream` hook is identical to the [useStream hook](#consuming-streamed-responses) except that it will attempt to parse the data as JSON once it has finished streaming:\n\n```tsx tab=React\nimport { useJsonStream } from \"@laravel/stream-react\";\n\ntype User = {\n    id: number;\n    name: string;\n    email: string;\n};\n\nfunction App() {\n    const { data, send } = useJsonStream<{ users: User[] }>(\"users\");\n\n    const loadUsers = () => {\n        send({\n            query: \"taylor\",\n        });\n    };\n\n    return (\n        <div>\n            <ul>\n                {data?.users.map((user) => (\n                    <li>\n                        {user.id}: {user.name}\n                    </li>\n                ))}\n            </ul>\n            <button onClick={loadUsers}>Load Users</button>\n        </div>\n    );\n}\n```\n\n```vue tab=Vue\n<script setup lang=\"ts\">\nimport { useJsonStream } from \"@laravel/stream-vue\";\n\ntype User = {\n    id: number;\n    name: string;\n    email: string;\n};\n\nconst { data, send } = useJsonStream<{ users: User[] }>(\"users\");\n\nconst loadUsers = () => {\n    send({\n        query: \"taylor\",\n    });\n};\n</script>\n\n<template>\n    <div>\n        <ul>\n            <li v-for=\"user in data?.users\" :key=\"user.id\">\n                {{ user.id }}: {{ user.name }}\n            </li>\n        </ul>\n        <button @click=\"loadUsers\">Load Users</button>\n    </div>\n</template>\n```\n\n```svelte tab=Svelte\n<script>\nimport { useJsonStream } from \"@laravel/stream-svelte\";\n\nconst stream = useJsonStream(\"users\");\n\nconst loadUsers = () => {\n    stream.send({\n        query: \"taylor\",\n    });\n};\n</script>\n\n<div>\n    <ul>\n        {#if $stream.data?.users}\n            {#each $stream.data.users as user (user.id)}\n                <li>{user.id}: {user.name}</li>\n            {/each}\n        {/if}\n    </ul>\n    <button onclick={loadUsers}>Load Users</button>\n</div>\n```\n\n---\n\n## Structure The Root Directory The Database Directory\nStructure | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/structure.md\n\n\n### The Database Directory\n\nThe `database` directory contains your database migrations, model factories, and seeds. If you wish, you may also use this directory to hold an SQLite database.\n\n---\n\n## Http Tests Available Assertions Response Assertions assertJsonValidationErrors\nHttp Tests | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/http-tests.md\n\n\n#### assertJsonValidationErrors\n\nAssert that the response has the given JSON validation errors for the given keys. This method should be used when asserting against responses where the validation errors are returned as a JSON structure instead of being flashed to the session:\n\n```php\n$response->assertJsonValidationErrors(array $data, $responseKey = 'errors');\n```\n\n> [!NOTE]\n> The more generic [assertInvalid](#assert-invalid) method may be used to assert that a response has validation errors returned as JSON **or** that errors were flashed to session storage.\n\n---\n\n## Responses Other Response Types JSON Responses\nResponses | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/responses.md\n\n\n### JSON Responses\n\nThe `json` method will automatically set the `Content-Type` header to `application/json`, as well as convert the given array to JSON using the `json_encode` PHP function:\n\n```php\nreturn response()->json([\n    'name' => 'Abigail',\n    'state' => 'CA',\n]);\n```\n\nIf you would like to create a JSONP response, you may use the `json` method in combination with the `withCallback` method:\n\n```php\nreturn response()\n    ->json(['name' => 'Abigail', 'state' => 'CA'])\n    ->withCallback($request->input('callback'));\n```\n\n---\n\n## Session Introduction Driver Prerequisites Database\nSession | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/session.md\n\n\n#### Database\n\nWhen using the `database` session driver, you will need to ensure that you have a database table to contain the session data. Typically, this is included in Laravel's default `0001_01_01_000000_create_users_table.php` [database migration](/docs/{{version}}/migrations); however, if for any reason you do not have a `sessions` table, you may use the `make:session-table` Artisan command to generate this migration:\n\n```shell\nphp artisan make:session-table\n\nphp artisan migrate\n```\n\n---\n\n## Requests Input Retrieving Input Retrieving Date Input Values\nRequests | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/requests.md\n\n\n#### Retrieving Date Input Values\n\nFor convenience, input values containing dates / times may be retrieved as Carbon instances using the `date` method. If the request does not contain an input value with the given name, `null` will be returned:\n\n```php\n$birthday = $request->date('birthday');\n```\n\nThe second and third arguments accepted by the `date` method may be used to specify the date's format and timezone, respectively:\n\n```php\n$elapsed = $request->date('elapsed', '!H:i', 'Europe/Madrid');\n```\n\nIf the input value is present but has an invalid format, an `InvalidArgumentException` will be thrown; therefore, it is recommended that you validate the input before invoking the `date` method.\n\n---\n\n## Validation Working With Validated Input\nValidation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md\n\n\n## Working With Validated Input\n\nAfter validating incoming request data using a form request or a manually created validator instance, you may wish to retrieve the incoming request data that actually underwent validation. This can be accomplished in several ways. First, you may call the `validated` method on a form request or validator instance. This method returns an array of the data that was validated:\n\n```php\n$validated = $request->validated();\n\n$validated = $validator->validated();\n```\n\nAlternatively, you may call the `safe` method on a form request or validator instance. This method returns an instance of `Illuminate\\Support\\ValidatedInput`. This object exposes `only`, `except`, and `all` methods to retrieve a subset of the validated data or the entire array of validated data:\n\n```php\n$validated = $request->safe()->only(['name', 'email']);\n\n$validated = $request->safe()->except(['name', 'email']);\n\n$validated = $request->safe()->all();\n```\n\nIn addition, the `Illuminate\\Support\\ValidatedInput` instance may be iterated over and accessed like an array:\n\n```php\n// Validated data may be iterated...\nforeach ($request->safe() as $key => $value) {\n    // ...\n}\n\n// Validated data may be accessed as an array...\n$validated = $request->safe();\n\n$email = $validated['email'];\n```\n\nIf you would like to add additional fields to the validated data, you may call the `merge` method:\n\n```php\n$validated = $request->safe()->merge(['name' => 'Taylor Otwell']);\n```\n\nIf you would like to retrieve the validated data as a [collection](/docs/{{version}}/collections) instance, you may call the `collect` method:\n\n```php\n$collection = $request->safe()->collect();\n```\n\n---\n\n## Search Laravel Scout Database Engine\nSearch | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/search.md\n\n\n### Database Engine\n\nScout's built-in database engine performs full-text and `LIKE` searches against your existing database — no external service or extra infrastructure required. Simply add the `Searchable` trait to your model and define a `toSearchableArray` method that returns the columns you want to be searchable.\n\nYou may use PHP attributes to control the search strategy for each column. `SearchUsingFullText` will use your database's full-text index, `SearchUsingPrefix` will only match from the beginning of the string (`example%`), and any columns without an attribute use a default `LIKE` strategy with wildcards on both sides (`%example%`):\n\n```php\n<?php\n\nnamespace App\\Models;\n\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Laravel\\Scout\\Attributes\\SearchUsingFullText;\nuse Laravel\\Scout\\Attributes\\SearchUsingPrefix;\nuse Laravel\\Scout\\Searchable;\n\nclass Article extends Model\n{\n    use Searchable;\n\n    #[SearchUsingPrefix(['id'])]\n    #[SearchUsingFullText(['title', 'body'])]\n    public function toSearchableArray(): array\n    {\n        return [\n            'id' => $this->id,\n            'title' => $this->title,\n            'body' => $this->body,\n        ];\n    }\n}\n```\n\n> [!WARNING]\n> Before specifying that a column should use full-text query constraints, ensure that the column has been assigned a [full-text index](/docs/{{version}}/migrations#available-index-types).\n\nOnce the trait is added, you may search your model using Scout's `search` method. Scout's database engine will automatically order results by relevance, even on PostgreSQL:\n\n```php\n$articles = Article::search('Laravel')->get();\n```\n\nThe database engine is a great choice when your search needs are moderate and you want the convenience of Scout's automatic index syncing without deploying an external service. It handles the most common search use cases well, including filtering, pagination, and soft-deleted record handling. For complete details, consult the [Scout documentation](/docs/{{version}}/scout#database-engine).\n\n---\n\n## Requests Input Retrieving Input Retrieving JSON Input Values\nRequests | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/requests.md\n\n\n#### Retrieving JSON Input Values\n\nWhen sending JSON requests to your application, you may access the JSON data via the `input` method as long as the `Content-Type` header of the request is properly set to `application/json`. You may even use \"dot\" syntax to retrieve values that are nested within JSON arrays / objects:\n\n```php\n$name = $request->input('user.name');\n```\n\n---\n\n## Http Tests Available Assertions Response Assertions assertJsonMissingValidationErrors\nHttp Tests | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/http-tests.md\n\n\n#### assertJsonMissingValidationErrors\n\nAssert that the response has no JSON validation errors for the given keys:\n\n```php\n$response->assertJsonMissingValidationErrors($keys);\n```\n\n> [!NOTE]\n> The more generic [assertValid](#assert-valid) method may be used to assert that a response does not have validation errors that were returned as JSON **and** that no errors were flashed to session storage.\n\n---\n\n## Queries Update Statements Updating JSON Columns\nQueries | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/queries.md\n\n\n### Updating JSON Columns\n\nWhen updating a JSON column, you should use `->` syntax to update the appropriate key in the JSON object. This operation is supported on MariaDB 10.3+, MySQL 5.7+, and PostgreSQL 9.5+:\n\n```php\n$affected = DB::table('users')\n    ->where('id', 1)\n    ->update(['options->enabled' => true]);\n```\n\n---\n\n## Scout Database / Collection Engines Collection Engine\nScout | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/scout.md\n\n\n### Collection Engine\n\nThe \"collection\" engine is intended for quick prototypes, extremely small datasets (a few hundred records), or running tests. It retrieves all possible records from your database and uses Laravel's `Str::is` helper to filter them in PHP, so it does not require any indexing or database-specific features. For anything beyond trivial use cases, you should use the [database engine](#database-engine) instead.\n\nTo use the collection engine, you may simply set the value of the `SCOUT_DRIVER` environment variable to `collection`, or specify the `collection` driver directly in your application's `scout` configuration file:\n\n```ini\nSCOUT_DRIVER=collection\n```\n\nOnce you have specified the collection driver as your preferred driver, you may start [executing search queries](#searching) against your models. Search engine indexing, such as the indexing needed to seed Algolia, Meilisearch, or Typesense indexes, is unnecessary when using the collection engine.\n\n#### Differences From Database Engine\n\nWhile the database engine uses full-text indexes and `LIKE` clauses to find matching records efficiently, the collection engine pulls all records and filters them in PHP. The collection engine is the most portable option as it works across all relational databases supported by Laravel (including SQLite and SQL Server); however, it is significantly less efficient than the database engine and should not be used with large datasets.\n\n_[Truncated]_"}]


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

<?php

namespace App\Services;

use App\Enums\AnalyticsEventType;
use App\Models\AnalyticsDaily;
use App\Models\AnalyticsEvent;
use App\Models\Store;
use Carbon\CarbonInterface;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;

class AnalyticsService
{
    /**
     * @param  array<string, mixed>  $properties
     */
    public function track(
        Store $store,
        string $type,
        array $properties = [],
        ?string $sessionId = null,
        ?int $customerId = null,
        ?string $clientEventId = null,
        ?CarbonInterface $occurredAt = null,
    ): bool {
        if ($clientEventId !== null && AnalyticsEvent::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->where('client_event_id', $clientEventId)
            ->exists()) {
            return false;
        }

        AnalyticsEvent::withoutGlobalScopes()->create([
            'store_id' => $store->getKey(),
            'type' => AnalyticsEventType::from($type),
            'session_id' => $sessionId,
            'customer_id' => $customerId,
            'properties_json' => $properties,
            'client_event_id' => $clientEventId,
            'occurred_at' => $occurredAt ?? now(),
            'created_at' => now(),
        ]);

        return true;
    }

    /**
     * @param  list<array<string, mixed>>  $events
     * @return array{accepted: int, rejected: int}
     */
    public function trackBatch(Store $store, array $events): array
    {
        $accepted = 0;
        $rejected = 0;

        foreach ($events as $event) {
            $tracked = $this->track(
                $store,
                (string) $event['type'],
                $event['properties'] ?? [],
                $event['session_id'] ?? null,
                $event['customer_id'] ?? null,
                $event['client_event_id'] ?? null,
                isset($event['occurred_at']) ? Carbon::parse($event['occurred_at']) : null,
            );

            $tracked ? $accepted++ : $rejected++;
        }

        return [
            'accepted' => $accepted,
            'rejected' => $rejected,
        ];
    }

    public function getDailyMetrics(Store $store, string $startDate, string $endDate): Collection
    {
        return AnalyticsDaily::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->whereBetween('date', [$startDate, $endDate])
            ->orderBy('date')
            ->get();
    }

    public function aggregate(?CarbonInterface $date = null): int
    {
        $date = ($date ? Carbon::parse($date->toDateString(), 'UTC') : now('UTC')->subDay())->startOfDay();
        $start = $date->copy()->startOfDay();
        $end = $date->copy()->endOfDay();
        $dateString = $date->toDateString();

        $storeIds = AnalyticsEvent::withoutGlobalScopes()
            ->whereBetween('occurred_at', [$start, $end])
            ->distinct()
            ->pluck('store_id');

        $rows = 0;

        foreach ($storeIds as $storeId) {
            $events = AnalyticsEvent::withoutGlobalScopes()
                ->where('store_id', $storeId)
                ->whereBetween('occurred_at', [$start, $end])
                ->get();

            $checkoutCompleted = $events->where('type', AnalyticsEventType::CheckoutCompleted);
            $ordersCount = $checkoutCompleted->count();
            $revenue = (int) $checkoutCompleted->sum(fn (AnalyticsEvent $event): int => (int) data_get($event->properties_json, 'total_amount', 0));

            DB::table('analytics_daily')->updateOrInsert(
                [
                    'store_id' => (int) $storeId,
                    'date' => $dateString,
                ],
                [
                    'orders_count' => $ordersCount,
                    'revenue_amount' => $revenue,
                    'aov_amount' => $ordersCount > 0 ? intdiv($revenue, $ordersCount) : 0,
                    'visits_count' => $events
                        ->where('type', AnalyticsEventType::PageView)
                        ->pluck('session_id')
                        ->filter()
                        ->unique()
                        ->count(),
                    'add_to_cart_count' => $events->where('type', AnalyticsEventType::AddToCart)->count(),
                    'checkout_started_count' => $events->where('type', AnalyticsEventType::CheckoutStarted)->count(),
                    'checkout_completed_count' => $ordersCount,
                ],
            );

            $rows++;
        }

        return $rows;
    }

    /**
     * @return array{orders_count: int, revenue_amount: int, aov_amount: int, visits_count: int, add_to_cart_count: int, checkout_started_count: int, checkout_completed_count: int}
     */
    public function totals(Store $store, string $startDate, string $endDate): array
    {
        $metrics = $this->getDailyMetrics($store, $startDate, $endDate);
        $orders = (int) $metrics->sum('orders_count');
        $revenue = (int) $metrics->sum('revenue_amount');

        return [
            'orders_count' => $orders,
            'revenue_amount' => $revenue,
            'aov_amount' => $orders > 0 ? intdiv($revenue, $orders) : 0,
            'visits_count' => (int) $metrics->sum('visits_count'),
            'add_to_cart_count' => (int) $metrics->sum('add_to_cart_count'),
            'checkout_started_count' => (int) $metrics->sum('checkout_started_count'),
            'checkout_completed_count' => (int) $metrics->sum('checkout_completed_count'),
        ];
    }
}


sed -n '1,280p' app/Livewire/Admin/Analytics/Index.php

<?php

namespace App\Livewire\Admin\Analytics;

use App\Enums\AnalyticsEventType;
use App\Enums\FinancialStatus;
use App\Enums\StoreUserRole;
use App\Models\AnalyticsEvent;
use App\Models\OrderLine;
use App\Models\Store;
use App\Services\AnalyticsService;
use App\Support\Money;
use Carbon\CarbonInterface;
use Carbon\CarbonPeriod;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use Livewire\Component;
use Throwable;

class Index extends Component
{
    use AuthorizesRequests;

    #[Locked]
    public int $storeId;

    public string $storeCurrency = 'EUR';

    public string $dateRange = 'last_30_days';

    public ?string $customStartDate = null;

    public ?string $customEndDate = null;

    public string $channelFilter = 'all';

    public string $deviceFilter = 'all';

    public int $totalSales = 0;

    public int $ordersCount = 0;

    public int $averageOrderValue = 0;

    public float $conversionRate = 0.0;

    /**
     * @var list<array{date: string, label: string, revenue: int, orders: int}>
     */
    public array $salesChartData = [];

    public int $maxSalesChartAmount = 1;

    /**
     * @var list<array{title: string, units_sold: int, revenue: int, percentage: float}>
     */
    public array $topProducts = [];

    /**
     * @var list<array{source: string, sessions: int, orders: int, conversion_rate: float}>
     */
    public array $topReferrers = [];

    public bool $isExporting = false;

    public ?string $exportUrl = null;

    public function mount(AnalyticsService $analytics): void
    {
        $store = $this->store();

        $this->authorize('view', $store);
        abort_unless($this->canViewAnalytics($store), 403);

        $this->storeId = $store->getKey();
        $this->storeCurrency = $store->default_currency;

        $this->loadAnalytics($analytics);
    }

    public function updatedDateRange(AnalyticsService $analytics): void
    {
        $this->loadAnalytics($analytics);
    }

    public function updatedCustomStartDate(AnalyticsService $analytics): void
    {
        if ($this->dateRange === 'custom') {
            $this->loadAnalytics($analytics);
        }
    }

    public function updatedCustomEndDate(AnalyticsService $analytics): void
    {
        if ($this->dateRange === 'custom') {
            $this->loadAnalytics($analytics);
        }
    }

    public function updatedChannelFilter(AnalyticsService $analytics): void
    {
        $this->loadAnalytics($analytics);
    }

    public function updatedDeviceFilter(AnalyticsService $analytics): void
    {
        $this->loadAnalytics($analytics);
    }

    public function loadAnalytics(AnalyticsService $analytics): void
    {
        [$start, $end] = $this->currentRange();
        $store = $this->scopedStore();

        $totals = $analytics->totals($store, $start->toDateString(), $end->toDateString());

        $this->totalSales = $totals['revenue_amount'];
        $this->ordersCount = $totals['orders_count'];
        $this->averageOrderValue = $totals['aov_amount'];
        $this->conversionRate = $totals['visits_count'] > 0
            ? round(($totals['checkout_completed_count'] / $totals['visits_count']) * 100, 2)
            : 0.0;

        $daily = $analytics->getDailyMetrics($store, $start->toDateString(), $end->toDateString())->keyBy('date');

        $this->salesChartData = collect(CarbonPeriod::create($start->copy()->startOfDay(), '1 day', $end->copy()->startOfDay()))
            ->map(function (CarbonInterface $date) use ($daily): array {
                $metric = $daily->get($date->toDateString());

                return [
                    'date' => $date->toDateString(),
                    'label' => $date->format('M j'),
                    'revenue' => (int) ($metric?->revenue_amount ?? 0),
                    'orders' => (int) ($metric?->orders_count ?? 0),
                ];
            })
            ->values()
            ->all();

        $this->maxSalesChartAmount = max(1, max(array_column($this->salesChartData, 'revenue') ?: [0]));
        $this->loadTopProducts($start, $end);
        $this->loadTopReferrers($start, $end);
    }

    public function exportCsv(AnalyticsService $analytics): void
    {
        $this->isExporting = true;
        $this->loadAnalytics($analytics);

        $lines = ['date,revenue_amount,orders_count'];

        foreach ($this->salesChartData as $point) {
            $lines[] = "{$point['date']},{$point['revenue']},{$point['orders']}";
        }

        $this->exportUrl = 'data:text/csv;charset=utf-8,'.rawurlencode(implode("\n", $lines));
        $this->isExporting = false;
    }

    #[Computed]
    public function formattedTotalSales(): string
    {
        return Money::format($this->totalSales, $this->storeCurrency);
    }

    #[Computed]
    public function formattedAov(): string
    {
        return Money::format($this->averageOrderValue, $this->storeCurrency);
    }

    public function render(): mixed
    {
        return view('livewire.admin.analytics.index')->layout('layouts.app', [
            'title' => __('Analytics'),
        ]);
    }

    private function loadTopProducts(CarbonInterface $start, CarbonInterface $end): void
    {
        $rows = OrderLine::query()
            ->selectRaw('order_lines.title_snapshot as title, sum(order_lines.quantity) as units_sold, sum(order_lines.total_amount) as revenue')
            ->join('orders', 'orders.id', '=', 'order_lines.order_id')
            ->where('orders.store_id', $this->storeId)
            ->whereBetween('orders.placed_at', [$start, $end])
            ->whereIn('orders.financial_status', [
                FinancialStatus::Paid->value,
                FinancialStatus::PartiallyRefunded->value,
            ])
            ->groupBy('order_lines.product_id', 'order_lines.title_snapshot')
            ->orderByDesc('revenue')
            ->limit(10)
            ->get();

        $totalRevenue = max(1, (int) $rows->sum('revenue'));

        $this->topProducts = $rows
            ->map(fn (OrderLine $line): array => [
                'title' => (string) $line->title,
                'units_sold' => (int) $line->units_sold,
                'revenue' => (int) $line->revenue,
                'percentage' => round(((int) $line->revenue / $totalRevenue) * 100, 1),
            ])
            ->all();
    }

    private function loadTopReferrers(CarbonInterface $start, CarbonInterface $end): void
    {
        $pageViews = $this->filteredEvents($start, $end, AnalyticsEventType::PageView);
        $orders = $this->filteredEvents($start, $end, AnalyticsEventType::CheckoutCompleted);
        $ordersBySource = $orders->groupBy(fn (AnalyticsEvent $event): string => $this->sourceFor($event));

        $this->topReferrers = $pageViews
            ->groupBy(fn (AnalyticsEvent $event): string => $this->sourceFor($event))
            ->map(function ($events, string $source) use ($ordersBySource): array {
                $sessions = $events->pluck('session_id')->filter()->unique()->count();
                $orders = $ordersBySource->get($source, collect())->count();

                return [
                    'source' => $source,
                    'sessions' => $sessions,
                    'orders' => $orders,
                    'conversion_rate' => $sessions > 0 ? round(($orders / $sessions) * 100, 2) : 0.0,
                ];
            })
            ->sortByDesc('sessions')
            ->take(5)
            ->values()
            ->all();
    }

    private function filteredEvents(CarbonInterface $start, CarbonInterface $end, AnalyticsEventType $type): Collection
    {
        return AnalyticsEvent::withoutGlobalScopes()
            ->where('store_id', $this->storeId)
            ->where('type', $type->value)
            ->whereBetween('occurred_at', [$start, $end])
            ->get()
            ->filter(fn (AnalyticsEvent $event): bool => $this->eventMatchesFilters($event))
            ->values();
    }

    private function eventMatchesFilters(AnalyticsEvent $event): bool
    {
        if ($this->channelFilter !== 'all' && data_get($event->properties_json, 'channel') !== $this->channelFilter) {
            return false;
        }

        return $this->deviceFilter === 'all'
            || data_get($event->properties_json, 'device') === $this->deviceFilter;
    }

    private function sourceFor(AnalyticsEvent $event): string
    {
        $referrer = (string) data_get($event->properties_json, 'referrer', 'direct');

        if ($referrer === '' || $referrer === 'direct') {
            return 'Direct';
        }

        $host = parse_url($referrer, PHP_URL_HOST);

        return $host ? str_replace('www.', '', $host) : 'Direct';
    }

    /**
     * @return array{0: CarbonInterface, 1: CarbonInterface}
     */
    private function currentRange(): array
    {
        return match ($this->dateRange) {
            'today' => [now()->startOfDay(), now()->endOfDay()],
            'last_7_days' => [now()->subDays(6)->startOfDay(), now()->endOfDay()],
            'custom' => $this->customRange() ?? [now()->subDays(29)->startOfDay(), now()->endOfDay()],
            default => [now()->subDays(29)->startOfDay(), now()->endOfDay()],
        };
    }


sed -n '2764,2848p' specs/02-API-ROUTES.md

### 3.11 Analytics and Exports

#### Admin Analytics Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/analytics/summary` | `read-analytics` | Get analytics summary for date range | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/exports/orders` | `read-orders` | Queue an order export job (async) | `api.admin` (60/min) |
| GET | `/api/admin/v1/stores/{storeId}/exports/{exportId}` | `read-orders` | Check export status and get download URL | `api.admin` (60/min) |

#### GET /api/admin/v1/stores/{storeId}/analytics/summary

Get analytics summary for a date range.

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `from` | string (YYYY-MM-DD) | Yes | Start date. |
| `to` | string (YYYY-MM-DD) | Yes | End date. Max range: 365 days. |
| `granularity` | string | No | One of: `day`, `week`, `month`. Default: `day`. |

**Success response:** `200 OK`

```json
{
  "data": {
    "period": {
      "from": "2026-02-01",
      "to": "2026-02-11"
    },
    "summary": {
      "orders_count": 156,
      "revenue_amount": 1250000,
      "aov_amount": 8012,
      "visits_count": 4520,
      "add_to_cart_count": 890,
      "checkout_started_count": 420,
      "conversion_rate": 0.0345,
      "currency": "EUR"
    },
    "daily": [
      {
        "date": "2026-02-01",
        "orders_count": 12,
        "revenue_amount": 95000,
        "aov_amount": 7917,
        "visits_count": 380,
        "add_to_cart_count": 72,
        "checkout_started_count": 35
      }
    ],
    "top_products": [
      {
        "product_id": 10,
        "title": "Classic T-Shirt",
        "units_sold": 45,
        "revenue_amount": 112500
      }
    ]
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `422` - Invalid date range

---

#### POST /api/admin/v1/stores/{storeId}/exports/orders

Queue an order export job. The export runs asynchronously and produces a downloadable CSV.

**Request body:**

```json
{
  "format": "csv",
  "filters": {
    "status": "paid",
    "created_after": "2026-01-01T00:00:00Z",
    "created_before": "2026-02-11T23:59:59Z"
  }
}


Output:
Chunk ID: 391a4d
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 569
Output:
### 3.11 Analytics and Exports

#### Admin Analytics Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/analytics/summary` | `read-analytics` | Get analytics summary for date range | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/exports/orders` | `read-orders` | Queue an order export job (async) | `api.admin` (60/min) |
| GET | `/api/admin/v1/stores/{storeId}/exports/{exportId}` | `read-orders` | Check export status and get download URL | `api.admin` (60/min) |

#### GET /api/admin/v1/stores/{storeId}/analytics/summary

Get analytics summary for a date range.

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `from` | string (YYYY-MM-DD) | Yes | Start date. |
| `to` | string (YYYY-MM-DD) | Yes | End date. Max range: 365 days. |
| `granularity` | string | No | One of: `day`, `week`, `month`. Default: `day`. |

**Success response:** `200 OK`

```json
{
  "data": {
    "period": {
      "from": "2026-02-01",
      "to": "2026-02-11"
    },
    "summary": {
      "orders_count": 156,
      "revenue_amount": 1250000,
      "aov_amount": 8012,
      "visits_count": 4520,
      "add_to_cart_count": 890,
      "checkout_started_count": 420,
      "conversion_rate": 0.0345,
      "currency": "EUR"
    },
    "daily": [
      {
        "date": "2026-02-01",
        "orders_count": 12,
        "revenue_amount": 95000,
        "aov_amount": 7917,
        "visits_count": 380,
        "add_to_cart_count": 72,
        "checkout_started_count": 35
      }
    ],
    "top_products": [
      {
        "product_id": 10,
        "title": "Classic T-Shirt",
        "units_sold": 45,
        "revenue_amount": 112500
      }
    ]
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `422` - Invalid date range

---

#### POST /api/admin/v1/stores/{storeId}/exports/orders

Queue an order export job. The export runs asynchronously and produces a downloadable CSV.

**Request body:**

```json
{
  "format": "csv",
  "filters": {
    "status": "paid",
    "created_after": "2026-01-01T00:00:00Z",
    "created_before": "2026-02-11T23:59:59Z"
  }
}