# Search Results

## Testing Using PHPUnit
Testing | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/testing

## Using PHPUnit

While Pest is recommended, you can absolutely use PHPUnit to test Livewire components. All the same testing utilities work with PHPUnit's syntax.

Here's a PHPUnit example for comparison:

```php
<?php

namespace Tests\Feature\Livewire;

use Livewire\Livewire;
use App\Models\Post;
use Tests\TestCase;

class CreatePostTest extends TestCase
{
    public function test_can_create_post()
    {
        $this->assertEquals(0, Post::count());

        Livewire::test('post.create')
            ->set('title', 'My new post')
            ->set('content', 'Post content')
            ->call('save');

        $this->assertEquals(1, Post::count());
    }

    public function test_title_is_required()
    {
        Livewire::test('post.create')
            ->set('title', '')
            ->call('save')
            ->assertHasErrors('title');
    }
}
```

All features documented on this page work identically with PHPUnit—just use PHPUnit's assertion syntax instead of Pest's.

> [!tip] Consider trying Pest
> If you're interested in exploring Pest's more elegant syntax and features, check out [pestphp.com](https://pestphp.com/) to learn more.

---

## Lifecycle Hooks Hydrate & Dehydrate
Lifecycle Hooks | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/lifecycle-hooks

## Hydrate & Dehydrate

Hydrate and dehydrate are lesser-known and lesser-utilized hooks. However, there are specific scenarios where they can be powerful.

The terms "dehydrate" and "hydrate" refer to a Livewire component being serialized to JSON for the client-side and then unserialized back into a PHP object on the subsequent request.

We often use the terms "hydrate" and "dehydrate" to refer to this process throughout Livewire's codebase and the documentation. If you'd like more clarity on these terms, you can learn more by [consulting our hydration documentation](/docs/4.x/hydration).

Let's look at an example that uses both `mount()` , `hydrate()`, and `dehydrate()` all together to support using a custom [data transfer object (DTO)](https://en.wikipedia.org/wiki/Data_transfer_object) instead of an Eloquent model to store the post data in the component:

```php
<?php // resources/views/components/post/⚡show.blade.php

use Livewire\Component;

new class extends Component {
    public $post;

    public function mount($title, $content)
    {
        // Runs at the beginning of the first initial request...

        $this->post = new PostDto([
            'title' => $title,
            'content' => $content,
        ]);
    }

    public function hydrate()
    {
        // Runs at the beginning of every "subsequent" request...
        // This doesn't run on the initial request ("mount" does)...

        $this->post = new PostDto($this->post);
    }

    public function dehydrate()
    {
        // Runs at the end of every single request...

        $this->post = $this->post->toArray();
    }

    // ...
};
```

Now, from actions and other places inside your component, you can access the `PostDto` object instead of the primitive data.

The above example mainly demonstrates the abilities and nature of the `hydrate()` and `dehydrate()` hooks. However, it is recommended that you use [Wireables or Synthesizers](/docs/4.x/properties#supporting-custom-types) to accomplish this instead.

---

## Security Authorizing action parameters
Security | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/security

## Authorizing action parameters

Livewire actions are extremely powerful, however, any parameters passed to Livewire actions are mutable on the client and should be treated as un-trusted user input.

Arguably the most common security pitfall in Livewire is failing to validate and authorize Livewire action calls before persisting changes to the database.

Here is an example of an insecurity resulting from a lack of authorization:

```php
<?php

use App\Models\Post;
use Livewire\Component;

class ShowPost extends Component
{
    // ...

    public function delete($id)
    {
        // INSECURE!

        $post = Post::find($id);

        $post->delete();
    }
}
```

```html
<button wire:click="delete({{ $post->id }})">Delete Post</button>
```

The reason the above example is insecure is that `wire:click="delete(...)"` can be modified in the browser to pass ANY post ID a malicious user wishes.

Action parameters (like `$id` in this case) should be treated the same as any untrusted input from the browser.

Therefore, to keep this application secure and prevent a user from deleting another user's post, we must add authorization to the `delete()` action.

First, let's create a [Laravel Policy](https://laravel.com/docs/authorization#creating-policies) for the Post model by running the following command:

```bash
php artisan make:policy PostPolicy --model=Post
```

After running the above command, a new Policy will be created inside `app/Policies/PostPolicy.php`. We can then update its contents with a `delete` method like so:

```php
<?php

namespace App\Policies;

use App\Models\Post;
use App\Models\User;

class PostPolicy
{
    /**
     * Determine if the given post can be deleted by the user.
     */
    public function delete(?User $user, Post $post): bool
    {
        return $user?->id === $post->user_id;
    }
}
```

Now, we can use the `$this->authorize()` method from the Livewire component to ensure the user owns the post before deleting it:

```php
public function delete($id)
{
    $post = Post::find($id);

    // If the user doesn't own the post,
    // an AuthorizationException will be thrown...
    $this->authorize('delete', $post); // [tl! highlight]

    $post->delete();
}
```

Further reading:
* [Laravel Gates](https://laravel.com/docs/authorization#gates)
* [Laravel Policies](https://laravel.com/docs/authorization#creating-policies)

---

## Browser Testing assertNoConsoleLogs
Browser Testing | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/browser-testing.md

### assertNoConsoleLogs

The `assertNoConsoleLogs` method asserts there are no console logs on the page:

```php
$page->assertNoConsoleLogs();
```

---

## Wire Ref Dispatching events
Wire Ref | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/wire-ref

## Dispatching events

Refs are a great way to target specific child components within Livewire's event system.

Consider the following Livewire modal component that listens for a _close_ event:

```php
<?php

new class extends Livewire\Component {
    public bool $isOpen = false;

    // ...

    #[On('close')]
    public function close()
    {
        $this->isOpen = false;
    }
};
?>

<div wire:show="isOpen">
    {{ $slot }}
</div>
```

By adding `wire:ref` to the component tag, you now dispatch the _close_ event directly to it using the `ref:` parameter:

```php
<?php

new class extends Livewire\Component {
    public function save()
    {
        //

        $this->dispatch('close')->to(ref: 'modal');
    }
};
?>

<div>
    <!-- ... -->

    <livewire:modal wire:ref="modal">
        <!-- ... -->

        <button wire:click="save">Save</button>
    </livewire:modal>
</div>
```

---

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

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

# Migrating from PHPUnit

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

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

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

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

So, typically, a PHPUnit test looks like this:

```php
<?php

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;

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

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

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

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

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

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

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

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

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

---

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

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

---

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

---

## Attribute Authorize Learn more
Attribute Authorize | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/attribute-authorize

## Learn more

For more information on defining abilities and policies, see the [Laravel Authorization documentation](https://laravel.com/docs/authorization).

---

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

### Requests Upload Data Rate

Returns the request upload data rate in bytes per second.

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

---

## Validation Validation Quickstart Writing the Validation Logic A Note on Nested Attributes
Validation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md

#### A Note on Nested Attributes

If the incoming HTTP request contains "nested" field data, you may specify these fields in your validation rules using "dot" syntax:

```php
$request->validate([
    'title' => 'required|unique:posts|max:255',
    'author.name' => 'required',
    'author.description' => 'required',
]);
```

On the other hand, if your field name contains a literal period, you can explicitly prevent this from being interpreted as "dot" syntax by escaping the period with a backslash:

```php
$request->validate([
    'title' => 'required|unique:posts|max:255',
    'v1\.0' => 'required',
]);
```

---

## Routing Rate Limiting Defining Rate Limiters
Routing | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/routing.md

### Defining Rate Limiters

Laravel includes powerful and customizable rate limiting services that you may utilize to restrict the amount of traffic for a given route or group of routes. To get started, you should define rate limiter configurations that meet your application's needs.

Rate limiters may be defined within the `boot` method of your application's `App\Providers\AppServiceProvider` class:

```php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;

/**
 * Bootstrap any application services.
 */
public function boot(): void
{
    RateLimiter::for('api', function (Request $request) {
        return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
    });
}
```

Rate limiters are defined using the `RateLimiter` facade's `for` method. The `for` method accepts a rate limiter name and a closure that returns the limit configuration that should apply to routes that are assigned to the rate limiter. Limit configuration are instances of the `Illuminate\Cache\RateLimiting\Limit` class. This class contains helpful "builder" methods so that you can quickly define your limit. The rate limiter name may be any string you wish:

```php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;

/**
 * Bootstrap any application services.
 */
public function boot(): void
{
    RateLimiter::for('global', function (Request $request) {
        return Limit::perMinute(1000);
    });
}
```

If the incoming request exceeds the specified rate limit, a response with a 429 HTTP status code will automatically be returned by Laravel. If you would like to define your own response that should be returned by a rate limit, you may use the `response` method:

```php
RateLimiter::for('global', function (Request $request) {
    return Limit::perMinute(1000)->response(function (Request $request, array $headers) {
        return response('Custom response...', 429, $headers);
    });
});
```

Since rate limiter callbacks receive the incoming HTTP request instance, you may build the appropriate rate limit dynamically based on the incoming request or authenticated user:

```php
RateLimiter::for('uploads', function (Request $request) {
    return $request->user()->vipCustomer()
        ? Limit::none()
        : Limit::perHour(10);
});
```

---

## Wire Model Event propagation
Wire Model | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/wire-model

## Event propagation

By default, `wire:model` only listens for input/change events that originate directly on the element itself, not events that bubble up from child elements. This prevents unexpected behavior when using `wire:model` on container elements like modals or accordions that contain other form inputs.

For example, if you have a modal with `wire:model="showModal"` and an input field inside it, clearing that input won't accidentally close the modal by bubbling up a change event.

### Listening to child events

In rare cases where you want `wire:model` to also respond to events bubbling up from child elements, you can use the `.deep` modifier:

```blade
<div wire:model.deep="value">
    <input type="text"> <!-- Changes here will update $value -->
</div>
```

> [!warning] Use `.deep` sparingly
> Most use cases don't require listening to child events. Only use `.deep` when you specifically need to capture events from descendant elements.

---

## Logging Writing Log Messages Writing to Specific Channels
Logging | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/logging.md

### Writing to Specific Channels

Sometimes you may wish to log a message to a channel other than your application's default channel. You may use the `channel` method on the `Log` facade to retrieve and log to any channel defined in your configuration file:

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

Log::channel('slack')->info('Something happened!');
```

If you would like to create an on-demand logging stack consisting of multiple channels, you may use the `stack` method:

```php
Log::stack(['single', 'slack'])->info('Something happened!');
```

---

## Authorization Authorizing Actions Using Policies
Authorization | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/authorization.md

## Authorizing Actions Using Policies

---

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

## Events

Laravel dispatches a variety of [events](/docs/{{version}}/events) during the authentication process. You may [define listeners](/docs/{{version}}/events) for any of the following events:

| Event Name                                     |
| ---------------------------------------------- |
| `Illuminate\Auth\Events\Registered`            |
| `Illuminate\Auth\Events\Attempting`            |
| `Illuminate\Auth\Events\Authenticated`         |
| `Illuminate\Auth\Events\Login`                 |
| `Illuminate\Auth\Events\Failed`                |
| `Illuminate\Auth\Events\Validated`             |
| `Illuminate\Auth\Events\Verified`              |
| `Illuminate\Auth\Events\Logout`                |
| `Illuminate\Auth\Events\CurrentDeviceLogout`   |
| `Illuminate\Auth\Events\OtherDeviceLogout`     |
| `Illuminate\Auth\Events\Lockout`               |
| `Illuminate\Auth\Events\PasswordReset`         |
| `Illuminate\Auth\Events\PasswordResetLinkSent` |

---

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

### Requests Download Data Rate

Returns the request download data rate in bytes per second.

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

---

## Attribute Authorize When NOT to use
Attribute Authorize | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/attribute-authorize

## When NOT to use

> [!warning]
> The `#[Authorize]` attribute only protects server-side execution of an action. It does not hide UI elements in your Blade template.

You should still use Blade's `@can` directives to hide buttons the user isn't allowed to use:

```blade
@can('update', $post)
    <button wire:click="save">Save</button>
@endcan
```

---

## Validation Validation Quickstart Writing the Validation Logic Stopping on First Validation Failure
Validation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md

#### Stopping on First Validation Failure

Sometimes you may wish to stop running validation rules on an attribute after the first validation failure. To do so, assign the `bail` rule to the attribute:

```php
$request->validate([
    'title' => 'bail|required|unique:posts|max:255',
    'body' => 'required',
]);
```

In this example, if the `unique` rule on the `title` attribute fails, the `max` rule will not be checked. Rules will be validated in the order they are assigned.

---

## Wire Intersect Enter and leave events
Wire Intersect | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/wire-intersect

## Enter and leave events

You can specify whether to run the action on enter, leave, or both:

```blade
<!-- Runs when entering viewport (default) -->
<div wire:intersect="trackView">...</div>

<!-- Runs when entering viewport (explicit) -->
<div wire:intersect:enter="trackView">...</div>

<!-- Runs when leaving viewport -->
<div wire:intersect:leave="pauseVideo">...</div>
```

---

## Routing Rate Limiting Attaching Rate Limiters to Routes
Routing | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/routing.md

### Attaching Rate Limiters to Routes

Rate limiters may be attached to routes or route groups using the `throttle` [middleware](/docs/{{version}}/middleware). The throttle middleware accepts the name of the rate limiter you wish to assign to the route:

```php
Route::middleware(['throttle:uploads'])->group(function () {
    Route::post('/audio', function () {
        // ...
    });

    Route::post('/video', function () {
        // ...
    });
});
```

---

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

### Requests Failed Rate

Returns the number of requests that failed per second.

```php
$result->requests()->failed()->rate();
```

_[Truncated]_


Output:
Wall time: 4.7302 seconds
Output:
[{"type":"text","text":"# Search Results\n\n## Testing Using PHPUnit\nTesting | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/testing\n\n## Using PHPUnit\n\nWhile Pest is recommended, you can absolutely use PHPUnit to test Livewire components. All the same testing utilities work with PHPUnit's syntax.\n\nHere's a PHPUnit example for comparison:\n\n```php\n<?php\n\nnamespace Tests\\Feature\\Livewire;\n\nuse Livewire\\Livewire;\nuse App\\Models\\Post;\nuse Tests\\TestCase;\n\nclass CreatePostTest extends TestCase\n{\n    public function test_can_create_post()\n    {\n        $this->assertEquals(0, Post::count());\n\n        Livewire::test('post.create')\n            ->set('title', 'My new post')\n            ->set('content', 'Post content')\n            ->call('save');\n\n        $this->assertEquals(1, Post::count());\n    }\n\n    public function test_title_is_required()\n    {\n        Livewire::test('post.create')\n            ->set('title', '')\n            ->call('save')\n            ->assertHasErrors('title');\n    }\n}\n```\n\nAll features documented on this page work identically with PHPUnit—just use PHPUnit's assertion syntax instead of Pest's.\n\n> [!tip] Consider trying Pest\n> If you're interested in exploring Pest's more elegant syntax and features, check out [pestphp.com](https://pestphp.com/) to learn more.\n\n---\n\n## Lifecycle Hooks Hydrate & Dehydrate\nLifecycle Hooks | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/lifecycle-hooks\n\n## Hydrate & Dehydrate\n\nHydrate and dehydrate are lesser-known and lesser-utilized hooks. However, there are specific scenarios where they can be powerful.\n\nThe terms \"dehydrate\" and \"hydrate\" refer to a Livewire component being serialized to JSON for the client-side and then unserialized back into a PHP object on the subsequent request.\n\nWe often use the terms \"hydrate\" and \"dehydrate\" to refer to this process throughout Livewire's codebase and the documentation. If you'd like more clarity on these terms, you can learn more by [consulting our hydration documentation](/docs/4.x/hydration).\n\nLet's look at an example that uses both `mount()` , `hydrate()`, and `dehydrate()` all together to support using a custom [data transfer object (DTO)](https://en.wikipedia.org/wiki/Data_transfer_object) instead of an Eloquent model to store the post data in the component:\n\n```php\n<?php // resources/views/components/post/⚡show.blade.php\n\nuse Livewire\\Component;\n\nnew class extends Component {\n    public $post;\n\n    public function mount($title, $content)\n    {\n        // Runs at the beginning of the first initial request...\n\n        $this->post = new PostDto([\n            'title' => $title,\n            'content' => $content,\n        ]);\n    }\n\n    public function hydrate()\n    {\n        // Runs at the beginning of every \"subsequent\" request...\n        // This doesn't run on the initial request (\"mount\" does)...\n\n        $this->post = new PostDto($this->post);\n    }\n\n    public function dehydrate()\n    {\n        // Runs at the end of every single request...\n\n        $this->post = $this->post->toArray();\n    }\n\n    // ...\n};\n```\n\nNow, from actions and other places inside your component, you can access the `PostDto` object instead of the primitive data.\n\nThe above example mainly demonstrates the abilities and nature of the `hydrate()` and `dehydrate()` hooks. However, it is recommended that you use [Wireables or Synthesizers](/docs/4.x/properties#supporting-custom-types) to accomplish this instead.\n\n---\n\n## Security Authorizing action parameters\nSecurity | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/security\n\n## Authorizing action parameters\n\nLivewire actions are extremely powerful, however, any parameters passed to Livewire actions are mutable on the client and should be treated as un-trusted user input.\n\nArguably the most common security pitfall in Livewire is failing to validate and authorize Livewire action calls before persisting changes to the database.\n\nHere is an example of an insecurity resulting from a lack of authorization:\n\n```php\n<?php\n\nuse App\\Models\\Post;\nuse Livewire\\Component;\n\nclass ShowPost extends Component\n{\n    // ...\n\n    public function delete($id)\n    {\n        // INSECURE!\n\n        $post = Post::find($id);\n\n        $post->delete();\n    }\n}\n```\n\n```html\n<button wire:click=\"delete({{ $post->id }})\">Delete Post</button>\n```\n\n\nThe reason the above example is insecure is that `wire:click=\"delete(...)\"` can be modified in the browser to pass ANY post ID a malicious user wishes.\n\nAction parameters (like `$id` in this case) should be treated the same as any untrusted input from the browser.\n\nTherefore, to keep this application secure and prevent a user from deleting another user's post, we must add authorization to the `delete()` action.\n\nFirst, let's create a [Laravel Policy](https://laravel.com/docs/authorization#creating-policies) for the Post model by running the following command:\n\n```bash\nphp artisan make:policy PostPolicy --model=Post\n```\n\nAfter running the above command, a new Policy will be created inside `app/Policies/PostPolicy.php`. We can then update its contents with a `delete` method like so:\n\n```php\n<?php\n\nnamespace App\\Policies;\n\nuse App\\Models\\Post;\nuse App\\Models\\User;\n\nclass PostPolicy\n{\n    /**\n     * Determine if the given post can be deleted by the user.\n     */\n    public function delete(?User $user, Post $post): bool\n    {\n        return $user?->id === $post->user_id;\n    }\n}\n```\n\nNow, we can use the `$this->authorize()` method from the Livewire component to ensure the user owns the post before deleting it:\n\n```php\npublic function delete($id)\n{\n    $post = Post::find($id);\n\n    // If the user doesn't own the post,\n    // an AuthorizationException will be thrown...\n    $this->authorize('delete', $post); // [tl! highlight]\n\n    $post->delete();\n}\n```\n\nFurther reading:\n* [Laravel Gates](https://laravel.com/docs/authorization#gates)\n* [Laravel Policies](https://laravel.com/docs/authorization#creating-policies)\n\n---\n\n## Browser Testing assertNoConsoleLogs\nBrowser Testing | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/browser-testing.md\n\n\n### assertNoConsoleLogs\n\nThe `assertNoConsoleLogs` method asserts there are no console logs on the page:\n\n```php\n$page->assertNoConsoleLogs();\n```\n\n---\n\n## Wire Ref Dispatching events\nWire Ref | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/wire-ref\n\n## Dispatching events\n\nRefs are a great way to target specific child components within Livewire's event system.\n\nConsider the following Livewire modal component that listens for a _close_ event:\n\n```php\n<?php\n\nnew class extends Livewire\\Component {\n    public bool $isOpen = false;\n\n    // ...\n\n    #[On('close')]\n    public function close()\n    {\n        $this->isOpen = false;\n    }\n};\n?>\n\n<div wire:show=\"isOpen\">\n    {{ $slot }}\n</div>\n```\n\nBy adding `wire:ref` to the component tag, you now dispatch the _close_ event directly to it using the `ref:` parameter:\n\n```php\n<?php\n\nnew class extends Livewire\\Component {\n    public function save()\n    {\n        //\n\n        $this->dispatch('close')->to(ref: 'modal');\n    }\n};\n?>\n\n<div>\n    <!-- ... -->\n\n    <livewire:modal wire:ref=\"modal\">\n        <!-- ... -->\n\n        <button wire:click=\"save\">Save</button>\n    </livewire:modal>\n</div>\n```\n\n---\n\n## Migrating From Phpunit Guide\nMigrating From Phpunit Guide | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/migrating-from-phpunit-guide.md\n\n---\ntitle: Migrating from PHPUnit\ndescription: Migrating from PHPUnit to Pest is a simple process that can be completed in just a few steps.\n---\n\n# Migrating from PHPUnit\n\nPest is built on top of PHPUnit, so migrating from PHPUnit to Pest is a simple process that can be completed in just a few steps. Once you have Pest installed, you should require the `pestphp/pest-plugin-drift` package as a \"dev\" dependency in your project.\n\n```bash\ncomposer require pestphp/pest-plugin-drift --dev\n```\n\nDrift is a simple yet powerful plugin that will automatically convert your PHPUnit tests to Pest, simply by running the `--drift` option.\n\n```bash\n./vendor/bin/pest --drift\n```\n\nSo, typically, a PHPUnit test looks like this:\n\n```php\n<?php\n\nnamespace Tests\\Unit;\n\nuse PHPUnit\\Framework\\TestCase;\n\nclass ExampleTest extends TestCase\n{\n    public function test_that_true_is_true(): void\n    {\n        $this->assertTrue(true);\n    }\n}\n```\n\nShould look like this after running `--drift`:\n\n```php\ntest('true is true', function () {\n    expect(true)->toBeTrue();\n});\n```\n\n---\n## Convert Phpunit tests only for certain folder\n\nIf you want to convert Phpunit tests only for certain folder, you can pass _path_ as first argument when calling `--drift`:\n\nExample call, if you want to run the conversion for folder `/tests/Helpers`: \n```console \n/vendor/bin/pest --drift tests/Helpers\n```\n\nOutput:\n```console\n/vendor/bin/pest --drift tests/Helpers\n\n✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔\n\nINFO  The [tests/Helpers] directory has been migrated to PEST with XY files changed.\n```\n\n---\n\nThe output will contain a summary of the conversion process, as well as a list of the files that were converted.\n\nWhile most of your tests should be converted automatically, and you should be able to run them without any issues, there are some cases where you may need to manually convert some of your tests.\n\n---\n\nOf course, this particular chapter is only for those who are migrating from PHPUnit. Next, let's learn how you can contribute to the growth of Pest: [Community Guide](/docs/community-guide)\n\n\n---\n\n## Attribute Authorize Learn more\nAttribute Authorize | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/attribute-authorize\n\n## Learn more\n\nFor more information on defining abilities and policies, see the [Laravel Authorization documentation](https://laravel.com/docs/authorization).\n\n---\n\n## Stress Testing The Stress function Requests Upload Data Rate\nStress Testing | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/stress-testing.md\n\n\n### Requests Upload Data Rate\n\nReturns the request upload data rate in bytes per second.\n\n```php\n$result->requests()->upload()->data()->rate();\n```\n\n---\n\n## Validation Validation Quickstart Writing the Validation Logic A Note on Nested Attributes\nValidation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md\n\n\n#### A Note on Nested Attributes\n\nIf the incoming HTTP request contains \"nested\" field data, you may specify these fields in your validation rules using \"dot\" syntax:\n\n```php\n$request->validate([\n    'title' => 'required|unique:posts|max:255',\n    'author.name' => 'required',\n    'author.description' => 'required',\n]);\n```\n\nOn the other hand, if your field name contains a literal period, you can explicitly prevent this from being interpreted as \"dot\" syntax by escaping the period with a backslash:\n\n```php\n$request->validate([\n    'title' => 'required|unique:posts|max:255',\n    'v1\\.0' => 'required',\n]);\n```\n\n---\n\n## Routing Rate Limiting Defining Rate Limiters\nRouting | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/routing.md\n\n\n### Defining Rate Limiters\n\nLaravel includes powerful and customizable rate limiting services that you may utilize to restrict the amount of traffic for a given route or group of routes. To get started, you should define rate limiter configurations that meet your application's needs.\n\nRate limiters may be defined within the `boot` method of your application's `App\\Providers\\AppServiceProvider` class:\n\n```php\nuse Illuminate\\Cache\\RateLimiting\\Limit;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Facades\\RateLimiter;\n\n/**\n * Bootstrap any application services.\n */\npublic function boot(): void\n{\n    RateLimiter::for('api', function (Request $request) {\n        return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());\n    });\n}\n```\n\nRate limiters are defined using the `RateLimiter` facade's `for` method. The `for` method accepts a rate limiter name and a closure that returns the limit configuration that should apply to routes that are assigned to the rate limiter. Limit configuration are instances of the `Illuminate\\Cache\\RateLimiting\\Limit` class. This class contains helpful \"builder\" methods so that you can quickly define your limit. The rate limiter name may be any string you wish:\n\n```php\nuse Illuminate\\Cache\\RateLimiting\\Limit;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Facades\\RateLimiter;\n\n/**\n * Bootstrap any application services.\n */\npublic function boot(): void\n{\n    RateLimiter::for('global', function (Request $request) {\n        return Limit::perMinute(1000);\n    });\n}\n```\n\nIf the incoming request exceeds the specified rate limit, a response with a 429 HTTP status code will automatically be returned by Laravel. If you would like to define your own response that should be returned by a rate limit, you may use the `response` method:\n\n```php\nRateLimiter::for('global', function (Request $request) {\n    return Limit::perMinute(1000)->response(function (Request $request, array $headers) {\n        return response('Custom response...', 429, $headers);\n    });\n});\n```\n\nSince rate limiter callbacks receive the incoming HTTP request instance, you may build the appropriate rate limit dynamically based on the incoming request or authenticated user:\n\n```php\nRateLimiter::for('uploads', function (Request $request) {\n    return $request->user()->vipCustomer()\n        ? Limit::none()\n        : Limit::perHour(10);\n});\n```\n\n---\n\n## Wire Model Event propagation\nWire Model | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/wire-model\n\n## Event propagation\n\nBy default, `wire:model` only listens for input/change events that originate directly on the element itself, not events that bubble up from child elements. This prevents unexpected behavior when using `wire:model` on container elements like modals or accordions that contain other form inputs.\n\nFor example, if you have a modal with `wire:model=\"showModal\"` and an input field inside it, clearing that input won't accidentally close the modal by bubbling up a change event.\n\n### Listening to child events\n\nIn rare cases where you want `wire:model` to also respond to events bubbling up from child elements, you can use the `.deep` modifier:\n\n```blade\n<div wire:model.deep=\"value\">\n    <input type=\"text\"> <!-- Changes here will update $value -->\n</div>\n```\n\n> [!warning] Use `.deep` sparingly\n> Most use cases don't require listening to child events. Only use `.deep` when you specifically need to capture events from descendant elements.\n\n---\n\n## Logging Writing Log Messages Writing to Specific Channels\nLogging | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/logging.md\n\n\n### Writing to Specific Channels\n\nSometimes you may wish to log a message to a channel other than your application's default channel. You may use the `channel` method on the `Log` facade to retrieve and log to any channel defined in your configuration file:\n\n```php\nuse Illuminate\\Support\\Facades\\Log;\n\nLog::channel('slack')->info('Something happened!');\n```\n\nIf you would like to create an on-demand logging stack consisting of multiple channels, you may use the `stack` method:\n\n```php\nLog::stack(['single', 'slack'])->info('Something happened!');\n```\n\n---\n\n## Authorization Authorizing Actions Using Policies\nAuthorization | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/authorization.md\n\n\n## Authorizing Actions Using Policies\n\n---\n\n## Authentication Events\nAuthentication | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/authentication.md\n\n\n## Events\n\nLaravel dispatches a variety of [events](/docs/{{version}}/events) during the authentication process. You may [define listeners](/docs/{{version}}/events) for any of the following events:\n\n
\n\n| Event Name                                     |\n| ---------------------------------------------- |\n| `Illuminate\\Auth\\Events\\Registered`            |\n| `Illuminate\\Auth\\Events\\Attempting`            |\n| `Illuminate\\Auth\\Events\\Authenticated`         |\n| `Illuminate\\Auth\\Events\\Login`                 |\n| `Illuminate\\Auth\\Events\\Failed`                |\n| `Illuminate\\Auth\\Events\\Validated`             |\n| `Illuminate\\Auth\\Events\\Verified`              |\n| `Illuminate\\Auth\\Events\\Logout`                |\n| `Illuminate\\Auth\\Events\\CurrentDeviceLogout`   |\n| `Illuminate\\Auth\\Events\\OtherDeviceLogout`     |\n| `Illuminate\\Auth\\Events\\Lockout`               |\n| `Illuminate\\Auth\\Events\\PasswordReset`         |\n| `Illuminate\\Auth\\Events\\PasswordResetLinkSent` |\n\n
\n\n---\n\n## Stress Testing The Stress function Requests Download Data Rate\nStress Testing | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/stress-testing.md\n\n\n### Requests Download Data Rate\n\nReturns the request download data rate in bytes per second.\n\n```php\n$result->requests()->download()->data()->rate();\n```\n\n---\n\n## Attribute Authorize When NOT to use\nAttribute Authorize | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/attribute-authorize\n\n## When NOT to use\n\n> [!warning]\n> The `#[Authorize]` attribute only protects server-side execution of an action. It does not hide UI elements in your Blade template.\n\nYou should still use Blade's `@can` directives to hide buttons the user isn't allowed to use:\n\n```blade\n@can('update', $post)\n    <button wire:click=\"save\">Save</button>\n@endcan\n```\n\n---\n\n## Validation Validation Quickstart Writing the Validation Logic Stopping on First Validation Failure\nValidation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md\n\n\n#### Stopping on First Validation Failure\n\nSometimes you may wish to stop running validation rules on an attribute after the first validation failure. To do so, assign the `bail` rule to the attribute:\n\n```php\n$request->validate([\n    'title' => 'bail|required|unique:posts|max:255',\n    'body' => 'required',\n]);\n```\n\nIn this example, if the `unique` rule on the `title` attribute fails, the `max` rule will not be checked. Rules will be validated in the order they are assigned.\n\n---\n\n## Wire Intersect Enter and leave events\nWire Intersect | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/wire-intersect\n\n## Enter and leave events\n\nYou can specify whether to run the action on enter, leave, or both:\n\n```blade\n<!-- Runs when entering viewport (default) -->\n<div wire:intersect=\"trackView\">...</div>\n\n<!-- Runs when entering viewport (explicit) -->\n<div wire:intersect:enter=\"trackView\">...</div>\n\n<!-- Runs when leaving viewport -->\n<div wire:intersect:leave=\"pauseVideo\">...</div>\n```\n\n---\n\n## Routing Rate Limiting Attaching Rate Limiters to Routes\nRouting | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/routing.md\n\n\n### Attaching Rate Limiters to Routes\n\nRate limiters may be attached to routes or route groups using the `throttle` [middleware](/docs/{{version}}/middleware). The throttle middleware accepts the name of the rate limiter you wish to assign to the route:\n\n```php\nRoute::middleware(['throttle:uploads'])->group(function () {\n    Route::post('/audio', function () {\n        // ...\n    });\n\n    Route::post('/video', function () {\n        // ...\n    });\n});\n```\n\n---\n\n## Stress Testing The Stress function Requests Failed Rate\nStress Testing | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/stress-testing.md\n\n\n### Requests Failed Rate\n\nReturns the number of requests that failed per second.\n\n```php\n$result->requests()->failed()->rate();\n```\n\n_[Truncated]_"}]


sed -n '1,220p' app/Models/User.php && sed -n '1,100p' database/migrations/0001_01_01_000000_create_users_table.php


sed -n '1,140p' database/seeders/UserSeeder.php && sed -n '1,80p' database/seeders/StoreUserSeeder.php


sed -n '1,180p' app/Http/Middleware/CheckStoreRole.php && sed -n '1,180p' app/Livewire/Admin/Dashboard.php


sed -n '1,120p' phpunit.xml && sed -n '50,80p' composer.json


sed -n '1,220p' app/Models/User.php && sed -n '1,100p' database/migrations/0001_01_01_000000_create_users_table.php

<?php

namespace App\Models;

use App\Enums\StoreUserRole;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Laravel\Fortify\TwoFactorAuthenticatable;

class User extends Authenticatable implements MustVerifyEmail
{
    /** @use HasFactory<\Database\Factories\UserFactory> */
    use HasFactory, Notifiable, TwoFactorAuthenticatable;

    /**
     * The attributes that are mass assignable.
     *
     * @var list<string>
     */
    protected $fillable = [
        'name',
        'email',
        'status',
        'password',
        'last_login_at',
    ];

    /**
     * The attributes that should be hidden for serialization.
     *
     * @var list<string>
     */
    protected $hidden = [
        'password_hash',
        'two_factor_secret',
        'two_factor_recovery_codes',
        'remember_token',
    ];

    /**
     * @return BelongsToMany<Store, $this>
     */
    public function stores(): BelongsToMany
    {
        return $this->belongsToMany(Store::class, 'store_users')
            ->using(StoreUser::class)
            ->withPivot('role');
    }

    public function roleForStore(Store $store): ?StoreUserRole
    {
        return $this->roleForStoreId($store->getKey());
    }

    public function roleForStoreId(int $storeId): ?StoreUserRole
    {
        $role = $this->stores()
            ->whereKey($storeId)
            ->first()
            ?->pivot
            ?->role;

        if ($role instanceof StoreUserRole) {
            return $role;
        }

        return is_string($role) ? StoreUserRole::tryFrom($role) : null;
    }

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

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

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

    /**
     * Get the attributes that should be cast.
     *
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'last_login_at' => 'datetime',
            'two_factor_confirmed_at' => 'datetime',
        ];
    }

    /**
     * Get the user's initials
     */
    public function initials(): string
    {
        return Str::of($this->name)
            ->explode(' ')
            ->take(2)
            ->map(fn ($word) => Str::substr($word, 0, 1))
            ->implode('');
    }
}
<?php

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('users', function (Blueprint $table) {
            $table->id();
            $table->string('email')->unique();
            $table->string('password_hash');
            $table->string('name');
            $table->enum('status', ['active', 'disabled'])->default('active')->index();
            $table->timestamp('email_verified_at')->nullable();
            $table->timestamp('last_login_at')->nullable();
            $table->text('two_factor_secret')->nullable();
            $table->text('two_factor_recovery_codes')->nullable();
            $table->timestamp('two_factor_confirmed_at')->nullable();
            $table->rememberToken();
            $table->timestamps();
        });

        Schema::create('password_reset_tokens', function (Blueprint $table) {
            $table->string('email')->primary();
            $table->string('token');
            $table->timestamp('created_at')->nullable();
        });

        Schema::create('sessions', function (Blueprint $table) {
            $table->string('id')->primary();
            $table->foreignId('user_id')->nullable()->index();
            $table->string('ip_address', 45)->nullable();
            $table->text('user_agent')->nullable();
            $table->longText('payload');
            $table->integer('last_activity')->index();
        });
    }

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


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

namespace App\Models;

use App\Enums\StoreUserRole;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Laravel\Fortify\TwoFactorAuthenticatable;

class User extends Authenticatable implements MustVerifyEmail
{
    /** @use HasFactory<\Database\Factories\UserFactory> */
    use HasFactory, Notifiable, TwoFactorAuthenticatable;

    /**
     * The attributes that are mass assignable.
     *
     * @var list<string>
     */
    protected $fillable = [
        'name',
        'email',
        'status',
        'password',
        'last_login_at',
    ];

    /**
     * The attributes that should be hidden for serialization.
     *
     * @var list<string>
     */
    protected $hidden = [
        'password_hash',
        'two_factor_secret',
        'two_factor_recovery_codes',
        'remember_token',
    ];

    /**
     * @return BelongsToMany<Store, $this>
     */
    public function stores(): BelongsToMany
    {
        return $this->belongsToMany(Store::class, 'store_users')
            ->using(StoreUser::class)
            ->withPivot('role');
    }

    public function roleForStore(Store $store): ?StoreUserRole
    {
        return $this->roleForStoreId($store->getKey());
    }

    public function roleForStoreId(int $storeId): ?StoreUserRole
    {
        $role = $this->stores()
            ->whereKey($storeId)
            ->first()
            ?->pivot
            ?->role;

        if ($role instanceof StoreUserRole) {
            return $role;
        }

        return is_string($role) ? StoreUserRole::tryFrom($role) : null;
    }

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

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

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

    /**
     * Get the attributes that should be cast.
     *
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'last_login_at' => 'datetime',
            'two_factor_confirmed_at' => 'datetime',
        ];
    }

    /**
     * Get the user's initials
     */
    public function initials(): string
    {
        return Str::of($this->name)
            ->explode(' ')
            ->take(2)
            ->map(fn ($word) => Str::substr($word, 0, 1))
            ->implode('');
    }
}
<?php

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('users', function (Blueprint $table) {
            $table->id();
            $table->string('email')->unique();
            $table->string('password_hash');
            $table->string('name');
            $table->enum('status', ['active', 'disabled'])->default('active')->index();
            $table->timestamp('email_verified_at')->nullable();
            $table->timestamp('last_login_at')->nullable();
            $table->text('two_factor_secret')->nullable();
            $table->text('two_factor_recovery_codes')->nullable();
            $table->timestamp('two_factor_confirmed_at')->nullable();
            $table->rememberToken();
            $table->timestamps();
        });

        Schema::create('password_reset_tokens', function (Blueprint $table) {
            $table->string('email')->primary();
            $table->string('token');
            $table->timestamp('created_at')->nullable();
        });

        Schema::create('sessions', function (Blueprint $table) {
            $table->string('id')->primary();
            $table->foreignId('user_id')->nullable()->index();
            $table->string('ip_address', 45)->nullable();
            $table->text('user_agent')->nullable();
            $table->longText('payload');
            $table->integer('last_activity')->index();
        });
    }

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


sed -n '1,140p' database/seeders/UserSeeder.php && sed -n '1,80p' database/seeders/StoreUserSeeder.php

StoreUserSeeder.php

<?php

namespace Database\Seeders;

use App\Models\User;
use Illuminate\Database\Seeder;

class UserSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        User::query()->updateOrCreate(
            ['email' => 'admin@acme.test'],
            [
                'name' => 'Acme Admin',
                'password' => 'password',
                'status' => 'active',
                'email_verified_at' => now(),
                'last_login_at' => now()->subDay(),
            ],
        );
    }
}
<?php

namespace Database\Seeders;

use App\Models\Store;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;

class StoreUserSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        $user = User::query()->where('email', 'admin@acme.test')->firstOrFail();

        Store::query()->each(function (Store $store) use ($user): void {
            DB::table('store_users')->updateOrInsert(
                [
                    'store_id' => $store->getKey(),
                    'user_id' => $user->getKey(),
                ],
                [
                    'role' => 'owner',
                    'created_at' => now(),
                ],
            );
        });
    }
}


Output:
Chunk ID: 5f7bbe
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 333
Output:
<?php

namespace Database\Seeders;

use App\Models\User;
use Illuminate\Database\Seeder;

class UserSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        User::query()->updateOrCreate(
            ['email' => 'admin@acme.test'],
            [
                'name' => 'Acme Admin',
                'password' => 'password',
                'status' => 'active',
                'email_verified_at' => now(),
                'last_login_at' => now()->subDay(),
            ],
        );
    }
}
<?php

namespace Database\Seeders;

use App\Models\Store;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;

class StoreUserSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        $user = User::query()->where('email', 'admin@acme.test')->firstOrFail();

        Store::query()->each(function (Store $store) use ($user): void {
            DB::table('store_users')->updateOrInsert(
                [
                    'store_id' => $store->getKey(),
                    'user_id' => $user->getKey(),
                ],
                [
                    'role' => 'owner',
                    'created_at' => now(),
                ],
            );
        });
    }
}


sed -n '1,180p' app/Http/Middleware/CheckStoreRole.php && sed -n '1,180p' app/Livewire/Admin/Dashboard.php

Dashboard.php

<?php

namespace App\Http\Middleware;

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

class CheckStoreRole
{
    /**
     * Handle an incoming request.
     *
     * @param  \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response)  $next
     */
    public function handle(Request $request, Closure $next, string ...$roles): Response
    {
        $store = app()->bound('current_store') ? app('current_store') : null;
        $user = $request->user();

        abort_unless($store instanceof Store && $user, 403);

        $role = $user->roleForStore($store);

        if ($roles === []) {
            abort_unless($role !== null, 403);

            return $next($request);
        }

        $allowedRoles = array_filter(array_map(
            fn (string $role): ?StoreUserRole => StoreUserRole::tryFrom($role),
            $roles,
        ));

        abort_unless($role !== null && in_array($role, $allowedRoles, true), 403);

        return $next($request);
    }
}
<?php

namespace App\Livewire\Admin;

use App\Enums\FinancialStatus;
use App\Models\Cart;
use App\Models\Checkout;
use App\Models\Order;
use App\Models\OrderLine;
use App\Models\Store;
use App\Support\Money;
use Carbon\CarbonInterface;
use Carbon\CarbonPeriod;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use Livewire\Component;
use Throwable;

class Dashboard extends Component
{
    #[Locked]
    public int $storeId;

    public string $storeCurrency = 'EUR';

    public string $dateRange = 'last_30_days';

    public ?string $customStartDate = null;

    public ?string $customEndDate = null;

    public int $totalSales = 0;

    public int $ordersCount = 0;

    public int $averageOrderValue = 0;

    public int $visitorsCount = 0;

    public float $salesChange = 0.0;

    public float $ordersChange = 0.0;

    public float $aovChange = 0.0;

    public float $visitorsChange = 0.0;

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

    public int $maxOrdersChartCount = 1;

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

    /**
     * @var array{visits: int, add_to_cart: int, checkout_started: int, checkout_completed: int}
     */
    public array $funnelData = [
        'visits' => 0,
        'add_to_cart' => 0,
        'checkout_started' => 0,
        'checkout_completed' => 0,
    ];

    public function mount(): void
    {
        $store = app('current_store');

        abort_unless($store instanceof Store, 404);

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

        $this->loadKpis();
    }

    public function updatedDateRange(): void
    {
        $this->loadKpis();
    }

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

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

    public function loadKpis(): void
    {
        [$start, $end] = $this->currentRange();
        [$previousStart, $previousEnd] = $this->previousRange($start, $end);

        $this->ordersCount = $this->ordersBetween($start, $end)->count();
        $previousOrdersCount = $this->ordersBetween($previousStart, $previousEnd)->count();

        $this->totalSales = (int) $this->revenueOrdersBetween($start, $end)->sum('total_amount');
        $previousTotalSales = (int) $this->revenueOrdersBetween($previousStart, $previousEnd)->sum('total_amount');

        $revenueOrdersCount = $this->revenueOrdersBetween($start, $end)->count();
        $previousRevenueOrdersCount = $this->revenueOrdersBetween($previousStart, $previousEnd)->count();

        $this->averageOrderValue = $revenueOrdersCount > 0 ? intdiv($this->totalSales, $revenueOrdersCount) : 0;
        $previousAverageOrderValue = $previousRevenueOrdersCount > 0 ? intdiv($previousTotalSales, $previousRevenueOrdersCount) : 0;

        $this->visitorsCount = 0;
        $this->salesChange = $this->percentChange($this->totalSales, $previousTotalSales);
        $this->ordersChange = $this->percentChange($this->ordersCount, $previousOrdersCount);
        $this->aovChange = $this->percentChange($this->averageOrderValue, $previousAverageOrderValue);
        $this->visitorsChange = 0.0;

        $this->loadChart();
        $this->loadTopProducts();
        $this->loadFunnel();
    }

    public function loadChart(): void
    {
        [$start, $end] = $this->currentRange();

        $counts = $this->ordersBetween($start, $end)
            ->selectRaw('date(placed_at) as order_date, count(*) as aggregate')
            ->groupBy('order_date')
            ->pluck('aggregate', 'order_date');

        $this->ordersChartData = collect(CarbonPeriod::create($start->copy()->startOfDay(), '1 day', $end->copy()->startOfDay()))
            ->map(fn (CarbonInterface $date): array => [
                'date' => $date->toDateString(),
                'label' => $date->format('M j'),
                'count' => (int) ($counts[$date->toDateString()] ?? 0),
            ])
            ->values()
            ->all();

        $this->maxOrdersChartCount = max(1, max(array_column($this->ordersChartData, 'count') ?: [0]));
    }

    public function loadTopProducts(): void
    {
        [$start, $end] = $this->currentRange();

        $this->topProducts = 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', $this->revenueStatuses())
            ->groupBy('order_lines.product_id', 'order_lines.title_snapshot')
            ->orderByDesc('revenue')
            ->limit(5)
            ->get()
            ->map(fn (OrderLine $line): array => [
                'title' => (string) $line->title,
                'units_sold' => (int) $line->units_sold,
                'revenue' => (int) $line->revenue,
            ])
            ->all();
    }

    public function loadFunnel(): void
    {
        [$start, $end] = $this->currentRange();

        $this->funnelData = [
            'visits' => 0,
            'add_to_cart' => Cart::withoutGlobalScopes()


sed -n '1,120p' phpunit.xml && sed -n '50,80p' composer.json

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true"
>
    <testsuites>
        <testsuite name="Unit">
            <directory>tests/Unit</directory>
        </testsuite>
        <testsuite name="Feature">
            <directory>tests/Feature</directory>
        </testsuite>
        <testsuite name="Browser">
            <directory>tests/Browser</directory>
        </testsuite>
    </testsuites>
    <source>
        <include>
            <directory>app</directory>
        </include>
    </source>
    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="APP_MAINTENANCE_DRIVER" value="file"/>
        <env name="BCRYPT_ROUNDS" value="4"/>
        <env name="BROADCAST_CONNECTION" value="null"/>
        <env name="CACHE_STORE" value="array"/>
        <env name="DB_CONNECTION" value="sqlite"/>
        <env name="DB_DATABASE" value=":memory:"/>
        <env name="MAIL_MAILER" value="array"/>
        <env name="QUEUE_CONNECTION" value="sync"/>
        <env name="SESSION_DRIVER" value="array"/>
        <env name="PULSE_ENABLED" value="false"/>
        <env name="TELESCOPE_ENABLED" value="false"/>
        <env name="NIGHTWATCH_ENABLED" value="false"/>
    </php>
</phpunit>
        ],
        "dev": [
            "Composer\\Config::disableProcessTimeout",
            "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
        ],
        "lint": [
            "pint --parallel"
        ],
        "test:lint": [
            "pint --parallel --test"
        ],
        "test": [
            "@php artisan config:clear --ansi",
            "@test:lint",
            "@php artisan test"
        ],
        "post-autoload-dump": [
            "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
            "@php artisan package:discover --ansi"
        ],
        "post-update-cmd": [
            "@php artisan vendor:publish --tag=laravel-assets --ansi --force"
        ],
        "post-root-package-install": [
            "@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
        ],
        "post-create-project-cmd": [
            "@php artisan key:generate --ansi",
            "@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
            "@php artisan migrate --graceful --ansi"
        ],


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

namespace App\Http\Middleware;

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

class CheckStoreRole
{
    /**
     * Handle an incoming request.
     *
     * @param  \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response)  $next
     */
    public function handle(Request $request, Closure $next, string ...$roles): Response
    {
        $store = app()->bound('current_store') ? app('current_store') : null;
        $user = $request->user();

        abort_unless($store instanceof Store && $user, 403);

        $role = $user->roleForStore($store);

        if ($roles === []) {
            abort_unless($role !== null, 403);

            return $next($request);
        }

        $allowedRoles = array_filter(array_map(
            fn (string $role): ?StoreUserRole => StoreUserRole::tryFrom($role),
            $roles,
        ));

        abort_unless($role !== null && in_array($role, $allowedRoles, true), 403);

        return $next($request);
    }
}
<?php

namespace App\Livewire\Admin;

use App\Enums\FinancialStatus;
use App\Models\Cart;
use App\Models\Checkout;
use App\Models\Order;
use App\Models\OrderLine;
use App\Models\Store;
use App\Support\Money;
use Carbon\CarbonInterface;
use Carbon\CarbonPeriod;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use Livewire\Component;
use Throwable;

class Dashboard extends Component
{
    #[Locked]
    public int $storeId;

    public string $storeCurrency = 'EUR';

    public string $dateRange = 'last_30_days';

    public ?string $customStartDate = null;

    public ?string $customEndDate = null;

    public int $totalSales = 0;

    public int $ordersCount = 0;

    public int $averageOrderValue = 0;

    public int $visitorsCount = 0;

    public float $salesChange = 0.0;

    public float $ordersChange = 0.0;

    public float $aovChange = 0.0;

    public float $visitorsChange = 0.0;

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

    public int $maxOrdersChartCount = 1;

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

    /**
     * @var array{visits: int, add_to_cart: int, checkout_started: int, checkout_completed: int}
     */
    public array $funnelData = [
        'visits' => 0,
        'add_to_cart' => 0,
        'checkout_started' => 0,
        'checkout_completed' => 0,
    ];

    public function mount(): void
    {
        $store = app('current_store');

        abort_unless($store instanceof Store, 404);

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

        $this->loadKpis();
    }

    public function updatedDateRange(): void
    {
        $this->loadKpis();
    }

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

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

    public function loadKpis(): void
    {
        [$start, $end] = $this->currentRange();
        [$previousStart, $previousEnd] = $this->previousRange($start, $end);

        $this->ordersCount = $this->ordersBetween($start, $end)->count();
        $previousOrdersCount = $this->ordersBetween($previousStart, $previousEnd)->count();

        $this->totalSales = (int) $this->revenueOrdersBetween($start, $end)->sum('total_amount');
        $previousTotalSales = (int) $this->revenueOrdersBetween($previousStart, $previousEnd)->sum('total_amount');

        $revenueOrdersCount = $this->revenueOrdersBetween($start, $end)->count();
        $previousRevenueOrdersCount = $this->revenueOrdersBetween($previousStart, $previousEnd)->count();

        $this->averageOrderValue = $revenueOrdersCount > 0 ? intdiv($this->totalSales, $revenueOrdersCount) : 0;
        $previousAverageOrderValue = $previousRevenueOrdersCount > 0 ? intdiv($previousTotalSales, $previousRevenueOrdersCount) : 0;

        $this->visitorsCount = 0;
        $this->salesChange = $this->percentChange($this->totalSales, $previousTotalSales);
        $this->ordersChange = $this->percentChange($this->ordersCount, $previousOrdersCount);
        $this->aovChange = $this->percentChange($this->averageOrderValue, $previousAverageOrderValue);
        $this->visitorsChange = 0.0;

        $this->loadChart();
        $this->loadTopProducts();
        $this->loadFunnel();
    }

    public function loadChart(): void
    {
        [$start, $end] = $this->currentRange();

        $counts = $this->ordersBetween($start, $end)
            ->selectRaw('date(placed_at) as order_date, count(*) as aggregate')
            ->groupBy('order_date')
            ->pluck('aggregate', 'order_date');

        $this->ordersChartData = collect(CarbonPeriod::create($start->copy()->startOfDay(), '1 day', $end->copy()->startOfDay()))
            ->map(fn (CarbonInterface $date): array => [
                'date' => $date->toDateString(),
                'label' => $date->format('M j'),
                'count' => (int) ($counts[$date->toDateString()] ?? 0),
            ])
            ->values()
            ->all();

        $this->maxOrdersChartCount = max(1, max(array_column($this->ordersChartData, 'count') ?: [0]));
    }

    public function loadTopProducts(): void
    {
        [$start, $end] = $this->currentRange();

        $this->topProducts = 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', $this->revenueStatuses())
            ->groupBy('order_lines.product_id', 'order_lines.title_snapshot')
            ->orderByDesc('revenue')
            ->limit(5)
            ->get()
            ->map(fn (OrderLine $line): array => [
                'title' => (string) $line->title,
                'units_sold' => (int) $line->units_sold,
                'revenue' => (int) $line->revenue,
            ])
            ->all();
    }

    public function loadFunnel(): void
    {
        [$start, $end] = $this->currentRange();

        $this->funnelData = [
            'visits' => 0,
            'add_to_cart' => Cart::withoutGlobalScopes()


Output:
Chunk ID: f946f7
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 664
Output:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true"
>
    <testsuites>
        <testsuite name="Unit">
            <directory>tests/Unit</directory>
        </testsuite>
        <testsuite name="Feature">
            <directory>tests/Feature</directory>
        </testsuite>
        <testsuite name="Browser">
            <directory>tests/Browser</directory>
        </testsuite>
    </testsuites>
    <source>
        <include>
            <directory>app</directory>
        </include>
    </source>
    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="APP_MAINTENANCE_DRIVER" value="file"/>
        <env name="BCRYPT_ROUNDS" value="4"/>
        <env name="BROADCAST_CONNECTION" value="null"/>
        <env name="CACHE_STORE" value="array"/>
        <env name="DB_CONNECTION" value="sqlite"/>
        <env name="DB_DATABASE" value=":memory:"/>
        <env name="MAIL_MAILER" value="array"/>
        <env name="QUEUE_CONNECTION" value="sync"/>
        <env name="SESSION_DRIVER" value="array"/>
        <env name="PULSE_ENABLED" value="false"/>
        <env name="TELESCOPE_ENABLED" value="false"/>
        <env name="NIGHTWATCH_ENABLED" value="false"/>
    </php>
</phpunit>
        ],
        "dev": [
            "Composer\\Config::disableProcessTimeout",
            "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
        ],
        "lint": [
            "pint --parallel"
        ],
        "test:lint": [
            "pint --parallel --test"
        ],
        "test": [
            "@php artisan config:clear --ansi",
            "@test:lint",
            "@php artisan test"
        ],
        "post-autoload-dump": [
            "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
            "@php artisan package:discover --ansi"
        ],
        "post-update-cmd": [
            "@php artisan vendor:publish --tag=laravel-assets --ansi --force"
        ],
        "post-root-package-install": [
            "@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
        ],
        "post-create-project-cmd": [
            "@php artisan key:generate --ansi",
            "@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
            "@php artisan migrate --graceful --ansi"
        ],


ls app/Policies && rg -n "Analytics|Dashboard|viewAny|view" app/Policies tests/Feature/Foundation/AuthorizationTest.php tests/Feature/Admin/DashboardTest.php


sed -n '940,1060p' specs/06-AUTH-AND-SECURITY.md && sed -n '340,375p' specs/06-AUTH-AND-SECURITY.md


sed -n '1,220p' app/Enums/StoreUserRole.php app/Policies/Concerns/ChecksStoreRole.php


sed -n '1,220p' app/Enums/StoreUserRole.php app/Policies/Concerns/ChecksStoreRole.php

sed: app/Policies/Concerns/ChecksStoreRole.php: No such file or directory
<?php

namespace App\Enums;

enum StoreUserRole: string
{
    case Owner = 'owner';
    case Admin = 'admin';
    case Staff = 'staff';
    case Support = 'support';
}


sed -n '940,1060p' specs/06-AUTH-AND-SECURITY.md && sed -n '340,375p' specs/06-AUTH-AND-SECURITY.md

### 7.2 Platform Webhooks (Outbound)

Service: `App\Services\WebhookDispatcher`

When the platform sends webhooks to external subscribers (third-party apps, integrations).

```mermaid
sequenceDiagram
    participant App as Platform
    participant Sub as Subscriber URL

    App->>App: Serialize payload as JSON
    App->>App: Generate Unix timestamp
    App->>App: Concatenate: "{timestamp}.{json_body}"
    App->>App: HMAC-SHA256(signing_secret, concatenated_message)
    App->>Sub: POST to subscriber URL
    Note right of Sub: Headers: X-Platform-Signature, X-Platform-Timestamp, Content-Type: application/json
    Sub-->>App: 200 OK (acknowledged)
```

**Requirements:**

1. The payload is serialized as JSON.
2. A Unix timestamp is generated (current time).
3. The message to sign is: `"{timestamp}.{json_body}"` (timestamp, dot, JSON string).
4. The signature is computed as HMAC-SHA256 using the per-subscription `signing_secret` (decrypted from the `signing_secret_encrypted` column).
5. The HTTP request to the subscriber includes three headers:
   - `X-Platform-Signature`: the hex-encoded HMAC digest
   - `X-Platform-Timestamp`: the Unix timestamp as a string
   - `Content-Type`: `application/json`
6. Replay protection guidance for recipients: reject any request where `X-Platform-Timestamp` is more than 5 minutes in the past.

---

## 8. Audit Logging

### 8.1 Logging Channel

A dedicated `audit` channel must be added to `config/logging.php`.

| Setting | Value |
|---------|-------|
| Driver | `daily` (file-based, daily rotation) |
| Path | `storage/logs/audit.log` |
| Level | `info` |
| Retention | 90 days |

---

### 8.2 Log Entry Format

All audit log entries are structured JSON objects with the following schema:

| Field | Type | Required | Description |
|-------|------|:--------:|-------------|
| `timestamp` | string (ISO-8601) | Y | When the event occurred |
| `event` | string | Y | Event identifier (e.g., `product.updated`, `auth.login`) |
| `user_id` | integer or null | N | ID of the admin user who triggered the event |
| `store_id` | integer or null | N | ID of the store context |
| `resource_type` | string or null | N | Type of resource affected (e.g., `product`, `order`) |
| `resource_id` | integer or null | N | ID of the affected resource |
| `ip` | string | Y | Client IP address |
| `user_agent` | string | Y | Client user agent string |
| `changes` | object or null | N | For update events: object mapping field names to `[old_value, new_value]` tuples |
| (additional keys) | varies | N | Event-specific extra data |

---

### 8.3 Events to Log

#### Authentication Events

| Event ID | Trigger | Extra Data |
|----------|---------|------------|
| `auth.login` | Successful admin login | user_id, ip, user_agent |
| `auth.logout` | Admin logout | user_id |
| `auth.failed_login` | Failed admin login attempt | email (never password), ip, user_agent |
| `customer.login` | Successful customer login | customer_id, store_id, ip |
| `customer.registered` | New customer registration | customer_id, store_id |
| `customer.failed_login` | Failed customer login attempt | email, store_id, ip |

#### Admin Resource Events

For each of the following resource types, log `created`, `updated`, `deleted`, and `restored` (where applicable) events:

**Resource types:** product, collection, discount, page, theme, order, fulfillment, refund, navigation_menu, shipping_zone, tax_setting, staff

| Event Pattern | Trigger | Extra Data |
|---------------|---------|------------|
| `{resource}.created` | Resource created | resource_id, key attributes of the new resource |
| `{resource}.updated` | Resource updated | resource_id, `changes` object with old/new values for each changed field |
| `{resource}.deleted` | Resource soft-deleted or archived | resource_id |
| `{resource}.restored` | Resource restored from soft-delete | resource_id |

#### Security Events

| Event ID | Trigger | Extra Data |
|----------|---------|------------|
| `api_token.created` | New API token issued | token_name, abilities list, user_id |
| `api_token.revoked` | API token revoked | token_name, user_id |
| `webhook.signature_invalid` | Outbound webhook delivery failed signature verification at subscriber | subscriber_url, ip |
| `store.settings_changed` | Store settings updated | changed fields with old/new values |

---

### 8.4 Audit Logger Service

File: `app/Services/AuditLogger.php`

A service class that encapsulates audit log writing.

**Method signature:**

| Parameter | Type | Required | Description |
|-----------|------|:--------:|-------------|
| `event` | string | Y | Event identifier |
| `userId` | int or null | N | Acting user ID |
| `storeId` | int or null | N | Store context ID |
| `resourceType` | string or null | N | Affected resource type |
| `resourceId` | int or null | N | Affected resource ID |
| Orders | List / View | Y | Y | Y | Y |
| Orders | Update | Y | Y | Y | N |
| Orders | Cancel | Y | Y | N | N |
| Fulfillments | Create | Y | Y | Y | N |
| Fulfillments | Update / Cancel | Y | Y | Y | N |
| Refunds | Create | Y | Y | N | N |
| Customers | List / View | Y | Y | Y | Y |
| Customers | Update | Y | Y | Y | N |
| Discounts | List / View | Y | Y | Y | Y |
| Discounts | Create | Y | Y | Y | N |
| Discounts | Update | Y | Y | Y | N |
| Discounts | Delete | Y | Y | N | N |
| Themes | List / View | Y | Y | N | N |
| Themes | Create / Upload | Y | Y | N | N |
| Themes | Customize | Y | Y | N | N |
| Themes | Publish | Y | Y | N | N |
| Themes | Delete | Y | Y | N | N |
| Pages | List / View | Y | Y | Y | N |
| Pages | Create | Y | Y | Y | N |
| Pages | Update | Y | Y | Y | N |
| Pages | Delete | Y | Y | N | N |
| Navigation | List / View | Y | Y | Y | N |
| Navigation | Manage | Y | Y | N | N |
| Apps | List / View | Y | Y | N | N |
| Apps | Install / Remove | Y | Y | N | N |
| Analytics | View | Y | Y | Y | N |
| Developers / API | Manage | Y | Y | N | N |
| Staff | List / View | Y | Y | N | N |
| Staff | Invite | Y | Y | N | N |
| Staff | Update Role | Y | Y | N | N |
| Staff | Remove | Y | Y | N | N |
| Shipping Zones | Manage | Y | Y | N | N |
| Tax Settings | Manage | Y | Y | N | N |
| Search Settings | Manage | Y | Y | N | N |

#### Role Group Shorthand


/bin/zsh
-lc
sed -n '1,130p' config/logging.php

<?php

use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;

return [

    /*
    |--------------------------------------------------------------------------
    | Default Log Channel
    |--------------------------------------------------------------------------
    |
    | This option defines the default log channel that is utilized to write
    | messages to your logs. The value provided here should match one of
    | the channels present in the list of "channels" configured below.
    |
    */

    'default' => env('LOG_CHANNEL', 'stack'),

    /*
    |--------------------------------------------------------------------------
    | Deprecations Log Channel
    |--------------------------------------------------------------------------
    |
    | This option controls the log channel that should be used to log warnings
    | regarding deprecated PHP and library features. This allows you to get
    | your application ready for upcoming major versions of dependencies.
    |
    */

    'deprecations' => [
        'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
        'trace' => env('LOG_DEPRECATIONS_TRACE', false),
    ],

    /*
    |--------------------------------------------------------------------------
    | Log Channels
    |--------------------------------------------------------------------------
    |
    | Here you may configure the log channels for your application. Laravel
    | utilizes the Monolog PHP logging library, which includes a variety
    | of powerful log handlers and formatters that you're free to use.
    |
    | Available drivers: "single", "daily", "slack", "syslog",
    |                    "errorlog", "monolog", "custom", "stack"
    |
    */

    'channels' => [

        'stack' => [
            'driver' => 'stack',
            'channels' => explode(',', (string) env('LOG_STACK', 'single')),
            'ignore_exceptions' => false,
        ],

        'single' => [
            'driver' => 'single',
            'path' => storage_path('logs/laravel.log'),
            'level' => env('LOG_LEVEL', 'debug'),
            'replace_placeholders' => true,
        ],

        'json' => [
            'driver' => 'single',
            'path' => storage_path('logs/shop-json.log'),
            'level' => env('LOG_LEVEL', 'debug'),
            'formatter' => Monolog\Formatter\JsonFormatter::class,
            'replace_placeholders' => true,
        ],

        'daily' => [
            'driver' => 'daily',
            'path' => storage_path('logs/laravel.log'),
            'level' => env('LOG_LEVEL', 'debug'),
            'days' => env('LOG_DAILY_DAYS', 14),
            'replace_placeholders' => true,
        ],

        'slack' => [
            'driver' => 'slack',
            'url' => env('LOG_SLACK_WEBHOOK_URL'),
            'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'),
            'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
            'level' => env('LOG_LEVEL', 'critical'),
            'replace_placeholders' => true,
        ],

        'papertrail' => [
            'driver' => 'monolog',
            'level' => env('LOG_LEVEL', 'debug'),
            'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
            'handler_with' => [
                'host' => env('PAPERTRAIL_URL'),
                'port' => env('PAPERTRAIL_PORT'),
                'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
            ],
            'processors' => [PsrLogMessageProcessor::class],
        ],

        'stderr' => [
            'driver' => 'monolog',
            'level' => env('LOG_LEVEL', 'debug'),
            'handler' => StreamHandler::class,
            'handler_with' => [
                'stream' => 'php://stderr',
            ],
            'formatter' => env('LOG_STDERR_FORMATTER'),
            'processors' => [PsrLogMessageProcessor::class],
        ],

        'syslog' => [
            'driver' => 'syslog',
            'level' => env('LOG_LEVEL', 'debug'),
            'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
            'replace_placeholders' => true,
        ],

        'errorlog' => [
            'driver' => 'errorlog',
            'level' => env('LOG_LEVEL', 'debug'),
            'replace_placeholders' => true,
        ],

        'null' => [
            'driver' => 'monolog',


ls app/Policies && rg -n "Analytics|Dashboard|viewAny|view" app/Policies tests/Feature/Foundation/AuthorizationTest.php tests/Feature/Admin/DashboardTest.php

rg -n 'Analytics|Dashboard|viewAny|view' app/Policies tests/Feature/Foundation/AuthorizationTest.php tests/Feature/Admin/DashboardTest.php

Question: Analytics|Dashboard|viewAny|view

CollectionPolicy.php
CustomerPolicy.php
DiscountPolicy.php
FulfillmentPolicy.php
OrderPolicy.php
PagePolicy.php
ProductPolicy.php
RefundPolicy.php
StorePolicy.php
ThemePolicy.php
app/Policies/DiscountPolicy.php:13:    public function viewAny(User $user): bool
app/Policies/DiscountPolicy.php:18:    public function view(User $user, Discount $discount): bool
tests/Feature/Admin/DashboardTest.php:3:use App\Livewire\Admin\Dashboard as AdminDashboard;
tests/Feature/Admin/DashboardTest.php:22:function adminDashboardStore(): Store
tests/Feature/Admin/DashboardTest.php:25:    $user = adminDashboardUser();
tests/Feature/Admin/DashboardTest.php:43:function adminDashboardUser(): User
tests/Feature/Admin/DashboardTest.php:48:function adminDashboardOrder(Store $store, string $title, int $quantity, int $unitPrice, array $orderAttributes = []): Order
tests/Feature/Admin/DashboardTest.php:84:    $store = adminDashboardStore();
tests/Feature/Admin/DashboardTest.php:85:    adminDashboardOrder($store, 'Dashboard Jacket', 2, 2500);
tests/Feature/Admin/DashboardTest.php:86:    adminDashboardOrder($store, 'Dashboard Cap', 1, 2000);
tests/Feature/Admin/DashboardTest.php:89:    adminDashboardOrder($otherStore, 'Other Store Product', 1, 9900);
tests/Feature/Admin/DashboardTest.php:93:    $this->actingAs(adminDashboardUser())
tests/Feature/Admin/DashboardTest.php:97:        ->assertSee('Dashboard')
tests/Feature/Admin/DashboardTest.php:99:        ->assertSee('Dashboard Jacket')
tests/Feature/Admin/DashboardTest.php:104:    $store = adminDashboardStore();
tests/Feature/Admin/DashboardTest.php:105:    $user = adminDashboardUser();
tests/Feature/Admin/DashboardTest.php:107:    adminDashboardOrder($store, 'Today Product', 1, 6000);
tests/Feature/Admin/DashboardTest.php:108:    adminDashboardOrder($store, 'Older Product', 1, 4000, [
tests/Feature/Admin/DashboardTest.php:113:        ->test(AdminDashboard::class)
tests/Feature/Foundation/AuthorizationTest.php:37:test('product policy allows support to view but not mutate products', function () {
tests/Feature/Foundation/AuthorizationTest.php:46:    expect($policy->viewAny($support))->toBeTrue()
tests/Feature/Foundation/AuthorizationTest.php:47:        ->and($policy->view($support, $product))->toBeTrue()
tests/Feature/Foundation/AuthorizationTest.php:85:            'view' => CatalogCollection::class,
tests/Feature/Foundation/AuthorizationTest.php:90:            'view' => Customer::class,
tests/Feature/Foundation/AuthorizationTest.php:94:            'view' => Discount::class,
tests/Feature/Foundation/AuthorizationTest.php:103:            'view' => Order::class,
tests/Feature/Foundation/AuthorizationTest.php:110:            'view' => Page::class,
tests/Feature/Foundation/AuthorizationTest.php:115:            'view' => Product::class,
tests/Feature/Foundation/AuthorizationTest.php:122:            'view' => Refund::class,
tests/Feature/Foundation/AuthorizationTest.php:125:            'view' => Theme::class,
app/Policies/OrderPolicy.php:13:    public function viewAny(User $user): bool
app/Policies/OrderPolicy.php:18:    public function view(User $user, Order $order): bool
app/Policies/PagePolicy.php:13:    public function viewAny(User $user): bool
app/Policies/PagePolicy.php:18:    public function view(User $user, Page $page): bool
app/Policies/RefundPolicy.php:18:    public function view(User $user, Refund $refund): bool
app/Policies/ThemePolicy.php:13:    public function viewAny(User $user): bool
app/Policies/ThemePolicy.php:18:    public function view(User $user, Theme $theme): bool
app/Policies/CollectionPolicy.php:13:    public function viewAny(User $user): bool
app/Policies/CollectionPolicy.php:18:    public function view(User $user, Collection $collection): bool
app/Policies/CustomerPolicy.php:13:    public function viewAny(User $user): bool
app/Policies/CustomerPolicy.php:18:    public function view(User $user, Customer $customer): bool
app/Policies/ProductPolicy.php:13:    public function viewAny(User $user): bool
app/Policies/ProductPolicy.php:18:    public function view(User $user, Product $product): bool
app/Policies/StorePolicy.php:13:    public function view(User $user, Store $store): bool


Output:
Chunk ID: fe9874
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1048
Output:
CollectionPolicy.php
CustomerPolicy.php
DiscountPolicy.php
FulfillmentPolicy.php
OrderPolicy.php
PagePolicy.php
ProductPolicy.php
RefundPolicy.php
StorePolicy.php
ThemePolicy.php
app/Policies/DiscountPolicy.php:13:    public function viewAny(User $user): bool
app/Policies/DiscountPolicy.php:18:    public function view(User $user, Discount $discount): bool
tests/Feature/Admin/DashboardTest.php:3:use App\Livewire\Admin\Dashboard as AdminDashboard;
tests/Feature/Admin/DashboardTest.php:22:function adminDashboardStore(): Store
tests/Feature/Admin/DashboardTest.php:25:    $user = adminDashboardUser();
tests/Feature/Admin/DashboardTest.php:43:function adminDashboardUser(): User
tests/Feature/Admin/DashboardTest.php:48:function adminDashboardOrder(Store $store, string $title, int $quantity, int $unitPrice, array $orderAttributes = []): Order
tests/Feature/Admin/DashboardTest.php:84:    $store = adminDashboardStore();
tests/Feature/Admin/DashboardTest.php:85:    adminDashboardOrder($store, 'Dashboard Jacket', 2, 2500);
tests/Feature/Admin/DashboardTest.php:86:    adminDashboardOrder($store, 'Dashboard Cap', 1, 2000);
tests/Feature/Admin/DashboardTest.php:89:    adminDashboardOrder($otherStore, 'Other Store Product', 1, 9900);
tests/Feature/Admin/DashboardTest.php:93:    $this->actingAs(adminDashboardUser())
tests/Feature/Admin/DashboardTest.php:97:        ->assertSee('Dashboard')
tests/Feature/Admin/DashboardTest.php:99:        ->assertSee('Dashboard Jacket')
tests/Feature/Admin/DashboardTest.php:104:    $store = adminDashboardStore();
tests/Feature/Admin/DashboardTest.php:105:    $user = adminDashboardUser();
tests/Feature/Admin/DashboardTest.php:107:    adminDashboardOrder($store, 'Today Product', 1, 6000);
tests/Feature/Admin/DashboardTest.php:108:    adminDashboardOrder($store, 'Older Product', 1, 4000, [
tests/Feature/Admin/DashboardTest.php:113:        ->test(AdminDashboard::class)
tests/Feature/Foundation/AuthorizationTest.php:37:test('product policy allows support to view but not mutate products', function () {
tests/Feature/Foundation/AuthorizationTest.php:46:    expect($policy->viewAny($support))->toBeTrue()
tests/Feature/Foundation/AuthorizationTest.php:47:        ->and($policy->view($support, $product))->toBeTrue()
tests/Feature/Foundation/AuthorizationTest.php:85:            'view' => CatalogCollection::class,
tests/Feature/Foundation/AuthorizationTest.php:90:            'view' => Customer::class,
tests/Feature/Foundation/AuthorizationTest.php:94:            'view' => Discount::class,
tests/Feature/Foundation/AuthorizationTest.php:103:            'view' => Order::class,
tests/Feature/Foundation/AuthorizationTest.php:110:            'view' => Page::class,
tests/Feature/Foundation/AuthorizationTest.php:115:            'view' => Product::class,
tests/Feature/Foundation/AuthorizationTest.php:122:            'view' => Refund::class,
tests/Feature/Foundation/AuthorizationTest.php:125:            'view' => Theme::class,
app/Policies/OrderPolicy.php:13:    public function viewAny(User $user): bool
app/Policies/OrderPolicy.php:18:    public function view(User $user, Order $order): bool
app/Policies/PagePolicy.php:13:    public function viewAny(User $user): bool
app/Policies/PagePolicy.php:18:    public function view(User $user, Page $page): bool
app/Policies/RefundPolicy.php:18:    public function view(User $user, Refund $refund): bool
app/Policies/ThemePolicy.php:13:    public function viewAny(User $user): bool
app/Policies/ThemePolicy.php:18:    public function view(User $user, Theme $theme): bool
app/Policies/CollectionPolicy.php:13:    public function viewAny(User $user): bool
app/Policies/CollectionPolicy.php:18:    public function view(User $user, Collection $collection): bool
app/Policies/CustomerPolicy.php:13:    public function viewAny(User $user): bool
app/Policies/CustomerPolicy.php:18:    public function view(User $user, Customer $customer): bool
app/Policies/ProductPolicy.php:13:    public function viewAny(User $user): bool
app/Policies/ProductPolicy.php:18:    public function view(User $user, Product $product): bool
app/Policies/StorePolicy.php:13:    public function view(User $user, Store $store): bool


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

### 7.2 Platform Webhooks (Outbound)

Service: `App\Services\WebhookDispatcher`

When the platform sends webhooks to external subscribers (third-party apps, integrations).

```mermaid
sequenceDiagram
    participant App as Platform
    participant Sub as Subscriber URL

    App->>App: Serialize payload as JSON
    App->>App: Generate Unix timestamp
    App->>App: Concatenate: "{timestamp}.{json_body}"
    App->>App: HMAC-SHA256(signing_secret, concatenated_message)
    App->>Sub: POST to subscriber URL
    Note right of Sub: Headers: X-Platform-Signature, X-Platform-Timestamp, Content-Type: application/json
    Sub-->>App: 200 OK (acknowledged)
```

**Requirements:**

1. The payload is serialized as JSON.
2. A Unix timestamp is generated (current time).
3. The message to sign is: `"{timestamp}.{json_body}"` (timestamp, dot, JSON string).
4. The signature is computed as HMAC-SHA256 using the per-subscription `signing_secret` (decrypted from the `signing_secret_encrypted` column).
5. The HTTP request to the subscriber includes three headers:
   - `X-Platform-Signature`: the hex-encoded HMAC digest
   - `X-Platform-Timestamp`: the Unix timestamp as a string
   - `Content-Type`: `application/json`
6. Replay protection guidance for recipients: reject any request where `X-Platform-Timestamp` is more than 5 minutes in the past.

---

## 8. Audit Logging

### 8.1 Logging Channel

A dedicated `audit` channel must be added to `config/logging.php`.

| Setting | Value |
|---------|-------|
| Driver | `daily` (file-based, daily rotation) |
| Path | `storage/logs/audit.log` |
| Level | `info` |
| Retention | 90 days |

---

### 8.2 Log Entry Format

All audit log entries are structured JSON objects with the following schema:

| Field | Type | Required | Description |
|-------|------|:--------:|-------------|
| `timestamp` | string (ISO-8601) | Y | When the event occurred |
| `event` | string | Y | Event identifier (e.g., `product.updated`, `auth.login`) |
| `user_id` | integer or null | N | ID of the admin user who triggered the event |
| `store_id` | integer or null | N | ID of the store context |
| `resource_type` | string or null | N | Type of resource affected (e.g., `product`, `order`) |
| `resource_id` | integer or null | N | ID of the affected resource |
| `ip` | string | Y | Client IP address |
| `user_agent` | string | Y | Client user agent string |
| `changes` | object or null | N | For update events: object mapping field names to `[old_value, new_value]` tuples |
| (additional keys) | varies | N | Event-specific extra data |

---

### 8.3 Events to Log

#### Authentication Events

| Event ID | Trigger | Extra Data |
|----------|---------|------------|
| `auth.login` | Successful admin login | user_id, ip, user_agent |
| `auth.logout` | Admin logout | user_id |
| `auth.failed_login` | Failed admin login attempt | email (never password), ip, user_agent |
| `customer.login` | Successful customer login | customer_id, store_id, ip |
| `customer.registered` | New customer registration | customer_id, store_id |
| `customer.failed_login` | Failed customer login attempt | email, store_id, ip |

#### Admin Resource Events

For each of the following resource types, log `created`, `updated`, `deleted`, and `restored` (where applicable) events:

**Resource types:** product, collection, discount, page, theme, order, fulfillment, refund, navigation_menu, shipping_zone, tax_setting, staff

| Event Pattern | Trigger | Extra Data |
|---------------|---------|------------|
| `{resource}.created` | Resource created | resource_id, key attributes of the new resource |
| `{resource}.updated` | Resource updated | resource_id, `changes` object with old/new values for each changed field |
| `{resource}.deleted` | Resource soft-deleted or archived | resource_id |
| `{resource}.restored` | Resource restored from soft-delete | resource_id |

#### Security Events

| Event ID | Trigger | Extra Data |
|----------|---------|------------|
| `api_token.created` | New API token issued | token_name, abilities list, user_id |
| `api_token.revoked` | API token revoked | token_name, user_id |
| `webhook.signature_invalid` | Outbound webhook delivery failed signature verification at subscriber | subscriber_url, ip |
| `store.settings_changed` | Store settings updated | changed fields with old/new values |

---

### 8.4 Audit Logger Service

File: `app/Services/AuditLogger.php`

A service class that encapsulates audit log writing.

**Method signature:**

| Parameter | Type | Required | Description |
|-----------|------|:--------:|-------------|
| `event` | string | Y | Event identifier |
| `userId` | int or null | N | Acting user ID |
| `storeId` | int or null | N | Store context ID |
| `resourceType` | string or null | N | Affected resource type |
| `resourceId` | int or null | N | Affected resource ID |
| Orders | List / View | Y | Y | Y | Y |
| Orders | Update | Y | Y | Y | N |
| Orders | Cancel | Y | Y | N | N |
| Fulfillments | Create | Y | Y | Y | N |
| Fulfillments | Update / Cancel | Y | Y | Y | N |
| Refunds | Create | Y | Y | N | N |
| Customers | List / View | Y | Y | Y | Y |
| Customers | Update | Y | Y | Y | N |
| Discounts | List / View | Y | Y | Y | Y |
| Discounts | Create | Y | Y | Y | N |
| Discounts | Update | Y | Y | Y | N |
| Discounts | Delete | Y | Y | N | N |
| Themes | List / View | Y | Y | N | N |
| Themes | Create / Upload | Y | Y | N | N |
| Themes | Customize | Y | Y | N | N |
| Themes | Publish | Y | Y | N | N |
| Themes | Delete | Y | Y | N | N |
| Pages | List / View | Y | Y | Y | N |
| Pages | Create | Y | Y | Y | N |
| Pages | Update | Y | Y | Y | N |
| Pages | Delete | Y | Y | N | N |
| Navigation | List / View | Y | Y | Y | N |
| Navigation | Manage | Y | Y | N | N |
| Apps | List / View | Y | Y | N | N |
| Apps | Install / Remove | Y | Y | N | N |
| Analytics | View | Y | Y | Y | N |
| Developers / API | Manage | Y | Y | N | N |
| Staff | List / View | Y | Y | N | N |
| Staff | Invite | Y | Y | N | N |
| Staff | Update Role | Y | Y | N | N |
| Staff | Remove | Y | Y | N | N |
| Shipping Zones | Manage | Y | Y | N | N |
| Tax Settings | Manage | Y | Y | N | N |
| Search Settings | Manage | Y | Y | N | N |

#### Role Group Shorthand


Output:
Chunk ID: 239855
Wall time: 0.0000 seconds
Process exited with code 1
Original token count: 61
Output:
sed: app/Policies/Concerns/ChecksStoreRole.php: No such file or directory
<?php

namespace App\Enums;

enum StoreUserRole: string
{
    case Owner = 'owner';
    case Admin = 'admin';
    case Staff = 'staff';
    case Support = 'support';
}